@delmaredigital/payload-better-auth 0.1.0

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 ADDED
@@ -0,0 +1,734 @@
1
+ # @delmaredigital/payload-better-auth
2
+
3
+ Better Auth adapter and plugins for Payload CMS. Enables seamless integration between Better Auth and Payload.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Installation](#installation)
10
+ - [Quick Start](#quick-start)
11
+ - [API Reference](#api-reference)
12
+ - [Admin Panel Integration](#admin-panel-integration)
13
+ - [Plugin Compatibility](#plugin-compatibility)
14
+ - [License](#license)
15
+
16
+ ---
17
+
18
+ ## Installation
19
+
20
+ ### Requirements
21
+
22
+ | Dependency | Version |
23
+ |------------|---------|
24
+ | `better-auth` | >= 1.0.0 |
25
+ | `payload` | >= 3.0.0 |
26
+ | `next` | >= 15.4.0 |
27
+ | `react` | >= 18.0.0 |
28
+
29
+ ### Install
30
+
31
+ ```bash
32
+ pnpm add @delmaredigital/payload-better-auth better-auth
33
+ ```
34
+
35
+ Or install from GitHub:
36
+
37
+ ```bash
38
+ pnpm add github:delmaredigital/payload-better-auth
39
+ ```
40
+
41
+ ### Local Development
42
+
43
+ For local development with hot reloading:
44
+
45
+ ```bash
46
+ # In the package directory
47
+ cd path/to/payload-better-auth
48
+ pnpm link --global
49
+
50
+ # In your project
51
+ pnpm link --global @delmaredigital/payload-better-auth
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Quick Start
57
+
58
+ ### Step 1: Create Your Auth Configuration
59
+
60
+ ```ts
61
+ // src/lib/auth/config.ts
62
+ import type { BetterAuthOptions } from 'better-auth'
63
+
64
+ export const betterAuthOptions: Partial<BetterAuthOptions> = {
65
+ user: {
66
+ modelName: 'users',
67
+ additionalFields: {
68
+ role: { type: 'string', defaultValue: 'user' },
69
+ },
70
+ },
71
+ session: {
72
+ modelName: 'sessions',
73
+ expiresIn: 60 * 60 * 24 * 30, // 30 days
74
+ },
75
+ account: { modelName: 'accounts' },
76
+ verification: { modelName: 'verifications' },
77
+ emailAndPassword: { enabled: true },
78
+ }
79
+
80
+ export const collectionSlugs = {
81
+ user: 'users',
82
+ session: 'sessions',
83
+ account: 'accounts',
84
+ verification: 'verifications',
85
+ } as const
86
+ ```
87
+
88
+ ### Step 2: Create the Auth Instance Factory
89
+
90
+ ```ts
91
+ // src/lib/auth/index.ts
92
+ import { betterAuth } from 'better-auth'
93
+ import type { BasePayload } from 'payload'
94
+ import { payloadAdapter } from '@delmaredigital/payload-better-auth'
95
+ import { betterAuthOptions, collectionSlugs } from './config'
96
+
97
+ export function createAuth(payload: BasePayload) {
98
+ return betterAuth({
99
+ ...betterAuthOptions,
100
+ database: payloadAdapter({
101
+ payloadClient: payload,
102
+ adapterConfig: {
103
+ collections: collectionSlugs,
104
+ enableDebugLogs: process.env.NODE_ENV === 'development',
105
+ idType: 'number', // Use Payload's default SERIAL IDs
106
+ },
107
+ }),
108
+ // Use serial/integer IDs (Payload default) instead of UUID
109
+ advanced: {
110
+ database: {
111
+ generateId: 'serial',
112
+ },
113
+ },
114
+ secret: process.env.BETTER_AUTH_SECRET,
115
+ trustedOrigins: [process.env.NEXT_PUBLIC_APP_URL || ''],
116
+ })
117
+ }
118
+ ```
119
+
120
+ ### Step 3: Configure Payload
121
+
122
+ ```ts
123
+ // src/payload.config.ts
124
+ import { buildConfig } from 'payload'
125
+ import {
126
+ betterAuthCollections,
127
+ createBetterAuthPlugin,
128
+ } from '@delmaredigital/payload-better-auth'
129
+ import { betterAuthOptions } from './lib/auth/config'
130
+ import { createAuth } from './lib/auth'
131
+ import { Users } from './collections/Users'
132
+
133
+ export default buildConfig({
134
+ collections: [Users /* ... other collections */],
135
+ plugins: [
136
+ // Auto-generate sessions, accounts, verifications collections
137
+ betterAuthCollections({
138
+ betterAuthOptions,
139
+ skipCollections: ['user'], // We define Users ourselves
140
+ }),
141
+ // Initialize Better Auth in Payload's lifecycle
142
+ createBetterAuthPlugin({
143
+ createAuth,
144
+ }),
145
+ ],
146
+ db: postgresAdapter({
147
+ pool: { connectionString: process.env.DATABASE_URL },
148
+ // Use Payload defaults - Better Auth adapter handles ID conversion
149
+ }),
150
+ })
151
+ ```
152
+
153
+ ### Step 4: Create Your Users Collection
154
+
155
+ ```ts
156
+ // src/collections/Users.ts
157
+ import type { CollectionConfig } from 'payload'
158
+ import { betterAuthStrategy } from '@delmaredigital/payload-better-auth'
159
+
160
+ export const Users: CollectionConfig = {
161
+ slug: 'users',
162
+ auth: {
163
+ disableLocalStrategy: true,
164
+ strategies: [betterAuthStrategy()],
165
+ },
166
+ access: {
167
+ read: ({ req }) => {
168
+ if (!req.user) return false
169
+ if (req.user.role === 'admin') return true
170
+ return { id: { equals: req.user.id } }
171
+ },
172
+ admin: ({ req }) => req.user?.role === 'admin',
173
+ },
174
+ fields: [
175
+ { name: 'email', type: 'email', required: true, unique: true },
176
+ { name: 'emailVerified', type: 'checkbox', defaultValue: false },
177
+ { name: 'name', type: 'text' },
178
+ { name: 'image', type: 'text' },
179
+ {
180
+ name: 'role',
181
+ type: 'select',
182
+ defaultValue: 'user',
183
+ options: [
184
+ { label: 'User', value: 'user' },
185
+ { label: 'Admin', value: 'admin' },
186
+ ],
187
+ },
188
+ ],
189
+ }
190
+ ```
191
+
192
+ ### Step 5: Create the Auth API Route
193
+
194
+ ```ts
195
+ // src/app/api/auth/[...all]/route.ts
196
+ import { getPayload } from 'payload'
197
+ import config from '@payload-config'
198
+ import type { NextRequest } from 'next/server'
199
+ import type { PayloadWithAuth } from '@delmaredigital/payload-better-auth'
200
+
201
+ export async function GET(request: NextRequest) {
202
+ const payload = (await getPayload({ config })) as PayloadWithAuth
203
+ return payload.betterAuth.handler(request)
204
+ }
205
+
206
+ export async function POST(request: NextRequest) {
207
+ const payload = (await getPayload({ config })) as PayloadWithAuth
208
+ return payload.betterAuth.handler(request)
209
+ }
210
+ ```
211
+
212
+ ### Step 6: Client-Side Auth
213
+
214
+ ```ts
215
+ // src/lib/auth/client.ts
216
+ 'use client'
217
+
218
+ import { createAuthClient } from '@delmaredigital/payload-better-auth/client'
219
+
220
+ export const authClient = createAuthClient({
221
+ baseURL:
222
+ typeof window !== 'undefined'
223
+ ? window.location.origin
224
+ : process.env.NEXT_PUBLIC_APP_URL,
225
+ })
226
+
227
+ export const { useSession, signIn, signUp, signOut } = authClient
228
+ ```
229
+
230
+ ### Step 7: Server-Side Session Access
231
+
232
+ ```ts
233
+ // In a server component or API route
234
+ import { headers } from 'next/headers'
235
+ import { getPayload } from 'payload'
236
+ import { getServerSession } from '@delmaredigital/payload-better-auth'
237
+
238
+ export default async function Dashboard() {
239
+ const payload = await getPayload({ config })
240
+ const headersList = await headers()
241
+ const session = await getServerSession(payload, headersList)
242
+
243
+ if (!session) {
244
+ redirect('/login')
245
+ }
246
+
247
+ return <div>Hello {session.user.name}</div>
248
+ }
249
+ ```
250
+
251
+ ---
252
+
253
+ ## API Reference
254
+
255
+ ### `payloadAdapter(config)`
256
+
257
+ Creates a Better Auth database adapter that uses Payload collections.
258
+
259
+ ```ts
260
+ payloadAdapter({
261
+ payloadClient: payload,
262
+ adapterConfig: {
263
+ collections: { user: 'users', session: 'sessions' },
264
+ enableDebugLogs: false,
265
+ idType: 'number',
266
+ },
267
+ })
268
+ ```
269
+
270
+ | Option | Type | Description |
271
+ |--------|------|-------------|
272
+ | `payloadClient` | `BasePayload \| () => Promise<BasePayload>` | Payload instance or factory function |
273
+ | `adapterConfig.collections` | `Record<string, string>` | Map Better Auth model names to Payload collection slugs |
274
+ | `adapterConfig.enableDebugLogs` | `boolean` | Enable debug logging (default: `false`) |
275
+ | `adapterConfig.idType` | `'number' \| 'text'` | `'number'` for SERIAL (recommended), `'text'` for UUID |
276
+
277
+ **ID Type Options:**
278
+ - `'number'` (recommended) - Works with Payload's default SERIAL IDs. Requires `generateId: 'serial'` in Better Auth config.
279
+ - `'text'` - Works with UUID IDs. Requires `idType: 'uuid'` in Payload's database adapter.
280
+
281
+ ### `betterAuthCollections(options)`
282
+
283
+ Payload plugin that auto-generates collections from Better Auth schema.
284
+
285
+ ```ts
286
+ betterAuthCollections({
287
+ betterAuthOptions,
288
+ slugOverrides: { user: 'users' },
289
+ skipCollections: ['user'],
290
+ adminGroup: 'Auth',
291
+ })
292
+ ```
293
+
294
+ | Option | Type | Description |
295
+ |--------|------|-------------|
296
+ | `betterAuthOptions` | `BetterAuthOptions` | Your Better Auth options |
297
+ | `slugOverrides` | `Record<string, string>` | Override collection names |
298
+ | `skipCollections` | `string[]` | Collections to skip generating (default: `['user']`) |
299
+ | `adminGroup` | `string` | Admin panel group name (default: `'Auth'`) |
300
+ | `access` | `CollectionConfig['access']` | Custom access control for generated collections |
301
+
302
+ ### `createBetterAuthPlugin(options)`
303
+
304
+ Payload plugin that initializes Better Auth during Payload's `onInit`.
305
+
306
+ ```ts
307
+ createBetterAuthPlugin({
308
+ createAuth: (payload) => betterAuth({ ... }),
309
+ })
310
+ ```
311
+
312
+ | Option | Type | Description |
313
+ |--------|------|-------------|
314
+ | `createAuth` | `(payload: BasePayload) => Auth` | Factory function that creates the Better Auth instance |
315
+
316
+ ### `betterAuthStrategy(options?)`
317
+
318
+ Payload auth strategy for Better Auth session validation.
319
+
320
+ ```ts
321
+ betterAuthStrategy({
322
+ usersCollection: 'users',
323
+ })
324
+ ```
325
+
326
+ | Option | Type | Description |
327
+ |--------|------|-------------|
328
+ | `usersCollection` | `string` | The collection slug for users (default: `'users'`) |
329
+
330
+ ### `getServerSession(payload, headers)`
331
+
332
+ Get the current session on the server.
333
+
334
+ ```ts
335
+ const session = await getServerSession(payload, headersList)
336
+ // Returns: { user: { id, email, name, ... }, session: { id, expiresAt, ... } } | null
337
+ ```
338
+
339
+ ### `getServerUser(payload, headers)`
340
+
341
+ Get the current user on the server (shorthand for `session.user`).
342
+
343
+ ```ts
344
+ const user = await getServerUser(payload, headersList)
345
+ // Returns: { id, email, name, ... } | null
346
+ ```
347
+
348
+ ---
349
+
350
+ ## Admin Panel Integration
351
+
352
+ When using `disableLocalStrategy: true` in your Users collection, you need custom admin authentication components since Payload's default login form won't work.
353
+
354
+ ### Why Custom Components Are Needed
355
+
356
+ With `disableLocalStrategy: true`:
357
+ - Payload's default login form is disabled
358
+ - Users must authenticate via Better Auth
359
+ - A custom login page is needed at `/admin/login`
360
+ - A custom logout button is needed to clear Better Auth sessions
361
+
362
+ <details>
363
+ <summary><strong>Step 1: Create BeforeLogin Component</strong></summary>
364
+
365
+ This component redirects unauthenticated users from Payload's login to your custom login page:
366
+
367
+ ```tsx
368
+ // src/components/admin/BeforeLogin.tsx
369
+ 'use client'
370
+
371
+ import { useEffect } from 'react'
372
+ import { useRouter } from 'next/navigation'
373
+
374
+ export default function BeforeLogin() {
375
+ const router = useRouter()
376
+
377
+ useEffect(() => {
378
+ router.replace('/admin/login')
379
+ }, [router])
380
+
381
+ return (
382
+ <div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center' }}>
383
+ <div>Redirecting to login...</div>
384
+ </div>
385
+ )
386
+ }
387
+ ```
388
+ </details>
389
+
390
+ <details>
391
+ <summary><strong>Step 2: Create Custom Logout Button</strong></summary>
392
+
393
+ **IMPORTANT**: The logout button must only trigger logout **on click**, not on mount. Triggering logout on mount would cause an infinite redirect loop since this component is rendered in the admin panel header.
394
+
395
+ ```tsx
396
+ // src/components/admin/Logout.tsx
397
+ 'use client'
398
+
399
+ import { useState } from 'react'
400
+ import { useRouter } from 'next/navigation'
401
+ import { signOut } from '@/lib/auth/client'
402
+
403
+ export default function Logout() {
404
+ const router = useRouter()
405
+ const [isLoading, setIsLoading] = useState(false)
406
+
407
+ async function handleLogout() {
408
+ if (isLoading) return
409
+ setIsLoading(true)
410
+
411
+ try {
412
+ await signOut()
413
+ router.push('/admin/login')
414
+ } catch (error) {
415
+ console.error('Logout error:', error)
416
+ setIsLoading(false)
417
+ }
418
+ }
419
+
420
+ return (
421
+ <button
422
+ onClick={handleLogout}
423
+ disabled={isLoading}
424
+ type="button"
425
+ className="btn btn--style-secondary btn--icon-style-without-border btn--size-small btn--withoutPopup"
426
+ >
427
+ {isLoading ? 'Logging out...' : 'Log out'}
428
+ </button>
429
+ )
430
+ }
431
+ ```
432
+ </details>
433
+
434
+ <details>
435
+ <summary><strong>Step 3: Create Admin Login Page</strong></summary>
436
+
437
+ ```tsx
438
+ // src/app/(frontend)/admin/login/page.tsx
439
+ 'use client'
440
+
441
+ import { useEffect, useState, type FormEvent } from 'react'
442
+ import { useRouter } from 'next/navigation'
443
+ import { useSession, signIn } from '@/lib/auth/client'
444
+
445
+ export default function AdminLoginPage() {
446
+ const { data: session, isPending } = useSession()
447
+ const router = useRouter()
448
+ const [email, setEmail] = useState('')
449
+ const [password, setPassword] = useState('')
450
+ const [error, setError] = useState<string | null>(null)
451
+ const [isLoading, setIsLoading] = useState(false)
452
+
453
+ useEffect(() => {
454
+ if (session?.user) {
455
+ const user = session.user as { role?: string }
456
+ if (user.role === 'admin') {
457
+ router.push('/admin')
458
+ } else {
459
+ setError('Access denied. Admin role required.')
460
+ }
461
+ }
462
+ }, [session, router])
463
+
464
+ async function handleSubmit(e: FormEvent) {
465
+ e.preventDefault()
466
+ setError(null)
467
+ setIsLoading(true)
468
+
469
+ try {
470
+ const result = await signIn.email({ email, password })
471
+ if (result.error) {
472
+ setError(result.error.message || 'Invalid credentials')
473
+ setIsLoading(false)
474
+ return
475
+ }
476
+ router.refresh()
477
+ } catch {
478
+ setError('An unexpected error occurred')
479
+ setIsLoading(false)
480
+ }
481
+ }
482
+
483
+ if (isPending) {
484
+ return <div>Loading...</div>
485
+ }
486
+
487
+ return (
488
+ <form onSubmit={handleSubmit}>
489
+ <h1>Admin Login</h1>
490
+ <input
491
+ type="email"
492
+ value={email}
493
+ onChange={(e) => setEmail(e.target.value)}
494
+ placeholder="Email"
495
+ required
496
+ />
497
+ <input
498
+ type="password"
499
+ value={password}
500
+ onChange={(e) => setPassword(e.target.value)}
501
+ placeholder="Password"
502
+ required
503
+ />
504
+ {error && <div style={{ color: 'red' }}>{error}</div>}
505
+ <button type="submit" disabled={isLoading}>
506
+ {isLoading ? 'Signing in...' : 'Sign in'}
507
+ </button>
508
+ </form>
509
+ )
510
+ }
511
+ ```
512
+ </details>
513
+
514
+ <details>
515
+ <summary><strong>Step 4: Configure Payload Admin Components</strong></summary>
516
+
517
+ ```ts
518
+ // payload.config.ts
519
+ export default buildConfig({
520
+ admin: {
521
+ user: Users.slug,
522
+ components: {
523
+ beforeLogin: ['@/components/admin/BeforeLogin'],
524
+ logout: {
525
+ Button: '@/components/admin/Logout',
526
+ },
527
+ },
528
+ },
529
+ // ... rest of config
530
+ })
531
+ ```
532
+ </details>
533
+
534
+ ---
535
+
536
+ ## Plugin Compatibility
537
+
538
+ | Plugin | Status | Notes |
539
+ |--------|--------|-------|
540
+ | OAuth | Works | Uses existing accounts table |
541
+ | Magic Link | Works | Uses verifications table |
542
+ | Email Verification | Works | Uses verifications table |
543
+ | Email OTP | Works | Uses verifications table |
544
+ | Password Reset | Works | Uses verifications table |
545
+ | API Keys | Needs join | See [API Keys](#api-keys) below |
546
+ | Organizations | Needs joins | See [Organizations](#organizations) below |
547
+ | 2FA/TOTP | Needs join | See [Two-Factor Auth](#two-factor-auth-totp) below |
548
+
549
+ ### Enabling Plugins That Need Joins
550
+
551
+ 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.
552
+
553
+ <details>
554
+ <summary><strong>API Keys</strong></summary>
555
+
556
+ The API Keys plugin creates an `apiKey` model with a `userId` reference.
557
+
558
+ **1. Add to your Better Auth config:**
559
+
560
+ ```ts
561
+ // src/lib/auth/config.ts
562
+ import { apiKey } from 'better-auth/plugins'
563
+
564
+ export const betterAuthOptions: Partial<BetterAuthOptions> = {
565
+ // ... existing config
566
+ plugins: [apiKey()],
567
+ }
568
+
569
+ export const collectionSlugs = {
570
+ user: 'users',
571
+ session: 'sessions',
572
+ account: 'accounts',
573
+ verification: 'verifications',
574
+ apiKey: 'api-keys', // Add this
575
+ } as const
576
+ ```
577
+
578
+ **2. Add join field to your Users collection:**
579
+
580
+ ```ts
581
+ // src/collections/Users.ts
582
+ export const Users: CollectionConfig = {
583
+ slug: 'users',
584
+ // ... existing config
585
+ fields: [
586
+ // ... existing fields
587
+ {
588
+ name: 'apiKeys',
589
+ type: 'join',
590
+ collection: 'api-keys',
591
+ on: 'user', // The field in api-keys that references users
592
+ },
593
+ ],
594
+ }
595
+ ```
596
+
597
+ **3. Update slugOverrides in betterAuthCollections:**
598
+
599
+ ```ts
600
+ betterAuthCollections({
601
+ betterAuthOptions,
602
+ slugOverrides: { apiKey: 'api-keys' },
603
+ skipCollections: ['user'],
604
+ })
605
+ ```
606
+ </details>
607
+
608
+ <details>
609
+ <summary><strong>Two-Factor Auth (TOTP)</strong></summary>
610
+
611
+ The Two-Factor plugin creates a `twoFactor` model with a `userId` reference.
612
+
613
+ **1. Add to your Better Auth config:**
614
+
615
+ ```ts
616
+ // src/lib/auth/config.ts
617
+ import { twoFactor } from 'better-auth/plugins'
618
+
619
+ export const betterAuthOptions: Partial<BetterAuthOptions> = {
620
+ // ... existing config
621
+ plugins: [twoFactor()],
622
+ }
623
+
624
+ export const collectionSlugs = {
625
+ // ... existing slugs
626
+ twoFactor: 'two-factors', // Add this
627
+ } as const
628
+ ```
629
+
630
+ **2. Add join field to your Users collection:**
631
+
632
+ ```ts
633
+ // src/collections/Users.ts
634
+ export const Users: CollectionConfig = {
635
+ slug: 'users',
636
+ fields: [
637
+ // ... existing fields
638
+ {
639
+ name: 'twoFactor',
640
+ type: 'join',
641
+ collection: 'two-factors',
642
+ on: 'user',
643
+ },
644
+ ],
645
+ }
646
+ ```
647
+ </details>
648
+
649
+ <details>
650
+ <summary><strong>Organizations</strong></summary>
651
+
652
+ The Organizations plugin creates multiple models: `organization`, `member`, and `invitation`.
653
+
654
+ **1. Add to your Better Auth config:**
655
+
656
+ ```ts
657
+ // src/lib/auth/config.ts
658
+ import { organization } from 'better-auth/plugins'
659
+
660
+ export const betterAuthOptions: Partial<BetterAuthOptions> = {
661
+ // ... existing config
662
+ plugins: [organization()],
663
+ }
664
+
665
+ export const collectionSlugs = {
666
+ // ... existing slugs
667
+ organization: 'organizations',
668
+ member: 'members',
669
+ invitation: 'invitations',
670
+ } as const
671
+ ```
672
+
673
+ **2. Add join fields to your Users collection:**
674
+
675
+ ```ts
676
+ // src/collections/Users.ts
677
+ export const Users: CollectionConfig = {
678
+ slug: 'users',
679
+ fields: [
680
+ // ... existing fields
681
+ {
682
+ name: 'memberships',
683
+ type: 'join',
684
+ collection: 'members',
685
+ on: 'user',
686
+ },
687
+ ],
688
+ }
689
+ ```
690
+
691
+ **3. Create an Organizations collection (or let it auto-generate and add joins):**
692
+
693
+ ```ts
694
+ // src/collections/Organizations.ts
695
+ export const Organizations: CollectionConfig = {
696
+ slug: 'organizations',
697
+ admin: { useAsTitle: 'name', group: 'Auth' },
698
+ fields: [
699
+ { name: 'name', type: 'text', required: true },
700
+ { name: 'slug', type: 'text', unique: true },
701
+ { name: 'logo', type: 'text' },
702
+ { name: 'metadata', type: 'json' },
703
+ {
704
+ name: 'members',
705
+ type: 'join',
706
+ collection: 'members',
707
+ on: 'organization',
708
+ },
709
+ {
710
+ name: 'invitations',
711
+ type: 'join',
712
+ collection: 'invitations',
713
+ on: 'organization',
714
+ },
715
+ ],
716
+ }
717
+ ```
718
+ </details>
719
+
720
+ ### General Pattern for Joins
721
+
722
+ When a Better Auth plugin creates a model with a foreign key (e.g., `userId`, `organizationId`), you need to:
723
+
724
+ 1. **Map the collection slug** in `collectionSlugs` config
725
+ 2. **Add a join field** to the parent collection pointing to the child collection
726
+ 3. **Specify the `on` field** - this is the relationship field name in the child collection (without `Id` suffix)
727
+
728
+ The auto-generated collections create relationship fields like `user` (from `userId`), so your join's `on` property should match that field name.
729
+
730
+ ---
731
+
732
+ ## License
733
+
734
+ MIT