@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.
- package/dist/components/management/ApiKeysManagementClient.d.ts +12 -1
- package/dist/components/management/ApiKeysManagementClient.js +78 -1
- package/dist/components/management/views/ApiKeysView.js +50 -1
- package/dist/plugin/index.js +114 -3
- package/dist/utils/detectEnabledPlugins.d.ts +1 -0
- package/dist/utils/detectEnabledPlugins.js +5 -1
- package/package.json +1 -1
|
@@ -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
|
}
|
package/dist/plugin/index.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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],
|
|
@@ -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;
|