@delmaredigital/payload-better-auth 0.3.7 → 0.3.8

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.
Files changed (160) hide show
  1. package/README.md +12 -1
  2. package/dist/adapter/collections.d.ts.map +1 -1
  3. package/dist/adapter/collections.js +126 -88
  4. package/dist/adapter/collections.js.map +1 -1
  5. package/dist/adapter/index.js +197 -150
  6. package/dist/adapter/index.js.map +1 -1
  7. package/dist/components/BeforeLogin.d.ts +1 -1
  8. package/dist/components/BeforeLogin.d.ts.map +1 -1
  9. package/dist/components/BeforeLogin.js +15 -7
  10. package/dist/components/BeforeLogin.js.map +1 -1
  11. package/dist/components/LoginView.d.ts +2 -2
  12. package/dist/components/LoginView.d.ts.map +1 -1
  13. package/dist/components/LoginView.js +660 -218
  14. package/dist/components/LoginView.js.map +1 -1
  15. package/dist/components/LoginViewWrapper.d.ts +1 -1
  16. package/dist/components/LoginViewWrapper.d.ts.map +1 -1
  17. package/dist/components/LoginViewWrapper.js +14 -4
  18. package/dist/components/LoginViewWrapper.js.map +1 -1
  19. package/dist/components/LogoutButton.d.ts +1 -1
  20. package/dist/components/LogoutButton.d.ts.map +1 -1
  21. package/dist/components/LogoutButton.js +19 -11
  22. package/dist/components/LogoutButton.js.map +1 -1
  23. package/dist/components/PasskeyRegisterButton.d.ts +2 -2
  24. package/dist/components/PasskeyRegisterButton.d.ts.map +1 -1
  25. package/dist/components/PasskeyRegisterButton.js +20 -16
  26. package/dist/components/PasskeyRegisterButton.js.map +1 -1
  27. package/dist/components/PasskeySignInButton.d.ts +2 -2
  28. package/dist/components/PasskeySignInButton.d.ts.map +1 -1
  29. package/dist/components/PasskeySignInButton.js +14 -12
  30. package/dist/components/PasskeySignInButton.js.map +1 -1
  31. package/dist/components/auth/ForgotPasswordView.d.ts +1 -1
  32. package/dist/components/auth/ForgotPasswordView.d.ts.map +1 -1
  33. package/dist/components/auth/ForgotPasswordView.js +133 -43
  34. package/dist/components/auth/ForgotPasswordView.js.map +1 -1
  35. package/dist/components/auth/ResetPasswordView.d.ts +1 -1
  36. package/dist/components/auth/ResetPasswordView.d.ts.map +1 -1
  37. package/dist/components/auth/ResetPasswordView.js +154 -50
  38. package/dist/components/auth/ResetPasswordView.js.map +1 -1
  39. package/dist/components/auth/index.js +2 -2
  40. package/dist/components/auth/index.js.map +1 -1
  41. package/dist/components/management/ApiKeysManagementClient.d.ts +2 -2
  42. package/dist/components/management/ApiKeysManagementClient.d.ts.map +1 -1
  43. package/dist/components/management/ApiKeysManagementClient.js +539 -222
  44. package/dist/components/management/ApiKeysManagementClient.js.map +1 -1
  45. package/dist/components/management/PasskeysManagementClient.d.ts +2 -2
  46. package/dist/components/management/PasskeysManagementClient.d.ts.map +1 -1
  47. package/dist/components/management/PasskeysManagementClient.js +215 -92
  48. package/dist/components/management/PasskeysManagementClient.js.map +1 -1
  49. package/dist/components/management/SecurityNavLinks.d.ts +1 -1
  50. package/dist/components/management/SecurityNavLinks.d.ts.map +1 -1
  51. package/dist/components/management/SecurityNavLinks.js +51 -24
  52. package/dist/components/management/SecurityNavLinks.js.map +1 -1
  53. package/dist/components/management/TwoFactorManagementClient.d.ts +2 -2
  54. package/dist/components/management/TwoFactorManagementClient.d.ts.map +1 -1
  55. package/dist/components/management/TwoFactorManagementClient.js +270 -111
  56. package/dist/components/management/TwoFactorManagementClient.js.map +1 -1
  57. package/dist/components/management/index.js +2 -2
  58. package/dist/components/management/index.js.map +1 -1
  59. package/dist/components/management/views/ApiKeysView.d.ts +1 -1
  60. package/dist/components/management/views/ApiKeysView.d.ts.map +1 -1
  61. package/dist/components/management/views/ApiKeysView.js +19 -4
  62. package/dist/components/management/views/ApiKeysView.js.map +1 -1
  63. package/dist/components/management/views/PasskeysView.d.ts +1 -1
  64. package/dist/components/management/views/PasskeysView.d.ts.map +1 -1
  65. package/dist/components/management/views/PasskeysView.js +16 -4
  66. package/dist/components/management/views/PasskeysView.js.map +1 -1
  67. package/dist/components/management/views/TwoFactorView.d.ts +1 -1
  68. package/dist/components/management/views/TwoFactorView.d.ts.map +1 -1
  69. package/dist/components/management/views/TwoFactorView.js +16 -4
  70. package/dist/components/management/views/TwoFactorView.js.map +1 -1
  71. package/dist/components/management/views/index.js +2 -2
  72. package/dist/components/management/views/index.js.map +1 -1
  73. package/dist/components/twoFactor/TwoFactorSetupView.d.ts +1 -1
  74. package/dist/components/twoFactor/TwoFactorSetupView.d.ts.map +1 -1
  75. package/dist/components/twoFactor/TwoFactorSetupView.js +240 -87
  76. package/dist/components/twoFactor/TwoFactorSetupView.js.map +1 -1
  77. package/dist/components/twoFactor/TwoFactorVerifyView.d.ts +1 -1
  78. package/dist/components/twoFactor/TwoFactorVerifyView.d.ts.map +1 -1
  79. package/dist/components/twoFactor/TwoFactorVerifyView.js +108 -45
  80. package/dist/components/twoFactor/TwoFactorVerifyView.js.map +1 -1
  81. package/dist/components/twoFactor/index.js +2 -2
  82. package/dist/components/twoFactor/index.js.map +1 -1
  83. package/dist/exports/client.js +9 -10
  84. package/dist/exports/client.js.map +1 -1
  85. package/dist/exports/components.js +2 -2
  86. package/dist/exports/components.js.map +1 -1
  87. package/dist/exports/management.js +3 -3
  88. package/dist/exports/management.js.map +1 -1
  89. package/dist/exports/rsc.js +2 -2
  90. package/dist/exports/rsc.js.map +1 -1
  91. package/dist/generated-types.js +4 -2
  92. package/dist/generated-types.js.map +1 -1
  93. package/dist/index.js +6 -6
  94. package/dist/index.js.map +1 -1
  95. package/dist/plugin/index.js +198 -162
  96. package/dist/plugin/index.js.map +1 -1
  97. package/dist/scripts/generate-types.js +66 -50
  98. package/dist/scripts/generate-types.js.map +1 -1
  99. package/dist/types/apiKey.js +7 -2
  100. package/dist/types/apiKey.js.map +1 -1
  101. package/dist/types/betterAuth.js +23 -2
  102. package/dist/types/betterAuth.js.map +1 -1
  103. package/dist/utils/access.js +78 -81
  104. package/dist/utils/access.js.map +1 -1
  105. package/dist/utils/apiKeyAccess.js +65 -72
  106. package/dist/utils/apiKeyAccess.js.map +1 -1
  107. package/dist/utils/betterAuthDefaults.js +8 -8
  108. package/dist/utils/betterAuthDefaults.js.map +1 -1
  109. package/dist/utils/detectAuthConfig.js +8 -11
  110. package/dist/utils/detectAuthConfig.js.map +1 -1
  111. package/dist/utils/detectEnabledPlugins.js +6 -7
  112. package/dist/utils/detectEnabledPlugins.js.map +1 -1
  113. package/dist/utils/firstUserAdmin.js +18 -20
  114. package/dist/utils/firstUserAdmin.js.map +1 -1
  115. package/dist/utils/generateScopes.js +40 -41
  116. package/dist/utils/generateScopes.js.map +1 -1
  117. package/dist/utils/session.js +8 -9
  118. package/dist/utils/session.js.map +1 -1
  119. package/package.json +97 -26
  120. package/src/adapter/collections.ts +621 -0
  121. package/src/adapter/index.ts +712 -0
  122. package/src/components/BeforeLogin.tsx +39 -0
  123. package/src/components/LoginView.tsx +1516 -0
  124. package/src/components/LoginViewWrapper.tsx +35 -0
  125. package/src/components/LogoutButton.tsx +58 -0
  126. package/src/components/PasskeyRegisterButton.tsx +105 -0
  127. package/src/components/PasskeySignInButton.tsx +96 -0
  128. package/src/components/auth/ForgotPasswordView.tsx +274 -0
  129. package/src/components/auth/ResetPasswordView.tsx +331 -0
  130. package/src/components/auth/index.ts +8 -0
  131. package/src/components/management/ApiKeysManagementClient.tsx +988 -0
  132. package/src/components/management/PasskeysManagementClient.tsx +409 -0
  133. package/src/components/management/SecurityNavLinks.tsx +117 -0
  134. package/src/components/management/TwoFactorManagementClient.tsx +560 -0
  135. package/src/components/management/index.ts +20 -0
  136. package/src/components/management/views/ApiKeysView.tsx +57 -0
  137. package/src/components/management/views/PasskeysView.tsx +42 -0
  138. package/src/components/management/views/TwoFactorView.tsx +42 -0
  139. package/src/components/management/views/index.ts +10 -0
  140. package/src/components/twoFactor/TwoFactorSetupView.tsx +515 -0
  141. package/src/components/twoFactor/TwoFactorVerifyView.tsx +238 -0
  142. package/src/components/twoFactor/index.ts +8 -0
  143. package/src/exports/client.ts +77 -0
  144. package/src/exports/components.ts +30 -0
  145. package/src/exports/management.ts +25 -0
  146. package/src/exports/rsc.ts +11 -0
  147. package/src/generated-types.ts +269 -0
  148. package/src/index.ts +135 -0
  149. package/src/plugin/index.ts +834 -0
  150. package/src/scripts/generate-types.ts +269 -0
  151. package/src/types/apiKey.ts +63 -0
  152. package/src/types/betterAuth.ts +253 -0
  153. package/src/utils/access.ts +410 -0
  154. package/src/utils/apiKeyAccess.ts +443 -0
  155. package/src/utils/betterAuthDefaults.ts +102 -0
  156. package/src/utils/detectAuthConfig.ts +47 -0
  157. package/src/utils/detectEnabledPlugins.ts +69 -0
  158. package/src/utils/firstUserAdmin.ts +164 -0
  159. package/src/utils/generateScopes.ts +150 -0
  160. package/src/utils/session.ts +91 -0
@@ -0,0 +1,834 @@
1
+ /**
2
+ * Payload Plugins for Better Auth
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ import type {
8
+ Plugin,
9
+ AuthStrategy,
10
+ Payload,
11
+ BasePayload,
12
+ Endpoint,
13
+ PayloadHandler,
14
+ Config,
15
+ } from 'payload'
16
+ import type { betterAuth, BetterAuthOptions } from 'better-auth'
17
+ import { detectAuthConfig } from '../utils/detectAuthConfig.js'
18
+ import {
19
+ detectEnabledPlugins,
20
+ type EnabledPluginsResult,
21
+ } from '../utils/detectEnabledPlugins.js'
22
+ import type { ApiKeyScopesConfig } from '../types/apiKey.js'
23
+ import {
24
+ buildAvailableScopes,
25
+ scopesToPermissions,
26
+ } from '../utils/generateScopes.js'
27
+
28
+ export type Auth = ReturnType<typeof betterAuth>
29
+ // PayloadWithAuth from types
30
+ import type { PayloadWithAuth } from '../types/betterAuth.js'
31
+ export type { PayloadWithAuth } from '../types/betterAuth.js'
32
+
33
+ export type CreateAuthFunction = (payload: BasePayload) => Auth
34
+
35
+ export type BetterAuthPluginAdminOptions = {
36
+ /** Disable auto-injection of logout button */
37
+ disableLogoutButton?: boolean
38
+ /** Disable auto-injection of BeforeLogin redirect */
39
+ disableBeforeLogin?: boolean
40
+ /** Disable auto-injection of login view */
41
+ disableLoginView?: boolean
42
+ /** Login page customization */
43
+ login?: {
44
+ /** Custom title for login page */
45
+ title?: string
46
+ /** Path to redirect after successful login. Default: '/admin' */
47
+ afterLoginPath?: string
48
+ /**
49
+ * Required role(s) for admin access.
50
+ * - string: Single role required (default: 'admin')
51
+ * - string[]: Multiple roles (behavior depends on requireAllRoles)
52
+ * - null: Disable role checking
53
+ */
54
+ requiredRole?: string | string[] | null
55
+ /**
56
+ * When requiredRole is an array, require ALL roles (true) or ANY role (false).
57
+ * Default: false (any matching role grants access)
58
+ */
59
+ requireAllRoles?: boolean
60
+ /**
61
+ * Enable passkey (WebAuthn) sign-in option.
62
+ * - true: Always show passkey button
63
+ * - false: Never show passkey button
64
+ * - 'auto': Auto-detect if passkey plugin is available (default for LoginView)
65
+ * Default: false (for backwards compatibility)
66
+ */
67
+ enablePasskey?: boolean | 'auto'
68
+ /**
69
+ * Enable user registration (sign up) option.
70
+ * - true: Always show "Create account" link
71
+ * - false: Never show registration option
72
+ * - 'auto': Auto-detect if sign-up endpoint is available
73
+ * Default: 'auto' - LoginView automatically detects if Better Auth has signup enabled
74
+ */
75
+ enableSignUp?: boolean | 'auto'
76
+ /**
77
+ * Default role to assign to new users during registration.
78
+ * Only used when enableSignUp is enabled.
79
+ * Default: 'user'
80
+ */
81
+ defaultSignUpRole?: string
82
+ /**
83
+ * Enable forgot password option.
84
+ * - true: Always show "Forgot password?" link
85
+ * - false: Never show forgot password option
86
+ * - 'auto': Auto-detect if password reset endpoint is available
87
+ * Default: 'auto' - LoginView automatically detects if Better Auth has password reset enabled
88
+ */
89
+ enableForgotPassword?: boolean | 'auto'
90
+ /**
91
+ * Custom URL for password reset page. If provided, users will be redirected here
92
+ * instead of showing the inline password reset form.
93
+ */
94
+ resetPasswordUrl?: string
95
+ }
96
+ /** Path to custom logout button component (import map format) */
97
+ logoutButtonComponent?: string
98
+ /** Path to custom BeforeLogin component (import map format) */
99
+ beforeLoginComponent?: string
100
+ /** Path to custom login view component (import map format) */
101
+ loginViewComponent?: string
102
+
103
+ /**
104
+ * Enable management UI for security features (2FA, API keys).
105
+ * Management views are auto-injected based on which Better Auth plugins are enabled.
106
+ * @default true
107
+ */
108
+ enableManagementUI?: boolean
109
+ /**
110
+ * Better Auth options - used to detect which plugins are enabled.
111
+ * Required for management UI to auto-detect enabled features.
112
+ */
113
+ betterAuthOptions?: Partial<BetterAuthOptions>
114
+ /** Custom paths for management views */
115
+ managementPaths?: {
116
+ /** Two-factor management view path. Default: '/security/two-factor' */
117
+ twoFactor?: string
118
+ /** API keys management view path. Default: '/security/api-keys' */
119
+ apiKeys?: string
120
+ /** Passkeys management view path. Default: '/security/passkeys' */
121
+ passkeys?: string
122
+ }
123
+ /**
124
+ * API key scopes configuration.
125
+ * Controls which permission scopes are available when creating API keys.
126
+ * When not provided, scopes are auto-generated from Payload collections.
127
+ */
128
+ apiKey?: ApiKeyScopesConfig
129
+ }
130
+
131
+ export type BetterAuthPluginOptions = {
132
+ /**
133
+ * Function that creates the Better Auth instance.
134
+ * Called during Payload's onInit lifecycle.
135
+ */
136
+ createAuth: CreateAuthFunction
137
+
138
+ /**
139
+ * Base path for auth API endpoints (registered via Payload endpoints).
140
+ * @default '/auth'
141
+ */
142
+ authBasePath?: string
143
+
144
+ /**
145
+ * Auto-register auth API endpoints via Payload's endpoint system.
146
+ * Set to false if you need custom route-level handling (rare).
147
+ * Note: All Better Auth customization (hooks, plugins, callbacks)
148
+ * is done in createAuth - the route handler is just a passthrough.
149
+ * @default true
150
+ */
151
+ autoRegisterEndpoints?: boolean
152
+
153
+ /**
154
+ * Auto-inject admin components when disableLocalStrategy is detected.
155
+ * @default true
156
+ */
157
+ autoInjectAdminComponents?: boolean
158
+
159
+ /**
160
+ * Admin UI customization options.
161
+ */
162
+ admin?: BetterAuthPluginAdminOptions
163
+ }
164
+
165
+ // Track auth instance for HMR
166
+ let authInstance: Auth | null = null
167
+
168
+ // Store API key scopes config for access by management views
169
+ let apiKeyScopesConfig: ApiKeyScopesConfig | undefined = undefined
170
+
171
+ /**
172
+ * Get the configured API key scopes config.
173
+ * Used by the ApiKeysView to build available scopes.
174
+ */
175
+ export function getApiKeyScopesConfig(): ApiKeyScopesConfig | undefined {
176
+ return apiKeyScopesConfig
177
+ }
178
+
179
+ // Type for auth api methods we need
180
+ type AuthApi = {
181
+ getSession: (options: { headers: Headers }) => Promise<{
182
+ user?: { id: string } | null
183
+ session?: Record<string, unknown> | null
184
+ } | null>
185
+ createApiKey?: (options: {
186
+ body: {
187
+ name?: string
188
+ expiresIn?: number
189
+ userId: string
190
+ prefix?: string
191
+ metadata?: Record<string, unknown>
192
+ permissions?: Record<string, string[]>
193
+ }
194
+ }) => Promise<{
195
+ key: string
196
+ id: string
197
+ name: string | null
198
+ userId: string
199
+ expiresAt: Date | null
200
+ createdAt: Date
201
+ metadata: Record<string, unknown> | null
202
+ }>
203
+ }
204
+
205
+ /**
206
+ * Handle API key creation with scopes server-side.
207
+ * Converts scopes to permissions and calls Better Auth's server API.
208
+ */
209
+ async function handleApiKeyCreateWithScopes(
210
+ authApi: AuthApi,
211
+ payload: Payload,
212
+ headers: Headers,
213
+ body: Record<string, unknown>
214
+ ): Promise<Response> {
215
+ try {
216
+ // Get the current session to find the user
217
+ const session = await authApi.getSession({ headers })
218
+ if (!session?.user?.id) {
219
+ return new Response(
220
+ JSON.stringify({ error: 'Unauthorized' }),
221
+ { status: 401, headers: { 'Content-Type': 'application/json' } }
222
+ )
223
+ }
224
+
225
+ // Extract scopes from the request body
226
+ const scopes = (body.scopes as string[] | undefined) ?? []
227
+
228
+ // Build permissions from scopes if any are provided
229
+ let permissions: Record<string, string[]> | undefined
230
+ if (scopes.length > 0) {
231
+ const scopesConfig = getApiKeyScopesConfig()
232
+ const availableScopes = buildAvailableScopes(
233
+ payload.config.collections,
234
+ scopesConfig
235
+ )
236
+ permissions = scopesToPermissions(scopes, availableScopes)
237
+ }
238
+
239
+ // Build the API key creation options
240
+ const createOptions = {
241
+ body: {
242
+ name: body.name as string | undefined,
243
+ userId: session.user.id,
244
+ expiresIn: body.expiresIn as number | undefined,
245
+ prefix: body.prefix as string | undefined,
246
+ permissions: permissions && Object.keys(permissions).length > 0 ? permissions : undefined,
247
+ metadata: scopes.length > 0
248
+ ? { ...(body.metadata as Record<string, unknown> | undefined), scopes }
249
+ : (body.metadata as Record<string, unknown> | undefined),
250
+ },
251
+ }
252
+
253
+ // Call Better Auth's server-side API
254
+ if (typeof authApi.createApiKey !== 'function') {
255
+ return new Response(
256
+ JSON.stringify({ error: 'API key plugin not enabled' }),
257
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
258
+ )
259
+ }
260
+
261
+ try {
262
+ const result = await authApi.createApiKey(createOptions)
263
+ return new Response(JSON.stringify(result), {
264
+ status: 200,
265
+ headers: { 'Content-Type': 'application/json' },
266
+ })
267
+ } catch (createError) {
268
+ // Check if error is due to metadata being disabled
269
+ const errorMessage = createError instanceof Error ? createError.message : String(createError)
270
+ const isMetadataDisabled = errorMessage.toLowerCase().includes('metadata') &&
271
+ errorMessage.toLowerCase().includes('disabled')
272
+
273
+ if (isMetadataDisabled && createOptions.body.metadata) {
274
+ // Retry without metadata - key will still work, just won't show scopes in UI
275
+ console.warn('[better-auth] Metadata disabled, creating API key without scope metadata. Enable metadata with apiKeyWithDefaults() for better UX.')
276
+ const optionsWithoutMetadata = {
277
+ body: {
278
+ ...createOptions.body,
279
+ metadata: undefined,
280
+ },
281
+ }
282
+ const result = await authApi.createApiKey(optionsWithoutMetadata)
283
+ return new Response(JSON.stringify(result), {
284
+ status: 200,
285
+ headers: { 'Content-Type': 'application/json' },
286
+ })
287
+ }
288
+
289
+ // Re-throw other errors
290
+ throw createError
291
+ }
292
+ } catch (error) {
293
+ console.error('[better-auth] API key creation error:', error)
294
+ const message = error instanceof Error ? error.message : 'Failed to create API key'
295
+ return new Response(
296
+ JSON.stringify({ error: message }),
297
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
298
+ )
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Creates the auth endpoint handler that proxies requests to Better Auth.
304
+ */
305
+ function createAuthEndpointHandler(): PayloadHandler {
306
+ return async (req) => {
307
+ const payloadWithAuth = req.payload as PayloadWithAuth
308
+ const auth = payloadWithAuth.betterAuth
309
+
310
+ if (!auth) {
311
+ return new Response(
312
+ JSON.stringify({ error: 'Better Auth not initialized' }),
313
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
314
+ )
315
+ }
316
+
317
+ try {
318
+ // Construct the full URL for Better Auth
319
+ // PayloadRequest provides these properties
320
+ const protocol = req.headers.get('x-forwarded-proto') || 'http'
321
+ const host = req.headers.get('host') || 'localhost'
322
+ const pathname = (req as unknown as { pathname?: string }).pathname || ''
323
+ const search =
324
+ (req as unknown as { search?: string }).search ||
325
+ (req as unknown as { url?: string }).url?.split('?')[1] ||
326
+ ''
327
+
328
+ const url = new URL(pathname, `${protocol}://${host}`)
329
+ if (search) {
330
+ url.search = search.startsWith('?') ? search : `?${search}`
331
+ }
332
+
333
+ // Get request body for non-GET methods
334
+ let body: string | undefined
335
+ let parsedBody: Record<string, unknown> | undefined
336
+ if (req.method && !['GET', 'HEAD'].includes(req.method)) {
337
+ try {
338
+ // Try to get body from request
339
+ if (typeof (req as unknown as { text?: () => Promise<string> }).text === 'function') {
340
+ body = await (req as unknown as { text: () => Promise<string> }).text()
341
+ if (body) {
342
+ try {
343
+ parsedBody = JSON.parse(body)
344
+ } catch {
345
+ // Not JSON, that's okay
346
+ }
347
+ }
348
+ } else if ((req as unknown as { data?: unknown }).data) {
349
+ parsedBody = (req as unknown as { data: Record<string, unknown> }).data
350
+ body = JSON.stringify(parsedBody)
351
+ }
352
+ } catch {
353
+ // Body might already be consumed, try data property
354
+ if ((req as unknown as { data?: unknown }).data) {
355
+ parsedBody = (req as unknown as { data: Record<string, unknown> }).data
356
+ body = JSON.stringify(parsedBody)
357
+ }
358
+ }
359
+ }
360
+
361
+ // Intercept API key creation requests with scopes
362
+ // Better Auth's API key create endpoint is POST /api-key/create
363
+ const isApiKeyCreate =
364
+ req.method === 'POST' &&
365
+ pathname.endsWith('/api-key/create') &&
366
+ parsedBody?.scopes &&
367
+ Array.isArray(parsedBody.scopes)
368
+
369
+ if (isApiKeyCreate && parsedBody) {
370
+ return handleApiKeyCreateWithScopes(
371
+ auth.api as unknown as AuthApi,
372
+ req.payload,
373
+ req.headers,
374
+ parsedBody
375
+ )
376
+ }
377
+
378
+ // Create a new Request for Better Auth
379
+ const request = new Request(url.toString(), {
380
+ method: req.method || 'GET',
381
+ headers: req.headers,
382
+ body,
383
+ })
384
+
385
+ const response = await auth.handler(request)
386
+
387
+ return response
388
+ } catch (error) {
389
+ console.error('[better-auth] Endpoint handler error:', error)
390
+ return new Response(
391
+ JSON.stringify({ error: 'Internal server error' }),
392
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
393
+ )
394
+ }
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Generates Payload endpoints for Better Auth.
400
+ */
401
+ function generateAuthEndpoints(basePath: string): Endpoint[] {
402
+ const handler = createAuthEndpointHandler()
403
+ const methods = ['get', 'post', 'patch', 'put', 'delete'] as const
404
+
405
+ return methods.map((method) => ({
406
+ path: `${basePath}/:path*`,
407
+ method,
408
+ handler,
409
+ }))
410
+ }
411
+
412
+ /**
413
+ * Injects admin components into the Payload config when disableLocalStrategy is detected.
414
+ */
415
+ function injectAdminComponents(
416
+ config: Config,
417
+ options: BetterAuthPluginOptions
418
+ ): Config {
419
+ const authDetection = detectAuthConfig(config)
420
+
421
+ // Skip if not using disableLocalStrategy or auto-injection is disabled
422
+ if (
423
+ !authDetection.hasDisableLocalStrategy ||
424
+ options.autoInjectAdminComponents === false
425
+ ) {
426
+ return config
427
+ }
428
+
429
+ const adminOptions = options.admin ?? {}
430
+ const existingComponents = config.admin?.components ?? {}
431
+
432
+ // Build logout button config
433
+ const logoutButton = adminOptions.disableLogoutButton
434
+ ? (existingComponents.logout as { Button?: string })?.Button
435
+ : adminOptions.logoutButtonComponent ??
436
+ '@delmaredigital/payload-better-auth/components#LogoutButton'
437
+
438
+ // Build beforeLogin config
439
+ const existingBeforeLogin = existingComponents.beforeLogin ?? []
440
+ const beforeLogin = adminOptions.disableBeforeLogin
441
+ ? existingBeforeLogin
442
+ : [
443
+ ...(Array.isArray(existingBeforeLogin)
444
+ ? existingBeforeLogin
445
+ : [existingBeforeLogin]),
446
+ adminOptions.beforeLoginComponent ??
447
+ '@delmaredigital/payload-better-auth/components#BeforeLogin',
448
+ ]
449
+
450
+ // Build login view config
451
+ const existingViews =
452
+ (existingComponents.views as Record<string, unknown> | undefined) ?? {}
453
+ const newLoginView = adminOptions.disableLoginView
454
+ ? undefined
455
+ : {
456
+ Component:
457
+ adminOptions.loginViewComponent ??
458
+ '@delmaredigital/payload-better-auth/rsc#LoginViewWrapper',
459
+ path: '/login' as const,
460
+ }
461
+
462
+ const views = {
463
+ ...existingViews,
464
+ ...(newLoginView ? { login: newLoginView } : {}),
465
+ }
466
+
467
+ // Store login config in config.custom for the RSC wrapper to read
468
+ const loginConfig = adminOptions.login ?? {}
469
+
470
+ // Note: enabledPlugins will be added by injectManagementComponents
471
+ return {
472
+ ...config,
473
+ custom: {
474
+ ...config.custom,
475
+ betterAuth: {
476
+ ...(config.custom?.betterAuth as Record<string, unknown> | undefined),
477
+ login: loginConfig,
478
+ },
479
+ },
480
+ admin: {
481
+ ...config.admin,
482
+ components: {
483
+ ...existingComponents,
484
+ logout: logoutButton
485
+ ? {
486
+ ...(typeof existingComponents.logout === 'object'
487
+ ? existingComponents.logout
488
+ : {}),
489
+ Button: logoutButton,
490
+ }
491
+ : existingComponents.logout,
492
+ beforeLogin,
493
+ views,
494
+ },
495
+ },
496
+ } as Config
497
+ }
498
+
499
+ /**
500
+ * Injects management UI components into the Payload config based on enabled plugins.
501
+ */
502
+ function injectManagementComponents(
503
+ config: Config,
504
+ options: BetterAuthPluginOptions
505
+ ): Config {
506
+ const adminOptions = options.admin ?? {}
507
+
508
+ // Skip if management UI is disabled
509
+ if (adminOptions.enableManagementUI === false) {
510
+ return config
511
+ }
512
+
513
+ // Detect which plugins are enabled
514
+ const enabledPlugins = detectEnabledPlugins(adminOptions.betterAuthOptions)
515
+
516
+ // Get custom paths or use defaults
517
+ const paths = {
518
+ twoFactor: adminOptions.managementPaths?.twoFactor ?? '/security/two-factor',
519
+ apiKeys: adminOptions.managementPaths?.apiKeys ?? '/security/api-keys',
520
+ passkeys: adminOptions.managementPaths?.passkeys ?? '/security/passkeys',
521
+ }
522
+
523
+ const existingComponents = config.admin?.components ?? {}
524
+ const existingViews =
525
+ (existingComponents.views as Record<string, unknown> | undefined) ?? {}
526
+ const existingAfterNavLinks = existingComponents.afterNavLinks ?? []
527
+
528
+ // Build management views based on enabled plugins
529
+ // Note: Sessions and passkeys use Payload's default collection views
530
+ const managementViews: Record<string, { Component: string; path: string }> = {}
531
+
532
+ // Two-factor (if enabled)
533
+ if (enabledPlugins.hasTwoFactor) {
534
+ managementViews.securityTwoFactor = {
535
+ Component: '@delmaredigital/payload-better-auth/rsc#TwoFactorView',
536
+ path: paths.twoFactor,
537
+ }
538
+ }
539
+
540
+ // API keys (if enabled)
541
+ if (enabledPlugins.hasApiKey) {
542
+ managementViews.securityApiKeys = {
543
+ Component: '@delmaredigital/payload-better-auth/rsc#ApiKeysView',
544
+ path: paths.apiKeys,
545
+ }
546
+ }
547
+
548
+ // Passkeys (if enabled)
549
+ if (enabledPlugins.hasPasskey) {
550
+ managementViews.securityPasskeys = {
551
+ Component: '@delmaredigital/payload-better-auth/rsc#PasskeysView',
552
+ path: paths.passkeys,
553
+ }
554
+ }
555
+
556
+ // Only add nav links if at least one plugin is enabled
557
+ const hasAnyPlugin = enabledPlugins.hasTwoFactor || enabledPlugins.hasApiKey || enabledPlugins.hasPasskey
558
+
559
+ // Add SecurityNavLinks to afterNavLinks with clientProps for enabled plugins
560
+ const afterNavLinks = hasAnyPlugin
561
+ ? [
562
+ ...(Array.isArray(existingAfterNavLinks)
563
+ ? existingAfterNavLinks
564
+ : [existingAfterNavLinks]),
565
+ {
566
+ path: '@delmaredigital/payload-better-auth/components/management#SecurityNavLinks',
567
+ clientProps: {
568
+ showTwoFactor: enabledPlugins.hasTwoFactor,
569
+ showApiKeys: enabledPlugins.hasApiKey,
570
+ showPasskeys: enabledPlugins.hasPasskey,
571
+ },
572
+ },
573
+ ]
574
+ : existingAfterNavLinks
575
+
576
+ return {
577
+ ...config,
578
+ admin: {
579
+ ...config.admin,
580
+ components: {
581
+ ...existingComponents,
582
+ views: {
583
+ ...existingViews,
584
+ ...managementViews,
585
+ },
586
+ afterNavLinks,
587
+ },
588
+ },
589
+ } as Config
590
+ }
591
+
592
+ /**
593
+ * Payload plugin that initializes Better Auth.
594
+ *
595
+ * Better Auth is created in onInit (after Payload is ready) to avoid
596
+ * circular dependency issues. The auth instance is then attached to
597
+ * payload.betterAuth for access throughout the app.
598
+ *
599
+ * Features:
600
+ * - Auto-registers auth API endpoints (configurable)
601
+ * - Auto-injects admin components when disableLocalStrategy is detected
602
+ * - Auto-injects management UI for security features based on enabled plugins
603
+ * - Handles HMR gracefully
604
+ *
605
+ * @example
606
+ * ```ts
607
+ * import { createBetterAuthPlugin } from '@delmaredigital/payload-better-auth/plugin'
608
+ *
609
+ * export default buildConfig({
610
+ * plugins: [
611
+ * createBetterAuthPlugin({
612
+ * createAuth: (payload) => betterAuth({
613
+ * database: payloadAdapter({ payloadClient: payload, ... }),
614
+ * // ... other options
615
+ * }),
616
+ * }),
617
+ * ],
618
+ * })
619
+ * ```
620
+ */
621
+ export function createBetterAuthPlugin(
622
+ options: BetterAuthPluginOptions
623
+ ): Plugin {
624
+ const {
625
+ createAuth,
626
+ authBasePath = '/auth',
627
+ autoRegisterEndpoints = true,
628
+ autoInjectAdminComponents = true,
629
+ } = options
630
+
631
+ // Store API key scopes config for access by management views
632
+ apiKeyScopesConfig = options.admin?.apiKey
633
+
634
+ return (incomingConfig) => {
635
+ // Inject admin components if enabled
636
+ let config =
637
+ autoInjectAdminComponents
638
+ ? injectAdminComponents(incomingConfig, options)
639
+ : incomingConfig
640
+
641
+ // Inject management UI components
642
+ config = injectManagementComponents(config, options)
643
+
644
+ // Generate auth endpoints if enabled
645
+ const authEndpoints = autoRegisterEndpoints
646
+ ? generateAuthEndpoints(authBasePath)
647
+ : []
648
+
649
+ // Merge endpoints
650
+ const existingEndpoints = config.endpoints ?? []
651
+
652
+ // Get existing onInit
653
+ const existingOnInit = config.onInit
654
+
655
+ return {
656
+ ...config,
657
+ endpoints: [...existingEndpoints, ...authEndpoints],
658
+ onInit: async (payload) => {
659
+ if (existingOnInit) {
660
+ await existingOnInit(payload)
661
+ }
662
+
663
+ // Check if already attached (HMR scenario)
664
+ if ('betterAuth' in payload) {
665
+ return
666
+ }
667
+
668
+ // Reuse or create auth instance
669
+ if (!authInstance) {
670
+ try {
671
+ authInstance = createAuth(payload)
672
+ } catch (error) {
673
+ console.error('[better-auth] Failed to create auth:', error)
674
+ throw error
675
+ }
676
+ }
677
+
678
+ // Attach to payload for global access
679
+ Object.defineProperty(payload, 'betterAuth', {
680
+ value: authInstance,
681
+ writable: false,
682
+ enumerable: false,
683
+ configurable: false,
684
+ })
685
+ },
686
+ }
687
+ }
688
+ }
689
+
690
+ export type BetterAuthStrategyOptions = {
691
+ /**
692
+ * The collection slug for users
693
+ * @default 'users'
694
+ */
695
+ usersCollection?: string
696
+ /**
697
+ * The collection slug for organization members (used for organization role lookup)
698
+ * @default 'members'
699
+ */
700
+ membersCollection?: string
701
+ }
702
+
703
+ /**
704
+ * Payload auth strategy that uses Better Auth for authentication.
705
+ *
706
+ * Use this in your Users collection to authenticate via Better Auth sessions.
707
+ *
708
+ * Session fields (like `activeOrganizationId` from the organization plugin) are
709
+ * automatically merged onto `req.user`, making them available in access control functions.
710
+ *
711
+ * If an active organization is set, the user's role in that organization is also
712
+ * fetched and available as `req.user.organizationRole`.
713
+ *
714
+ * @example
715
+ * ```ts
716
+ * import { betterAuthStrategy } from '@delmaredigital/payload-better-auth/plugin'
717
+ *
718
+ * export const Users: CollectionConfig = {
719
+ * slug: 'users',
720
+ * auth: {
721
+ * disableLocalStrategy: true,
722
+ * strategies: [betterAuthStrategy()],
723
+ * },
724
+ * // ...
725
+ * }
726
+ * ```
727
+ *
728
+ * @example Access control with organization data
729
+ * ```ts
730
+ * // In your access control:
731
+ * export const orgReadAccess: Access = ({ req }) => {
732
+ * if (!req.user?.activeOrganizationId) return false
733
+ * return {
734
+ * organization: { equals: req.user.activeOrganizationId }
735
+ * }
736
+ * }
737
+ * ```
738
+ */
739
+ export function betterAuthStrategy(
740
+ options: BetterAuthStrategyOptions = {}
741
+ ): AuthStrategy {
742
+ const { usersCollection = 'users', membersCollection = 'members' } = options
743
+
744
+ return {
745
+ name: 'better-auth',
746
+ authenticate: async ({
747
+ payload,
748
+ headers,
749
+ }: {
750
+ payload: Payload
751
+ headers: Headers
752
+ }) => {
753
+ try {
754
+ const payloadWithAuth = payload as PayloadWithAuth
755
+ const auth = payloadWithAuth.betterAuth
756
+
757
+ if (!auth) {
758
+ console.error('Better Auth not initialized on payload instance')
759
+ return { user: null }
760
+ }
761
+
762
+ const sessionData = await auth.api.getSession({ headers })
763
+
764
+ if (!sessionData?.user?.id) {
765
+ return { user: null }
766
+ }
767
+
768
+ const users = await payload.find({
769
+ collection: usersCollection,
770
+ where: { id: { equals: sessionData.user.id } },
771
+ limit: 1,
772
+ depth: 0,
773
+ })
774
+
775
+ if (users.docs.length === 0) {
776
+ return { user: null }
777
+ }
778
+
779
+ // Extract session fields to merge onto user (e.g., activeOrganizationId from org plugin)
780
+ // Exclude fields that might conflict with user fields
781
+ const {
782
+ id: _sessionId,
783
+ userId: _userId,
784
+ expiresAt: _expiresAt,
785
+ token: _token,
786
+ ...sessionFields
787
+ } = (sessionData.session as Record<string, unknown>) || {}
788
+
789
+ // If there's an active organization, fetch the user's role in that org
790
+ let organizationRole: string | undefined
791
+ if (sessionFields.activeOrganizationId) {
792
+ try {
793
+ const memberships = await payload.find({
794
+ collection: membersCollection,
795
+ where: {
796
+ and: [
797
+ { user: { equals: sessionData.user.id } },
798
+ { organization: { equals: sessionFields.activeOrganizationId } },
799
+ ],
800
+ },
801
+ limit: 1,
802
+ depth: 0,
803
+ })
804
+ if (memberships.docs.length > 0) {
805
+ organizationRole = (memberships.docs[0] as { role?: string }).role
806
+ }
807
+ } catch {
808
+ // Members collection might not exist (org plugin not used), silently ignore
809
+ }
810
+ }
811
+
812
+ return {
813
+ user: {
814
+ ...users.docs[0],
815
+ ...sessionFields, // Merge session fields (activeOrganizationId, etc.)
816
+ ...(organizationRole && { organizationRole }), // Add org role if found
817
+ collection: usersCollection,
818
+ _strategy: 'better-auth',
819
+ },
820
+ }
821
+ } catch (error) {
822
+ console.error('Better Auth strategy error:', error)
823
+ return { user: null }
824
+ }
825
+ },
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Reset the auth instance (useful for testing)
831
+ */
832
+ export function resetAuthInstance(): void {
833
+ authInstance = null
834
+ }