@delmaredigital/payload-better-auth 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,119 +13,45 @@ Better Auth adapter and plugins for Payload CMS. Enables seamless integration be
13
13
  </p>
14
14
 
15
15
  ---
16
+
16
17
  ## Documentation
17
- For additional documentation and references, visit: [https://deepwiki.com/delmaredigital/payload-better-auth](https://deepwiki.com/delmaredigital/payload-better-auth)
18
18
 
19
- ## Table of Contents
19
+ **[Full Documentation](https://delmaredigital.github.io/payload-better-auth/)** API reference, guides, recipes, UI components, and more.
20
20
 
21
- - [Installation](#installation)
22
- - [Quick Start](#quick-start)
23
- - [API Reference](#api-reference)
24
- - [Customization](#customization)
25
- - [Access Control Helpers](#access-control-helpers)
26
- - [API Key Scope Enforcement](#api-key-scope-enforcement)
27
- - [Plugin Compatibility](#plugin-compatibility)
28
- - [Recipes](#recipes)
29
- - [Types](#types)
30
- - [License](#license)
21
+ For AI-assisted exploration: [DeepWiki](https://deepwiki.com/delmaredigital/payload-better-auth)
31
22
 
32
23
  ---
33
24
 
34
- ## Installation
35
-
36
- ### Requirements
37
-
38
- | Dependency | Version |
39
- |------------|---------|
40
- | `payload` | >= 3.69.0 |
41
- | `@payloadcms/next` | >= 3.69.0 |
42
- | `@payloadcms/ui` | >= 3.69.0 |
43
- | `better-auth` | >= 1.4.0 |
44
- | `@better-auth/passkey` | >= 1.4.18 (if using passkeys) |
45
- | `next` | >= 15.4.8 |
46
- | `react` | >= 19.2.1 |
47
-
48
- ### Install
25
+ ## Install
49
26
 
50
27
  ```bash
51
28
  pnpm add @delmaredigital/payload-better-auth better-auth
52
-
53
- # If using passkeys:
54
- pnpm add @better-auth/passkey
55
- ```
56
-
57
- ### Environment Variables
58
-
59
- Better Auth requires these environment variables:
60
-
61
- ```bash
62
- # Required
63
- BETTER_AUTH_SECRET=your-secret-key-min-32-chars # Must be at least 32 characters
64
-
65
- # Optional - only needed if not using the getBaseUrl() helper below
66
- BETTER_AUTH_URL=http://localhost:3000 # Your app's base URL
67
-
68
- # OAuth Providers (if using social login)
69
- GOOGLE_CLIENT_ID=...
70
- GOOGLE_CLIENT_SECRET=...
71
- # etc.
72
- ```
73
-
74
- **Notes:**
75
- - `BETTER_AUTH_SECRET` is used for signing sessions and tokens - use a secure random string
76
- - `BETTER_AUTH_URL` tells Better Auth where it's hosted - plugins like passkey derive their config from this
77
- - WebAuthn (passkeys) requires HTTPS in production but works on `localhost` for development
78
-
79
- **Vercel Deployment:**
80
-
81
- For seamless support of Vercel preview deployments, use this helper instead of hardcoding URLs:
82
-
83
- ```ts
84
- // src/lib/auth/getBaseUrl.ts
85
- export function getBaseUrl() {
86
- // Vercel preview/production deployments
87
- if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
88
- // Explicit override
89
- if (process.env.BETTER_AUTH_URL) return process.env.BETTER_AUTH_URL
90
- // Local development
91
- return 'http://localhost:3000'
92
- }
93
29
  ```
94
30
 
95
- This automatically handles:
96
- - **Local dev**: Uses `http://localhost:3000`
97
- - **Vercel preview**: Uses the auto-generated `*.vercel.app` URL
98
- - **Production**: Uses your custom domain (set `BETTER_AUTH_URL` in production env)
99
-
100
- ---
31
+ **Requirements:** `payload` >= 3.69.0 · `better-auth` >= 1.4.0 · `next` >= 15.4.8 · `react` >= 19.2.1
101
32
 
102
33
  ## Quick Start
103
34
 
104
- ### Step 1: Create Your Auth Configuration
35
+ ### 1. Auth Configuration
105
36
 
106
37
  ```ts
107
38
  // src/lib/auth/config.ts
108
39
  import type { BetterAuthOptions } from 'better-auth'
109
40
 
110
41
  export const betterAuthOptions: Partial<BetterAuthOptions> = {
111
- // Model names are SINGULAR - they get pluralized automatically
112
- // 'user' becomes 'users', 'session' becomes 'sessions', etc.
113
42
  user: {
114
43
  additionalFields: {
115
44
  role: { type: 'string', defaultValue: 'user' },
116
45
  },
117
46
  },
118
- session: {
119
- expiresIn: 60 * 60 * 24 * 30, // 30 days
120
- },
121
47
  emailAndPassword: { enabled: true },
122
48
  }
123
49
  ```
124
50
 
125
- ### Step 2: Create Your Users Collection
51
+ ### 2. Users Collection
126
52
 
127
53
  ```ts
128
- // src/collections/Users/index.ts (vanilla starter uses folder-based collections)
54
+ // src/collections/Users/index.ts
129
55
  import type { CollectionConfig } from 'payload'
130
56
  import { betterAuthStrategy } from '@delmaredigital/payload-better-auth'
131
57
 
@@ -161,12 +87,7 @@ export const Users: CollectionConfig = {
161
87
  }
162
88
  ```
163
89
 
164
- > **Note:** Plugin-specific fields (e.g., `twoFactorEnabled` for 2FA, `banned` for admin) are **automatically added** to your Users collection by `betterAuthCollections()`. You'll see a log message like:
165
- > ```
166
- > [better-auth] Auto-adding fields to 'users': ['twoFactorEnabled']
167
- > ```
168
-
169
- ### Step 3: Configure Payload
90
+ ### 3. Payload Config
170
91
 
171
92
  ```ts
172
93
  // src/payload.config.ts
@@ -185,46 +106,31 @@ import { getBaseUrl } from './lib/auth/getBaseUrl'
185
106
  const baseUrl = getBaseUrl()
186
107
 
187
108
  export default buildConfig({
188
- collections: [Users /* ...other collections */],
109
+ collections: [Users],
189
110
  plugins: [
190
- // Auto-generate sessions, accounts, verifications collections
191
111
  betterAuthCollections({
192
112
  betterAuthOptions,
193
- skipCollections: ['user'], // We define Users ourselves
113
+ skipCollections: ['user'],
194
114
  }),
195
- // Initialize Better Auth with auto-injected endpoints and admin components
196
115
  createBetterAuthPlugin({
197
116
  createAuth: (payload) =>
198
117
  betterAuth({
199
118
  ...betterAuthOptions,
200
- database: payloadAdapter({
201
- payloadClient: payload,
202
- // adapterConfig: { enableDebugLogs: true }, // Uncomment to enable debug logging
203
- }),
204
- // For Payload's default SERIAL IDs:
205
- advanced: {
206
- database: {
207
- generateId: 'serial',
208
- },
209
- },
119
+ database: payloadAdapter({ payloadClient: payload }),
120
+ advanced: { database: { generateId: 'serial' } },
210
121
  baseURL: baseUrl,
211
122
  secret: process.env.BETTER_AUTH_SECRET,
212
- trustedOrigins: [baseUrl], // Or use withBetterAuthDefaults() below
123
+ trustedOrigins: [baseUrl],
213
124
  }),
214
125
  }),
215
126
  ],
216
- admin: {
217
- user: 'users',
218
- },
219
127
  db: postgresAdapter({
220
128
  pool: { connectionString: process.env.DATABASE_URL },
221
129
  }),
222
130
  })
223
131
  ```
224
132
 
225
- > **⚠️ Note:** The plugin automatically injects its own login page, logout button, and redirect handling. Don't add a custom `beforeLogin` in Payload's `admin.components` directly - use the plugin's options instead (see [Customization](#customization) for `disableLoginView`, `loginViewComponent`, etc.).
226
-
227
- ### Step 4: Client-Side Auth
133
+ ### 4. Client-Side Auth
228
134
 
229
135
  ```ts
230
136
  // src/lib/auth/client.ts
@@ -232,42 +138,14 @@ export default buildConfig({
232
138
 
233
139
  import { createPayloadAuthClient } from '@delmaredigital/payload-better-auth/client'
234
140
 
235
- // Pre-configured with twoFactor, apiKey, and passkey plugins
236
- // Uses window.location.origin automatically - works on any deployment URL
237
141
  export const authClient = createPayloadAuthClient()
238
142
 
239
143
  export const { useSession, signIn, signUp, signOut, twoFactor, passkey } = authClient
240
144
  ```
241
145
 
242
- **Note:** `createPayloadAuthClient()` automatically uses `window.location.origin` as the base URL, so it works seamlessly across local dev, Vercel previews, and production without any configuration.
243
-
244
- **Adding custom plugins (e.g., Stripe):**
245
-
246
- For custom plugins with full TypeScript support, use `createAuthClient` with `payloadAuthPlugins`:
247
-
248
- ```ts
249
- // src/lib/auth/client.ts
250
- 'use client'
251
-
252
- import { createAuthClient, payloadAuthPlugins } from '@delmaredigital/payload-better-auth/client'
253
- import { stripeClient } from '@better-auth/stripe/client'
254
-
255
- // Spread payloadAuthPlugins to include defaults (twoFactor, apiKey, passkey)
256
- // Then add your custom plugins - full type safety!
257
- export const authClient = createAuthClient({
258
- plugins: [...payloadAuthPlugins, stripeClient({ subscription: true })],
259
- })
260
-
261
- // authClient.subscription is fully typed
262
- export const { useSession, signIn, signUp, signOut, twoFactor, passkey, subscription } = authClient
263
- ```
264
-
265
- This approach uses Better Auth's native `createAuthClient` with our default plugins, giving you full type inference for any custom plugins you add.
266
-
267
- ### Step 5: Server-Side Session Access
146
+ ### 5. Server-Side Session
268
147
 
269
148
  ```ts
270
- // In a server component or API route
271
149
  import { headers } from 'next/headers'
272
150
  import { getPayload } from 'payload'
273
151
  import { getServerSession } from '@delmaredigital/payload-better-auth'
@@ -277,1589 +155,17 @@ export default async function Dashboard() {
277
155
  const headersList = await headers()
278
156
  const session = await getServerSession(payload, headersList)
279
157
 
280
- if (!session) {
281
- redirect('/login')
282
- }
158
+ if (!session) { redirect('/login') }
283
159
 
284
160
  return <div>Hello {session.user.name}</div>
285
161
  }
286
162
  ```
287
163
 
288
- **That's it!** The plugin automatically:
289
- - Registers auth API endpoints at `/api/auth/*`
290
- - Injects logout button, login redirect, and login page components
291
- - Handles session management via Better Auth
292
-
293
- ---
294
-
295
- ## API Reference
296
-
297
- ### `payloadAdapter(config)`
298
-
299
- Creates a Better Auth database adapter that uses Payload collections. Uses Better Auth's `createAdapterFactory` for schema-aware transformations, automatically supporting all Better Auth plugins.
300
-
301
- ```ts
302
- payloadAdapter({
303
- payloadClient: payload,
304
- adapterConfig: {
305
- enableDebugLogs: false,
306
- idType: 'number', // Optional - auto-detects from generateId setting
307
- },
308
- })
309
- ```
310
-
311
- | Option | Type | Description |
312
- |--------|------|-------------|
313
- | `payloadClient` | `BasePayload \| () => Promise<BasePayload>` | Payload instance or factory function |
314
- | `adapterConfig.enableDebugLogs` | `boolean` | Enable debug logging (default: `false`) |
315
- | `adapterConfig.idType` | `'number' \| 'text'` | ID type (default: `'number'` for Payload's SERIAL IDs) |
316
- | `adapterConfig.idFieldsAllowlist` | `string[]` | Additional fields to convert to numeric IDs (default: `[]`) |
317
- | `adapterConfig.idFieldsBlocklist` | `string[]` | Fields to exclude from numeric ID conversion (default: `[]`) |
318
-
319
- **ID Type:**
320
- - Defaults to `'number'` (SERIAL) - Payload's default
321
- - Set `idType: 'text'` if using UUIDs
322
-
323
- **Note:** When using number IDs (default), you can optionally set `generateId: 'serial'` in Better Auth to be explicit:
324
- ```typescript
325
- advanced: { database: { generateId: 'serial' } }
326
- ```
327
- This is not required - the adapter handles it automatically. A warning will only appear if you explicitly set `generateId` to something incompatible.
328
-
329
- **ID Field Conversion:**
330
-
331
- When using serial IDs (`idType: 'number'`), the adapter automatically converts string ID fields to numbers. This applies to fields matching `*Id` or `*_id` patterns (like `activeOrganizationId`). This ensures Payload relationship lookups work correctly in access control.
332
-
333
- Use `idFieldsAllowlist` and `idFieldsBlocklist` to customize this behavior:
334
-
335
- ```typescript
336
- payloadAdapter({
337
- payloadClient: payload,
338
- adapterConfig: {
339
- // Add fields that don't follow the *Id pattern but should be converted
340
- idFieldsAllowlist: ['customOrgRef', 'legacyIdentifier'],
341
-
342
- // Exclude fields that end in 'Id' but aren't actually ID references
343
- idFieldsBlocklist: ['visitorId', 'correlationId'],
344
- },
345
- })
346
- ```
347
-
348
- **Custom Collection Names (Optional):**
349
-
350
- By default, the adapter uses standard collection names (`users`, `sessions`, `accounts`, `verifications`). You only need `modelName` if you want **custom** names:
351
-
352
- ```ts
353
- betterAuth({
354
- database: payloadAdapter({ payloadClient: payload }),
355
- // Only set modelName to CUSTOMIZE collection names
356
- // Use SINGULAR form - gets pluralized automatically
357
- user: { modelName: 'member' }, // Changes 'users' → 'members'
358
- session: { modelName: 'auth_session' }, // Changes 'sessions' → 'auth_sessions'
359
- })
360
- ```
361
-
362
- **Note:** If you're using the default collection names, don't set `modelName` at all.
363
-
364
- ### `betterAuthCollections(options)`
365
-
366
- Payload plugin that auto-generates collections from Better Auth schema.
367
-
368
- ```ts
369
- betterAuthCollections({
370
- betterAuthOptions,
371
- skipCollections: ['user'],
372
- adminGroup: 'Auth',
373
- usePlural: true,
374
- customizeCollection: (modelKey, collection) => collection,
375
- })
376
- ```
377
-
378
- | Option | Type | Description |
379
- |--------|------|-------------|
380
- | `betterAuthOptions` | `BetterAuthOptions` | Your Better Auth options |
381
- | `skipCollections` | `string[]` | Collections to skip generating (default: `['user']`) |
382
- | `adminGroup` | `string` | Admin panel group name (default: `'Auth'`) |
383
- | `access` | `CollectionConfig['access']` | Custom access control for generated collections. **Note**: Replaces default access entirely (see caution below). |
384
- | `usePlural` | `boolean` | Pluralize collection slugs (default: `true`) |
385
- | `configureSaveToJWT` | `boolean` | Auto-configure `saveToJWT` for session-critical fields (default: `true`) |
386
- | `firstUserAdmin` | `boolean \| FirstUserAdminOptions` | Make first registered user an admin (default: `true`) |
387
- | `customizeCollection` | `(modelKey, collection) => CollectionConfig` | Customize generated collections |
388
-
389
- > **⚠️ Caution on Custom Access:**
390
- > When providing the `access` option, it **completely replaces** the default access object for all auto-generated collections. It does not merge with or override individual properties.
391
- >
392
- > By default, the plugin sets:
393
- > - `read`: `isAdmin()`
394
- > - `delete`: `isAdmin()`
395
- > - `create`: `() => false` (Manual creation disabled - Better Auth manages these)
396
- > - `update`: `() => false` (Manual update disabled - Better Auth manages these)
397
- >
398
- > You must explicitly handle all access types to ensure your collections remain secure and functional.
399
-
400
- **First User Admin:**
401
-
402
- By default, the first user to register is automatically assigned the admin role. This provides a better out-of-the-box experience - no need to manually update the database to create your first admin.
403
-
404
- ```typescript
405
- // Enabled by default - first user gets role='admin'
406
- betterAuthCollections({ betterAuthOptions })
407
-
408
- // Customize roles
409
- betterAuthCollections({
410
- betterAuthOptions,
411
- firstUserAdmin: {
412
- adminRole: 'super-admin', // Role for first user (default: 'admin')
413
- defaultRole: 'member', // Role for subsequent users (default: 'user')
414
- roleField: 'userRole', // Field name (default: 'role')
415
- },
416
- })
417
-
418
- // Disable
419
- betterAuthCollections({
420
- betterAuthOptions,
421
- firstUserAdmin: false,
422
- })
423
-
424
- **Customization Example:**
425
-
426
- ```ts
427
- betterAuthCollections({
428
- betterAuthOptions,
429
- customizeCollection: (modelKey, collection) => {
430
- if (modelKey === 'session') {
431
- return {
432
- ...collection,
433
- hooks: {
434
- afterDelete: [cleanupExpiredSessions],
435
- },
436
- }
437
- }
438
- return collection
439
- },
440
- })
441
- ```
442
-
443
- ### `createBetterAuthPlugin(options)`
444
-
445
- Payload plugin that initializes Better Auth during Payload's `onInit`.
446
-
447
- ```ts
448
- createBetterAuthPlugin({
449
- createAuth: (payload) => betterAuth({ ... }),
450
- authBasePath: '/auth',
451
- autoRegisterEndpoints: true,
452
- autoInjectAdminComponents: true,
453
- admin: {
454
- login: { title: 'Admin Login' },
455
- },
456
- })
457
- ```
458
-
459
- | Option | Type | Default | Description |
460
- |--------|------|---------|-------------|
461
- | `createAuth` | `(payload: BasePayload) => Auth` | *required* | Factory function that creates the Better Auth instance |
462
- | `authBasePath` | `string` | `'/auth'` | Base path for auth API endpoints |
463
- | `autoRegisterEndpoints` | `boolean` | `true` | Auto-register auth API endpoints |
464
- | `autoInjectAdminComponents` | `boolean` | `true` | Auto-inject admin components when `disableLocalStrategy` detected |
465
- | `admin.disableLogoutButton` | `boolean` | `false` | Disable logout button injection |
466
- | `admin.disableBeforeLogin` | `boolean` | `false` | Disable BeforeLogin redirect injection |
467
- | `admin.disableLoginView` | `boolean` | `false` | Disable login view injection |
468
- | `admin.login.title` | `string` | `'Login'` | Custom login page title |
469
- | `admin.login.afterLoginPath` | `string` | `'/admin'` | Redirect path after successful login |
470
- | `admin.login.requiredRole` | `string \| string[] \| null` | `'admin'` | Required role(s) for admin access. Array = any role matches (unless `requireAllRoles`). Set to `null` to disable. |
471
- | `admin.login.requireAllRoles` | `boolean` | `false` | When `requiredRole` is an array, require ALL roles (true) or ANY role (false). |
472
- | `admin.login.enablePasskey` | `boolean \| 'auto'` | `false` | Enable passkey (WebAuthn) sign-in option. `'auto'` detects if passkey plugin is available. |
473
- | `admin.login.enableSignUp` | `boolean \| 'auto'` | `'auto'` | Enable user registration. `'auto'` detects if sign-up endpoint is available. |
474
- | `admin.login.defaultSignUpRole` | `string` | `'user'` | Default role assigned to new users during registration |
475
- | `admin.login.enableForgotPassword` | `boolean \| 'auto'` | `'auto'` | Enable forgot password link. `'auto'` detects if endpoint is available. |
476
- | `admin.login.resetPasswordUrl` | `string` | - | Custom URL for password reset. If not set, uses inline reset form. |
477
- | `admin.logoutButtonComponent` | `string` | - | Override logout button (import map format) |
478
- | `admin.beforeLoginComponent` | `string` | - | Override BeforeLogin component |
479
- | `admin.loginViewComponent` | `string` | - | Override login view component |
480
- | `admin.betterAuthOptions` | `BetterAuthOptions` | - | Better Auth options (required for management UI auto-detection) |
481
- | `admin.enableManagementUI` | `boolean` | `true` | Enable security management UI (2FA, API keys) |
482
- | `admin.managementPaths.twoFactor` | `string` | `'/security/two-factor'` | Two-factor management view path |
483
- | `admin.managementPaths.apiKeys` | `string` | `'/security/api-keys'` | API keys management view path |
484
- | `admin.managementPaths.passkeys` | `string` | `'/security/passkeys'` | Passkeys management view path |
485
- | `admin.apiKey` | `ApiKeyScopesConfig` | - | API key scopes configuration (see below) |
486
-
487
- #### API Key Scopes Configuration
488
-
489
- API keys can have granular permission scopes. By default, scopes are auto-generated from your Payload collections.
490
-
491
- | Option | Type | Default | Description |
492
- |--------|------|---------|-------------|
493
- | `scopes` | `Record<string, ScopeDefinition>` | - | Custom scope definitions |
494
- | `includeCollectionScopes` | `boolean` | `true` when no custom scopes, `false` when custom scopes provided | Include auto-generated collection scopes |
495
- | `excludeCollections` | `string[]` | `['sessions', 'verifications', 'accounts', 'twoFactors', 'apikeys']` | Collections to exclude from auto-generated scopes |
496
- | `defaultScopes` | `string[]` | `[]` | Default scopes pre-selected when creating a key |
497
- | `requiredRole` | `string \| string[] \| null` | Inherits from `admin.login.requiredRole` or `'admin'` | Role(s) required to create/update/delete API keys. Set to `null` to allow any authenticated user (not recommended) |
498
-
499
- **Restricting API key management to admins:**
500
-
501
- If your `admin.login.requiredRole` includes non-admin roles (e.g., editors who need admin panel access but shouldn't manage API keys), set `requiredRole` explicitly:
502
-
503
- ```typescript
504
- createBetterAuthPlugin({
505
- createAuth,
506
- admin: {
507
- login: {
508
- requiredRole: ['admin', 'content_editor'], // both can access admin panel
509
- },
510
- apiKey: {
511
- requiredRole: 'admin', // only admins can create/update/delete API keys
512
- },
513
- },
514
- })
515
- ```
516
-
517
- > **Note:** API key **verification** is not affected by this setting — existing keys continue to work regardless of who created them. This only restricts key management (create, update, delete).
518
-
519
- **Zero Config (recommended):**
520
- ```typescript
521
- createBetterAuthPlugin({
522
- createAuth,
523
- // Auto-generates: posts:read, posts:write, posts:delete, etc.
524
- })
525
- ```
526
-
527
- **Custom Scopes:**
528
- ```typescript
529
- createBetterAuthPlugin({
530
- createAuth,
531
- admin: {
532
- apiKey: {
533
- scopes: {
534
- 'content:read': {
535
- label: 'Read Content',
536
- description: 'View posts and pages',
537
- permissions: { posts: ['read'], pages: ['read'] }
538
- },
539
- 'content:manage': {
540
- label: 'Manage Content',
541
- description: 'Full content management',
542
- permissions: { posts: ['*'], pages: ['*'] }
543
- }
544
- },
545
- defaultScopes: ['content:read']
546
- }
547
- }
548
- })
549
- ```
550
-
551
- **Hybrid (custom + auto-generated):**
552
- ```typescript
553
- admin: {
554
- apiKey: {
555
- scopes: { 'content:manage': { /* ... */ } },
556
- includeCollectionScopes: true, // Also include posts:read, etc.
557
- excludeCollections: ['users', 'sessions']
558
- }
559
- }
560
- ```
561
-
562
- **How Permissions Work:**
563
-
564
- When creating API keys through the admin UI, users select scopes (e.g., `posts:read`, `posts:write`). These scopes are converted to Better Auth permissions server-side for security - the conversion happens in the Payload endpoint handler before calling Better Auth's API. This ensures that permission assignment is always controlled server-side, following Better Auth's security model.
565
-
566
- ### `betterAuthStrategy(options?)`
567
-
568
- Payload auth strategy for Better Auth session validation.
569
-
570
- ```ts
571
- betterAuthStrategy({
572
- usersCollection: 'users',
573
- idType: 'number', // default — coerces session field IDs for serial IDs
574
- })
575
- ```
576
-
577
- | Option | Type | Description |
578
- |--------|------|-------------|
579
- | `usersCollection` | `string` | The collection slug for users (default: `'users'`) |
580
- | `idType` | `'number' \| 'text'` | Coerces string IDs in session fields (`activeOrganizationId`, etc.) to numbers. Defaults to `'number'` matching the adapter default. Set to `'text'` for UUID IDs. |
581
-
582
- ### `getServerSession<TUser>(payload, headers)`
583
-
584
- Get the current session on the server. Pass your Payload `User` type for full type safety:
585
-
586
- ```ts
587
- import { getServerSession } from '@delmaredigital/payload-better-auth'
588
- import type { User } from '@/payload-types'
589
-
590
- const session = await getServerSession<User>(payload, headersList)
591
- // session.user.role, session.user.firstName, etc. are fully typed
592
- ```
593
-
594
- ### `getServerUser<TUser>(payload, headers)`
595
-
596
- Get the current user on the server (shorthand for `session.user`):
597
-
598
- ```ts
599
- import { getServerUser } from '@delmaredigital/payload-better-auth'
600
- import type { User } from '@/payload-types'
601
-
602
- const user = await getServerUser<User>(payload, headersList)
603
- // user.role, user.firstName, etc. are fully typed
604
- ```
605
-
606
- ### `createSessionHelpers<TUser>(options?)`
607
-
608
- Create typed session helpers bound to your User type. Define once, import everywhere — no generics needed at call sites:
609
-
610
- ```ts
611
- // lib/auth.ts
612
- import { createSessionHelpers } from '@delmaredigital/payload-better-auth'
613
- import type { User } from '@/payload-types'
614
-
615
- export const { getServerSession, getServerUser } = createSessionHelpers<User>()
616
- ```
617
-
618
- ```ts
619
- // app/page.tsx
620
- import { getServerSession } from '@/lib/auth'
621
-
622
- const session = await getServerSession(payload, headersList)
623
- // session.user is typed as User — no generic needed
624
- ```
625
-
626
- **Serial IDs (Payload default):** Better Auth always returns string IDs from `api.getSession()`, which causes Payload relationship fields to reject them. Pass `idType: 'number'` to coerce ID fields to numbers automatically:
627
-
628
- ```ts
629
- export const { getServerSession, getServerUser } = createSessionHelpers<User>({
630
- idType: 'number', // coerces user.id, session.userId, etc. to numbers
631
- })
632
- ```
633
-
634
- | Option | Type | Description |
635
- |--------|------|-------------|
636
- | `idType` | `'number' \| 'text'` | Set to `'number'` when using serial IDs to coerce string IDs to numbers. Matches the adapter's `adapterConfig.idType` option. |
637
-
638
- ### `withBetterAuthDefaults(options)`
639
-
640
- Applies sensible defaults to Better Auth options. Useful for simplifying common configurations.
641
-
642
- ```ts
643
- import { withBetterAuthDefaults } from '@delmaredigital/payload-better-auth'
644
-
645
- betterAuth(withBetterAuthDefaults({
646
- baseURL: 'https://myapp.com',
647
- // trustedOrigins automatically becomes ['https://myapp.com']
648
- }))
649
- ```
650
-
651
- | Default Applied | Condition |
652
- |-----------------|-----------|
653
- | `trustedOrigins: [baseURL]` | When `trustedOrigins` is not set but `baseURL` is |
654
-
655
- Explicit values are never overridden - if you set `trustedOrigins` manually, it won't be changed.
656
-
657
- ### `apiKeyWithDefaults(options?)`
658
-
659
- Wraps Better Auth's `apiKey()` plugin with sensible defaults for use with this package.
660
-
661
- ```ts
662
- import { apiKeyWithDefaults } from '@delmaredigital/payload-better-auth'
663
-
664
- export const betterAuthOptions = {
665
- plugins: [
666
- apiKeyWithDefaults(), // Use instead of apiKey()
667
- ],
668
- }
669
- ```
670
-
671
- | Default Applied | Purpose |
672
- |-----------------|---------|
673
- | `enableMetadata: true` | Allows storing scope names for display in admin UI |
674
-
675
- **Why use this?**
676
-
677
- Better Auth's `apiKey()` plugin disables metadata by default. When you create API keys with scopes through the admin UI, the selected scopes need to be stored in metadata to display them later. Without metadata enabled, keys are created successfully but scopes won't appear in the UI.
678
-
679
- You can still pass any `apiKey()` options:
680
-
681
- ```ts
682
- apiKeyWithDefaults({
683
- rateLimit: { max: 100, window: 60 },
684
- // enableMetadata is already true
685
- })
686
- ```
687
-
688
- ---
689
-
690
- ## Customization
691
-
692
- ### Role-Based Access Control
693
-
694
- By default, the login page checks that users have the `admin` role before allowing access to the admin panel. Users without the required role see an "Access Denied" message.
695
-
696
- ```ts
697
- createBetterAuthPlugin({
698
- createAuth,
699
- admin: {
700
- login: {
701
- // Default: 'admin' - only users with role='admin' can access
702
- requiredRole: 'admin',
703
-
704
- // Use a different role name
705
- requiredRole: 'editor',
706
-
707
- // Multiple roles - any of these grants access
708
- requiredRole: ['admin', 'editor', 'moderator'],
709
-
710
- // Require ALL roles (instead of any)
711
- requiredRole: ['admin', 'content-manager'],
712
- requireAllRoles: true,
713
-
714
- // Disable role checking entirely
715
- requiredRole: null,
716
- },
717
- },
718
- })
719
- ```
720
-
721
- **For complex RBAC** (multiple roles, permissions, etc.), disable the login view and create your own:
722
-
723
- ```ts
724
- createBetterAuthPlugin({
725
- createAuth,
726
- admin: {
727
- disableLoginView: true,
728
- loginViewComponent: '@/components/admin/CustomLoginWithRBAC',
729
- },
730
- })
731
- ```
732
-
733
- You can use the built-in `LoginView` as a starting point:
734
-
735
- ```tsx
736
- // src/components/admin/CustomLoginWithRBAC.tsx
737
- 'use client'
738
-
739
- import { LoginView } from '@delmaredigital/payload-better-auth/components'
740
-
741
- // Option 1: Wrap and extend the built-in component
742
- export default function CustomLoginWithRBAC() {
743
- // Add your custom RBAC logic here
744
- return <LoginView requiredRole={null} /> // Disable built-in role check
745
- }
746
-
747
- // Option 2: Copy the LoginView source code from the package and customize fully
748
- // See: node_modules/@delmaredigital/payload-better-auth/dist/components/LoginView.js
749
- ```
750
-
751
- ### Disabling Auto-Injection
752
-
753
- If you prefer to handle API routes or admin components manually:
754
-
755
- ```ts
756
- createBetterAuthPlugin({
757
- createAuth,
758
- autoRegisterEndpoints: false, // Handle API route yourself
759
- autoInjectAdminComponents: false, // Handle admin components yourself
760
- })
761
- ```
762
-
763
- **Disabling Only the LoginView:**
764
-
765
- To disable just the login view while keeping other auto-injected components:
766
-
767
- ```ts
768
- createBetterAuthPlugin({
769
- createAuth,
770
- admin: {
771
- disableLoginView: true,
772
- // Optionally provide your own:
773
- loginViewComponent: '@/components/admin/CustomLogin',
774
- },
775
- })
776
- ```
777
-
778
- This is useful when you need:
779
- - Complex RBAC logic beyond simple role checks
780
- - Custom 2FA flows different from the built-in inline handling
781
- - Integration with external identity providers
782
- - Custom branding or UI requirements
783
-
784
- **Frontend Login (outside admin panel):**
785
-
786
- For user-facing login pages (not the Payload admin), you don't need to configure anything in the plugin. Just use the auth client directly in your own React components:
787
-
788
- ```tsx
789
- import { authClient } from '@/lib/auth/client'
790
-
791
- // Use authClient.signIn.email(), authClient.signUp.email(), etc.
792
- // See "Handling 2FA in Custom Login Forms" section below for a complete example
793
- ```
794
-
795
- ### Custom Admin Components
796
-
797
- Override specific admin components while keeping others auto-injected:
798
-
799
- ```ts
800
- createBetterAuthPlugin({
801
- createAuth,
802
- admin: {
803
- // Use custom components (import map format)
804
- loginViewComponent: '@/components/admin/CustomLogin',
805
- logoutButtonComponent: '@/components/admin/CustomLogout',
806
-
807
- // Or disable specific components
808
- disableBeforeLogin: true,
809
- },
810
- })
811
- ```
812
-
813
- ### Manual API Route (Advanced)
814
-
815
- If you disable `autoRegisterEndpoints`, create your own route:
816
-
817
- ```ts
818
- // src/app/api/auth/[...all]/route.ts
819
- import { getPayload } from 'payload'
820
- import config from '@payload-config'
821
- import type { NextRequest } from 'next/server'
822
- import type { PayloadWithAuth } from '@delmaredigital/payload-better-auth'
823
-
824
- export async function GET(request: NextRequest) {
825
- const payload = (await getPayload({ config })) as PayloadWithAuth
826
- return payload.betterAuth.handler(request)
827
- }
828
-
829
- export async function POST(request: NextRequest) {
830
- const payload = (await getPayload({ config })) as PayloadWithAuth
831
- return payload.betterAuth.handler(request)
832
- }
833
- ```
164
+ **That's it!** The plugin automatically registers auth API endpoints at `/api/auth/*`, injects admin UI components, and handles session management.
834
165
 
835
166
  ---
836
167
 
837
- ## Access Control Helpers
838
-
839
- Pre-built access control functions for common authorization patterns.
840
-
841
- ### Role-Based Access
842
-
843
- ```typescript
844
- import {
845
- isAdmin,
846
- isAdminField,
847
- isAdminOrSelf,
848
- hasRole,
849
- requireAllRoles,
850
- isAuthenticated,
851
- isAuthenticatedField,
852
- } from '@delmaredigital/payload-better-auth'
853
-
854
- export const Posts: CollectionConfig = {
855
- slug: 'posts',
856
- access: {
857
- read: isAuthenticated(), // Any logged-in user
858
- create: hasRole(['editor', 'admin']), // Any of these roles
859
- update: hasRole(['editor', 'admin']),
860
- delete: requireAllRoles(['admin', 'content-manager']), // Must have ALL roles
861
- },
862
- fields: [
863
- {
864
- name: 'title',
865
- type: 'text',
866
- },
867
- {
868
- name: 'internalNotes',
869
- type: 'textarea',
870
- access: {
871
- read: isAdminField(), // Only admins can read this field
872
- },
873
- },
874
- ],
875
- }
876
- ```
877
-
878
- ### Self-Access Patterns
879
-
880
- ```typescript
881
- import { isAdminOrSelf, canUpdateOwnFields } from '@delmaredigital/payload-better-auth'
882
-
883
- export const Users: CollectionConfig = {
884
- slug: 'users',
885
- access: {
886
- // Admins can read all; users can only read themselves
887
- read: isAdminOrSelf({ adminRoles: ['admin'] }),
888
-
889
- // Users can update only specific fields on their own profile
890
- update: canUpdateOwnFields({
891
- allowedFields: ['name', 'image', 'password'],
892
- userSlug: 'users',
893
- requireCurrentPassword: true, // Require currentPassword for password changes
894
- }),
895
-
896
- // Only admins can delete
897
- delete: isAdmin({ adminRoles: ['admin'] }),
898
- },
899
- // ...
900
- }
901
- ```
902
-
903
- ### Utility Functions
904
-
905
- ```typescript
906
- import { normalizeRoles, hasAnyRole, hasAllRoles, hasAdminRoles } from '@delmaredigital/payload-better-auth'
907
-
908
- // Normalize role field (handles string, array, or comma-separated)
909
- const roles = normalizeRoles(user.role) // ['admin', 'editor']
910
-
911
- // Check role membership
912
- hasAnyRole(user, ['admin', 'editor']) // true if user has any
913
- hasAllRoles(user, ['admin', 'editor']) // true if user has all
914
- hasAdminRoles(user, { adminRoles: ['admin', 'super-admin'] }) // true if admin
915
- ```
916
-
917
- ---
918
-
919
- ## API Key Scope Enforcement
920
-
921
- Enforce API key scopes in your Payload access control. API keys can have granular permission scopes that control what resources they can access.
922
-
923
- ### Basic Usage
924
-
925
- ```typescript
926
- import { requireScope, requireAnyScope, requireAllScopes } from '@delmaredigital/payload-better-auth'
927
-
928
- export const Posts: CollectionConfig = {
929
- slug: 'posts',
930
- access: {
931
- read: requireScope('posts:read'),
932
- create: requireScope('posts:write'),
933
- update: requireScope('posts:write'),
934
- delete: requireAllScopes(['posts:delete', 'admin:write']), // Must have both
935
- },
936
- }
937
- ```
938
-
939
- ### Wildcard Scopes
940
-
941
- ```typescript
942
- // API key with scope 'posts:*' matches 'posts:read', 'posts:write', 'posts:delete'
943
- // API key with scope '*' matches everything
944
- ```
945
-
946
- ### Allow Both Session and API Key
947
-
948
- ```typescript
949
- import { allowSessionOrScope, allowSessionOrAnyScope } from '@delmaredigital/payload-better-auth'
950
-
951
- export const Posts: CollectionConfig = {
952
- slug: 'posts',
953
- access: {
954
- // Allow authenticated users OR API keys with the scope
955
- read: allowSessionOrScope('posts:read'),
956
- create: allowSessionOrAnyScope(['posts:write', 'content:manage']),
957
- },
958
- }
959
- ```
960
-
961
- ### Manual Validation
962
-
963
- ```typescript
964
- import { validateApiKey, extractApiKeyFromRequest, hasScope } from '@delmaredigital/payload-better-auth'
965
-
966
- // In a custom endpoint
967
- async function myEndpoint({ req }) {
968
- const keyInfo = await validateApiKey(req)
969
-
970
- if (!keyInfo) {
971
- return Response.json({ error: 'Invalid API key' }, { status: 401 })
972
- }
973
-
974
- // Check specific scope
975
- if (!hasScope(keyInfo.scopes, 'custom:action')) {
976
- return Response.json({ error: 'Insufficient permissions' }, { status: 403 })
977
- }
978
-
979
- // keyInfo contains: { id, userId, scopes, keyPrefix, metadata }
980
- return Response.json({ userId: keyInfo.userId })
981
- }
982
- ```
983
-
984
- ### Configuration Options
985
-
986
- ```typescript
987
- requireScope('posts:read', {
988
- apiKeysCollection: 'apikeys', // Collection slug (auto-detected)
989
- allowAuthenticatedUsers: false, // Also allow session auth
990
- extractApiKey: (req) => { ... }, // Custom extraction function
991
- })
992
- ```
993
-
994
- ---
995
-
996
- ## Plugin Compatibility
997
-
998
- The adapter uses Better Auth's `createAdapterFactory` which is **schema-aware** - it automatically supports all Better Auth plugins without additional configuration. Just install the plugin, add it to your config, and our adapter handles the rest.
999
-
1000
- ### How It Works
1001
-
1002
- 1. **You install** the plugin package (if separate from core)
1003
- 2. **You configure** the plugin in Better Auth options
1004
- 3. **Our adapter automatically**:
1005
- - Creates the necessary collections via `betterAuthCollections()`
1006
- - Handles all CRUD operations via schema-aware transformations
1007
- - No plugin-specific adapter configuration needed
1008
-
1009
- ### Supported Plugins
1010
-
1011
- | Plugin | Package | Notes |
1012
- |--------|---------|-------|
1013
- | OAuth | `better-auth` (core) | Uses accounts collection |
1014
- | Magic Link | `better-auth` (core) | Uses verifications collection |
1015
- | Email Verification | `better-auth` (core) | Uses verifications collection |
1016
- | Email OTP | `better-auth` (core) | Uses verifications collection |
1017
- | Password Reset | `better-auth` (core) | Uses verifications collection |
1018
- | Two-Factor (TOTP) | `better-auth` (core) | Auto-generates twoFactors collection |
1019
- | API Keys | `better-auth` (core) | Auto-generates apikeys collection |
1020
- | Organizations | `better-auth` (core) | Auto-generates organizations, members, invitations |
1021
- | Admin | `better-auth` (core) | Adds admin fields to users |
1022
- | Passkey | `@better-auth/passkey` (peer dep) | Auto-generates passkeys collection |
1023
-
1024
- ### Example: Core Plugins
1025
-
1026
- Core plugins are included in `better-auth`:
1027
-
1028
- ```typescript
1029
- import { twoFactor, organization, admin } from 'better-auth/plugins'
1030
- import { apiKeyWithDefaults } from '@delmaredigital/payload-better-auth'
1031
-
1032
- betterAuth({
1033
- database: payloadAdapter({ payloadClient: payload }),
1034
- plugins: [
1035
- twoFactor(),
1036
- apiKeyWithDefaults(), // Use this instead of apiKey() for better admin UI support
1037
- organization(),
1038
- admin(),
1039
- ],
1040
- })
1041
- ```
1042
-
1043
- ### Adding Join Fields for Relationships
1044
-
1045
- Some plugins create related data (e.g., user's API keys). To query these relationships from the parent, add join fields:
1046
-
1047
- <details>
1048
- <summary><strong>Why Join Fields?</strong></summary>
1049
-
1050
- Payload uses `join` fields to establish queryable relationships from parent to child. Without them, you can still query the child collection directly (e.g., find API keys by userId), but you can't include them when fetching the parent (e.g., get user with their API keys).
1051
-
1052
- </details>
1053
-
1054
- ### Enabling Plugins That Need Joins
1055
-
1056
- Some Better Auth plugins expect to access related data via joins (e.g., `user.apiKeys`). Payload handles this via `join` fields. Below are the patterns for each plugin.
1057
-
1058
- <details>
1059
- <summary><strong>API Keys</strong></summary>
1060
-
1061
- The API Keys plugin creates an `apiKey` model with a `userId` reference.
1062
-
1063
- **1. Add to your Better Auth config:**
1064
-
1065
- ```ts
1066
- import { apiKeyWithDefaults } from '@delmaredigital/payload-better-auth'
1067
-
1068
- export const betterAuthOptions: Partial<BetterAuthOptions> = {
1069
- // ... existing config
1070
- plugins: [apiKeyWithDefaults()], // Enables metadata for scope display in admin UI
1071
- }
1072
- ```
1073
-
1074
- > **Note:** `apiKeyWithDefaults()` wraps Better Auth's `apiKey()` plugin with `enableMetadata: true` so that selected scopes are stored and displayed in the admin UI. You can still use the raw `apiKey()` from `better-auth/plugins` if you don't need this feature.
1075
-
1076
- **2. Add join field to your Users collection:**
1077
-
1078
- ```ts
1079
- // src/collections/Users.ts
1080
- export const Users: CollectionConfig = {
1081
- slug: 'users',
1082
- // ... existing config
1083
- fields: [
1084
- // ... existing fields
1085
- {
1086
- name: 'apiKeys',
1087
- type: 'join',
1088
- collection: 'apikeys', // Auto-generated collection (lowercase)
1089
- on: 'user', // The field in apikeys that references users
1090
- },
1091
- ],
1092
- }
1093
- ```
1094
-
1095
- **3. (Optional) Configure permission scopes:**
1096
-
1097
- The API Keys management UI (`/admin/security/api-keys`) lets users select permission scopes when creating keys. By default, scopes are auto-generated from your Payload collections. See [API Key Scopes Configuration](#api-key-scopes-configuration) for customization options.
1098
- </details>
1099
-
1100
- <details>
1101
- <summary><strong>Two-Factor Auth (TOTP)</strong></summary>
1102
-
1103
- The Two-Factor plugin creates a `twoFactor` model and adds a `twoFactorEnabled` field to users.
1104
-
1105
- **1. Add to your Better Auth config:**
1106
-
1107
- ```ts
1108
- import { twoFactor } from 'better-auth/plugins'
1109
-
1110
- export const betterAuthOptions: Partial<BetterAuthOptions> = {
1111
- // ... existing config
1112
- plugins: [twoFactor()],
1113
- }
1114
- ```
1115
-
1116
- **2. (Automatic) The `twoFactorEnabled` field is auto-added:**
1117
-
1118
- The `betterAuthCollections()` plugin automatically adds `twoFactorEnabled` to your Users collection. You'll see:
1119
- ```
1120
- [better-auth] Auto-adding fields to 'users': ['twoFactorEnabled']
1121
- ```
1122
-
1123
- **3. (Optional) Add join field for querying user's 2FA records:**
1124
-
1125
- ```ts
1126
- // src/collections/Users.ts
1127
- export const Users: CollectionConfig = {
1128
- slug: 'users',
1129
- fields: [
1130
- // ... existing fields
1131
- {
1132
- name: 'twoFactor',
1133
- type: 'join',
1134
- collection: 'twoFactors',
1135
- on: 'user',
1136
- },
1137
- ],
1138
- }
1139
- ```
1140
-
1141
- **4. (Optional) Add UI components:**
1142
-
1143
- See [Two-Factor Authentication Flow](#two-factor-authentication-flow) for pre-built setup and verification components.
1144
- </details>
1145
-
1146
- <details>
1147
- <summary><strong>Organizations</strong></summary>
1148
-
1149
- The Organizations plugin creates multiple models: `organization`, `member`, and `invitation`.
1150
-
1151
- **1. Add to your Better Auth config:**
1152
-
1153
- ```ts
1154
- import { organization } from 'better-auth/plugins'
1155
-
1156
- export const betterAuthOptions: Partial<BetterAuthOptions> = {
1157
- // ... existing config
1158
- plugins: [organization()],
1159
- }
1160
- ```
1161
-
1162
- **2. Add join field to your Users collection:**
1163
-
1164
- ```ts
1165
- // src/collections/Users.ts
1166
- export const Users: CollectionConfig = {
1167
- slug: 'users',
1168
- fields: [
1169
- // ... existing fields
1170
- {
1171
- name: 'memberships',
1172
- type: 'join',
1173
- collection: 'members', // Auto-generated collection
1174
- on: 'user',
1175
- },
1176
- ],
1177
- }
1178
- ```
1179
-
1180
- **3. (Optional) Customize the Organizations collection:**
1181
-
1182
- ```ts
1183
- betterAuthCollections({
1184
- betterAuthOptions,
1185
- customizeCollection: (modelKey, collection) => {
1186
- if (modelKey === 'organization') {
1187
- return {
1188
- ...collection,
1189
- fields: [
1190
- ...collection.fields,
1191
- {
1192
- name: 'members',
1193
- type: 'join',
1194
- collection: 'members',
1195
- on: 'organization',
1196
- },
1197
- {
1198
- name: 'invitations',
1199
- type: 'join',
1200
- collection: 'invitations',
1201
- on: 'organization',
1202
- },
1203
- ],
1204
- }
1205
- }
1206
- return collection
1207
- },
1208
- })
1209
- ```
1210
- </details>
1211
-
1212
- ### General Pattern for Joins
1213
-
1214
- When a Better Auth plugin creates a model with a foreign key (e.g., `userId`, `organizationId`), you need to:
1215
-
1216
- 1. **Add a join field** to the parent collection pointing to the child collection
1217
- 2. **Specify the `on` field** - this is the relationship field name in the child collection (without `Id` suffix)
1218
-
1219
- The auto-generated collections create relationship fields like `user` (from `userId`), so your join's `on` property should match that field name.
1220
-
1221
- **Collection Slug Casing:** Collection slugs are derived from Better Auth's model names (pluralized). Some plugins use lowercase (`apikey` → `apikeys`, `passkey` → `passkeys`) while others use camelCase (`twoFactor` → `twoFactors`). Always check the actual slug in your Payload admin panel if unsure.
1222
-
1223
- ### Cascade Delete (Cleanup Orphaned Records)
1224
-
1225
- When a user is deleted, their related records (sessions, accounts, API keys, passkeys, etc.) become orphaned. Better Auth provides an `afterDelete` hook for cleanup:
1226
-
1227
- ```typescript
1228
- // src/lib/auth/index.ts
1229
- import { betterAuth } from 'better-auth'
1230
- import { payloadAdapter } from '@delmaredigital/payload-better-auth'
1231
-
1232
- export const auth = betterAuth({
1233
- database: payloadAdapter({ payloadClient: payload }),
1234
- user: {
1235
- deleteUser: {
1236
- enabled: true,
1237
- afterDelete: async (user) => {
1238
- // Clean up all related records
1239
- const collections = ['sessions', 'accounts', 'apikeys', 'passkeys', 'twoFactors']
1240
-
1241
- for (const collection of collections) {
1242
- try {
1243
- await payload.delete({
1244
- collection,
1245
- where: { user: { equals: user.id } },
1246
- })
1247
- } catch {
1248
- // Collection may not exist if plugin not enabled
1249
- }
1250
- }
1251
- },
1252
- },
1253
- },
1254
- // ... other options
1255
- })
1256
- ```
1257
-
1258
- **Note:** This is optional. Orphaned records don't cause errors (Payload doesn't enforce foreign key constraints), but cleanup keeps your database tidy.
1259
-
1260
- ---
1261
-
1262
- ## Additional UI Components
1263
-
1264
- ### User Registration
1265
-
1266
- The `LoginView` **automatically detects** if user registration is available by checking Better Auth's sign-up endpoint. If your Better Auth config has `emailAndPassword.enabled: true` (and not `disableSignUp: true`), the "Create account" link appears automatically.
1267
-
1268
- **No configuration needed** for most cases - it just works based on your Better Auth settings.
1269
-
1270
- **Optional overrides** (only if you need to force show/hide):
1271
- ```typescript
1272
- createBetterAuthPlugin({
1273
- createAuth,
1274
- admin: {
1275
- login: {
1276
- // These options override auto-detection (usually not needed)
1277
- enableSignUp: 'auto', // 'auto' (default) | true | false
1278
- defaultSignUpRole: 'user', // Role assigned to new users (default: 'user')
1279
- },
1280
- },
1281
- })
1282
- ```
1283
-
1284
- **Notes:**
1285
- - `'auto'` (default): Detects availability from Better Auth's sign-up endpoint
1286
- - `true`: Always show registration (even if Better Auth returns 404)
1287
- - `false`: Never show registration
1288
- - New users are assigned `defaultSignUpRole` (default: `'user'`)
1289
- - If email verification is required, users see a success message to check their email
1290
- - Role-based access control still applies - users without `requiredRole` see "Access Denied"
1291
-
1292
- ### Password Reset Flow
1293
-
1294
- The `LoginView` **automatically detects** if password reset is available by checking Better Auth's reset endpoint. The "Forgot password?" link appears automatically when available.
1295
-
1296
- **No configuration needed** for most cases - it just works based on your Better Auth settings.
1297
-
1298
- **Optional overrides** (only if you need to force show/hide or use a custom URL):
1299
- ```typescript
1300
- createBetterAuthPlugin({
1301
- createAuth,
1302
- admin: {
1303
- login: {
1304
- // These options override auto-detection (usually not needed)
1305
- enableForgotPassword: 'auto', // 'auto' (default) | true | false
1306
- resetPasswordUrl: '/custom-reset', // Optional: redirect to custom page instead of inline form
1307
- },
1308
- },
1309
- })
1310
- ```
1311
-
1312
- #### Standalone Components
1313
-
1314
- For custom password reset pages outside the admin panel:
1315
-
1316
- ```typescript
1317
- import { ForgotPasswordView, ResetPasswordView } from '@delmaredigital/payload-better-auth/components/auth'
1318
- ```
1319
-
1320
- **ForgotPasswordView** - Email input form to request a password reset link.
1321
-
1322
- ```tsx
1323
- <ForgotPasswordView
1324
- logo={<MyLogo />}
1325
- title="Forgot Password"
1326
- loginPath="/admin/login"
1327
- successMessage="Check your email for a reset link."
1328
- />
1329
- ```
1330
-
1331
- **ResetPasswordView** - Form to set a new password (expects `?token=` in URL).
1332
-
1333
- ```tsx
1334
- <ResetPasswordView
1335
- logo={<MyLogo />}
1336
- title="Reset Password"
1337
- afterResetPath="/admin/login"
1338
- minPasswordLength={8}
1339
- />
1340
- ```
1341
-
1342
- ### Two-Factor Authentication Flow
1343
-
1344
- The plugin's `LoginView` **automatically handles 2FA verification inline**. When a user with 2FA enabled signs in:
1345
-
1346
- 1. User enters email/password and submits
1347
- 2. If 2FA is enabled, the form transitions to a TOTP code input step
1348
- 3. User enters their 6-digit code from their authenticator app
1349
- 4. Upon successful verification, the user is redirected to the admin panel
1350
-
1351
- **No additional configuration required** - the LoginView handles the full flow automatically.
1352
-
1353
- #### Standalone Components for Custom Flows
1354
-
1355
- For custom frontend implementations (outside the admin panel), use these components:
1356
-
1357
- ```typescript
1358
- import { TwoFactorSetupView, TwoFactorVerifyView } from '@delmaredigital/payload-better-auth/components/twoFactor'
1359
- ```
1360
-
1361
- **TwoFactorSetupView** - QR code display, manual secret entry, backup codes, and verification.
1362
-
1363
- ```tsx
1364
- <TwoFactorSetupView
1365
- logo={<MyLogo />}
1366
- title="Set Up Two-Factor Authentication"
1367
- afterSetupPath="/admin"
1368
- onSetupComplete={() => console.log('2FA enabled!')}
1369
- />
1370
- ```
1371
-
1372
- **TwoFactorVerifyView** - TOTP code or backup code entry during login.
1373
-
1374
- ```tsx
1375
- <TwoFactorVerifyView
1376
- logo={<MyLogo />}
1377
- title="Two-Factor Authentication"
1378
- afterVerifyPath="/admin"
1379
- onVerifyComplete={() => console.log('Verified!')}
1380
- />
1381
- ```
1382
-
1383
- All components use Payload CSS variables for native theme integration (light/dark mode).
1384
-
1385
- #### Handling 2FA in Custom Login Forms
1386
-
1387
- **Important:** If you have a custom login form (outside the admin panel), you **must** check for `twoFactorRedirect` in the sign-in response. Without this check, users with 2FA enabled will appear to log in but won't actually be authenticated.
1388
-
1389
- ```typescript
1390
- // src/lib/auth/client.ts
1391
- 'use client'
1392
-
1393
- import { createPayloadAuthClient } from '@delmaredigital/payload-better-auth/client'
1394
-
1395
- // Pre-configured with twoFactor, apiKey, and passkey plugins
1396
- export const authClient = createPayloadAuthClient()
1397
-
1398
- export const { signIn, signUp, signOut, twoFactor } = authClient
1399
- ```
1400
-
1401
- ```tsx
1402
- // src/components/auth/login-form.tsx
1403
- 'use client'
1404
-
1405
- import { useState } from 'react'
1406
- import { useRouter } from 'next/navigation'
1407
- import { signIn, twoFactor } from '@/lib/auth/client'
1408
-
1409
- export function LoginForm() {
1410
- const router = useRouter()
1411
- const [email, setEmail] = useState('')
1412
- const [password, setPassword] = useState('')
1413
- const [totpCode, setTotpCode] = useState('')
1414
- const [twoFactorRequired, setTwoFactorRequired] = useState(false)
1415
- const [error, setError] = useState<string | null>(null)
1416
- const [loading, setLoading] = useState(false)
1417
-
1418
- async function handleLogin(e: React.FormEvent) {
1419
- e.preventDefault()
1420
- setError(null)
1421
- setLoading(true)
1422
-
1423
- try {
1424
- const result = await signIn.email({ email, password })
1425
-
1426
- if (result.error) {
1427
- setError(result.error.message || 'Failed to sign in')
1428
- return
1429
- }
1430
-
1431
- // IMPORTANT: Check if 2FA is required
1432
- if (result.data?.twoFactorRedirect) {
1433
- setTwoFactorRequired(true)
1434
- return
1435
- }
1436
-
1437
- // Success - redirect to dashboard
1438
- router.push('/dashboard')
1439
- router.refresh()
1440
- } catch {
1441
- setError('An unexpected error occurred')
1442
- } finally {
1443
- setLoading(false)
1444
- }
1445
- }
1446
-
1447
- async function handleTotpVerify(e: React.FormEvent) {
1448
- e.preventDefault()
1449
- setError(null)
1450
- setLoading(true)
1451
-
1452
- try {
1453
- const result = await twoFactor.verifyTotp({ code: totpCode })
1454
-
1455
- if (result.error) {
1456
- setError(result.error.message || 'Invalid verification code')
1457
- return
1458
- }
1459
-
1460
- // Success - redirect to dashboard
1461
- router.push('/dashboard')
1462
- router.refresh()
1463
- } catch {
1464
- setError('An unexpected error occurred')
1465
- } finally {
1466
- setLoading(false)
1467
- }
1468
- }
1469
-
1470
- // Show TOTP verification form
1471
- if (twoFactorRequired) {
1472
- return (
1473
- <form onSubmit={handleTotpVerify}>
1474
- <h2>Two-Factor Authentication</h2>
1475
- <p>Enter the 6-digit code from your authenticator app</p>
1476
- <input
1477
- type="text"
1478
- inputMode="numeric"
1479
- pattern="[0-9]*"
1480
- value={totpCode}
1481
- onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
1482
- placeholder="000000"
1483
- autoComplete="one-time-code"
1484
- />
1485
- {error && <div className="error">{error}</div>}
1486
- <button type="submit" disabled={loading || totpCode.length !== 6}>
1487
- {loading ? 'Verifying...' : 'Verify'}
1488
- </button>
1489
- <button type="button" onClick={() => setTwoFactorRequired(false)}>
1490
- Back to login
1491
- </button>
1492
- </form>
1493
- )
1494
- }
1495
-
1496
- // Show login form
1497
- return (
1498
- <form onSubmit={handleLogin}>
1499
- <input
1500
- type="email"
1501
- value={email}
1502
- onChange={(e) => setEmail(e.target.value)}
1503
- placeholder="Email"
1504
- required
1505
- />
1506
- <input
1507
- type="password"
1508
- value={password}
1509
- onChange={(e) => setPassword(e.target.value)}
1510
- placeholder="Password"
1511
- required
1512
- />
1513
- {error && <div className="error">{error}</div>}
1514
- <button type="submit" disabled={loading}>
1515
- {loading ? 'Signing in...' : 'Sign In'}
1516
- </button>
1517
- </form>
1518
- )
1519
- }
1520
- ```
1521
-
1522
- **Key Points:**
1523
- - Always check `result.data?.twoFactorRedirect` after `signIn.email()`
1524
- - When 2FA is required, the sign-in sets a temporary cookie - show the TOTP form immediately
1525
- - Use `twoFactor.verifyTotp({ code })` to complete authentication
1526
- - The TOTP cookie is session-scoped, so the user must complete verification in the same browser session
1527
-
1528
- ### Passkey Sign-In
1529
-
1530
- Enable passwordless authentication using WebAuthn passkeys.
1531
-
1532
- **Option 1: Enable in LoginView**
1533
-
1534
- ```typescript
1535
- createBetterAuthPlugin({
1536
- createAuth,
1537
- admin: {
1538
- login: {
1539
- enablePasskey: true, // Shows "Sign in with Passkey" button
1540
- },
1541
- },
1542
- })
1543
- ```
1544
-
1545
- **Option 2: Standalone Button for Custom Forms**
1546
-
1547
- ```typescript
1548
- import { PasskeySignInButton } from '@delmaredigital/payload-better-auth/components'
1549
-
1550
- function CustomLoginForm() {
1551
- return (
1552
- <div>
1553
- {/* Your email/password form */}
1554
-
1555
- <PasskeySignInButton
1556
- onSuccess={(user) => {
1557
- router.push('/dashboard')
1558
- }}
1559
- onError={(error) => {
1560
- setError(error)
1561
- }}
1562
- label="Sign in with Passkey"
1563
- loadingLabel="Authenticating..."
1564
- className="my-button-class" // Accepts all button props
1565
- />
1566
- </div>
1567
- )
1568
- }
1569
- ```
1570
-
1571
- The `PasskeySignInButton` handles the full WebAuthn authentication flow with Better Auth.
1572
-
1573
- ### Passkey Registration
1574
-
1575
- For registering new passkeys (e.g., in account security settings):
1576
-
1577
- **Bundled Component:**
1578
-
1579
- ```typescript
1580
- import { PasskeyRegisterButton } from '@delmaredigital/payload-better-auth/components'
1581
-
1582
- function SecuritySettings() {
1583
- return (
1584
- <PasskeyRegisterButton
1585
- passkeyName="My MacBook"
1586
- onSuccess={(passkey) => {
1587
- console.log('Passkey registered:', passkey.id)
1588
- refetchPasskeys()
1589
- }}
1590
- onError={(error) => {
1591
- setError(error)
1592
- }}
1593
- label="Add Passkey"
1594
- loadingLabel="Registering..."
1595
- />
1596
- )
1597
- }
1598
- ```
1599
-
1600
- **Using the Auth Client Directly:**
1601
-
1602
- ```typescript
1603
- import { createPayloadAuthClient } from '@delmaredigital/payload-better-auth/client'
1604
-
1605
- // Already includes passkeyClient plugin
1606
- const authClient = createPayloadAuthClient()
1607
-
1608
- // Register a new passkey
1609
- await authClient.passkey.addPasskey({ name: 'My Device' })
1610
-
1611
- // List user's passkeys
1612
- const { data: passkeys } = await authClient.passkey.listUserPasskeys()
1613
-
1614
- // Delete a passkey
1615
- await authClient.passkey.deletePasskey({ id: passkeyId })
1616
- ```
1617
-
1618
- **Full Passkey Management Component:**
1619
-
1620
- For building custom passkey management UIs, you can use the bundled management client:
1621
-
1622
- ```typescript
1623
- import { PasskeysManagementClient } from '@delmaredigital/payload-better-auth/management'
1624
-
1625
- function PasskeysSettingsPage() {
1626
- return <PasskeysManagementClient title="Manage Passkeys" />
1627
- }
1628
- ```
1629
-
1630
- This component provides a complete UI for listing, registering, and deleting passkeys, using Payload CSS variables for theme integration.
1631
-
1632
- ### Security Management UI
1633
-
1634
- The plugin auto-injects management views for security features based on which Better Auth plugins are enabled:
1635
-
1636
- | View | Path | Plugin Required |
1637
- |------|------|-----------------|
1638
- | Two-Factor Auth | `/admin/security/two-factor` | `twoFactor()` |
1639
- | API Keys | `/admin/security/api-keys` | `apiKey()` |
1640
- | Passkeys | `/admin/security/passkeys` | `passkey()` |
1641
-
1642
- A "Security" navigation section is added to the admin sidebar.
1643
-
1644
- **Configuration:**
1645
-
1646
- ```typescript
1647
- createBetterAuthPlugin({
1648
- createAuth,
1649
- admin: {
1650
- betterAuthOptions, // Required for plugin detection
1651
- enableManagementUI: true, // Default: true
1652
- },
1653
- })
1654
- ```
1655
-
1656
- **Note:** Sessions are managed via Payload's default collection view at `/admin/collections/sessions`.
1657
-
1658
- ---
1659
-
1660
- ## Recipes
1661
-
1662
- Common patterns and solutions for Better Auth integration.
1663
-
1664
- ### Auto-Create Organization on User Signup
1665
-
1666
- A common pattern is to automatically create a personal workspace/organization when a user signs up (and verifies their email). The key is to use Better Auth's organization API (`auth.api.createOrganization()`) rather than raw adapter calls, so that `organizationHooks` fire properly.
1667
-
1668
- **The Challenge:** Database hooks are defined in `betterAuthOptions` before the `auth` instance exists, so you can't directly reference `auth.api` in your hooks.
1669
-
1670
- **The Solution:** Use a lazy auth instance singleton:
1671
-
1672
- **Step 1: Create an auth instance singleton**
1673
-
1674
- ```typescript
1675
- // src/lib/auth/instance.ts
1676
- import type { betterAuth } from 'better-auth'
1677
-
1678
- type AuthInstance = ReturnType<typeof betterAuth>
1679
-
1680
- let authInstance: AuthInstance | null = null
1681
-
1682
- export function setAuthInstance(auth: AuthInstance): void {
1683
- authInstance = auth
1684
- }
1685
-
1686
- export function getAuthInstance(): AuthInstance {
1687
- if (!authInstance) {
1688
- throw new Error('Auth not initialized')
1689
- }
1690
- return authInstance
1691
- }
1692
- ```
1693
-
1694
- **Step 2: Store the instance after creation**
1695
-
1696
- ```typescript
1697
- // src/payload.config.ts (or wherever you configure plugins)
1698
- import { setAuthInstance } from '@/lib/auth/instance'
1699
-
1700
- createBetterAuthPlugin({
1701
- createAuth: (payload) => {
1702
- const auth = betterAuth({
1703
- ...betterAuthOptions,
1704
- database: payloadAdapter({ payloadClient: payload }),
1705
- // ... other options
1706
- })
1707
-
1708
- // Store for use in database hooks
1709
- setAuthInstance(auth)
1710
-
1711
- return auth
1712
- },
1713
- })
1714
- ```
1715
-
1716
- **Step 3: Use the organization API in database hooks**
1717
-
1718
- ```typescript
1719
- // src/lib/auth/config.ts
1720
- import { getAuthInstance } from './instance'
1721
-
1722
- export const betterAuthOptions: Partial<BetterAuthOptions> = {
1723
- // ... other options
1724
-
1725
- plugins: [
1726
- organization({
1727
- // organizationHooks fire when using auth.api.createOrganization()
1728
- organizationHooks: {
1729
- afterCreateOrganization: async ({ organization }) => {
1730
- // This runs for ALL org creations - auto-created, manual, API
1731
- console.log(`Organization ${organization.id} created`)
1732
- // Create related records, send welcome email, etc.
1733
- },
1734
- },
1735
- }),
1736
- ],
1737
-
1738
- databaseHooks: {
1739
- user: {
1740
- update: {
1741
- after: async (user, ctx) => {
1742
- // Only proceed if user just verified their email
1743
- if (!user.emailVerified) return
1744
-
1745
- // Check if user already has an organization (e.g., joined via invitation)
1746
- const existingMembership = await ctx?.context?.adapter?.findOne({
1747
- model: 'member',
1748
- where: [{ field: 'userId', value: user.id }],
1749
- })
1750
- if (existingMembership) return
1751
-
1752
- // Create organization using the proper API
1753
- // This ensures organizationHooks.afterCreateOrganization fires
1754
- const auth = getAuthInstance()
1755
- await auth.api.createOrganization({
1756
- body: {
1757
- name: `${user.name}'s Workspace`,
1758
- slug: generateUniqueSlug(user.name),
1759
- userId: user.id,
1760
- },
1761
- })
1762
- },
1763
- },
1764
- },
1765
- },
1766
- }
1767
- ```
1768
-
1769
- **Why This Matters:**
1770
-
1771
- | Approach | organizationHooks fire? | Recommended? |
1772
- |----------|------------------------|--------------|
1773
- | `auth.api.createOrganization()` | ✅ Yes | ✅ Yes |
1774
- | `ctx.context.adapter.create({ model: 'organization' })` | ❌ No | ❌ No |
1775
-
1776
- Using the raw adapter bypasses Better Auth's organization plugin entirely. Any logic in `organizationHooks` (like creating related records, sending notifications, or syncing with external systems) won't run.
1777
-
1778
- **Handling Invitations:**
1779
-
1780
- Users who join via invitation already have a membership record (created when they accept the invitation), so the `existingMembership` check prevents creating a duplicate personal organization for them.
1781
-
1782
- ---
1783
-
1784
- ## Types
1785
-
1786
- The package exports comprehensive TypeScript types for Better Auth integration.
1787
-
1788
- ### Core Types
1789
-
1790
- ```typescript
1791
- import type {
1792
- PayloadWithAuth,
1793
- PayloadRequestWithBetterAuth,
1794
- BetterAuthReturn,
1795
- } from '@delmaredigital/payload-better-auth'
1796
-
1797
- // PayloadWithAuth - Payload instance with betterAuth attached
1798
- const payload = await getPayload({ config }) as PayloadWithAuth
1799
- const session = await payload.betterAuth.api.getSession({ headers })
1800
-
1801
- // PayloadRequestWithBetterAuth - Request type with typed payload
1802
- function myHook({ req }: { req: PayloadRequestWithBetterAuth }) {
1803
- const auth = req.payload.betterAuth
1804
- }
1805
- ```
1806
-
1807
- ### Hook and Endpoint Types
1808
-
1809
- ```typescript
1810
- import type {
1811
- CollectionHookWithBetterAuth,
1812
- EndpointWithBetterAuth,
1813
- } from '@delmaredigital/payload-better-auth'
1814
-
1815
- // Typed collection hooks with Better Auth access
1816
- const afterChangeHook: CollectionHookWithBetterAuth<typeof authOptions, MyDoc> = async ({
1817
- req,
1818
- doc,
1819
- }) => {
1820
- const session = await req.payload.betterAuth.api.getSession({ headers: req.headers })
1821
- // ...
1822
- return doc
1823
- }
1824
-
1825
- // Typed endpoints
1826
- const myEndpoint: EndpointWithBetterAuth<typeof authOptions> = {
1827
- path: '/custom',
1828
- method: 'get',
1829
- handler: async ({ req }) => {
1830
- const auth = req.payload.betterAuth
1831
- // ...
1832
- },
1833
- }
1834
- ```
1835
-
1836
- ### Generated Schema Types
1837
-
1838
- Auto-generated types for all Better Auth models:
1839
-
1840
- ```typescript
1841
- import type {
1842
- User,
1843
- BetterAuthSession,
1844
- Account,
1845
- Apikey,
1846
- Passkey,
1847
- Organization,
1848
- Member,
1849
- TwoFactor,
1850
- // ... and more
1851
- } from '@delmaredigital/payload-better-auth'
1852
- ```
1853
-
1854
- ### Type Generation
1855
-
1856
- Regenerate types from Better Auth schema (useful after adding plugins):
1857
-
1858
- ```bash
1859
- pnpm generate:types
1860
- ```
1861
-
1862
- ---
168
+ For MongoDB setup, API reference, customization, access control helpers, API key scopes, plugin compatibility, UI components (2FA, passkeys, password reset), recipes, and types — see the **[full documentation](https://delmaredigital.github.io/payload-better-auth/)**.
1863
169
 
1864
170
  ## License
1865
171