Skip to content

Commit 18bcb64

Browse files
wobsorianobrkalowalexcarpenterpanteliselefLekoArts
authored
feat(clerk-js,clerk-react,nextjs): Introduce <APIKeys /> AIO component (#5858)
Co-authored-by: Bryce Kalow <[email protected]> Co-authored-by: Alex Carpenter <[email protected]> Co-authored-by: panteliselef <[email protected]> Co-authored-by: Lennart <[email protected]> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 4319257 commit 18bcb64

File tree

66 files changed

+1727
-34
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+1727
-34
lines changed

.changeset/fluffy-numbers-stick.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/localizations': patch
3+
'@clerk/types': patch
4+
---
5+
6+
Add TypeScript types and en-US localization for upcoming `<APIKeys />` component. This component will initially be in early access.

.changeset/ninety-candles-sleep.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/nextjs': minor
4+
'@clerk/clerk-react': minor
5+
---
6+
7+
Add `<APIKeys />` component. This component will initially be in early access and not recommended for production usage just yet.

.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
129129
"nextjs/create-sync-get-auth.mdx",
130130
"nextjs/current-user.mdx",
131131
"nextjs/get-auth.mdx",
132+
"clerk-react/api-keys.mdx",
132133
"clerk-react/clerk-provider-props.mdx",
133134
"clerk-react/protect.mdx",
134135
"clerk-react/redirect-to-create-organization.mdx",

packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
exports[`public exports should not include a breaking change 1`] = `
44
[
5+
"APIKeys",
56
"AuthenticateWithRedirectCallback",
67
"ClerkDegraded",
78
"ClerkFailed",

packages/clerk-js/bundlewatch.config.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "605kB" },
4-
{ "path": "./dist/clerk.browser.js", "maxSize": "69.3KB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "608.89kB" },
4+
{ "path": "./dist/clerk.browser.js", "maxSize": "70.15KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "113KB" },
6-
{ "path": "./dist/clerk.headless*.js", "maxSize": "53KB" },
7-
{ "path": "./dist/ui-common*.js", "maxSize": "107.5KB" },
6+
{ "path": "./dist/clerk.headless*.js", "maxSize": "53.06KB" },
7+
{ "path": "./dist/ui-common*.js", "maxSize": "108.35KB" },
88
{ "path": "./dist/vendors*.js", "maxSize": "40.2KB" },
99
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },
1010
{ "path": "./dist/createorganization*.js", "maxSize": "5KB" },

packages/clerk-js/sandbox/app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const AVAILABLE_COMPONENTS = [
3333
'organizationSwitcher',
3434
'waitlist',
3535
'pricingTable',
36+
'apiKeys',
3637
'oauthConsent',
3738
] as const;
3839

@@ -92,6 +93,7 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component
9293
organizationSwitcher: buildComponentControls('organizationSwitcher'),
9394
waitlist: buildComponentControls('waitlist'),
9495
pricingTable: buildComponentControls('pricingTable'),
96+
apiKeys: buildComponentControls('apiKeys'),
9597
oauthConsent: buildComponentControls('oauthConsent'),
9698
};
9799

@@ -312,6 +314,9 @@ void (async () => {
312314
'/pricing-table': () => {
313315
Clerk.mountPricingTable(app, componentControls.pricingTable.getProps() ?? {});
314316
},
317+
'/api-keys': () => {
318+
Clerk.mountApiKeys(app, componentControls.apiKeys.getProps() ?? {});
319+
},
315320
'/oauth-consent': () => {
316321
const searchParams = new URLSearchParams(window.location.search);
317322
const scopes = (searchParams.get('scopes')?.split(',') ?? []).map(scope => ({

packages/clerk-js/sandbox/template.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,14 @@
260260
PricingTable
261261
</a>
262262
</li>
263+
<li class="relative">
264+
<a
265+
class="relative isolate flex w-full rounded-md border border-white px-2 py-[0.4375rem] text-sm hover:bg-gray-50 aria-[current]:bg-gray-50"
266+
href="/api-keys"
267+
>
268+
API Keys
269+
</a>
270+
</li>
263271
<li class="relative">
264272
<a
265273
class="relative isolate flex w-full rounded-md border border-white px-2 py-[0.4375rem] text-sm hover:bg-gray-50 aria-[current]:bg-gray-50"

packages/clerk-js/src/core/clerk.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import type {
2020
__internal_OAuthConsentProps,
2121
__internal_PlanDetailsProps,
2222
__internal_UserVerificationModalProps,
23+
APIKeysNamespace,
24+
APIKeysProps,
2325
AuthenticateWithCoinbaseWalletParams,
2426
AuthenticateWithGoogleOneTapParams,
2527
AuthenticateWithMetamaskParams,
@@ -88,6 +90,7 @@ import {
8890
createAllowedRedirectOrigins,
8991
createBeforeUnloadTracker,
9092
createPageLifecycle,
93+
disabledAPIKeysFeature,
9194
disabledBillingFeature,
9295
disabledOrganizationsFeature,
9396
errorThrower,
@@ -132,6 +135,7 @@ import { eventBus, events } from './events';
132135
import type { FapiClient, FapiRequestCallback } from './fapiClient';
133136
import { createFapiClient } from './fapiClient';
134137
import { createClientFromJwt } from './jwt-client';
138+
import { APIKeys } from './modules/apiKeys';
135139
import { CommerceBilling } from './modules/commerce';
136140
import {
137141
BaseResource,
@@ -163,6 +167,7 @@ const CANNOT_RENDER_USER_MISSING_ERROR_CODE = 'cannot_render_user_missing';
163167
const CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE = 'cannot_render_organizations_disabled';
164168
const CANNOT_RENDER_ORGANIZATION_MISSING_ERROR_CODE = 'cannot_render_organization_missing';
165169
const CANNOT_RENDER_SINGLE_SESSION_ENABLED_ERROR_CODE = 'cannot_render_single_session_enabled';
170+
const CANNOT_RENDER_API_KEYS_DISABLED_ERROR_CODE = 'cannot_render_api_keys_disabled';
166171
const defaultOptions: ClerkOptions = {
167172
polling: true,
168173
standardBrowser: true,
@@ -189,6 +194,7 @@ export class Clerk implements ClerkInterface {
189194
environment: process.env.NODE_ENV || 'production',
190195
};
191196
private static _billing: CommerceBillingNamespace;
197+
private static _apiKeys: APIKeysNamespace;
192198

193199
public client: ClientResource | undefined;
194200
public session: SignedInSessionResource | null | undefined;
@@ -324,6 +330,13 @@ export class Clerk implements ClerkInterface {
324330
return Clerk._billing;
325331
}
326332

333+
get apiKeys(): APIKeysNamespace {
334+
if (!Clerk._apiKeys) {
335+
Clerk._apiKeys = new APIKeys();
336+
}
337+
return Clerk._apiKeys;
338+
}
339+
327340
public __internal_getOption<K extends keyof ClerkOptions>(key: K): ClerkOptions[K] {
328341
return this.#options[key];
329342
}
@@ -1055,6 +1068,53 @@ export class Clerk implements ClerkInterface {
10551068
void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node }));
10561069
};
10571070

1071+
/**
1072+
* @experimental
1073+
* This API is in early access and may change in future releases.
1074+
*
1075+
* Mount a api keys component at the target element.
1076+
* @param targetNode Target to mount the APIKeys component.
1077+
* @param props Configuration parameters.
1078+
*/
1079+
public mountApiKeys = (node: HTMLDivElement, props?: APIKeysProps) => {
1080+
this.assertComponentsReady(this.#componentControls);
1081+
1082+
logger.warnOnce('Clerk: <APIKeys /> component is in early access and not yet recommended for production use.');
1083+
1084+
if (disabledAPIKeysFeature(this, this.environment)) {
1085+
if (this.#instanceType === 'development') {
1086+
throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponent, {
1087+
code: CANNOT_RENDER_API_KEYS_DISABLED_ERROR_CODE,
1088+
});
1089+
}
1090+
return;
1091+
}
1092+
void this.#componentControls.ensureMounted({ preloadHint: 'APIKeys' }).then(controls =>
1093+
controls.mountComponent({
1094+
name: 'APIKeys',
1095+
appearanceKey: 'apiKeys',
1096+
node,
1097+
props,
1098+
}),
1099+
);
1100+
1101+
this.telemetry?.record(eventPrebuiltComponentMounted('APIKeys', props));
1102+
};
1103+
1104+
/**
1105+
* @experimental
1106+
* This API is in early access and may change in future releases.
1107+
*
1108+
* Unmount a api keys component from the target element.
1109+
* If there is no component mounted at the target node, results in a noop.
1110+
*
1111+
* @param targetNode Target node to unmount the ApiKeys component from.
1112+
*/
1113+
public unmountApiKeys = (node: HTMLDivElement) => {
1114+
this.assertComponentsReady(this.#componentControls);
1115+
void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node }));
1116+
};
1117+
10581118
/**
10591119
* `setActive` can be used to set the active session and/or organization.
10601120
*/

packages/clerk-js/src/core/fapiClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,8 @@ export function createFapiClient(options: FapiClientOptions): FapiClient {
225225
const urlStr = requestInit.url.toString();
226226
const fetchOpts: FapiRequestInit = {
227227
...requestInit,
228-
credentials: 'include',
229228
method: overwrittenRequestMethod,
229+
credentials: requestInit.credentials || 'include',
230230
};
231231

232232
try {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type {
2+
ApiKeyJSON,
3+
APIKeyResource,
4+
APIKeysNamespace,
5+
CreateAPIKeyParams,
6+
GetAPIKeysParams,
7+
RevokeAPIKeyParams,
8+
} from '@clerk/types';
9+
10+
import type { FapiRequestInit } from '@/core/fapiClient';
11+
12+
import { APIKey, BaseResource, ClerkRuntimeError } from '../../resources/internal';
13+
14+
export class APIKeys implements APIKeysNamespace {
15+
/**
16+
* Returns the base options for the FAPI proxy requests.
17+
*/
18+
private async getBaseFapiProxyOptions(): Promise<FapiRequestInit> {
19+
const token = await BaseResource.clerk.session?.getToken();
20+
if (!token) {
21+
throw new ClerkRuntimeError('No valid session token available', { code: 'no_session_token' });
22+
}
23+
24+
return {
25+
// Set to an empty string because FAPI Proxy does not include the version in the path.
26+
pathPrefix: '',
27+
// Set the session token as a Bearer token in the Authorization header for authentication.
28+
headers: {
29+
Authorization: `Bearer ${token}`,
30+
'Content-Type': 'application/json',
31+
},
32+
// Set to `same-origin` to ensure cookies and credentials are sent with requests, avoiding CORS issues.
33+
credentials: 'same-origin',
34+
};
35+
}
36+
37+
async getAll(params?: GetAPIKeysParams): Promise<APIKeyResource[]> {
38+
return BaseResource.clerk
39+
.getFapiClient()
40+
.request<{ api_keys: ApiKeyJSON[] }>({
41+
...(await this.getBaseFapiProxyOptions()),
42+
method: 'GET',
43+
path: '/api_keys',
44+
search: {
45+
subject: params?.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '',
46+
},
47+
})
48+
.then(res => {
49+
const apiKeysJSON = res.payload as unknown as { api_keys: ApiKeyJSON[] };
50+
return apiKeysJSON.api_keys.map(json => new APIKey(json));
51+
});
52+
}
53+
54+
async getSecret(id: string): Promise<string> {
55+
return BaseResource.clerk
56+
.getFapiClient()
57+
.request<{ secret: string }>({
58+
...(await this.getBaseFapiProxyOptions()),
59+
method: 'GET',
60+
path: `/api_keys/${id}/secret`,
61+
})
62+
.then(res => {
63+
const { secret } = res.payload as unknown as { secret: string };
64+
return secret;
65+
});
66+
}
67+
68+
async create(params: CreateAPIKeyParams): Promise<APIKeyResource> {
69+
const json = (
70+
await BaseResource._fetch<ApiKeyJSON>({
71+
...(await this.getBaseFapiProxyOptions()),
72+
path: '/api_keys',
73+
method: 'POST',
74+
body: JSON.stringify({
75+
type: params.type ?? 'api_key',
76+
name: params.name,
77+
subject: params.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '',
78+
description: params.description,
79+
seconds_until_expiration: params.secondsUntilExpiration,
80+
}),
81+
})
82+
)?.response as ApiKeyJSON;
83+
84+
return new APIKey(json);
85+
}
86+
87+
async revoke(params: RevokeAPIKeyParams): Promise<APIKeyResource> {
88+
const json = (
89+
await BaseResource._fetch<ApiKeyJSON>({
90+
...(await this.getBaseFapiProxyOptions()),
91+
method: 'POST',
92+
path: `/api_keys/${params.apiKeyID}/revoke`,
93+
body: JSON.stringify({
94+
revocation_reason: params.revocationReason,
95+
}),
96+
})
97+
)?.response as ApiKeyJSON;
98+
99+
return new APIKey(json);
100+
}
101+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { ApiKeyJSON, APIKeyResource } from '@clerk/types';
2+
3+
import { unixEpochToDate } from '../../utils/date';
4+
import { BaseResource } from './internal';
5+
6+
export class APIKey extends BaseResource implements APIKeyResource {
7+
pathRoot = '/api_keys';
8+
9+
id!: string;
10+
type!: string;
11+
name!: string;
12+
subject!: string;
13+
scopes!: string[];
14+
claims!: Record<string, any> | null;
15+
revoked!: boolean;
16+
revocationReason!: string | null;
17+
expired!: boolean;
18+
expiration!: Date | null;
19+
createdBy!: string | null;
20+
description!: string | null;
21+
lastUsedAt!: Date | null;
22+
createdAt!: Date;
23+
updatedAt!: Date;
24+
25+
constructor(data: ApiKeyJSON) {
26+
super();
27+
this.fromJSON(data);
28+
}
29+
30+
protected fromJSON(data: ApiKeyJSON | null): this {
31+
if (!data) {
32+
return this;
33+
}
34+
35+
this.id = data.id;
36+
this.type = data.type;
37+
this.name = data.name;
38+
this.subject = data.subject;
39+
this.scopes = data.scopes;
40+
this.claims = data.claims;
41+
this.revoked = data.revoked;
42+
this.revocationReason = data.revocation_reason;
43+
this.expired = data.expired;
44+
this.expiration = data.expiration ? unixEpochToDate(data.expiration) : null;
45+
this.createdBy = data.created_by;
46+
this.description = data.description;
47+
this.lastUsedAt = data.last_used_at ? unixEpochToDate(data.last_used_at) : null;
48+
this.updatedAt = unixEpochToDate(data.updated_at);
49+
this.createdAt = unixEpochToDate(data.created_at);
50+
return this;
51+
}
52+
53+
public __internal_toSnapshot(): ApiKeyJSON {
54+
return {
55+
object: 'api_key',
56+
id: this.id,
57+
type: this.type,
58+
name: this.name,
59+
subject: this.subject,
60+
scopes: this.scopes,
61+
claims: this.claims,
62+
revoked: this.revoked,
63+
revocation_reason: this.revocationReason,
64+
expired: this.expired,
65+
expiration: this.expiration ? this.expiration.getTime() : null,
66+
created_by: this.createdBy,
67+
description: this.description,
68+
last_used_at: this.lastUsedAt ? this.lastUsedAt.getTime() : null,
69+
created_at: this.createdAt.getTime(),
70+
updated_at: this.updatedAt.getTime(),
71+
};
72+
}
73+
}

0 commit comments

Comments
 (0)