@delmaredigital/payload-better-auth 0.6.0 → 0.6.2

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.
@@ -1,4 +1,9 @@
1
1
  import type { PermissionDefinition } from '../../types/apiKey.js';
2
+ /** Organization option for the org selector */
3
+ export type OrganizationOption = {
4
+ id: string | number;
5
+ name: string;
6
+ };
2
7
  export type ApiKeysManagementClientProps = {
3
8
  /** Optional pre-configured auth client with apiKey plugin */
4
9
  authClient?: any;
@@ -6,10 +11,16 @@ export type ApiKeysManagementClientProps = {
6
11
  title?: string;
7
12
  /** Available permission definitions (collections + actions). Auto-generated if not provided. */
8
13
  permissions?: PermissionDefinition[];
14
+ /**
15
+ * Available organizations for scoping API keys.
16
+ * When provided, shows an organization selector in the creation form.
17
+ * Each key can be optionally bound to one organization.
18
+ */
19
+ organizations?: OrganizationOption[];
9
20
  };
10
21
  /**
11
22
  * Client component for API keys management.
12
23
  * Lists, creates, and deletes API keys with permission selection (read/write per collection).
13
24
  */
14
- export declare function ApiKeysManagementClient({ authClient: providedClient, title, permissions, }?: ApiKeysManagementClientProps): import("react").JSX.Element;
25
+ export declare function ApiKeysManagementClient({ authClient: providedClient, title, permissions, organizations, }?: ApiKeysManagementClientProps): import("react").JSX.Element;
15
26
  export default ApiKeysManagementClient;
@@ -5,7 +5,7 @@ import { createAuthClient } from 'better-auth/react';
5
5
  /**
6
6
  * Client component for API keys management.
7
7
  * Lists, creates, and deletes API keys with permission selection (read/write per collection).
8
- */ export function ApiKeysManagementClient({ authClient: providedClient, title = 'API Keys', permissions = [] } = {}) {
8
+ */ export function ApiKeysManagementClient({ authClient: providedClient, title = 'API Keys', permissions = [], organizations = [] } = {}) {
9
9
  const [apiKeys, setApiKeys] = useState([]);
10
10
  const [loading, setLoading] = useState(true);
11
11
  const [error, setError] = useState(null);
@@ -17,7 +17,9 @@ import { createAuthClient } from 'better-auth/react';
17
17
  // Selected permissions: { posts: ['read', 'write'], pages: ['read'] }
18
18
  const [selectedPermissions, setSelectedPermissions] = useState({});
19
19
  const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
20
+ const [selectedOrganizationId, setSelectedOrganizationId] = useState('');
20
21
  const hasPermissions = permissions.length > 0;
22
+ const hasOrganizations = organizations.length > 0;
21
23
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
24
  const clientRef = useRef(null);
23
25
  const getClient = async ()=>{
@@ -205,6 +207,10 @@ import { createAuthClient } from 'better-auth/react';
205
207
  if (hasPermissions && selectedCount > 0) {
206
208
  createOptions.permissions = selectedPermissions;
207
209
  }
210
+ // Bind to organization if selected
211
+ if (selectedOrganizationId) {
212
+ createOptions.organizationId = selectedOrganizationId;
213
+ }
208
214
  const result = await client.apiKey.create(createOptions);
209
215
  if (result.error) {
210
216
  setError(result.error.message ?? 'Failed to create API key');
@@ -214,6 +220,7 @@ import { createAuthClient } from 'better-auth/react';
214
220
  setNewKeyName('');
215
221
  setNewKeyExpiry('');
216
222
  setSelectedPermissions({});
223
+ setSelectedOrganizationId('');
217
224
  fetchApiKeys();
218
225
  }
219
226
  } catch {
@@ -448,6 +455,53 @@ import { createAuthClient } from 'better-auth/react';
448
455
  })
449
456
  ]
450
457
  }),
458
+ hasOrganizations && /*#__PURE__*/ _jsxs("div", {
459
+ style: {
460
+ marginBottom: 'var(--base)'
461
+ },
462
+ children: [
463
+ /*#__PURE__*/ _jsx("label", {
464
+ style: {
465
+ display: 'block',
466
+ color: 'var(--theme-text)',
467
+ fontSize: 'var(--font-size-small)',
468
+ marginBottom: 'calc(var(--base) * 0.25)'
469
+ },
470
+ children: "Organization (optional)"
471
+ }),
472
+ /*#__PURE__*/ _jsxs("select", {
473
+ value: selectedOrganizationId,
474
+ onChange: (e)=>setSelectedOrganizationId(e.target.value),
475
+ style: {
476
+ width: '100%',
477
+ padding: 'calc(var(--base) * 0.5)',
478
+ background: 'var(--theme-input-bg)',
479
+ border: '1px solid var(--theme-elevation-150)',
480
+ borderRadius: 'var(--style-radius-s)',
481
+ color: 'var(--theme-text)',
482
+ boxSizing: 'border-box'
483
+ },
484
+ children: [
485
+ /*#__PURE__*/ _jsx("option", {
486
+ value: "",
487
+ children: "No organization (global key)"
488
+ }),
489
+ organizations.map((org)=>/*#__PURE__*/ _jsx("option", {
490
+ value: String(org.id),
491
+ children: org.name
492
+ }, String(org.id)))
493
+ ]
494
+ }),
495
+ /*#__PURE__*/ _jsx("div", {
496
+ style: {
497
+ marginTop: 'calc(var(--base) * 0.25)',
498
+ fontSize: '11px',
499
+ color: 'var(--theme-elevation-600)'
500
+ },
501
+ children: selectedOrganizationId ? 'API key will only have access to this organization\'s data.' : 'Without an organization, the key will not have org-scoped access.'
502
+ })
503
+ ]
504
+ }),
451
505
  hasPermissions && /*#__PURE__*/ _jsxs("div", {
452
506
  style: {
453
507
  marginBottom: 'var(--base)'
@@ -745,6 +799,29 @@ import { createAuthClient } from 'better-auth/react';
745
799
  })
746
800
  ]
747
801
  }),
802
+ Boolean(key.metadata?.organizationId) && /*#__PURE__*/ _jsx("div", {
803
+ style: {
804
+ marginTop: 'calc(var(--base) * 0.5)'
805
+ },
806
+ children: /*#__PURE__*/ _jsxs("span", {
807
+ style: {
808
+ padding: '2px 6px',
809
+ background: 'var(--theme-elevation-150)',
810
+ borderRadius: 'var(--style-radius-s)',
811
+ fontSize: '11px',
812
+ color: 'var(--theme-elevation-700)',
813
+ fontWeight: 500
814
+ },
815
+ children: [
816
+ "Org: ",
817
+ (()=>{
818
+ const orgId = String(key.metadata?.organizationId ?? '');
819
+ const org = organizations.find((o)=>String(o.id) === orgId);
820
+ return org?.name ?? orgId;
821
+ })()
822
+ ]
823
+ })
824
+ }),
748
825
  key.permissions && Object.keys(key.permissions).length > 0 && /*#__PURE__*/ _jsx("div", {
749
826
  style: {
750
827
  display: 'flex',
@@ -4,6 +4,51 @@ import { getVisibleEntities } from '@payloadcms/ui/shared';
4
4
  import { ApiKeysManagementClient } from '../ApiKeysManagementClient.js';
5
5
  import { getApiKeyPermissionsConfig } from '../../../plugin/index.js';
6
6
  import { generateCollectionPermissions } from '../../../utils/generatePermissions.js';
7
+ /**
8
+ * Fetch organizations the current user belongs to.
9
+ * Returns an empty array if the members/organizations collections don't exist.
10
+ */ async function getUserOrganizations(payload, userId) {
11
+ try {
12
+ // Check if members and organizations collections exist
13
+ const collectionSlugs = payload.config.collections.map((c)=>c.slug);
14
+ if (!collectionSlugs.includes('members') || !collectionSlugs.includes('organizations')) {
15
+ return [];
16
+ }
17
+ // Find all memberships for this user
18
+ const memberships = await payload.find({
19
+ collection: 'members',
20
+ where: {
21
+ user: {
22
+ equals: userId
23
+ }
24
+ },
25
+ limit: 100,
26
+ depth: 0,
27
+ overrideAccess: true
28
+ });
29
+ if (memberships.docs.length === 0) return [];
30
+ // Fetch organization details
31
+ const orgIds = memberships.docs.map((m)=>m.organization);
32
+ const orgs = await payload.find({
33
+ collection: 'organizations',
34
+ where: {
35
+ id: {
36
+ in: orgIds
37
+ }
38
+ },
39
+ limit: 100,
40
+ depth: 0,
41
+ overrideAccess: true
42
+ });
43
+ return orgs.docs.map((org)=>({
44
+ id: org.id,
45
+ name: org.name || String(org.id)
46
+ }));
47
+ } catch {
48
+ // Collections might not exist or have different schemas — return empty
49
+ return [];
50
+ }
51
+ }
7
52
  /**
8
53
  * API Keys management view for Payload admin panel.
9
54
  * Server component that provides the admin layout.
@@ -19,6 +64,9 @@ import { generateCollectionPermissions } from '../../../utils/generatePermission
19
64
  // Build permission definitions from collections
20
65
  const permissionsConfig = getApiKeyPermissionsConfig();
21
66
  const permissions = generateCollectionPermissions(payload.config.collections, permissionsConfig?.excludeCollections);
67
+ // Fetch user's organizations if the organization plugin is in use
68
+ const userId = req.user?.id;
69
+ const organizations = userId ? await getUserOrganizations(payload, userId) : [];
22
70
  return /*#__PURE__*/ _jsx(DefaultTemplate, {
23
71
  i18n: req.i18n,
24
72
  locale: req.locale,
@@ -29,7 +77,8 @@ import { generateCollectionPermissions } from '../../../utils/generatePermission
29
77
  user: req.user ?? undefined,
30
78
  visibleEntities: visibleEntities,
31
79
  children: /*#__PURE__*/ _jsx(ApiKeysManagementClient, {
32
- permissions: permissions
80
+ permissions: permissions,
81
+ organizations: organizations
33
82
  })
34
83
  });
35
84
  }
@@ -18,7 +18,11 @@ let apiKeyPermissionsConfig = undefined;
18
18
  /**
19
19
  * Handle API key creation server-side.
20
20
  * Passes permissions directly from the client to Better Auth's server API.
21
- */ async function handleApiKeyCreate(authApi, headers, body) {
21
+ *
22
+ * When `organizationId` is provided in the body, it is validated against the
23
+ * user's memberships and stored in the API key's metadata. This allows the
24
+ * `betterAuthStrategy` to resolve organization context for API key requests.
25
+ */ async function handleApiKeyCreate(authApi, headers, body, payload, membersCollection = 'members') {
22
26
  try {
23
27
  // Get the current session to find the user
24
28
  const session = await authApi.getSession({
@@ -34,8 +38,51 @@ let apiKeyPermissionsConfig = undefined;
34
38
  }
35
39
  });
36
40
  }
41
+ // If organizationId is provided, validate that the user is a member
42
+ const organizationId = body.organizationId;
43
+ if (organizationId && payload) {
44
+ try {
45
+ const memberships = await payload.find({
46
+ collection: membersCollection,
47
+ where: {
48
+ and: [
49
+ {
50
+ user: {
51
+ equals: session.user.id
52
+ }
53
+ },
54
+ {
55
+ organization: {
56
+ equals: organizationId
57
+ }
58
+ }
59
+ ]
60
+ },
61
+ limit: 1,
62
+ depth: 0
63
+ });
64
+ if (memberships.docs.length === 0) {
65
+ return new Response(JSON.stringify({
66
+ error: 'You are not a member of this organization'
67
+ }), {
68
+ status: 403,
69
+ headers: {
70
+ 'Content-Type': 'application/json'
71
+ }
72
+ });
73
+ }
74
+ } catch {
75
+ // Members collection might not exist — allow creation without org binding
76
+ }
77
+ }
37
78
  // Permissions come directly from the client in BA's native format
38
79
  const permissions = body.permissions;
80
+ // Merge organizationId into metadata if provided
81
+ const existingMetadata = body.metadata;
82
+ const metadata = organizationId ? {
83
+ ...existingMetadata || {},
84
+ organizationId
85
+ } : existingMetadata;
39
86
  const createOptions = {
40
87
  body: {
41
88
  name: body.name,
@@ -43,7 +90,7 @@ let apiKeyPermissionsConfig = undefined;
43
90
  expiresIn: body.expiresIn,
44
91
  prefix: body.prefix,
45
92
  permissions: permissions && Object.keys(permissions).length > 0 ? permissions : undefined,
46
- metadata: body.metadata
93
+ metadata: metadata && Object.keys(metadata).length > 0 ? metadata : undefined
47
94
  }
48
95
  };
49
96
  // Call Better Auth's server-side API
@@ -177,7 +224,7 @@ let apiKeyPermissionsConfig = undefined;
177
224
  // Intercept API key creation requests to inject userId from session
178
225
  const isApiKeyCreate = req.method === 'POST' && pathname.endsWith('/api-key/create');
179
226
  if (isApiKeyCreate && parsedBody) {
180
- return handleApiKeyCreate(auth.api, req.headers, parsedBody);
227
+ return handleApiKeyCreate(auth.api, req.headers, parsedBody, req.payload);
181
228
  }
182
229
  // Create a new Request for Better Auth
183
230
  const request = new Request(url.toString(), {
@@ -434,6 +481,12 @@ let apiKeyPermissionsConfig = undefined;
434
481
  console.error('[better-auth] Failed to create auth:', error);
435
482
  throw error;
436
483
  }
484
+ // Warn if nextCookies() plugin is detected — it's incompatible with Payload CMS.
485
+ // Check via betterAuthOptions (if provided) or the auth instance's options.
486
+ const pluginsToCheck = options.admin?.betterAuthOptions?.plugins ?? authInstance.options?.plugins;
487
+ if (pluginsToCheck?.some((p)=>p.id === 'next-cookies')) {
488
+ console.warn('\n⚠️ [payload-better-auth] The nextCookies() plugin was detected in your Better Auth config.\n' + ' This plugin is INCOMPATIBLE with Payload CMS and will cause infinite form-state\n' + ' submissions and input resets in the admin panel.\n\n' + ' The nextCookies() plugin is designed for Server Actions, but payload-better-auth\n' + ' handles cookie passthrough automatically via its endpoint proxy.\n\n' + ' → Remove nextCookies() from your Better Auth plugins to fix this issue.\n' + ' → See: https://github.com/delmaredigital/payload-better-auth/issues/15\n');
489
+ }
437
490
  }
438
491
  // Attach to payload for global access
439
492
  Object.defineProperty(payload, 'betterAuth', {
@@ -564,6 +617,64 @@ let apiKeyPermissionsConfig = undefined;
564
617
  // Members collection might not exist (org plugin not used), silently ignore
565
618
  }
566
619
  }
620
+ // If activeOrganizationId is not set (common with API key mock sessions),
621
+ // check if the API key has an organizationId in its metadata
622
+ if (!sessionFields.activeOrganizationId) {
623
+ const apiKeyHeader = headers.get('x-api-key') || headers.get('authorization')?.replace('Bearer ', '');
624
+ if (apiKeyHeader) {
625
+ const auth = payloadWithAuth.betterAuth;
626
+ const verifyApiKey = auth.api.verifyApiKey;
627
+ if (typeof verifyApiKey === 'function') {
628
+ try {
629
+ const verifyResult = await verifyApiKey({
630
+ body: {
631
+ key: apiKeyHeader
632
+ }
633
+ });
634
+ if (verifyResult.valid && verifyResult.key?.metadata) {
635
+ const metadata = typeof verifyResult.key.metadata === 'string' ? JSON.parse(verifyResult.key.metadata) : verifyResult.key.metadata;
636
+ if (metadata.organizationId) {
637
+ // Coerce to number if using serial IDs
638
+ let orgId = metadata.organizationId;
639
+ if (idType === 'number' && typeof orgId === 'string' && /^\d+$/.test(orgId)) {
640
+ orgId = parseInt(orgId, 10);
641
+ }
642
+ // Verify the user is actually a member of this org
643
+ try {
644
+ const memberships = await payload.find({
645
+ collection: membersCollection,
646
+ where: {
647
+ and: [
648
+ {
649
+ user: {
650
+ equals: sessionData.user.id
651
+ }
652
+ },
653
+ {
654
+ organization: {
655
+ equals: orgId
656
+ }
657
+ }
658
+ ]
659
+ },
660
+ limit: 1,
661
+ depth: 0
662
+ });
663
+ if (memberships.docs.length > 0) {
664
+ sessionFields.activeOrganizationId = orgId;
665
+ organizationRole = memberships.docs[0].role;
666
+ }
667
+ } catch {
668
+ // Members collection might not exist, continue without org context
669
+ }
670
+ }
671
+ }
672
+ } catch {
673
+ // API key verification failed, continue without org context
674
+ }
675
+ }
676
+ }
677
+ }
567
678
  return {
568
679
  user: {
569
680
  ...users.docs[0],
@@ -10,6 +10,7 @@ export type EnabledPluginsResult = {
10
10
  hasMagicLink: boolean;
11
11
  hasMultiSession: boolean;
12
12
  hasOrganization: boolean;
13
+ hasNextCookies: boolean;
13
14
  };
14
15
  /**
15
16
  * Detects which Better Auth plugins are enabled from the options.
@@ -15,7 +15,8 @@
15
15
  hasPasskey: false,
16
16
  hasMagicLink: false,
17
17
  hasMultiSession: false,
18
- hasOrganization: false
18
+ hasOrganization: false,
19
+ hasNextCookies: false
19
20
  };
20
21
  for (const plugin of plugins){
21
22
  // Better Auth plugins have an id property
@@ -42,6 +43,9 @@
42
43
  case 'organization':
43
44
  result.hasOrganization = true;
44
45
  break;
46
+ case 'next-cookies':
47
+ result.hasNextCookies = true;
48
+ break;
45
49
  }
46
50
  }
47
51
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-better-auth",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Better Auth adapter and plugins for Payload CMS",
5
5
  "type": "module",
6
6
  "license": "MIT",