Skip to content

feat(clerk-js,clerk-react,types,localization): <ApiKeys /> AIO MVP #5858

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Draft
wants to merge 35 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
89ceb0e
chore: set initial files for new component
wobsoriano May 5, 2025
bdafb68
chore: add resources and test methods
wobsoriano May 6, 2025
cf45005
chore(clerk-js): Remove Clerk.commerce (#5846)
brkalow May 6, 2025
99df5e1
chore: add simple table with calls to fapi
wobsoriano May 6, 2025
547f9e3
chore: use built in components
wobsoriano May 6, 2025
430a292
chore: add fake form
wobsoriano May 7, 2025
6981d7e
chore: fix bad rebase
wobsoriano May 7, 2025
c473fcd
chore: fix bad rebase
wobsoriano May 7, 2025
866b8e3
chore: add create api key func
wobsoriano May 7, 2025
926c455
chore: add prop types and improve fetching
wobsoriano May 7, 2025
f50e396
chore: add React component
wobsoriano May 7, 2025
e152dc2
chore: accept props
wobsoriano May 7, 2025
9257d60
chore: add copy button functionality
wobsoriano May 7, 2025
55d6559
chore: fetch secret on clipboard copy
wobsoriano May 7, 2025
fe52297
chore: add api key revokation
wobsoriano May 7, 2025
5d5b67e
chore: set minimum fields
wobsoriano May 8, 2025
aaef70c
chore: add pagination and improve form
wobsoriano May 8, 2025
97eb691
chore: try refetch
wobsoriano May 8, 2025
55a9c10
chore: fix revalidation and more styling
wobsoriano May 9, 2025
80f7e91
chore: rename component to <ApiKeys />
wobsoriano May 9, 2025
c85d387
chore: add expiration field
wobsoriano May 9, 2025
874f719
chore: add api keys component or user and org profile
wobsoriano May 9, 2025
709a340
chore: add missing org profile sidebar nav
wobsoriano May 9, 2025
5be01e5
chore: clean up props
wobsoriano May 9, 2025
3c67fb9
chore: clean up props
wobsoriano May 9, 2025
636da55
Merge branch 'main' into rob/robo-20-manage-api-keys
wobsoriano May 12, 2025
692fb02
chore: add api key secret fetcher and clean up components
wobsoriano May 13, 2025
74224cb
chore: adjust table heading widths
wobsoriano May 13, 2025
c4d8e6f
chore: improve secret fetching
wobsoriano May 13, 2025
278435a
chore: improve secret fetching
wobsoriano May 13, 2025
bc95160
chore: add locales
wobsoriano May 13, 2025
201f811
Merge branch 'main' into rob/robo-20-manage-api-keys
wobsoriano May 13, 2025
be26494
chore: action locales
wobsoriano May 13, 2025
1f34bac
chore: add locales to api keys page in user and org profile
wobsoriano May 14, 2025
338186a
Merge branch 'main' into rob/robo-20-manage-api-keys
wobsoriano May 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const AVAILABLE_COMPONENTS = [
'organizationSwitcher',
'waitlist',
'#Table',
'apiKeys',
] as const;

const COMPONENT_PROPS_NAMESPACE = 'clerk-js-sandbox';
Expand Down Expand Up @@ -91,6 +92,7 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component
organizationSwitcher: buildComponentControls('organizationSwitcher'),
waitlist: buildComponentControls('waitlist'),
#Table: buildComponentControls('#Table'),
apiKeys: buildComponentControls('apiKeys'),
};

declare global {
Expand Down Expand Up @@ -310,6 +312,9 @@ void (async () => {
'/#-table': () => {
Clerk.mount#Table(app, componentControls.#Table.getProps() ?? {});
},
'/api-keys': () => {
Clerk.mountApiKeys(app, componentControls.apiKeys.getProps() ?? {});
},
'/open-sign-in': () => {
mountOpenSignInButton(app, componentControls.signIn.getProps() ?? {});
},
Expand Down
8 changes: 8 additions & 0 deletions packages/clerk-js/sandbox/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,14 @@
#Table
</a>
</li>
<li class="relative">
<a
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"
href="/api-keys"
>
Manage API Keys
</a>
</li>
<li class="relative">
<a
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"
Expand Down
37 changes: 37 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import type {
__internal_ComponentNavigationContext,
__internal_PlanDetailsProps,
__internal_UserVerificationModalProps,
ApiKeyResource,
ApiKeysProps,
AuthenticateWithCoinbaseWalletParams,
AuthenticateWithGoogleOneTapParams,
AuthenticateWithMetamaskParams,
Expand All @@ -30,13 +32,15 @@ import type {
ClientJSONSnapshot,
ClientResource,
CommerceBillingNamespace,
CreateApiKeyParams,
CreateOrganizationParams,
CreateOrganizationProps,
CredentialReturn,
DomainOrProxyUrl,
EnvironmentJSON,
EnvironmentJSONSnapshot,
EnvironmentResource,
GetApiKeysParams,
GoogleOneTapProps,
HandleEmailLinkVerificationParams,
HandleOAuthCallbackParams,
Expand All @@ -57,6 +61,7 @@ import type {
PublicKeyCredentialWithAuthenticatorAttestationResponse,
RedirectOptions,
Resources,
RevokeApiKeyParams,
SDKMetadata,
SetActiveParams,
SignedInSessionResource,
Expand Down Expand Up @@ -133,6 +138,7 @@ import { createFapiClient } from './fapiClient';
import { createClientFromJwt } from './jwt-client';
import { CommerceBilling } from './modules/commerce';
import {
ApiKey,
BaseResource,
Client,
EmailLinkError,
Expand Down Expand Up @@ -1037,6 +1043,29 @@ export class Clerk implements ClerkInterface {
);
};

public mountApiKeys = (node: HTMLDivElement, props?: ApiKeysProps): void => {
this.assertComponentsReady(this.#componentControls);
void this.#componentControls.ensureMounted({ preloadHint: 'ApiKeys' }).then(controls =>
controls.mountComponent({
name: 'ApiKeys',
appearanceKey: 'apiKeys',
node,
props,
}),
);

this.telemetry?.record(eventPrebuiltComponentMounted('ApiKeys', props));
};

public unmountApiKeys = (node: HTMLDivElement): void => {
this.assertComponentsReady(this.#componentControls);
void this.#componentControls.ensureMounted().then(controls =>
controls.unmountComponent({
node,
}),
);
};

/**
* `setActive` can be used to set the active session and/or organization.
*/
Expand Down Expand Up @@ -2027,6 +2056,14 @@ export class Clerk implements ClerkInterface {
this.environment = environment;
}

public getApiKeys = (params?: GetApiKeysParams): Promise<ApiKeyResource[]> => ApiKey.getAll(params);

public getApiKeySecret = (apiKeyID: string): Promise<string> => ApiKey.getSecret(apiKeyID);

public createApiKey = (params: CreateApiKeyParams): Promise<ApiKeyResource> => ApiKey.create(params);

public revokeApiKey = (params: RevokeApiKeyParams): Promise<ApiKeyResource> => ApiKey.revoke(params);

__internal_setCountry = (country: string | null) => {
if (!this.__internal_country) {
this.__internal_country = country;
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/fapiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,8 @@ export function createFapiClient(options: FapiClientOptions): FapiClient {
let response: Response;
const urlStr = requestInit.url.toString();
const fetchOpts: FapiRequestInit = {
...requestInit,
credentials: 'include',
...requestInit,
Comment on lines 227 to +228
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temporary fix for cors issue

method: overwrittenRequestMethod,
};

Expand Down
141 changes: 141 additions & 0 deletions packages/clerk-js/src/core/resources/ApiKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type {
ApiKeyJSON,
ApiKeyResource,
CreateApiKeyParams,
GetApiKeysParams,
RevokeApiKeyParams,
} from '@clerk/types';

import { unixEpochToDate } from '../../utils/date';
import { BaseResource } from './internal';

export class ApiKey extends BaseResource implements ApiKeyResource {
pathRoot = '/api_keys';

id!: string;
type!: string;
name!: string;
subject!: string;
scopes!: string[];
claims!: Record<string, any> | null;
revoked!: boolean;
revocationReason!: string | null;
expired!: boolean;
expiration!: Date | null;
createdBy!: string | null;
creationReason!: string | null;
createdAt!: Date;
updatedAt!: Date;

constructor(data: ApiKeyJSON) {
super();
this.fromJSON(data);
}

protected fromJSON(data: ApiKeyJSON | null): this {
if (!data) {
return this;
}

this.id = data.id;
this.type = data.type;
this.name = data.name;
this.subject = data.subject;
this.scopes = data.scopes;
this.claims = data.claims;
this.revoked = data.revoked;
this.revocationReason = data.revocation_reason;
this.expired = data.expired;
this.expiration = data.expiration ? unixEpochToDate(data.expiration) : null;
this.createdBy = data.created_by;
this.creationReason = data.creation_reason;
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);
return this;
}

static async getAll(params?: GetApiKeysParams): Promise<ApiKeyResource[]> {
return this.clerk
.getFapiClient()
.request<{ api_keys: ApiKeyJSON[] }>({
method: 'GET',
path: '/api_keys',
pathPrefix: '',
search: {
subject: params?.subject ?? this.clerk.organization?.id ?? this.clerk.user?.id ?? '',
},
headers: {
Authorization: `Bearer ${await this.clerk.session?.getToken()}`,
},
credentials: 'same-origin',
})
.then(res => {
const apiKeysJSON = res.payload as unknown as { api_keys: ApiKeyJSON[] };
return apiKeysJSON.api_keys.map(json => new ApiKey(json));
})
.catch(() => []);
}

static async getSecret(id: string): Promise<string> {
return this.clerk
.getFapiClient()
.request<{ secret: string }>({
method: 'GET',
path: `/api_keys/${id}/secret`,
credentials: 'same-origin',
pathPrefix: '',
headers: {
Authorization: `Bearer ${await this.clerk.session?.getToken()}`,
},
})
.then(res => {
const { secret } = res.payload as unknown as { secret: string };
return secret;
})
.catch(() => '');
}

static async create(params: CreateApiKeyParams): Promise<ApiKeyResource> {
const json = (
await BaseResource._fetch<ApiKeyJSON>({
path: '/api_keys',
method: 'POST',
pathPrefix: '',
headers: {
Authorization: `Bearer ${await this.clerk.session?.getToken()}`,
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
type: params.type ?? 'api_key',
name: params.name,
subject: params.subject ?? this.clerk.organization?.id ?? this.clerk.user?.id ?? '',
creation_reason: params.creationReason,
seconds_until_expiration: params.secondsUntilExpiration,
}),
})
)?.response as ApiKeyJSON;

return new ApiKey(json);
}

static async revoke(params: RevokeApiKeyParams): Promise<ApiKeyResource> {
const json = (
await BaseResource._fetch<ApiKeyJSON>({
path: `/api_keys/${params.apiKeyID}/revoke`,
method: 'POST',
pathPrefix: '',
headers: {
Authorization: `Bearer ${await this.clerk.session?.getToken()}`,
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
revocation_reason: params.revocationReason,
}),
})
)?.response as ApiKeyJSON;

return new ApiKey(json);
}
}
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/resources/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ export * from './UserOrganizationInvitation';
export * from './Verification';
export * from './Web3Wallet';
export * from './Waitlist';
export * from './ApiKey';
97 changes: 97 additions & 0 deletions packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useOrganization, useUser } from '@clerk/shared/react';

import { useApiKeysContext } from '../../contexts';
import { Box, Button, Col, Flex, Flow, Icon, localizationKeys, useLocalizations } from '../../customizables';
import { Card, InputWithIcon, Pagination, withCardStateProvider } from '../../elements';
import { Action } from '../../elements/Action';
import { MagnifyingGlass } from '../../icons';
import { ApiKeysTable } from './ApiKeysTable';
import { CreateApiKeyForm } from './CreateApiKeyForm';
import { useApiKeys } from './useApiKeys';

export const ApiKeysInternal = ({ subject, perPage }: { subject: string; perPage?: number }) => {
const {
apiKeys,
isLoading,
revokeApiKey,
search,
setSearch,
page,
setPage,
pageCount,
itemCount,
startingRow,
endingRow,
handleCreate,
} = useApiKeys({ subject, perPage });
const { t } = useLocalizations();

return (
<Col gap={4}>
<Action.Root>
<Flex
justify='between'
align='center'
>
<Box>
<InputWithIcon
placeholder={t(localizationKeys('apiKey.action__search'))}
leftIcon={<Icon icon={MagnifyingGlass} />}
value={search}
onChange={e => {
setSearch(e.target.value);
setPage(1);
}}
/>
</Box>
<Action.Trigger value='add'>
<Button
variant='solid'
localizationKey={localizationKeys('apiKey.action__add')}
/>
</Action.Trigger>
</Flex>
<Action.Open value='add'>
<Flex sx={t => ({ paddingTop: t.space.$6, paddingBottom: t.space.$6 })}>
<Action.Card sx={{ width: '100%' }}>
<CreateApiKeyForm onCreate={params => void handleCreate(params)} />
</Action.Card>
</Flex>
</Action.Open>
</Action.Root>
<ApiKeysTable
rows={apiKeys}
isLoading={isLoading}
onRevoke={revokeApiKey}
/>
{itemCount > 5 && (
<Pagination
count={pageCount}
page={page}
onChange={setPage}
siblingCount={1}
rowInfo={{ allRowsCount: itemCount, startingRow, endingRow }}
/>
)}
</Col>
);
};

export const ApiKeys = withCardStateProvider(() => {
const ctx = useApiKeysContext();
const { user } = useUser();
const { organization } = useOrganization();

return (
<Flow.Root flow='apiKey'>
<Card.Root sx={{ width: '100%' }}>
<Card.Content sx={{ textAlign: 'left' }}>
<ApiKeysInternal
subject={ctx.subject ?? organization?.id ?? user?.id ?? ''}
perPage={ctx.perPage}
/>
</Card.Content>
</Card.Root>
</Flow.Root>
);
});
Loading
Loading