@delmaredigital/payload-better-auth 0.4.2 → 0.4.3

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
@@ -20,6 +20,7 @@ For additional documentation and references, visit: [https://deepwiki.com/delmar
20
20
 
21
21
  - [Installation](#installation)
22
22
  - [Quick Start](#quick-start)
23
+ - [MongoDB Setup](#mongodb-setup)
23
24
  - [API Reference](#api-reference)
24
25
  - [Customization](#customization)
25
26
  - [Access Control Helpers](#access-control-helpers)
@@ -166,7 +167,9 @@ export const Users: CollectionConfig = {
166
167
  > [better-auth] Auto-adding fields to 'users': ['twoFactorEnabled']
167
168
  > ```
168
169
 
169
- ### Step 3: Configure Payload
170
+ ### Step 3: Configure Payload (Postgres)
171
+
172
+ > For MongoDB, see [MongoDB Setup](#mongodb-setup).
170
173
 
171
174
  ```ts
172
175
  // src/payload.config.ts
@@ -292,6 +295,111 @@ export default async function Dashboard() {
292
295
 
293
296
  ---
294
297
 
298
+ ## MongoDB Setup
299
+
300
+ The adapter auto-detects MongoDB and configures itself accordingly. No special adapter configuration is needed.
301
+
302
+ ### Key Differences from Postgres
303
+
304
+ | | Postgres (default) | MongoDB |
305
+ |---|---|---|
306
+ | **ID type** | `'number'` (SERIAL) | `'text'` (ObjectId strings) |
307
+ | **`generateId`** | Set to `'serial'` | Do not set (MongoDB generates ObjectIds) |
308
+ | **`idType`** | `'number'` (default) | Auto-detected as `'text'` |
309
+ | **`betterAuthStrategy`** | `idType: 'number'` (default) | `idType: 'text'` |
310
+ | **`createSessionHelpers`** | `idType: 'number'` | `idType: 'text'` (or omit) |
311
+
312
+ ### Quick Start (MongoDB)
313
+
314
+ ```ts
315
+ // src/payload.config.ts
316
+ import { buildConfig } from 'payload'
317
+ import { mongooseAdapter } from '@payloadcms/db-mongodb'
318
+ import { betterAuth } from 'better-auth'
319
+ import {
320
+ betterAuthCollections,
321
+ createBetterAuthPlugin,
322
+ payloadAdapter,
323
+ } from '@delmaredigital/payload-better-auth'
324
+ import { betterAuthOptions } from './lib/auth/config'
325
+ import { Users } from './collections/Users'
326
+
327
+ export default buildConfig({
328
+ collections: [Users],
329
+ plugins: [
330
+ betterAuthCollections({
331
+ betterAuthOptions,
332
+ skipCollections: ['user'],
333
+ }),
334
+ createBetterAuthPlugin({
335
+ createAuth: (payload) =>
336
+ betterAuth({
337
+ ...betterAuthOptions,
338
+ database: payloadAdapter({ payloadClient: payload }),
339
+ // Do NOT set advanced.database.generateId — MongoDB generates ObjectIds
340
+ secret: process.env.BETTER_AUTH_SECRET,
341
+ trustedOrigins: ['http://localhost:3000'],
342
+ }),
343
+ }),
344
+ ],
345
+ db: mongooseAdapter({
346
+ url: process.env.DATABASE_URI!,
347
+ }),
348
+ })
349
+ ```
350
+
351
+ **Users collection:**
352
+
353
+ ```ts
354
+ import { betterAuthStrategy } from '@delmaredigital/payload-better-auth'
355
+
356
+ export const Users: CollectionConfig = {
357
+ slug: 'users',
358
+ auth: {
359
+ disableLocalStrategy: true,
360
+ strategies: [betterAuthStrategy({ idType: 'text' })],
361
+ },
362
+ // ... fields and access control same as Postgres setup
363
+ }
364
+ ```
365
+
366
+ **Session helpers:**
367
+
368
+ ```ts
369
+ import { createSessionHelpers } from '@delmaredigital/payload-better-auth'
370
+ import type { User } from '@/payload-types'
371
+
372
+ export const { getServerSession, getServerUser } = createSessionHelpers<User>({
373
+ idType: 'text',
374
+ })
375
+ ```
376
+
377
+ ### Migrating from Postgres to MongoDB
378
+
379
+ 1. **Remove `generateId: 'serial'`** from your Better Auth config (the `advanced.database` block)
380
+ 2. **Change `betterAuthStrategy()`** to use `idType: 'text'`:
381
+ ```ts
382
+ strategies: [betterAuthStrategy({ idType: 'text' })]
383
+ ```
384
+ 3. **Update `createSessionHelpers`** (if using) to `idType: 'text'`:
385
+ ```ts
386
+ createSessionHelpers<User>({ idType: 'text' })
387
+ ```
388
+ 4. **Remove `idType: 'number'`** from `adapterConfig` if you had it set explicitly — the adapter auto-detects `'text'` for MongoDB
389
+ 5. **Switch the Payload database adapter**:
390
+ ```ts
391
+ // Before
392
+ import { postgresAdapter } from '@payloadcms/db-postgres'
393
+ db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URL } })
394
+
395
+ // After
396
+ import { mongooseAdapter } from '@payloadcms/db-mongodb'
397
+ db: mongooseAdapter({ url: process.env.DATABASE_URI! })
398
+ ```
399
+ 6. **Remove `idFieldsAllowlist`/`idFieldsBlocklist`** from `adapterConfig` if set — these are only relevant for serial ID conversion
400
+
401
+ ---
402
+
295
403
  ## API Reference
296
404
 
297
405
  ### `payloadAdapter(config)`
@@ -312,13 +420,16 @@ payloadAdapter({
312
420
  |--------|------|-------------|
313
421
  | `payloadClient` | `BasePayload \| () => Promise<BasePayload>` | Payload instance or factory function |
314
422
  | `adapterConfig.enableDebugLogs` | `boolean` | Enable debug logging (default: `false`) |
315
- | `adapterConfig.idType` | `'number' \| 'text'` | ID type (default: `'number'` for Payload's SERIAL IDs) |
423
+ | `adapterConfig.dbType` | `'postgres' \| 'mongodb' \| 'sqlite'` | Database type (auto-detected from Payload's adapter) |
424
+ | `adapterConfig.idType` | `'number' \| 'text'` | ID type (auto-detected: `'number'` for Postgres/SQLite, `'text'` for MongoDB) |
316
425
  | `adapterConfig.idFieldsAllowlist` | `string[]` | Additional fields to convert to numeric IDs (default: `[]`) |
317
426
  | `adapterConfig.idFieldsBlocklist` | `string[]` | Fields to exclude from numeric ID conversion (default: `[]`) |
318
427
 
319
- **ID Type:**
320
- - Defaults to `'number'` (SERIAL) - Payload's default
321
- - Set `idType: 'text'` if using UUIDs
428
+ **Database & ID Type:**
429
+ - **Auto-detected**: The adapter reads `payload.db.name` to determine the database type, then sets `idType` accordingly
430
+ - **Postgres/SQLite**: `idType` defaults to `'number'` (SERIAL IDs)
431
+ - **MongoDB**: `idType` is always `'text'` (ObjectId strings)
432
+ - Set `dbType` explicitly only if auto-detection doesn't work for your adapter
322
433
 
323
434
  **Note:** When using number IDs (default), you can optionally set `generateId: 'serial'` in Better Auth to be explicit:
324
435
  ```typescript
@@ -303,11 +303,19 @@ function generateCollection(modelKey, table, usePlural, adminGroup, customAccess
303
303
  });
304
304
  } else {
305
305
  const saveToJWT = configureSaveToJWT ? getSaveToJWT(modelKey, payloadFieldName) : undefined;
306
+ // Fields managed exclusively by Better Auth should be read-only in the admin UI
307
+ const readOnlyFields = [
308
+ 'twoFactorEnabled'
309
+ ];
310
+ const isReadOnly = readOnlyFields.includes(payloadFieldName);
306
311
  const field = {
307
312
  name: payloadFieldName,
308
313
  type: fieldType,
309
314
  admin: {
310
- description: `Auto-added by Better Auth (${fieldKey})`
315
+ description: `Auto-added by Better Auth (${fieldKey})`,
316
+ ...isReadOnly && {
317
+ readOnly: true
318
+ }
311
319
  },
312
320
  ...saveToJWT !== undefined && {
313
321
  saveToJWT
@@ -8,6 +8,20 @@
8
8
  */
9
9
  import type { Adapter, BetterAuthOptions } from 'better-auth';
10
10
  import type { BasePayload } from 'payload';
11
+ /**
12
+ * Database types supported by Payload CMS.
13
+ */
14
+ export type DbType = 'postgres' | 'mongodb' | 'sqlite';
15
+ /**
16
+ * Detect the database type from the Payload instance.
17
+ */
18
+ export declare function detectDbType(payload: BasePayload): DbType;
19
+ /**
20
+ * Determine ID type based on database type and Better Auth config.
21
+ * MongoDB always uses text IDs (ObjectId strings).
22
+ * Postgres defaults to 'number' (SERIAL) unless generateId indicates otherwise.
23
+ */
24
+ export declare function resolveIdType(dbType: DbType, options: BetterAuthOptions, explicitIdType?: 'number' | 'text'): 'number' | 'text';
11
25
  export type PayloadAdapterConfig = {
12
26
  /**
13
27
  * The Payload instance or a function that returns it.
@@ -22,6 +36,11 @@ export type PayloadAdapterConfig = {
22
36
  * Enable debug logging for troubleshooting
23
37
  */
24
38
  enableDebugLogs?: boolean;
39
+ /**
40
+ * Database type. Auto-detected from Payload's database adapter if not set.
41
+ * Set explicitly if auto-detection doesn't work for your adapter.
42
+ */
43
+ dbType?: DbType;
25
44
  /**
26
45
  * ID type used by Payload.
27
46
  * If not specified, auto-detects from Better Auth's generateId setting.
@@ -7,15 +7,26 @@
7
7
  * @packageDocumentation
8
8
  */ import { createAdapterFactory } from 'better-auth/adapters';
9
9
  /**
10
- * Detect ID type from Better Auth options.
11
- * Defaults to 'number' (SERIAL) since Payload uses SERIAL IDs by default.
12
- */ function detectIdType(options) {
10
+ * Detect the database type from the Payload instance.
11
+ */ export function detectDbType(payload) {
12
+ const dbName = payload.db?.name;
13
+ if (typeof dbName === 'string') {
14
+ if (dbName.includes('mongo') || dbName.includes('mongoose')) return 'mongodb';
15
+ if (dbName.includes('sqlite')) return 'sqlite';
16
+ }
17
+ return 'postgres';
18
+ }
19
+ /**
20
+ * Determine ID type based on database type and Better Auth config.
21
+ * MongoDB always uses text IDs (ObjectId strings).
22
+ * Postgres defaults to 'number' (SERIAL) unless generateId indicates otherwise.
23
+ */ export function resolveIdType(dbType, options, explicitIdType) {
24
+ if (dbType === 'mongodb') return 'text';
25
+ if (explicitIdType) return explicitIdType;
13
26
  const generateId = options.advanced?.database?.generateId;
14
- // If explicitly set to something other than 'serial', use text (UUID)
15
27
  if (generateId !== undefined && generateId !== 'serial') {
16
28
  return 'text';
17
29
  }
18
- // Default to number (SERIAL) - Payload's default
19
30
  return 'number';
20
31
  }
21
32
  /**
@@ -59,7 +70,7 @@
59
70
  async function resolvePayloadClient() {
60
71
  return typeof payloadClient === 'function' ? await payloadClient() : payloadClient;
61
72
  }
62
- function convertOperator(operator, value) {
73
+ function convertOperator(operator, value, dbType) {
63
74
  switch(operator){
64
75
  case 'eq':
65
76
  return {
@@ -89,15 +100,25 @@
89
100
  return {
90
101
  in: value
91
102
  };
103
+ case 'not_in':
104
+ return {
105
+ not_in: value
106
+ };
92
107
  case 'contains':
93
108
  return {
94
109
  contains: value
95
110
  };
96
111
  case 'starts_with':
112
+ if (dbType === 'mongodb') return {
113
+ contains: value
114
+ };
97
115
  return {
98
116
  like: `${value}%`
99
117
  };
100
118
  case 'ends_with':
119
+ if (dbType === 'mongodb') return {
120
+ contains: value
121
+ };
101
122
  return {
102
123
  like: `%${value}`
103
124
  };
@@ -122,9 +143,11 @@
122
143
  }
123
144
  // Return the adapter factory function
124
145
  return (options)=>{
125
- // Determine ID type: explicit config > auto-detect
126
- // Defaults to 'number' (SERIAL) since Payload uses SERIAL IDs by default
127
- const idType = adapterConfig.idType ?? detectIdType(options);
146
+ // Determine ID type based on database type
147
+ // If payloadClient is already resolved, detect dbType immediately
148
+ // Otherwise default to 'postgres' (will be updated on first operation)
149
+ const effectiveDbType = adapterConfig.dbType ?? (typeof payloadClient !== 'function' ? detectDbType(payloadClient) : 'postgres');
150
+ const idType = resolveIdType(effectiveDbType, options, adapterConfig.idType);
128
151
  const generateId = options.advanced?.database?.generateId;
129
152
  // Warn if using number IDs but Better Auth is explicitly configured to generate its own IDs
130
153
  // This would cause Better Auth to generate UUIDs which won't work with SERIAL columns
@@ -153,10 +176,10 @@
153
176
  // Payload collections are plural by default (users, sessions, etc.)
154
177
  // Users can customize via BetterAuthOptions: user: { modelName: 'custom_users' }
155
178
  usePlural: true,
156
- // Let Payload generate IDs when using serial/auto-increment
157
- disableIdGeneration: idType === 'number',
158
- // Payload supports these features
159
- supportsNumericIds: true,
179
+ // Payload always generates IDs (SERIAL for postgres/sqlite, ObjectId for mongodb)
180
+ disableIdGeneration: true,
181
+ // MongoDB uses ObjectId strings, not numeric IDs
182
+ supportsNumericIds: effectiveDbType !== 'mongodb',
160
183
  supportsDates: true,
161
184
  supportsBooleans: true,
162
185
  supportsJSON: true,
@@ -171,11 +194,18 @@
171
194
  // so we'll resolve it lazily on first operation
172
195
  let resolvedPayload = null;
173
196
  let resolvePromise = null;
197
+ let resolvedDbType = adapterConfig.dbType ?? null;
174
198
  const getPayload = async ()=>{
175
199
  if (resolvedPayload) return resolvedPayload;
176
200
  if (!resolvePromise) {
177
201
  resolvePromise = resolvePayloadClient().then((p)=>{
178
202
  resolvedPayload = p;
203
+ if (!resolvedDbType) {
204
+ resolvedDbType = detectDbType(p);
205
+ if (enableDebugLogs) {
206
+ console.log('[payload-adapter] Detected database type:', resolvedDbType);
207
+ }
208
+ }
179
209
  return p;
180
210
  });
181
211
  }
@@ -295,19 +325,6 @@
295
325
  }
296
326
  }
297
327
  }
298
- // Convert date strings to Date objects
299
- // Better Auth expects Date objects for date field comparisons
300
- for (const [key, value] of Object.entries(transformed)){
301
- if (typeof value !== 'string') continue;
302
- // Check if schema defines this field as a date type
303
- const fieldDef = modelSchema.fields[key];
304
- if (fieldDef?.type === 'date') {
305
- const dateValue = new Date(value);
306
- if (!isNaN(dateValue.getTime())) {
307
- transformed[key] = dateValue;
308
- }
309
- }
310
- }
311
328
  // Convert semantic ID fields to numbers when using serial IDs
312
329
  // Heuristic: fields ending in 'Id' or '_id' containing numeric strings
313
330
  // Modified by allowlist (add) and blocklist (exclude)
@@ -337,7 +354,7 @@
337
354
  if (where.length === 1) {
338
355
  const w = where[0];
339
356
  return {
340
- [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value)
357
+ [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value, resolvedDbType ?? 'postgres')
341
358
  };
342
359
  }
343
360
  const andConditions = where.filter((w)=>w.connector !== 'OR');
@@ -345,12 +362,12 @@
345
362
  const result = {};
346
363
  if (andConditions.length > 0) {
347
364
  result.and = andConditions.map((w)=>({
348
- [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value)
365
+ [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value, resolvedDbType ?? 'postgres')
349
366
  }));
350
367
  }
351
368
  if (orConditions.length > 0) {
352
369
  result.or = orConditions.map((w)=>({
353
- [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value)
370
+ [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value, resolvedDbType ?? 'postgres')
354
371
  }));
355
372
  }
356
373
  return result;
@@ -389,10 +406,19 @@
389
406
  // Transform back and merge with input data for Better Auth
390
407
  // Database result takes precedence (handles hooks that modify data like firstUserAdmin)
391
408
  const transformed = transformDataFromPayload(model, result);
392
- return {
409
+ const merged = {
393
410
  ...data,
394
411
  ...transformed
395
412
  };
413
+ if (enableDebugLogs) {
414
+ debugLog('create result', {
415
+ collection,
416
+ resultId: result.id,
417
+ transformedKeys: Object.keys(transformed),
418
+ mergedKeys: Object.keys(merged)
419
+ });
420
+ }
421
+ return merged;
396
422
  } catch (error) {
397
423
  console.error('[payload-adapter] create failed:', {
398
424
  collection,
@@ -424,15 +450,39 @@
424
450
  depth: join ? 1 : 0,
425
451
  overrideAccess: true
426
452
  });
427
- return transformDataFromPayload(model, result);
453
+ const transformed = transformDataFromPayload(model, result);
454
+ if (enableDebugLogs) {
455
+ debugLog('findOne result (byID)', {
456
+ collection,
457
+ id: convertId(id),
458
+ found: true,
459
+ transformedKeys: Object.keys(transformed)
460
+ });
461
+ }
462
+ return transformed;
428
463
  } catch (error) {
429
464
  if (error instanceof Error && 'status' in error && error.status === 404) {
465
+ if (enableDebugLogs) {
466
+ debugLog('findOne result (byID)', {
467
+ collection,
468
+ id: convertId(id),
469
+ found: false
470
+ });
471
+ }
430
472
  return null;
431
473
  }
432
474
  throw error;
433
475
  }
434
476
  }
435
477
  const payloadWhere = convertWhereToPayload(model, where);
478
+ if (enableDebugLogs) {
479
+ debugLog('findOne query', {
480
+ collection,
481
+ payloadWhere: JSON.stringify(payloadWhere),
482
+ resolvedDbType,
483
+ idType
484
+ });
485
+ }
436
486
  const result = await payload.find({
437
487
  collection,
438
488
  where: payloadWhere,
@@ -440,8 +490,22 @@
440
490
  depth: join ? 1 : 0,
441
491
  overrideAccess: true
442
492
  });
493
+ if (enableDebugLogs) {
494
+ debugLog('findOne result', {
495
+ collection,
496
+ totalDocs: result.totalDocs,
497
+ found: result.docs.length > 0
498
+ });
499
+ }
443
500
  if (!result.docs[0]) return null;
444
- return transformDataFromPayload(model, result.docs[0]);
501
+ const transformed = transformDataFromPayload(model, result.docs[0]);
502
+ if (enableDebugLogs) {
503
+ debugLog('findOne transformed', {
504
+ collection,
505
+ transformedKeys: Object.keys(transformed)
506
+ });
507
+ }
508
+ return transformed;
445
509
  } catch (error) {
446
510
  console.error('[payload-adapter] findOne failed:', {
447
511
  model,
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Logout button component styled to match Payload's admin nav.
3
3
  * Uses Payload's CSS classes and variables for native theme integration.
4
+ *
5
+ * Clears both Better Auth session and Payload's JWT cookie to ensure
6
+ * clean state when switching between users.
4
7
  */
5
8
  export declare function LogoutButton(): import("react").JSX.Element;
6
9
  export default LogoutButton;
@@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation.js';
5
5
  /**
6
6
  * Logout button component styled to match Payload's admin nav.
7
7
  * Uses Payload's CSS classes and variables for native theme integration.
8
+ *
9
+ * Clears both Better Auth session and Payload's JWT cookie to ensure
10
+ * clean state when switching between users.
8
11
  */ export function LogoutButton() {
9
12
  const router = useRouter();
10
13
  const [isLoading, setIsLoading] = useState(false);
@@ -12,14 +15,23 @@ import { useRouter } from 'next/navigation.js';
12
15
  if (isLoading) return;
13
16
  setIsLoading(true);
14
17
  try {
15
- await fetch('/api/auth/sign-out', {
16
- method: 'POST',
17
- credentials: 'include',
18
- headers: {
19
- 'Content-Type': 'application/json'
20
- },
21
- body: JSON.stringify({})
22
- });
18
+ // Clear both sessions simultaneously while cookies are still valid.
19
+ // - Better Auth: clears BA session cookie
20
+ // - Payload: clears JWT cookie (payload-token) so useAuth() resets
21
+ await Promise.allSettled([
22
+ fetch('/api/auth/sign-out', {
23
+ method: 'POST',
24
+ credentials: 'include',
25
+ headers: {
26
+ 'Content-Type': 'application/json'
27
+ },
28
+ body: JSON.stringify({})
29
+ }),
30
+ fetch('/api/users/logout', {
31
+ method: 'POST',
32
+ credentials: 'include'
33
+ })
34
+ ]);
23
35
  router.push('/admin/login');
24
36
  } catch (error) {
25
37
  console.error('[better-auth] Logout error:', error);
@@ -1,5 +1,13 @@
1
1
  /**
2
2
  * Thin wrapper around PasskeysManagementClient for use as a Payload `ui` field.
3
+ *
4
+ * Better Auth's passkey APIs are session-based (always operate on the logged-in user).
5
+ * When viewing another user's document, we show an info message instead of the
6
+ * management UI to avoid displaying the admin's own passkeys on someone else's page.
7
+ *
8
+ * While auth context is hydrating (user is null), we show the management UI since
9
+ * the API is session-based and will only ever return the logged-in user's passkeys —
10
+ * no other user's data can be leaked.
3
11
  */
4
12
  export declare function PasskeysField(): import("react").JSX.Element;
5
13
  export default PasskeysField;
@@ -1,9 +1,32 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useAuth, useDocumentInfo } from '@payloadcms/ui';
3
4
  import { PasskeysManagementClient } from '../PasskeysManagementClient.js';
4
5
  /**
5
6
  * Thin wrapper around PasskeysManagementClient for use as a Payload `ui` field.
7
+ *
8
+ * Better Auth's passkey APIs are session-based (always operate on the logged-in user).
9
+ * When viewing another user's document, we show an info message instead of the
10
+ * management UI to avoid displaying the admin's own passkeys on someone else's page.
11
+ *
12
+ * While auth context is hydrating (user is null), we show the management UI since
13
+ * the API is session-based and will only ever return the logged-in user's passkeys —
14
+ * no other user's data can be leaked.
6
15
  */ export function PasskeysField() {
16
+ const { id: documentId } = useDocumentInfo();
17
+ const { user } = useAuth();
18
+ // Only block when we KNOW it's a different user.
19
+ // While auth is loading (user is null), show the UI — it's session-based
20
+ // and only returns the logged-in user's own passkeys regardless.
21
+ if (user && String(documentId) !== String(user.id)) {
22
+ return /*#__PURE__*/ _jsx("div", {
23
+ className: "field-type",
24
+ children: /*#__PURE__*/ _jsx("p", {
25
+ className: "field-description",
26
+ children: "Passkeys can only be managed by the account owner."
27
+ })
28
+ });
29
+ }
7
30
  return /*#__PURE__*/ _jsx(PasskeysManagementClient, {});
8
31
  }
9
32
  export default PasskeysField;
@@ -1,6 +1,15 @@
1
1
  /**
2
2
  * Wrapper around TwoFactorManagementClient for use as a Payload `ui` field.
3
3
  *
4
+ * Better Auth's 2FA APIs are session-based (always operate on the logged-in user).
5
+ * When viewing another user's document, we show an info message instead of the
6
+ * management UI to avoid the admin accidentally modifying their own 2FA settings
7
+ * while on someone else's page.
8
+ *
9
+ * While auth context is hydrating (user is null), we show the management UI since
10
+ * the API is session-based and will only ever operate on the logged-in user's own
11
+ * 2FA settings — no other user's data can be leaked or modified.
12
+ *
4
13
  * After 2FA is enabled or disabled, triggers a Next.js router refresh so that
5
14
  * the document form re-fetches from the DB and picks up the `twoFactorEnabled`
6
15
  * value that Better Auth wrote. Without this, navigating away without clicking
@@ -2,21 +2,45 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useRouter } from 'next/navigation.js';
4
4
  import { useCallback } from 'react';
5
+ import { useAuth, useDocumentInfo } from '@payloadcms/ui';
5
6
  import { TwoFactorManagementClient } from '../TwoFactorManagementClient.js';
6
7
  /**
7
8
  * Wrapper around TwoFactorManagementClient for use as a Payload `ui` field.
8
9
  *
10
+ * Better Auth's 2FA APIs are session-based (always operate on the logged-in user).
11
+ * When viewing another user's document, we show an info message instead of the
12
+ * management UI to avoid the admin accidentally modifying their own 2FA settings
13
+ * while on someone else's page.
14
+ *
15
+ * While auth context is hydrating (user is null), we show the management UI since
16
+ * the API is session-based and will only ever operate on the logged-in user's own
17
+ * 2FA settings — no other user's data can be leaked or modified.
18
+ *
9
19
  * After 2FA is enabled or disabled, triggers a Next.js router refresh so that
10
20
  * the document form re-fetches from the DB and picks up the `twoFactorEnabled`
11
21
  * value that Better Auth wrote. Without this, navigating away without clicking
12
22
  * Save would overwrite the change.
13
23
  */ export function TwoFactorField() {
14
24
  const router = useRouter();
25
+ const { id: documentId } = useDocumentInfo();
26
+ const { user } = useAuth();
15
27
  const handleComplete = useCallback(()=>{
16
28
  router.refresh();
17
29
  }, [
18
30
  router
19
31
  ]);
32
+ // Only block when we KNOW it's a different user.
33
+ // While auth is loading (user is null), show the UI — it's session-based
34
+ // and only operates on the logged-in user's own 2FA regardless.
35
+ if (user && String(documentId) !== String(user.id)) {
36
+ return /*#__PURE__*/ _jsx("div", {
37
+ className: "field-type",
38
+ children: /*#__PURE__*/ _jsx("p", {
39
+ className: "field-description",
40
+ children: "Two-factor authentication can only be managed by the account owner."
41
+ })
42
+ });
43
+ }
20
44
  return /*#__PURE__*/ _jsx(TwoFactorManagementClient, {
21
45
  onComplete: handleComplete
22
46
  });
package/dist/index.d.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * @packageDocumentation
8
8
  */
9
- export { payloadAdapter } from './adapter/index.js';
10
- export type { PayloadAdapterConfig } from './adapter/index.js';
9
+ export { payloadAdapter, detectDbType, resolveIdType } from './adapter/index.js';
10
+ export type { PayloadAdapterConfig, DbType } from './adapter/index.js';
11
11
  export { betterAuthCollections } from './adapter/collections.js';
12
12
  export type { BetterAuthCollectionsOptions } from './adapter/collections.js';
13
13
  export { createBetterAuthPlugin, betterAuthStrategy, resetAuthInstance, getApiKeyScopesConfig, } from './plugin/index.js';
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @packageDocumentation
8
8
  */ // Adapter
9
- export { payloadAdapter } from './adapter/index.js';
9
+ export { payloadAdapter, detectDbType, resolveIdType } from './adapter/index.js';
10
10
  // Collection generator plugin
11
11
  export { betterAuthCollections } from './adapter/collections.js';
12
12
  // Payload plugin and strategy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-better-auth",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Better Auth adapter and plugins for Payload CMS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -89,17 +89,17 @@
89
89
  }
90
90
  },
91
91
  "devDependencies": {
92
- "@payloadcms/next": "^3.76.1",
93
- "@payloadcms/ui": "^3.76.1",
92
+ "@better-auth/passkey": "^1.4.18",
93
+ "@payloadcms/next": "^3.77.0",
94
+ "@payloadcms/ui": "^3.77.0",
94
95
  "@swc/cli": "^0.6.0",
95
96
  "@swc/core": "^1.15.11",
96
- "@types/node": "^25.2.3",
97
+ "@types/node": "^25.3.0",
97
98
  "@types/react": "^19.2.14",
98
99
  "@vitest/coverage-v8": "^2.1.9",
99
- "@better-auth/passkey": "^1.4.18",
100
100
  "better-auth": "^1.4.18",
101
101
  "next": "^16.1.6",
102
- "payload": "^3.76.1",
102
+ "payload": "^3.77.0",
103
103
  "react": "^19.2.4",
104
104
  "tsx": "^4.21.0",
105
105
  "typescript": "^5.9.3",