@delmaredigital/payload-better-auth 0.3.15 → 0.4.1

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
@@ -41,6 +41,7 @@ For additional documentation and references, visit: [https://deepwiki.com/delmar
41
41
  | `@payloadcms/next` | >= 3.69.0 |
42
42
  | `@payloadcms/ui` | >= 3.69.0 |
43
43
  | `better-auth` | >= 1.4.0 |
44
+ | `@better-auth/passkey` | >= 1.4.18 (if using passkeys) |
44
45
  | `next` | >= 15.4.8 |
45
46
  | `react` | >= 19.2.1 |
46
47
 
@@ -48,6 +49,9 @@ For additional documentation and references, visit: [https://deepwiki.com/delmar
48
49
 
49
50
  ```bash
50
51
  pnpm add @delmaredigital/payload-better-auth better-auth
52
+
53
+ # If using passkeys:
54
+ pnpm add @better-auth/passkey
51
55
  ```
52
56
 
53
57
  ### Environment Variables
@@ -490,6 +494,27 @@ API keys can have granular permission scopes. By default, scopes are auto-genera
490
494
  | `includeCollectionScopes` | `boolean` | `true` when no custom scopes, `false` when custom scopes provided | Include auto-generated collection scopes |
491
495
  | `excludeCollections` | `string[]` | `['sessions', 'verifications', 'accounts', 'twoFactors', 'apikeys']` | Collections to exclude from auto-generated scopes |
492
496
  | `defaultScopes` | `string[]` | `[]` | Default scopes pre-selected when creating a key |
497
+ | `requiredRole` | `string \| string[] \| null` | Inherits from `admin.login.requiredRole` or `'admin'` | Role(s) required to create/update/delete API keys. Set to `null` to allow any authenticated user (not recommended) |
498
+
499
+ **Restricting API key management to admins:**
500
+
501
+ If your `admin.login.requiredRole` includes non-admin roles (e.g., editors who need admin panel access but shouldn't manage API keys), set `requiredRole` explicitly:
502
+
503
+ ```typescript
504
+ createBetterAuthPlugin({
505
+ createAuth,
506
+ admin: {
507
+ login: {
508
+ requiredRole: ['admin', 'content_editor'], // both can access admin panel
509
+ },
510
+ apiKey: {
511
+ requiredRole: 'admin', // only admins can create/update/delete API keys
512
+ },
513
+ },
514
+ })
515
+ ```
516
+
517
+ > **Note:** API key **verification** is not affected by this setting — existing keys continue to work regardless of who created them. This only restricts key management (create, update, delete).
493
518
 
494
519
  **Zero Config (recommended):**
495
520
  ```typescript
@@ -994,9 +1019,7 @@ The adapter uses Better Auth's `createAdapterFactory` which is **schema-aware**
994
1019
  | API Keys | `better-auth` (core) | Auto-generates apikeys collection |
995
1020
  | Organizations | `better-auth` (core) | Auto-generates organizations, members, invitations |
996
1021
  | Admin | `better-auth` (core) | Adds admin fields to users |
997
- | Passkey | Bundled | Auto-generates passkeys collection |
998
-
999
- **Note:** The `@better-auth/passkey` package is bundled with this package - no separate installation required.
1022
+ | Passkey | `@better-auth/passkey` (peer dep) | Auto-generates passkeys collection |
1000
1023
 
1001
1024
  ### Example: Core Plugins
1002
1025
 
@@ -11,7 +11,7 @@ export type SecurityNavLinksProps = {
11
11
  /**
12
12
  * Navigation links for security management features.
13
13
  * Rendered in admin sidebar via afterNavLinks injection.
14
- * Uses Payload's nav CSS classes for native styling.
14
+ * Uses Payload's NavGroup and nav CSS classes for native styling.
15
15
  *
16
16
  * Links are conditionally shown based on which Better Auth plugins are enabled.
17
17
  */
@@ -1,92 +1,45 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { NavGroup } from '@payloadcms/ui';
3
4
  /**
4
5
  * Navigation links for security management features.
5
6
  * Rendered in admin sidebar via afterNavLinks injection.
6
- * Uses Payload's nav CSS classes for native styling.
7
+ * Uses Payload's NavGroup and nav CSS classes for native styling.
7
8
  *
8
9
  * Links are conditionally shown based on which Better Auth plugins are enabled.
9
10
  */ export function SecurityNavLinks({ basePath = '/admin/security', showTwoFactor = true, showApiKeys = true, showPasskeys = true } = {}) {
10
- // Build links based on enabled plugins
11
11
  const links = [];
12
12
  if (showTwoFactor) {
13
13
  links.push({
14
14
  href: `${basePath}/two-factor`,
15
- label: 'Two-Factor Auth',
16
- icon: '📱'
15
+ label: 'Two-Factor Auth'
17
16
  });
18
17
  }
19
18
  if (showApiKeys) {
20
19
  links.push({
21
20
  href: `${basePath}/api-keys`,
22
- label: 'API Keys',
23
- icon: '🔑'
21
+ label: 'API Keys'
24
22
  });
25
23
  }
26
24
  if (showPasskeys) {
27
25
  links.push({
28
26
  href: `${basePath}/passkeys`,
29
- label: 'Passkeys',
30
- icon: '🔐'
27
+ label: 'Passkeys'
31
28
  });
32
29
  }
33
- // Don't render anything if no plugins are enabled
34
30
  if (links.length === 0) {
35
31
  return null;
36
32
  }
37
- return /*#__PURE__*/ _jsxs("div", {
38
- style: {
39
- borderTop: '1px solid var(--theme-elevation-100)',
40
- marginTop: 'var(--base)',
41
- paddingTop: 'var(--base)'
42
- },
43
- children: [
44
- /*#__PURE__*/ _jsx("div", {
45
- style: {
46
- fontSize: '11px',
47
- fontWeight: 600,
48
- color: 'var(--theme-elevation-500)',
49
- padding: '0 calc(var(--base) * 0.75)',
50
- marginBottom: 'calc(var(--base) * 0.5)',
51
- textTransform: 'uppercase',
52
- letterSpacing: '0.5px'
53
- },
54
- children: "Security"
55
- }),
56
- links.map((link)=>/*#__PURE__*/ _jsxs("a", {
57
- href: link.href,
58
- className: "nav__link",
59
- style: {
60
- display: 'flex',
61
- alignItems: 'center',
62
- gap: 'calc(var(--base) * 0.5)',
63
- padding: 'calc(var(--base) * 0.5) calc(var(--base) * 0.75)',
64
- color: 'var(--theme-elevation-800)',
65
- textDecoration: 'none',
66
- fontSize: 'var(--font-size-small)',
67
- borderRadius: 'var(--style-radius-s)',
68
- transition: 'background-color 150ms ease'
69
- },
70
- onMouseEnter: (e)=>{
71
- e.currentTarget.style.backgroundColor = 'var(--theme-elevation-50)';
72
- },
73
- onMouseLeave: (e)=>{
74
- e.currentTarget.style.backgroundColor = 'transparent';
75
- },
76
- children: [
77
- /*#__PURE__*/ _jsx("span", {
78
- style: {
79
- fontSize: '14px'
80
- },
81
- children: link.icon
82
- }),
83
- /*#__PURE__*/ _jsx("span", {
84
- className: "nav__link-label",
85
- children: link.label
86
- })
87
- ]
88
- }, link.href))
89
- ]
33
+ return /*#__PURE__*/ _jsx(NavGroup, {
34
+ label: "Security",
35
+ children: links.map((link)=>/*#__PURE__*/ _jsx("a", {
36
+ href: link.href,
37
+ className: "nav__link",
38
+ children: /*#__PURE__*/ _jsx("span", {
39
+ className: "nav__link-label",
40
+ children: link.label
41
+ })
42
+ }, link.href))
90
43
  });
91
44
  }
92
45
  export default SecurityNavLinks;
@@ -722,6 +722,8 @@ export declare const payloadAuthPlugins: readonly [{
722
722
  responseHeaders?: Headers | undefined;
723
723
  } & import("better-auth").PluginContext & import("better-auth").InfoContext & {
724
724
  options: import("better-auth").BetterAuthOptions;
725
+ appName: string;
726
+ baseURL: string;
725
727
  trustedOrigins: string[];
726
728
  isTrustedOrigin: (url: string, settings?: {
727
729
  allowRelativePaths: boolean;
@@ -790,8 +792,8 @@ export declare const payloadAuthPlugins: readonly [{
790
792
  }) => Promise<void>;
791
793
  skipOriginCheck: boolean | string[];
792
794
  skipCSRFCheck: boolean;
793
- runInBackground: (promise: Promise<void>) => void;
794
- runInBackgroundOrAwait: (promise: Promise<unknown> | Promise<void> | void | unknown) => Promise<unknown>;
795
+ runInBackground: (promise: Promise<unknown>) => void;
796
+ runInBackgroundOrAwait: (promise: Promise<unknown> | void) => import("better-auth").Awaitable<unknown>;
795
797
  }>;
796
798
  }>;
797
799
  }[];
@@ -5,6 +5,7 @@
5
5
  */ import { detectAuthConfig } from '../utils/detectAuthConfig.js';
6
6
  import { detectEnabledPlugins } from '../utils/detectEnabledPlugins.js';
7
7
  import { buildAvailableScopes, scopesToPermissions } from '../utils/generateScopes.js';
8
+ import { hasAnyRole, normalizeRoles } from '../utils/access.js';
8
9
  // Track auth instance for HMR
9
10
  let authInstance = null;
10
11
  // Store API key scopes config for access by management views
@@ -115,7 +116,7 @@ let apiKeyScopesConfig = undefined;
115
116
  }
116
117
  /**
117
118
  * Creates the auth endpoint handler that proxies requests to Better Auth.
118
- */ function createAuthEndpointHandler() {
119
+ */ function createAuthEndpointHandler(adminOptions) {
119
120
  return async (req)=>{
120
121
  const payloadWithAuth = req.payload;
121
122
  const auth = payloadWithAuth.betterAuth;
@@ -170,6 +171,45 @@ let apiKeyScopesConfig = undefined;
170
171
  }
171
172
  }
172
173
  }
174
+ // Guard API key mutation endpoints — require admin role
175
+ const isApiKeyMutation = req.method === 'POST' && (pathname.endsWith('/api-key/create') || pathname.endsWith('/api-key/update') || pathname.endsWith('/api-key/delete'));
176
+ if (isApiKeyMutation) {
177
+ const session = await auth.api.getSession({
178
+ headers: req.headers
179
+ });
180
+ if (!session?.user?.id) {
181
+ return new Response(JSON.stringify({
182
+ error: 'Unauthorized'
183
+ }), {
184
+ status: 401,
185
+ headers: {
186
+ 'Content-Type': 'application/json'
187
+ }
188
+ });
189
+ }
190
+ // Resolve required role: apiKey config > login config > default 'admin'
191
+ const requiredRole = apiKeyScopesConfig?.requiredRole ?? adminOptions?.login?.requiredRole ?? 'admin';
192
+ if (requiredRole !== null) {
193
+ // Find the auth collection slug from Payload's config
194
+ const authSlug = req.payload.config.collections.find((c)=>typeof c.auth === 'object' || c.auth === true)?.slug ?? 'users';
195
+ const user = await req.payload.findByID({
196
+ collection: authSlug,
197
+ id: session.user.id,
198
+ depth: 0,
199
+ overrideAccess: true
200
+ });
201
+ if (!hasAnyRole(user, normalizeRoles(requiredRole))) {
202
+ return new Response(JSON.stringify({
203
+ error: 'Forbidden: insufficient permissions to manage API keys'
204
+ }), {
205
+ status: 403,
206
+ headers: {
207
+ 'Content-Type': 'application/json'
208
+ }
209
+ });
210
+ }
211
+ }
212
+ }
173
213
  // Intercept API key creation requests with scopes
174
214
  // Better Auth's API key create endpoint is POST /api-key/create
175
215
  const isApiKeyCreate = req.method === 'POST' && pathname.endsWith('/api-key/create') && parsedBody?.scopes && Array.isArray(parsedBody.scopes);
@@ -199,8 +239,8 @@ let apiKeyScopesConfig = undefined;
199
239
  }
200
240
  /**
201
241
  * Generates Payload endpoints for Better Auth.
202
- */ function generateAuthEndpoints(basePath) {
203
- const handler = createAuthEndpointHandler();
242
+ */ function generateAuthEndpoints(basePath, adminOptions) {
243
+ const handler = createAuthEndpointHandler(adminOptions);
204
244
  const methods = [
205
245
  'get',
206
246
  'post',
@@ -384,7 +424,7 @@ let apiKeyScopesConfig = undefined;
384
424
  // Inject management UI components
385
425
  config = injectManagementComponents(config, options);
386
426
  // Generate auth endpoints if enabled
387
- const authEndpoints = autoRegisterEndpoints ? generateAuthEndpoints(authBasePath) : [];
427
+ const authEndpoints = autoRegisterEndpoints ? generateAuthEndpoints(authBasePath, options.admin) : [];
388
428
  // Merge endpoints
389
429
  const existingEndpoints = config.endpoints ?? [];
390
430
  // Get existing onInit
@@ -50,6 +50,14 @@ export type ApiKeyScopesConfig = {
50
50
  * If not provided, keys without scopes will have no permissions.
51
51
  */
52
52
  defaultScopes?: string[];
53
+ /**
54
+ * Role(s) required to create, update, and delete API keys.
55
+ * - string: Single role required (e.g., 'admin')
56
+ * - string[]: Any matching role grants access
57
+ * - null: Allow any authenticated user (not recommended)
58
+ * @default Inherits from admin.login.requiredRole, or 'admin' if unset
59
+ */
60
+ requiredRole?: string | string[] | null;
53
61
  };
54
62
  /**
55
63
  * Scope data passed to the API keys management client component.
@@ -89,6 +89,8 @@ export declare function apiKeyWithDefaults(options?: ApiKeyPluginOptions): {
89
89
  responseHeaders?: Headers | undefined;
90
90
  } & import("better-auth").PluginContext & import("better-auth").InfoContext & {
91
91
  options: BetterAuthOptions;
92
+ appName: string;
93
+ baseURL: string;
92
94
  trustedOrigins: string[];
93
95
  isTrustedOrigin: (url: string, settings?: {
94
96
  allowRelativePaths: boolean;
@@ -157,8 +159,8 @@ export declare function apiKeyWithDefaults(options?: ApiKeyPluginOptions): {
157
159
  }) => Promise<void>;
158
160
  skipOriginCheck: boolean | string[];
159
161
  skipCSRFCheck: boolean;
160
- runInBackground: (promise: Promise<void>) => void;
161
- runInBackgroundOrAwait: (promise: Promise<unknown> | Promise<void> | void | unknown) => Promise<unknown>;
162
+ runInBackground: (promise: Promise<unknown>) => void;
163
+ runInBackgroundOrAwait: (promise: Promise<unknown> | void) => import("better-auth").Awaitable<unknown>;
162
164
  }>;
163
165
  }>;
164
166
  }[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-better-auth",
3
- "version": "0.3.15",
3
+ "version": "0.4.1",
4
4
  "description": "Better Auth adapter and plugins for Payload CMS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -74,10 +74,8 @@
74
74
  "test:coverage": "vitest run --coverage",
75
75
  "prepublishOnly": "pnpm build"
76
76
  },
77
- "dependencies": {
78
- "@better-auth/passkey": "^1.4.17"
79
- },
80
77
  "peerDependencies": {
78
+ "@better-auth/passkey": ">=1.4.18",
81
79
  "@payloadcms/next": ">=3.69.0",
82
80
  "@payloadcms/ui": ">=3.69.0",
83
81
  "better-auth": ">=1.4.0",
@@ -85,17 +83,23 @@
85
83
  "payload": ">=3.69.0",
86
84
  "react": ">=19.2.1"
87
85
  },
86
+ "peerDependenciesMeta": {
87
+ "@better-auth/passkey": {
88
+ "optional": true
89
+ }
90
+ },
88
91
  "devDependencies": {
89
- "@payloadcms/next": "^3.73.0",
90
- "@payloadcms/ui": "^3.73.0",
92
+ "@payloadcms/next": "^3.76.1",
93
+ "@payloadcms/ui": "^3.76.1",
91
94
  "@swc/cli": "^0.6.0",
92
- "@swc/core": "^1.10.18",
93
- "@types/node": "^25.0.10",
94
- "@types/react": "^19.2.9",
95
+ "@swc/core": "^1.15.11",
96
+ "@types/node": "^25.2.3",
97
+ "@types/react": "^19.2.14",
95
98
  "@vitest/coverage-v8": "^2.1.9",
96
- "better-auth": "^1.4.17",
97
- "next": "^16.1.4",
98
- "payload": "^3.73.0",
99
+ "@better-auth/passkey": "^1.4.18",
100
+ "better-auth": "^1.4.18",
101
+ "next": "^16.1.6",
102
+ "payload": "^3.76.1",
99
103
  "react": "^19.2.4",
100
104
  "tsx": "^4.21.0",
101
105
  "typescript": "^5.9.3",