@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.
@@ -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,11 +176,13 @@
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,
160
- supportsDates: 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',
183
+ // Payload returns dates as ISO strings via its Local API, not Date objects.
184
+ // Setting false tells the factory to convert string dates ↔ Date objects.
185
+ supportsDates: false,
161
186
  supportsBooleans: true,
162
187
  supportsJSON: true,
163
188
  supportsArrays: false,
@@ -171,11 +196,18 @@
171
196
  // so we'll resolve it lazily on first operation
172
197
  let resolvedPayload = null;
173
198
  let resolvePromise = null;
199
+ let resolvedDbType = adapterConfig.dbType ?? null;
174
200
  const getPayload = async ()=>{
175
201
  if (resolvedPayload) return resolvedPayload;
176
202
  if (!resolvePromise) {
177
203
  resolvePromise = resolvePayloadClient().then((p)=>{
178
204
  resolvedPayload = p;
205
+ if (!resolvedDbType) {
206
+ resolvedDbType = detectDbType(p);
207
+ if (enableDebugLogs) {
208
+ console.log('[payload-adapter] Detected database type:', resolvedDbType);
209
+ }
210
+ }
179
211
  return p;
180
212
  });
181
213
  }
@@ -295,19 +327,6 @@
295
327
  }
296
328
  }
297
329
  }
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
330
  // Convert semantic ID fields to numbers when using serial IDs
312
331
  // Heuristic: fields ending in 'Id' or '_id' containing numeric strings
313
332
  // Modified by allowlist (add) and blocklist (exclude)
@@ -337,7 +356,7 @@
337
356
  if (where.length === 1) {
338
357
  const w = where[0];
339
358
  return {
340
- [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value)
359
+ [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value, resolvedDbType ?? 'postgres')
341
360
  };
342
361
  }
343
362
  const andConditions = where.filter((w)=>w.connector !== 'OR');
@@ -345,12 +364,12 @@
345
364
  const result = {};
346
365
  if (andConditions.length > 0) {
347
366
  result.and = andConditions.map((w)=>({
348
- [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value)
367
+ [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value, resolvedDbType ?? 'postgres')
349
368
  }));
350
369
  }
351
370
  if (orConditions.length > 0) {
352
371
  result.or = orConditions.map((w)=>({
353
- [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value)
372
+ [getPayloadFieldName(model, w.field)]: convertOperator(w.operator, w.value, resolvedDbType ?? 'postgres')
354
373
  }));
355
374
  }
356
375
  return result;
@@ -389,10 +408,19 @@
389
408
  // Transform back and merge with input data for Better Auth
390
409
  // Database result takes precedence (handles hooks that modify data like firstUserAdmin)
391
410
  const transformed = transformDataFromPayload(model, result);
392
- return {
411
+ const merged = {
393
412
  ...data,
394
413
  ...transformed
395
414
  };
415
+ if (enableDebugLogs) {
416
+ debugLog('create result', {
417
+ collection,
418
+ resultId: result.id,
419
+ transformedKeys: Object.keys(transformed),
420
+ mergedKeys: Object.keys(merged)
421
+ });
422
+ }
423
+ return merged;
396
424
  } catch (error) {
397
425
  console.error('[payload-adapter] create failed:', {
398
426
  collection,
@@ -424,15 +452,39 @@
424
452
  depth: join ? 1 : 0,
425
453
  overrideAccess: true
426
454
  });
427
- return transformDataFromPayload(model, result);
455
+ const transformed = transformDataFromPayload(model, result);
456
+ if (enableDebugLogs) {
457
+ debugLog('findOne result (byID)', {
458
+ collection,
459
+ id: convertId(id),
460
+ found: true,
461
+ transformedKeys: Object.keys(transformed)
462
+ });
463
+ }
464
+ return transformed;
428
465
  } catch (error) {
429
466
  if (error instanceof Error && 'status' in error && error.status === 404) {
467
+ if (enableDebugLogs) {
468
+ debugLog('findOne result (byID)', {
469
+ collection,
470
+ id: convertId(id),
471
+ found: false
472
+ });
473
+ }
430
474
  return null;
431
475
  }
432
476
  throw error;
433
477
  }
434
478
  }
435
479
  const payloadWhere = convertWhereToPayload(model, where);
480
+ if (enableDebugLogs) {
481
+ debugLog('findOne query', {
482
+ collection,
483
+ payloadWhere: JSON.stringify(payloadWhere),
484
+ resolvedDbType,
485
+ idType
486
+ });
487
+ }
436
488
  const result = await payload.find({
437
489
  collection,
438
490
  where: payloadWhere,
@@ -440,8 +492,22 @@
440
492
  depth: join ? 1 : 0,
441
493
  overrideAccess: true
442
494
  });
495
+ if (enableDebugLogs) {
496
+ debugLog('findOne result', {
497
+ collection,
498
+ totalDocs: result.totalDocs,
499
+ found: result.docs.length > 0
500
+ });
501
+ }
443
502
  if (!result.docs[0]) return null;
444
- return transformDataFromPayload(model, result.docs[0]);
503
+ const transformed = transformDataFromPayload(model, result.docs[0]);
504
+ if (enableDebugLogs) {
505
+ debugLog('findOne transformed', {
506
+ collection,
507
+ transformedKeys: Object.keys(transformed)
508
+ });
509
+ }
510
+ return transformed;
445
511
  } catch (error) {
446
512
  console.error('[payload-adapter] findOne failed:', {
447
513
  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.4",
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",