diff --git a/.changeset/fluffy-numbers-stick.md b/.changeset/fluffy-numbers-stick.md new file mode 100644 index 00000000000..5b2c494922f --- /dev/null +++ b/.changeset/fluffy-numbers-stick.md @@ -0,0 +1,6 @@ +--- +'@clerk/localizations': patch +'@clerk/types': patch +--- + +Add TypeScript types and en-US localization for upcoming `` component. This component will initially be in early access. diff --git a/.changeset/ninety-candles-sleep.md b/.changeset/ninety-candles-sleep.md new file mode 100644 index 00000000000..bb3a4e60c90 --- /dev/null +++ b/.changeset/ninety-candles-sleep.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/nextjs': minor +'@clerk/clerk-react': minor +--- + +Add `` component. This component will initially be in early access and not recommended for production usage just yet. diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index 3d5ad36e5b4..9c78fd7dd1c 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -129,6 +129,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "nextjs/create-sync-get-auth.mdx", "nextjs/current-user.mdx", "nextjs/get-auth.mdx", + "clerk-react/api-keys.mdx", "clerk-react/clerk-provider-props.mdx", "clerk-react/protect.mdx", "clerk-react/redirect-to-create-organization.mdx", diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index eb1bbed8da6..46f768216a0 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -163,6 +163,12 @@ const withWhatsappPhoneCode = base .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-whatsapp-phone-code').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-whatsapp-phone-code').pk); +const withAPIKeys = base + .clone() + .setId('withAPIKeys') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-api-keys').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-api-keys').pk); + export const envs = { base, withKeyless, @@ -187,4 +193,5 @@ export const envs = { withBillingStaging, withBilling, withWhatsappPhoneCode, + withAPIKeys, } as const; diff --git a/integration/templates/next-app-router/src/app/api-keys/page.tsx b/integration/templates/next-app-router/src/app/api-keys/page.tsx new file mode 100644 index 00000000000..220e27275e7 --- /dev/null +++ b/integration/templates/next-app-router/src/app/api-keys/page.tsx @@ -0,0 +1,5 @@ +import { APIKeys } from '@clerk/nextjs'; + +export default function APIKeysPage() { + return ; +} diff --git a/integration/tests/api-keys.test.ts b/integration/tests/api-keys.test.ts new file mode 100644 index 00000000000..92371cd850c --- /dev/null +++ b/integration/tests/api-keys.test.ts @@ -0,0 +1,63 @@ +import { test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @apiKeys', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test.skip('can create an API key', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.page.goToRelative('/api-keys'); + + // TODO: Replace with custom page object + await u.po.page.waitForSelector('.cl-apiKeys-root'); + + await u.po.page.getByRole('button', { name: 'Add new key' }).click(); + await u.po.page.locator('input[name=name]').fill('test-key'); + }); + + test.skip('can create an API key with description and expiration', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.page.goToRelative('/api-keys'); + + // TODO: Replace with custom page object + await u.po.page.waitForSelector('.cl-apiKeys-root'); + + await u.po.page.getByRole('button', { name: 'Add new key' }).click(); + await u.po.page.locator('input[name=name]').fill('test-key'); + + await u.po.page.getByRole('button', { name: 'Show advanced settings' }).click(); + + await u.po.page.locator('input[name=description]').fill('test-description'); + await u.po.page.locator('input[name=expiration]').fill('2025-01-01'); + }); + + test.skip('user is prompted before revoking and can revoke an API key', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.page.goToRelative('/api-keys'); + + // TODO: Replace with custom page object + await u.po.page.waitForSelector('.cl-apiKeys-root'); + }); +}); diff --git a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap index f3220be64f2..209abfc6d51 100644 --- a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap @@ -2,6 +2,7 @@ exports[`public exports should not include a breaking change 1`] = ` [ + "APIKeys", "AuthenticateWithRedirectCallback", "ClerkDegraded", "ClerkFailed", diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index a446de1a2ba..32c1f4e5935 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,10 +1,10 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "605kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "69.2KB" }, + { "path": "./dist/clerk.js", "maxSize": "608.05kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "70KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "113KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "53KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "106.3KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "107.33KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 707b52b1674..e2752c2f7b3 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -33,6 +33,7 @@ const AVAILABLE_COMPONENTS = [ 'organizationSwitcher', 'waitlist', 'pricingTable', + 'apiKeys', 'oauthConsent', ] as const; @@ -92,6 +93,7 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component organizationSwitcher: buildComponentControls('organizationSwitcher'), waitlist: buildComponentControls('waitlist'), pricingTable: buildComponentControls('pricingTable'), + apiKeys: buildComponentControls('apiKeys'), oauthConsent: buildComponentControls('oauthConsent'), }; @@ -312,6 +314,9 @@ void (async () => { '/pricing-table': () => { Clerk.mountPricingTable(app, componentControls.pricingTable.getProps() ?? {}); }, + '/api-keys': () => { + Clerk.mountApiKeys(app, componentControls.apiKeys.getProps() ?? {}); + }, '/oauth-consent': () => { const searchParams = new URLSearchParams(window.location.search); const scopes = (searchParams.get('scopes')?.split(',') ?? []).map(scope => ({ diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index b9b0b3b5b98..9122941cf8e 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -260,6 +260,14 @@ PricingTable +
  • + + API Keys + +
  • (key: K): ClerkOptions[K] { return this.#options[key]; } @@ -1055,6 +1068,48 @@ export class Clerk implements ClerkInterface { void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node })); }; + /** + * @experimental + * This API is in early access and may change in future releases. + * + * Mount a api keys component at the target element. + * @param targetNode Target to mount the APIKeys component. + * @param props Configuration parameters. + */ + public mountApiKeys = (node: HTMLDivElement, props?: APIKeysProps) => { + this.assertComponentsReady(this.#componentControls); + if (disabledAPIKeysFeature(this, this.environment)) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponent, { + code: CANNOT_RENDER_API_KEYS_DISABLED_ERROR_CODE, + }); + } + return; + } + void this.#componentControls.ensureMounted({ preloadHint: 'APIKeys' }).then(controls => + controls.mountComponent({ + name: 'APIKeys', + appearanceKey: 'apiKeys', + node, + props, + }), + ); + }; + + /** + * @experimental + * This API is in early access and may change in future releases. + * + * Unmount a api keys component from the target element. + * If there is no component mounted at the target node, results in a noop. + * + * @param targetNode Target node to unmount the ApiKeys component from. + */ + public unmountApiKeys = (node: HTMLDivElement) => { + 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. */ diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index 2ae7bd9a502..f4373fc7584 100644 --- a/packages/clerk-js/src/core/fapiClient.ts +++ b/packages/clerk-js/src/core/fapiClient.ts @@ -225,8 +225,8 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { const urlStr = requestInit.url.toString(); const fetchOpts: FapiRequestInit = { ...requestInit, - credentials: 'include', method: overwrittenRequestMethod, + credentials: requestInit.credentials || 'include', }; try { diff --git a/packages/clerk-js/src/core/modules/apiKeys/index.ts b/packages/clerk-js/src/core/modules/apiKeys/index.ts new file mode 100644 index 00000000000..bf8234468f2 --- /dev/null +++ b/packages/clerk-js/src/core/modules/apiKeys/index.ts @@ -0,0 +1,103 @@ +import type { + ApiKeyJSON, + APIKeyResource, + APIKeysNamespace, + CreateAPIKeyParams, + GetAPIKeysParams, + RevokeAPIKeyParams, +} from '@clerk/types'; + +import type { FapiRequestInit } from '@/core/fapiClient'; + +import { APIKey, BaseResource, ClerkRuntimeError } from '../../resources/internal'; + +export class APIKeys implements APIKeysNamespace { + /** + * Returns the base options for the FAPI proxy requests. + */ + private async getBaseFapiProxyOptions(): Promise { + const token = await BaseResource.clerk.session?.getToken(); + if (!token) { + throw new ClerkRuntimeError('No valid session token available', { code: 'no_session_token' }); + } + + return { + // Set to an empty string because FAPI Proxy does not include the version in the path. + pathPrefix: '', + // Set the session token as a Bearer token in the Authorization header for authentication. + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + // Set to `same-origin` to ensure cookies and credentials are sent with requests, avoiding CORS issues. + credentials: 'same-origin', + }; + } + + async getAll(params?: GetAPIKeysParams): Promise { + return BaseResource.clerk + .getFapiClient() + .request<{ api_keys: ApiKeyJSON[] }>({ + ...(await this.getBaseFapiProxyOptions()), + method: 'GET', + path: '/api_keys', + search: { + subject: params?.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '', + }, + }) + .then(res => { + const apiKeysJSON = res.payload as unknown as { api_keys: ApiKeyJSON[] }; + return apiKeysJSON.api_keys.map(json => new APIKey(json)); + }) + .catch(() => []); + } + + async getSecret(id: string): Promise { + return BaseResource.clerk + .getFapiClient() + .request<{ secret: string }>({ + ...(await this.getBaseFapiProxyOptions()), + method: 'GET', + path: `/api_keys/${id}/secret`, + }) + .then(res => { + const { secret } = res.payload as unknown as { secret: string }; + return secret; + }) + .catch(() => ''); + } + + async create(params: CreateAPIKeyParams): Promise { + const json = ( + await BaseResource._fetch({ + ...(await this.getBaseFapiProxyOptions()), + path: '/api_keys', + method: 'POST', + body: JSON.stringify({ + type: params.type ?? 'api_key', + name: params.name, + subject: params.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '', + description: params.description, + seconds_until_expiration: params.secondsUntilExpiration, + }), + }) + )?.response as ApiKeyJSON; + + return new APIKey(json); + } + + async revoke(params: RevokeAPIKeyParams): Promise { + const json = ( + await BaseResource._fetch({ + ...(await this.getBaseFapiProxyOptions()), + method: 'POST', + path: `/api_keys/${params.apiKeyID}/revoke`, + body: JSON.stringify({ + revocation_reason: params.revocationReason, + }), + }) + )?.response as ApiKeyJSON; + + return new APIKey(json); + } +} diff --git a/packages/clerk-js/src/core/resources/APIKey.ts b/packages/clerk-js/src/core/resources/APIKey.ts new file mode 100644 index 00000000000..9b0bba52e4b --- /dev/null +++ b/packages/clerk-js/src/core/resources/APIKey.ts @@ -0,0 +1,52 @@ +import type { ApiKeyJSON, APIKeyResource } 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 | null; + revoked!: boolean; + revocationReason!: string | null; + expired!: boolean; + expiration!: Date | null; + createdBy!: string | null; + description!: string | null; + lastUsedAt!: Date | 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.description = data.description; + this.lastUsedAt = data.last_used_at ? unixEpochToDate(data.last_used_at) : null; + this.updatedAt = unixEpochToDate(data.updated_at); + this.createdAt = unixEpochToDate(data.created_at); + return this; + } +} diff --git a/packages/clerk-js/src/core/resources/APIKeySettings.ts b/packages/clerk-js/src/core/resources/APIKeySettings.ts new file mode 100644 index 00000000000..d21ff5fe84f --- /dev/null +++ b/packages/clerk-js/src/core/resources/APIKeySettings.ts @@ -0,0 +1,31 @@ +import type { APIKeysSettingsJSON, APIKeysSettingsJSONSnapshot, APIKeysSettingsResource } from '@clerk/types'; + +import { BaseResource } from './internal'; + +/** + * @internal + */ +export class APIKeySettings extends BaseResource implements APIKeysSettingsResource { + enabled: boolean = false; + + public constructor(data: APIKeysSettingsJSON | APIKeysSettingsJSONSnapshot | null = null) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: APIKeysSettingsJSON | APIKeysSettingsJSONSnapshot | null): this { + if (!data) { + return this; + } + + this.enabled = this.withDefault(data.enabled, false); + + return this; + } + + public __internal_toSnapshot(): APIKeysSettingsJSONSnapshot { + return { + enabled: this.enabled, + } as APIKeysSettingsJSONSnapshot; + } +} diff --git a/packages/clerk-js/src/core/resources/Environment.ts b/packages/clerk-js/src/core/resources/Environment.ts index e0bb18b426b..eadb9a9f698 100644 --- a/packages/clerk-js/src/core/resources/Environment.ts +++ b/packages/clerk-js/src/core/resources/Environment.ts @@ -10,6 +10,7 @@ import type { } from '@clerk/types'; import { eventBus, events } from '../../core/events'; +import { APIKeySettings } from './APIKeySettings'; import { AuthConfig, BaseResource, CommerceSettings, DisplayConfig, UserSettings } from './internal'; import { OrganizationSettings } from './OrganizationSettings'; @@ -23,6 +24,7 @@ export class Environment extends BaseResource implements EnvironmentResource { userSettings: UserSettingsResource = new UserSettings(); organizationSettings: OrganizationSettingsResource = new OrganizationSettings(); commerceSettings: CommerceSettingsResource = new CommerceSettings(); + apiKeysSettings: APIKeySettings = new APIKeySettings(); public static getInstance(): Environment { if (!Environment.instance) { @@ -49,6 +51,7 @@ export class Environment extends BaseResource implements EnvironmentResource { this.organizationSettings = new OrganizationSettings(data.organization_settings); this.userSettings = new UserSettings(data.user_settings); this.commerceSettings = new CommerceSettings(data.commerce_settings); + this.apiKeysSettings = new APIKeySettings(data.api_keys_settings); return this; } @@ -88,6 +91,7 @@ export class Environment extends BaseResource implements EnvironmentResource { organization_settings: this.organizationSettings.__internal_toSnapshot(), user_settings: this.userSettings.__internal_toSnapshot(), commerce_settings: this.commerceSettings.__internal_toSnapshot(), + api_keys_settings: this.apiKeysSettings.__internal_toSnapshot(), }; } } diff --git a/packages/clerk-js/src/core/resources/__tests__/Environment.spec.ts b/packages/clerk-js/src/core/resources/__tests__/Environment.spec.ts index 87b54aefcc4..37c035a531a 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Environment.spec.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Environment.spec.ts @@ -8,6 +8,10 @@ describe('Environment', () => { const environment = new Environment(); expect(environment).toMatchObject({ + apiKeysSettings: expect.objectContaining({ + enabled: false, + pathRoot: '', + }), authConfig: expect.objectContaining({ singleSessionMode: false, reverification: false, @@ -48,6 +52,11 @@ describe('Environment', () => { const environmentJSON = { object: 'environment', id: '', + api_keys_settings: { + enabled: false, + id: undefined, + path_root: '', + }, auth_config: { object: 'auth_config', id: '', @@ -568,6 +577,9 @@ describe('Environment', () => { expect(environment.__internal_toSnapshot()).toMatchObject({ object: 'environment', + api_keys_settings: expect.objectContaining({ + enabled: false, + }), auth_config: expect.objectContaining({ single_session_mode: true, reverification: true, diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index f9ce0f4e82a..15655efc2f3 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -43,3 +43,4 @@ export * from './UserOrganizationInvitation'; export * from './Verification'; export * from './Web3Wallet'; export * from './Waitlist'; +export * from './APIKey'; diff --git a/packages/clerk-js/src/core/warnings.ts b/packages/clerk-js/src/core/warnings.ts index 91801292e9c..2af87837fec 100644 --- a/packages/clerk-js/src/core/warnings.ts +++ b/packages/clerk-js/src/core/warnings.ts @@ -38,6 +38,8 @@ const warnings = { 'The Checkout drawer cannot render unless a user is signed in. Since no user is signed in, this is no-op.', cannotOpenSignInOrSignUp: 'The SignIn or SignUp modals do not render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, this is no-op.', + cannotRenderAPIKeysComponent: + 'The component cannot be rendered when API keys is disabled. Since API keys is disabled, this is no-op.', }; type SerializableWarnings = Serializable; diff --git a/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx b/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx new file mode 100644 index 00000000000..6cefb696f91 --- /dev/null +++ b/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx @@ -0,0 +1,182 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { useClerk, useOrganization, useUser } from '@clerk/shared/react'; +import type { CreateAPIKeyParams } from '@clerk/types'; +import { lazy, useState } from 'react'; +import useSWRMutation from 'swr/mutation'; + +import { useApiKeysContext, withCoreUserGuard } from '@/ui/contexts'; +import { Box, Button, Col, Flex, Flow, Icon, localizationKeys, useLocalizations } from '@/ui/customizables'; +import { Action } from '@/ui/elements/Action'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; +import { InputWithIcon } from '@/ui/elements/InputWithIcon'; +import { Pagination } from '@/ui/elements/Pagination'; +import { MagnifyingGlass } from '@/ui/icons'; +import { mqu } from '@/ui/styledSystem'; + +import { ApiKeysTable } from './ApiKeysTable'; +import type { OnCreateParams } from './CreateApiKeyForm'; +import { CreateApiKeyForm } from './CreateApiKeyForm'; +import { useApiKeys } from './useApiKeys'; + +type APIKeysPageProps = { + subject: string; + perPage?: number; + revokeModalRoot?: React.MutableRefObject; +}; + +const RevokeAPIKeyConfirmationModal = lazy(() => + import(/* webpackChunkName: "revoke-api-key-modal"*/ './RevokeAPIKeyConfirmationModal').then(module => ({ + default: module.RevokeAPIKeyConfirmationModal, + })), +); + +export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPageProps) => { + const { + apiKeys, + isLoading, + search, + setSearch, + page, + setPage, + pageCount, + itemCount, + startingRow, + endingRow, + cacheKey, + } = useApiKeys({ subject, perPage }); + const card = useCardState(); + const { trigger: createApiKey, isMutating } = useSWRMutation(cacheKey, (_, { arg }: { arg: CreateAPIKeyParams }) => + clerk.apiKeys.create(arg), + ); + const { t } = useLocalizations(); + const clerk = useClerk(); + const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false); + const [selectedApiKeyId, setSelectedApiKeyId] = useState(''); + const [selectedApiKeyName, setSelectedApiKeyName] = useState(''); + + const handleCreateApiKey = async (params: OnCreateParams, closeCardFn: () => void) => { + try { + await createApiKey({ + ...params, + subject, + }); + closeCardFn(); + card.setError(undefined); + } catch (err: any) { + if (isClerkAPIResponseError(err)) { + if (err.status === 409) { + card.setError('API Key name already exists'); + } + } + } + }; + + const handleRevoke = (apiKeyId: string, apiKeyName: string) => { + setSelectedApiKeyId(apiKeyId); + setSelectedApiKeyName(apiKeyName); + setIsRevokeModalOpen(true); + }; + + return ( + + + + + } + value={search} + onChange={e => { + setSearch(e.target.value); + setPage(1); + }} + /> + + + + ); +}; + +const SecretInputWithToggle = ({ apiKeyID }: { apiKeyID: string }) => { + const [revealed, setRevealed] = useState(false); + const { data: apiKeySecret } = useApiKeySecret({ apiKeyID, enabled: revealed }); + + return ( + + ({ + paddingInlineEnd: t.sizes.$12, + })} + /> + + + ); +}; + +export const ApiKeysTable = ({ + rows, + isLoading, + onRevoke, +}: { + rows: APIKeyResource[]; + isLoading: boolean; + onRevoke: (id: string, name: string) => void; +}) => { + return ( + ({ width: '100%', [mqu.sm]: { overflowX: 'auto', padding: t.space.$0x25 } })}> + ({ background: t.colors.$colorBackground })}> + + + + + + + + + + {isLoading ? ( + + + + ) : !rows.length ? ( + + ) : ( + rows.map(apiKey => ( + + + + + + + )) + )} + +
    NameLast usedKeyActions
    + +
    + + + {apiKey.name} + + + Created at{' '} + {apiKey.createdAt.toLocaleDateString(undefined, { + month: 'short', + day: '2-digit', + year: 'numeric', + })} + + + + + + + + + + + + + onRevoke(apiKey.id, apiKey.name), + }, + ]} + /> +
    +
    + ); +}; + +const EmptyRow = () => { + return ( + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui/components/ApiKeys/CreateApiKeyForm.tsx b/packages/clerk-js/src/ui/components/ApiKeys/CreateApiKeyForm.tsx new file mode 100644 index 00000000000..cbb01a1848d --- /dev/null +++ b/packages/clerk-js/src/ui/components/ApiKeys/CreateApiKeyForm.tsx @@ -0,0 +1,198 @@ +import React, { useState } from 'react'; + +import { Box, Button, Col, Flex, FormLabel, localizationKeys, Text } from '@/ui/customizables'; +import { useActionContext } from '@/ui/elements/Action/ActionRoot'; +import { Form } from '@/ui/elements/Form'; +import { FormButtons } from '@/ui/elements/FormButtons'; +import { FormContainer } from '@/ui/elements/FormContainer'; +import { SegmentedControl } from '@/ui/elements/SegmentedControl'; +import { mqu } from '@/ui/styledSystem'; +import { useFormControl } from '@/ui/utils/useFormControl'; + +export type OnCreateParams = { name: string; description?: string; expiration: number | undefined }; + +interface CreateApiKeyFormProps { + onCreate: (params: OnCreateParams, closeCardFn: () => void) => void; + isSubmitting: boolean; +} + +export type Expiration = 'never' | '30d' | '90d' | 'custom'; + +const getTimeLeftInSeconds = (expirationOption: Expiration, customDate?: string) => { + if (expirationOption === 'never') { + return; + } + + const now = new Date(); + let future = new Date(now); + + switch (expirationOption) { + case '30d': + future.setDate(future.getDate() + 30); + break; + case '90d': + future.setDate(future.getDate() + 90); + break; + case 'custom': + future = new Date(customDate as string); + break; + default: + throw new Error('Invalid expiration option'); + } + + const diffInMs = future.getTime() - now.getTime(); + const diffInSecs = Math.floor(diffInMs / 1000); + return diffInSecs; +}; + +const getMinDate = () => { + const min = new Date(); + min.setDate(min.getDate() + 1); + return min.toISOString().split('T')[0]; +}; + +export const CreateApiKeyForm = ({ onCreate, isSubmitting }: CreateApiKeyFormProps) => { + const [showAdvanced, setShowAdvanced] = useState(false); + const [expiration, setExpiration] = useState('never'); + const createApiKeyFormId = React.useId(); + const segmentedControlId = `${createApiKeyFormId}-segmented-control`; + const { close: closeCardFn } = useActionContext(); + + const nameField = useFormControl('name', '', { + type: 'text', + label: localizationKeys('formFieldLabel__apiKeyName'), + placeholder: localizationKeys('formFieldInputPlaceholder__apiKeyName'), + isRequired: true, + }); + + const descriptionField = useFormControl('description', '', { + type: 'text', + label: localizationKeys('formFieldLabel__apiKeyDescription'), + placeholder: localizationKeys('formFieldInputPlaceholder__apiKeyDescription'), + isRequired: false, + }); + + const expirationDateField = useFormControl('expirationDate', '', { + type: 'date', + label: localizationKeys('formFieldLabel__apiKeyExpirationDate'), + placeholder: localizationKeys('formFieldInputPlaceholder__apiKeyExpirationDate'), + isRequired: false, + }); + + const canSubmit = nameField.value.length > 2; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onCreate( + { + name: nameField.value, + description: descriptionField.value || undefined, + expiration: getTimeLeftInSeconds(expiration, expirationDateField.value), + }, + closeCardFn, + ); + }; + + return ( + + + + + + {showAdvanced && ( + <> + + + + + + + + + setExpiration(value as Expiration)} + fullWidth + sx={t => ({ height: t.sizes.$8 })} + > + + + + + + + {expiration === 'custom' ? ( + + + + ) : ( + + )} + + + )} + + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui/components/ApiKeys/RevokeAPIKeyConfirmationModal.tsx b/packages/clerk-js/src/ui/components/ApiKeys/RevokeAPIKeyConfirmationModal.tsx new file mode 100644 index 00000000000..3522c4f86db --- /dev/null +++ b/packages/clerk-js/src/ui/components/ApiKeys/RevokeAPIKeyConfirmationModal.tsx @@ -0,0 +1,109 @@ +import { useClerk } from '@clerk/shared/react'; +import { useSWRConfig } from 'swr'; + +import { Card } from '@/ui/elements/Card'; +import { Form } from '@/ui/elements/Form'; +import { FormButtons } from '@/ui/elements/FormButtons'; +import { FormContainer } from '@/ui/elements/FormContainer'; +import { Modal } from '@/ui/elements/Modal'; +import { localizationKeys } from '@/ui/localization'; +import { useFormControl } from '@/ui/utils'; + +type RevokeAPIKeyConfirmationModalProps = { + subject: string; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; + apiKeyId?: string; + apiKeyName?: string; + modalRoot?: React.MutableRefObject; +}; + +export const RevokeAPIKeyConfirmationModal = ({ + subject, + isOpen, + onOpen, + onClose, + apiKeyId, + apiKeyName, + modalRoot, +}: RevokeAPIKeyConfirmationModalProps) => { + const clerk = useClerk(); + const { mutate } = useSWRConfig(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!apiKeyId) return; + + await clerk.apiKeys.revoke({ apiKeyID: apiKeyId }); + const cacheKey = { key: 'api-keys', subject }; + void mutate(cacheKey); + onClose(); + }; + + const revokeField = useFormControl('revokeConfirmation', '', { + type: 'text', + label: `Type "Revoke" to confirm`, + placeholder: 'Revoke', + isRequired: true, + }); + + // TODO: Maybe use secret key name for confirmation + const canSubmit = revokeField.value === 'Revoke'; + + if (!isOpen) { + return null; + } + + return ( + ({ + position: 'absolute', + right: 0, + bottom: 0, + backgroundColor: 'inherit', + backdropFilter: `blur(${t.sizes.$2})`, + display: 'flex', + justifyContent: 'center', + minHeight: '100%', + height: '100%', + width: '100%', + }) + : {}, + ]} + > + + ({ + textAlign: 'left', + padding: `${t.sizes.$4} ${t.sizes.$5} ${t.sizes.$4} ${t.sizes.$6}`, + })} + > + + + + + + + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui/components/ApiKeys/useApiKeys.ts b/packages/clerk-js/src/ui/components/ApiKeys/useApiKeys.ts new file mode 100644 index 00000000000..757b99643c9 --- /dev/null +++ b/packages/clerk-js/src/ui/components/ApiKeys/useApiKeys.ts @@ -0,0 +1,60 @@ +import { useClerk } from '@clerk/shared/react'; +import { useState } from 'react'; +import useSWR from 'swr'; + +export const useApiKeys = ({ subject, perPage = 5 }: { subject: string; perPage?: number }) => { + const clerk = useClerk(); + const cacheKey = { + key: 'api-keys', + subject, + }; + + const { data: apiKeys, isLoading, mutate } = useSWR(cacheKey, () => clerk.apiKeys.getAll({ subject })); + const [search, setSearch] = useState(''); + const filteredApiKeys = (apiKeys ?? []).filter(key => key.name.toLowerCase().includes(search.toLowerCase())); + + const { + page, + setPage, + pageCount, + itemCount, + startingRow, + endingRow, + paginatedItems: paginatedApiKeys, + } = useClientSidePagination(filteredApiKeys, perPage); + + return { + apiKeys: paginatedApiKeys, + cacheKey, + mutate, + isLoading, + search, + setSearch, + page, + setPage, + pageCount, + itemCount, + startingRow, + endingRow, + }; +}; + +const useClientSidePagination = (items: T[], itemsPerPage: number) => { + const [page, setPage] = useState(1); + + const itemCount = items.length; + const pageCount = Math.max(1, Math.ceil(itemCount / itemsPerPage)); + const startingRow = itemCount > 0 ? (page - 1) * itemsPerPage + 1 : 0; + const endingRow = Math.min(page * itemsPerPage, itemCount); + const paginatedItems = items.slice(startingRow - 1, endingRow); + + return { + page, + setPage, + pageCount, + itemCount, + startingRow, + endingRow, + paginatedItems, + }; +}; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationApiKeysPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationApiKeysPage.tsx new file mode 100644 index 00000000000..efdef3cb73f --- /dev/null +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationApiKeysPage.tsx @@ -0,0 +1,35 @@ +import { useOrganization } from '@clerk/shared/react'; + +import { ApiKeysContext } from '@/ui/contexts'; +import { Col, localizationKeys } from '@/ui/customizables'; +import { Header } from '@/ui/elements/Header'; +import { useUnsafeNavbarContext } from '@/ui/elements/Navbar'; + +import { APIKeysPage } from '../ApiKeys/ApiKeys'; + +export const OrganizationAPIKeysPage = () => { + const { organization } = useOrganization(); + const { contentRef } = useUnsafeNavbarContext(); + + if (!organization) { + // We should never reach this point, but we'll return null to make TS happy + return null; + } + + return ( + + + + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx index 30d83b0d65c..5d0ef479f31 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx @@ -45,7 +45,7 @@ export const OrganizationProfileNavbar = ( } return ( - + })), ); +const OrganizationAPIKeysPage = lazy(() => + import(/* webpackChunkName: "op-api-keys-page"*/ './OrganizationApiKeysPage').then(module => ({ + default: module.OrganizationAPIKeysPage, + })), +); + export const OrganizationProfileRoutes = () => { - const { pages, isMembersPageRoot, isGeneralPageRoot, isBillingPageRoot } = useOrganizationProfileContext(); - const { commerceSettings } = useEnvironment(); + const { pages, isMembersPageRoot, isGeneralPageRoot, isBillingPageRoot, isApiKeysPageRoot } = + useOrganizationProfileContext(); + const { apiKeysSettings, commerceSettings } = useEnvironment(); const customPageRoutesWithContents = pages.contents?.map((customPage, index) => { const shouldFirstCustomItemBeOnRoot = !isGeneralPageRoot && !isMembersPageRoot && index === 0; @@ -96,6 +103,17 @@ export const OrganizationProfileRoutes = () => { )} + {apiKeysSettings.enabled && ( + + + + + + + + + + )} ); diff --git a/packages/clerk-js/src/ui/components/UserProfile/ApiKeysPage.tsx b/packages/clerk-js/src/ui/components/UserProfile/ApiKeysPage.tsx new file mode 100644 index 00000000000..1e289df4f01 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserProfile/ApiKeysPage.tsx @@ -0,0 +1,35 @@ +import { useUser } from '@clerk/shared/react'; + +import { ApiKeysContext } from '@/ui/contexts'; +import { Col, localizationKeys } from '@/ui/customizables'; +import { Header } from '@/ui/elements/Header'; +import { useUnsafeNavbarContext } from '@/ui/elements/Navbar'; + +import { APIKeysPage as APIKeysPageInternal } from '../ApiKeys/ApiKeys'; + +export const APIKeysPage = () => { + const { user } = useUser(); + const { contentRef } = useUnsafeNavbarContext(); + + if (!user) { + // We should never reach this point, but we'll return null to make TS happy + return null; + } + + return ( + + + + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx index 995da4cb02c..29aa272e0c2 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx @@ -12,7 +12,7 @@ export const UserProfileNavbar = ( const { pages } = useUserProfileContext(); return ( - + })), ); +const APIKeysPage = lazy(() => + import(/* webpackChunkName: "up-api-keys-page"*/ './ApiKeysPage').then(module => ({ + default: module.APIKeysPage, + })), +); + export const UserProfileRoutes = () => { const { pages } = useUserProfileContext(); - const { commerceSettings } = useEnvironment(); + const { apiKeysSettings, commerceSettings } = useEnvironment(); const isAccountPageRoot = pages.routes[0].id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT; const isSecurityPageRoot = pages.routes[0].id === USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY; const isBillingPageRoot = pages.routes[0].id === USER_PROFILE_NAVBAR_ROUTE_ID.BILLING; + const isApiKeysPageRoot = pages.routes[0].id === USER_PROFILE_NAVBAR_ROUTE_ID.API_KEYS; const customPageRoutesWithContents = pages.contents?.map((customPage, index) => { const shouldFirstCustomItemBeOnRoot = !isAccountPageRoot && !isSecurityPageRoot && index === 0; @@ -87,6 +94,17 @@ export const UserProfileRoutes = () => { )} + {apiKeysSettings.enabled && ( + + + + + + + + + + )} ); diff --git a/packages/clerk-js/src/ui/constants.ts b/packages/clerk-js/src/ui/constants.ts index 0dc8bc2a1cc..b32e4bc8b7f 100644 --- a/packages/clerk-js/src/ui/constants.ts +++ b/packages/clerk-js/src/ui/constants.ts @@ -2,12 +2,14 @@ export const USER_PROFILE_NAVBAR_ROUTE_ID = { ACCOUNT: 'account', SECURITY: 'security', BILLING: 'billing', + API_KEYS: 'apiKeys', }; export const ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID = { GENERAL: 'general', MEMBERS: 'members', BILLING: 'billing', + API_KEYS: 'apiKeys', }; export const USER_BUTTON_ITEM_ID = { diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 52ed0ec2774..d09ba7c7e1f 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -1,8 +1,15 @@ -import type { __internal_OAuthConsentProps, PricingTableProps, UserButtonProps, WaitlistProps } from '@clerk/types'; +import type { + __internal_OAuthConsentProps, + APIKeysProps, + PricingTableProps, + UserButtonProps, + WaitlistProps, +} from '@clerk/types'; import type { ReactNode } from 'react'; import type { AvailableComponentName, AvailableComponentProps } from '../types'; import { + ApiKeysContext, CreateOrganizationContext, GoogleOneTapContext, OAuthConsentContext, @@ -89,6 +96,12 @@ export function ComponentContextProvider({ ); + case 'APIKeys': + return ( + + {children} + + ); case 'OAuthConsent': return ( diff --git a/packages/clerk-js/src/ui/contexts/components/ApiKeys.ts b/packages/clerk-js/src/ui/contexts/components/ApiKeys.ts new file mode 100644 index 00000000000..f1432222428 --- /dev/null +++ b/packages/clerk-js/src/ui/contexts/components/ApiKeys.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +import type { APIKeysCtx } from '../../types'; + +export const ApiKeysContext = createContext(null); + +export const useApiKeysContext = () => { + const context = useContext(ApiKeysContext); + + if (!context || context.componentName !== 'APIKeys') { + throw new Error('Clerk: useApiKeysContext called outside ApiKeys.'); + } + + const { componentName, ...ctx } = context; + + return { + ...ctx, + componentName, + }; +}; diff --git a/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts b/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts index 2ec5ccffd0a..9af4850a8ff 100644 --- a/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts +++ b/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts @@ -23,6 +23,7 @@ export type OrganizationProfileContextType = OrganizationProfileCtx & { isMembersPageRoot: boolean; isGeneralPageRoot: boolean; isBillingPageRoot: boolean; + isApiKeysPageRoot: boolean; }; export const OrganizationProfileContext = createContext(null); @@ -50,6 +51,7 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType const isMembersPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS; const isGeneralPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.GENERAL; const isBillingPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING; + const isApiKeysPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.API_KEYS; const navigateToGeneralPageRoot = () => navigate(isGeneralPageRoot ? '../' : isMembersPageRoot ? './organization-general' : '../organization-general'); @@ -62,5 +64,6 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType isMembersPageRoot, isGeneralPageRoot, isBillingPageRoot, + isApiKeysPageRoot, }; }; diff --git a/packages/clerk-js/src/ui/contexts/components/index.ts b/packages/clerk-js/src/ui/contexts/components/index.ts index 1c091e23834..0d79e53f98f 100644 --- a/packages/clerk-js/src/ui/contexts/components/index.ts +++ b/packages/clerk-js/src/ui/contexts/components/index.ts @@ -16,4 +16,5 @@ export * from './Checkout'; export * from './Statements'; export * from './PaymentAttempts'; export * from './Plans'; +export * from './ApiKeys'; export * from './OAuthConsent'; diff --git a/packages/clerk-js/src/ui/elements/FormContainer.tsx b/packages/clerk-js/src/ui/elements/FormContainer.tsx index 4ea0b8028f3..060023910a8 100644 --- a/packages/clerk-js/src/ui/elements/FormContainer.tsx +++ b/packages/clerk-js/src/ui/elements/FormContainer.tsx @@ -40,14 +40,16 @@ export const FormContainer = (props: PageProps) => { {headerTitle && ( )} {headerSubtitle && ( )} diff --git a/packages/clerk-js/src/ui/elements/Modal.tsx b/packages/clerk-js/src/ui/elements/Modal.tsx index 01308703099..ed98cf0aaad 100644 --- a/packages/clerk-js/src/ui/elements/Modal.tsx +++ b/packages/clerk-js/src/ui/elements/Modal.tsx @@ -19,11 +19,12 @@ type ModalProps = React.PropsWithChildren<{ containerSx?: ThemableCssProp; canCloseModal?: boolean; style?: React.CSSProperties; + portalRoot?: HTMLElement | React.MutableRefObject; }>; export const Modal = withFloatingTree((props: ModalProps) => { const { disableScrollLock, enableScrollLock } = useScrollLock(); - const { handleClose, handleOpen, contentSx, containerSx, canCloseModal, id, style } = props; + const { handleClose, handleOpen, contentSx, containerSx, canCloseModal, id, style, portalRoot } = props; const overlayRef = useRef(null); const { floating, isOpen, context, nodeId, toggle } = usePopover({ defaultOpen: true, @@ -55,6 +56,7 @@ export const Modal = withFloatingTree((props: ModalProps) => { context={context} isOpen={isOpen} outsideElementsInert + root={portalRoot} > void; close: () => void }; +type NavbarContextValue = { + isOpen: boolean; + open: () => void; + close: () => void; + contentRef?: React.RefObject; +}; export const [NavbarContext, useNavbarContext, useUnsafeNavbarContext] = createContextAndHook('NavbarContext'); -export const NavbarContextProvider = (props: React.PropsWithChildren>) => { +export const NavbarContextProvider = ({ + children, + contentRef, +}: React.PropsWithChildren<{ contentRef?: React.RefObject }>) => { const [isOpen, setIsOpen] = React.useState(false); const open = React.useCallback(() => setIsOpen(true), []); const close = React.useCallback(() => setIsOpen(false), []); - const value = React.useMemo(() => ({ value: { isOpen, open, close } }), [isOpen]); - return {props.children}; + const value = React.useMemo(() => ({ value: { isOpen, open, close, contentRef } }), [isOpen]); + return {children}; }; export type NavbarRoute = { diff --git a/packages/clerk-js/src/ui/elements/Popover.tsx b/packages/clerk-js/src/ui/elements/Popover.tsx index 52f48cd467d..826adc7860f 100644 --- a/packages/clerk-js/src/ui/elements/Popover.tsx +++ b/packages/clerk-js/src/ui/elements/Popover.tsx @@ -15,6 +15,11 @@ type PopoverProps = PropsWithChildren<{ outsideElementsInert?: boolean; order?: Array<'reference' | 'floating' | 'content'>; portal?: boolean; + /** + * The root element to render the portal into. + * @default document.body + */ + root?: HTMLElement | React.MutableRefObject; }>; export const Popover = (props: PopoverProps) => { @@ -26,13 +31,14 @@ export const Popover = (props: PopoverProps) => { nodeId, isOpen, portal = true, + root, children, } = props; if (portal) { return ( - + {isOpen && ( void; size?: SegmentedControlSize; fullWidth?: boolean; + sx?: ThemableCssProp; } const Root = React.forwardRef( @@ -53,6 +55,7 @@ const Root = React.forwardRef( 'aria-labelledby': ariaLabelledby, size = 'md', fullWidth = false, + sx, }, ref, ) => { @@ -79,14 +82,17 @@ const Root = React.forwardRef( elementDescriptor={descriptors.segmentedControlRoot} aria-label={ariaLabel} aria-labelledby={ariaLabelledby} - sx={t => ({ - backgroundColor: t.colors.$neutralAlpha50, - borderRadius: t.radii.$md, - borderWidth: t.borderWidths.$normal, - borderStyle: t.borderStyles.$solid, - borderColor: t.colors.$neutralAlpha100, - isolation: 'isolate', - })} + sx={[ + t => ({ + backgroundColor: t.colors.$neutralAlpha50, + borderRadius: t.radii.$md, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$neutralAlpha100, + isolation: 'isolate', + }), + sx, + ]} /> } > diff --git a/packages/clerk-js/src/ui/elements/contexts/index.tsx b/packages/clerk-js/src/ui/elements/contexts/index.tsx index b38690cae82..efa927e5593 100644 --- a/packages/clerk-js/src/ui/elements/contexts/index.tsx +++ b/packages/clerk-js/src/ui/elements/contexts/index.tsx @@ -87,6 +87,7 @@ export type FlowMetadata = { | 'checkout' | 'planDetails' | 'pricingTable' + | 'apiKeys' | 'oauthConsent'; part?: | 'start' diff --git a/packages/clerk-js/src/ui/icons/clipboard-outline.svg b/packages/clerk-js/src/ui/icons/clipboard-outline.svg new file mode 100644 index 00000000000..d4b4370a932 --- /dev/null +++ b/packages/clerk-js/src/ui/icons/clipboard-outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/clerk-js/src/ui/icons/code.svg b/packages/clerk-js/src/ui/icons/code.svg new file mode 100644 index 00000000000..e23d99d96e3 --- /dev/null +++ b/packages/clerk-js/src/ui/icons/code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/clerk-js/src/ui/icons/index.ts b/packages/clerk-js/src/ui/icons/index.ts index 28800738099..497b15f1abf 100644 --- a/packages/clerk-js/src/ui/icons/index.ts +++ b/packages/clerk-js/src/ui/icons/index.ts @@ -23,7 +23,9 @@ export { default as CheckmarkFilled } from './checkmark-filled.svg'; export { default as ChevronDown } from './chevron-down.svg'; export { default as ChevronUpDown } from './chevron-up-down.svg'; export { default as Clipboard } from './clipboard.svg'; +export { default as ClipboardOutline } from './clipboard-outline.svg'; export { default as Close } from './close.svg'; +export { default as Code } from './code.svg'; export { default as CogFilled } from './cog-filled.svg'; export { default as Copy } from './copy.svg'; export { default as CreditCard } from './credit-card.svg'; diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts index 823c389a9bc..d0c38843dc2 100644 --- a/packages/clerk-js/src/ui/lazyModules/components.ts +++ b/packages/clerk-js/src/ui/lazyModules/components.ts @@ -21,6 +21,7 @@ const componentImportPaths = { Checkout: () => import(/* webpackChunkName: "checkout" */ '../components/Checkout'), SessionTasks: () => import(/* webpackChunkName: "sessionTasks" */ '../components/SessionTasks'), PlanDetails: () => import(/* webpackChunkName: "planDetails" */ '../components/Plans'), + APIKeys: () => import(/* webpackChunkName: "apiKeys" */ '../components/ApiKeys/ApiKeys'), OAuthConsent: () => import(/* webpackChunkName: "oauthConsent" */ '../components/OAuthConsent/OAuthConsent'), } as const; @@ -97,6 +98,8 @@ export const PricingTable = lazy(() => componentImportPaths.PricingTable().then(module => ({ default: module.PricingTable })), ); +export const APIKeys = lazy(() => componentImportPaths.APIKeys().then(module => ({ default: module.APIKeys }))); + export const Checkout = lazy(() => componentImportPaths.Checkout().then(module => ({ default: module.Checkout }))); export const PlanDetails = lazy(() => @@ -138,6 +141,7 @@ export const ClerkComponents = { PricingTable, Checkout, PlanDetails, + APIKeys, OAuthConsent, }; diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index 4fd33ad7c46..98359b49c54 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -3,6 +3,7 @@ import type { __internal_OAuthConsentProps, __internal_PlanDetailsProps, __internal_UserVerificationProps, + APIKeysProps, CreateOrganizationProps, GoogleOneTapProps, NewSubscriptionRedirectUrl, @@ -49,7 +50,8 @@ export type AvailableComponentProps = | PricingTableProps | __internal_CheckoutProps | __internal_UserVerificationProps - | __internal_PlanDetailsProps; + | __internal_PlanDetailsProps + | APIKeysProps; type ComponentMode = 'modal' | 'mounted'; @@ -116,6 +118,11 @@ export type PricingTableCtx = PricingTableProps & { mode?: ComponentMode; }; +export type APIKeysCtx = APIKeysProps & { + componentName: 'APIKeys'; + mode?: ComponentMode; +}; + export type CheckoutCtx = __internal_CheckoutProps & { componentName: 'Checkout'; } & NewSubscriptionRedirectUrl; @@ -143,5 +150,6 @@ export type AvailableComponentCtx = | WaitlistCtx | PricingTableCtx | CheckoutCtx + | APIKeysCtx | OAuthConsentCtx; export type AvailableComponentName = AvailableComponentCtx['componentName']; diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx index b92be852602..c3929f842c6 100644 --- a/packages/clerk-js/src/ui/utils/createCustomPages.tsx +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -1,9 +1,15 @@ import type { CustomPage, EnvironmentResource, LoadedClerk } from '@clerk/types'; -import { disabledBillingFeature, hasPaidOrgPlans, hasPaidUserPlans, isValidUrl } from '../../utils'; +import { + disabledAPIKeysFeature, + disabledBillingFeature, + hasPaidOrgPlans, + hasPaidUserPlans, + isValidUrl, +} from '../../utils'; import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID, USER_PROFILE_NAVBAR_ROUTE_ID } from '../constants'; import type { NavbarRoute } from '../elements/Navbar'; -import { CreditCard, Organization, TickShield, User, Users } from '../icons'; +import { Code, CreditCard, Organization, TickShield, User, Users } from '../icons'; import { localizationKeys } from '../localization'; import { ExternalElementMounter } from './ExternalElementMounter'; import { isDevelopmentSDK } from './runtimeEnvironment'; @@ -43,7 +49,7 @@ type GetDefaultRoutesReturnType = { type CreateCustomPagesParams = { customPages: CustomPage[]; - getDefaultRoutes: ({ commerce }: { commerce: boolean }) => GetDefaultRoutesReturnType; + getDefaultRoutes: ({ commerce, apiKeys }: { commerce: boolean; apiKeys: boolean }) => GetDefaultRoutesReturnType; setFirstPathToRoot: (routes: NavbarRoute[]) => NavbarRoute[]; excludedPathsFromDuplicateWarning: string[]; }; @@ -93,6 +99,7 @@ const createCustomPages = ( commerce: !disabledBillingFeature(clerk, environment) && (organization ? hasPaidOrgPlans(clerk, environment) : hasPaidUserPlans(clerk, environment)), + apiKeys: !disabledAPIKeysFeature(clerk, environment), }); if (isDevelopmentSDK(clerk)) { @@ -245,7 +252,13 @@ const assertExternalLinkAsRoot = (routes: NavbarRoute[]) => { } }; -const getUserProfileDefaultRoutes = ({ commerce }: { commerce: boolean }): GetDefaultRoutesReturnType => { +const getUserProfileDefaultRoutes = ({ + commerce, + apiKeys, +}: { + commerce: boolean; + apiKeys: boolean; +}): GetDefaultRoutesReturnType => { const INITIAL_ROUTES: NavbarRoute[] = [ { name: localizationKeys('userProfile.navbar.account'), @@ -268,6 +281,14 @@ const getUserProfileDefaultRoutes = ({ commerce }: { commerce: boolean }): GetDe path: 'billing', }); } + if (apiKeys) { + INITIAL_ROUTES.push({ + name: localizationKeys('userProfile.navbar.apiKeys'), + id: USER_PROFILE_NAVBAR_ROUTE_ID.API_KEYS, + icon: Code, + path: 'api-keys', + }); + } const pageToRootNavbarRouteMap: Record = { profile: INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, @@ -285,7 +306,13 @@ const getUserProfileDefaultRoutes = ({ commerce }: { commerce: boolean }): GetDe return { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels }; }; -const getOrganizationProfileDefaultRoutes = ({ commerce }: { commerce: boolean }): GetDefaultRoutesReturnType => { +const getOrganizationProfileDefaultRoutes = ({ + commerce, + apiKeys, +}: { + commerce: boolean; + apiKeys: boolean; +}): GetDefaultRoutesReturnType => { const INITIAL_ROUTES: NavbarRoute[] = [ { name: localizationKeys('organizationProfile.navbar.general'), @@ -308,6 +335,14 @@ const getOrganizationProfileDefaultRoutes = ({ commerce }: { commerce: boolean } path: 'organization-billing', }); } + if (apiKeys) { + INITIAL_ROUTES.push({ + name: localizationKeys('organizationProfile.navbar.apiKeys'), + id: ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.API_KEYS, + icon: Code, + path: 'organization-api-keys', + }); + } const pageToRootNavbarRouteMap: Record = { 'invite-members': INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS) as NavbarRoute, diff --git a/packages/clerk-js/src/ui/utils/timeAgo.ts b/packages/clerk-js/src/ui/utils/timeAgo.ts new file mode 100644 index 00000000000..85174cd38c8 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/timeAgo.ts @@ -0,0 +1,26 @@ +import { localizationKeys } from '../localization'; + +export function timeAgo(date: Date | string | number) { + const now = new Date(); + const then = new Date(date); + + if (isNaN(then.getTime())) return ''; + + const seconds = Math.floor((now.getTime() - then.getTime()) / 1000); + if (seconds < 60) return localizationKeys('apiKeys.dates.lastUsed.seconds', { date: seconds }); + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return localizationKeys('apiKeys.dates.lastUsed.minutes', { date: minutes }); + + const hours = Math.floor(minutes / 60); + if (hours < 24) return localizationKeys('apiKeys.dates.lastUsed.hours', { date: hours }); + + const days = Math.floor(hours / 24); + if (days < 30) return localizationKeys('apiKeys.dates.lastUsed.days', { date: days }); + + const months = Math.floor(days / 30); + if (months < 12) return localizationKeys('apiKeys.dates.lastUsed.months', { date: months }); + + const years = Math.floor(months / 12); + return localizationKeys('apiKeys.dates.lastUsed.years', { date: years }); +} diff --git a/packages/clerk-js/src/utils/componentGuards.ts b/packages/clerk-js/src/utils/componentGuards.ts index 1444da564a8..d704cdf5997 100644 --- a/packages/clerk-js/src/utils/componentGuards.ts +++ b/packages/clerk-js/src/utils/componentGuards.ts @@ -33,3 +33,7 @@ export const hasPaidOrgPlans: ComponentGuard = (_, environment) => { export const hasPaidUserPlans: ComponentGuard = (_, environment) => { return environment?.commerceSettings.billing.hasPaidUserPlans || false; }; + +export const disabledAPIKeysFeature: ComponentGuard = (_, environment) => { + return !environment?.apiKeysSettings?.enabled; +}; diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index 52e37d3198b..c136e9eceb0 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -14,6 +14,30 @@ import type { LocalizationResource } from '@clerk/types'; export const deDE: LocalizationResource = { locale: 'de-DE', + apiKeys: { + action__add: 'Neuen API-Key hinzufügen', + action__search: 'Suche', + dates: { + lastUsed: { + days: undefined, + hours: undefined, + minutes: undefined, + months: undefined, + seconds: undefined, + years: undefined, + }, + }, + detailsTitle__emptyRow: 'Keine API-Keys gefunden', + formButtonPrimary__add: 'API-Key erstellen', + formHint: 'Geben Sie einen Namen an, um einen API-Key zu erstellen. Sie können ihn jederzeit widerrufen.', + formTitle: 'Neuen API-Key hinzufügen', + menuAction__revoke: 'API-Key widerrufen', + revokeConfirmation: { + formButtonPrimary__revoke: 'API-Key widerrufen', + formHint: 'Sind Sie sicher, dass Sie diesen API-Key löschen wollen?', + formTitle: 'API-Key "{{apiKeyName}}" widerrufen?', + }, + }, backButton: 'Zurück', badge__activePlan: 'Aktiv', badge__canceledEndsAt: "Storniert • Endet am {{ date | shortDate('de-DE') }}", @@ -65,6 +89,7 @@ export const deDE: LocalizationResource = { title__paymentSuccessful: 'Zahlung erfolgreich!', title__subscriptionSuccessful: 'Geschafft!', }, + credit: undefined, creditRemainder: 'Verbleibendes Guthaben für den restlichen Abrechnungszeitraum.', defaultFreePlanActive: 'Sie nutzen aktuell den kostenlosen Plan.', free: 'Kostenlos', @@ -138,6 +163,9 @@ export const deDE: LocalizationResource = { formFieldHintText__optional: 'Optional', formFieldHintText__slug: 'Der Slug ist eine für Menschen lesbare ID. Sie muss einzigartig sein und wird oft in URLs verwendet.', + formFieldInputPlaceholder__apiKeyDescription: 'Geben Sie eine Beschreibung an', + formFieldInputPlaceholder__apiKeyExpirationDate: 'Geben Sie ein Ablaufdatum an', + formFieldInputPlaceholder__apiKeyName: 'Geben Sie einen Namen an', formFieldInputPlaceholder__backupCode: 'Sicherheitscode eingeben', formFieldInputPlaceholder__confirmDeletionUserAccount: 'Konto löschen', formFieldInputPlaceholder__emailAddress: 'E-Mail-Adresse eingeben', @@ -152,6 +180,10 @@ export const deDE: LocalizationResource = { formFieldInputPlaceholder__password: 'Passwort eingeben', formFieldInputPlaceholder__phoneNumber: 'Telefonnummer eingeben', formFieldInputPlaceholder__username: 'Benutzername eingeben', + formFieldLabel__apiKeyDescription: 'Beschreibung', + formFieldLabel__apiKeyExpiration: 'Ablaufdatum', + formFieldLabel__apiKeyExpirationDate: 'Datum auswählen', + formFieldLabel__apiKeyName: 'Name', formFieldLabel__automaticInvitations: 'Aktivieren Sie automatische Einladungen für diese Domain', formFieldLabel__backupCode: 'Sicherungscode', formFieldLabel__confirmDeletion: 'Bestätigung', @@ -197,6 +229,9 @@ export const deDE: LocalizationResource = { titleWithoutPersonal: 'Organisation auswählen', }, organizationProfile: { + apiKeysPage: { + title: 'API-Keys', + }, badge__automaticInvitation: 'Automatische Einladungen', badge__automaticSuggestion: 'Automatische Vorschläge', badge__manualInvitation: 'Keine automatische Aufnahme', @@ -221,6 +256,7 @@ export const deDE: LocalizationResource = { title: 'Zahlungsmethoden', }, start: { + headerTitle__payments: undefined, headerTitle__plans: 'Pläne', headerTitle__statements: 'Abrechnungen', headerTitle__subscriptions: 'Abonnements', @@ -295,6 +331,7 @@ export const deDE: LocalizationResource = { }, }, navbar: { + apiKeys: 'API-Keys', billing: 'Abrechnung', description: 'Verwalten Sie ihre Organisation.', general: 'Allgemein', @@ -825,6 +862,9 @@ export const deDE: LocalizationResource = { action__signOutAll: 'Melden Sie sich von allen Konten ab', }, userProfile: { + apiKeysPage: { + title: 'API-Keys', + }, backupCodePage: { actionLabel__copied: 'Kopiert!', actionLabel__copy: 'Kopiere alle', @@ -861,6 +901,7 @@ export const deDE: LocalizationResource = { title: 'Zahlungsmethoden', }, start: { + headerTitle__payments: undefined, headerTitle__plans: 'Pläne', headerTitle__statements: 'Abrechnungen', headerTitle__subscriptions: 'Abonnements', @@ -987,6 +1028,7 @@ export const deDE: LocalizationResource = { mobileButton__menu: 'Menü', navbar: { account: 'Profil', + apiKeys: 'API-Keys', billing: 'Abrechnung', description: 'Verwalten Sie Ihre Kontoinformationen.', security: 'Sicherheit', diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index e8e829d98e4..fb582e6d680 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -2,6 +2,30 @@ import type { LocalizationResource } from '@clerk/types'; export const enUS: LocalizationResource = { locale: 'en-US', + apiKeys: { + action__add: 'Add new key', + action__search: 'Search keys', + dates: { + lastUsed: { + days: '{{date}}d ago', + hours: '{{date}}h ago', + minutes: '{{date}}m ago', + months: '{{date}}mo ago', + seconds: '{{date}}s ago', + years: '{{date}}y ago', + }, + }, + detailsTitle__emptyRow: 'No API keys found', + formButtonPrimary__add: 'Create key', + formHint: 'Provide a name to generate a new key. You’ll be able to revoke it anytime.', + formTitle: 'Add new API key', + menuAction__revoke: 'Revoke key', + revokeConfirmation: { + formButtonPrimary__revoke: 'Revoke key', + formHint: 'Are you sure you want to delete this Secret key?', + formTitle: 'Revoke "{{apiKeyName}}" secret key?', + }, + }, backButton: 'Back', badge__activePlan: 'Active', badge__canceledEndsAt: "Canceled • Ends {{ date | shortDate('en-US') }}", @@ -126,6 +150,9 @@ export const enUS: LocalizationResource = { formFieldError__verificationLinkExpired: 'The verification link expired. Please request a new link.', formFieldHintText__optional: 'Optional', formFieldHintText__slug: 'A slug is a human-readable ID that must be unique. It’s often used in URLs.', + formFieldInputPlaceholder__apiKeyDescription: 'Enter your secret key description', + formFieldInputPlaceholder__apiKeyExpirationDate: 'Enter expiration date', + formFieldInputPlaceholder__apiKeyName: 'Enter your secret key name', formFieldInputPlaceholder__backupCode: 'Enter backup code', formFieldInputPlaceholder__confirmDeletionUserAccount: 'Delete account', formFieldInputPlaceholder__emailAddress: 'Enter your email address', @@ -140,6 +167,10 @@ export const enUS: LocalizationResource = { formFieldInputPlaceholder__password: 'Enter your password', formFieldInputPlaceholder__phoneNumber: 'Enter your phone number', formFieldInputPlaceholder__username: undefined, + formFieldLabel__apiKeyDescription: 'Description', + formFieldLabel__apiKeyExpiration: 'Expiration', + formFieldLabel__apiKeyExpirationDate: 'Select date', + formFieldLabel__apiKeyName: 'Name', formFieldLabel__automaticInvitations: 'Enable automatic invitations for this domain', formFieldLabel__backupCode: 'Backup code', formFieldLabel__confirmDeletion: 'Confirmation', @@ -185,6 +216,9 @@ export const enUS: LocalizationResource = { titleWithoutPersonal: 'Choose an organization', }, organizationProfile: { + apiKeysPage: { + title: 'API Keys', + }, badge__automaticInvitation: 'Automatic invitations', badge__automaticSuggestion: 'Automatic suggestions', badge__manualInvitation: 'No automatic enrollment', @@ -284,6 +318,7 @@ export const enUS: LocalizationResource = { }, }, navbar: { + apiKeys: 'API Keys', billing: 'Billing', description: 'Manage your organization.', general: 'General', @@ -801,6 +836,9 @@ export const enUS: LocalizationResource = { action__signOutAll: 'Sign out of all accounts', }, userProfile: { + apiKeysPage: { + title: 'API Keys', + }, backupCodePage: { actionLabel__copied: 'Copied!', actionLabel__copy: 'Copy all', @@ -962,6 +1000,7 @@ export const enUS: LocalizationResource = { mobileButton__menu: 'Menu', navbar: { account: 'Profile', + apiKeys: 'API keys', billing: 'Billing', description: 'Manage your account info.', security: 'Security', diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index dbcc933bf2e..38f0a9be4f9 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -23,6 +23,7 @@ export { GoogleOneTap, Waitlist, PricingTable, + APIKeys, } from '@clerk/clerk-react'; // The assignment of UserProfile with BaseUserProfile props is used diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index c69287ea42e..f57260044ac 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -35,6 +35,7 @@ export { GoogleOneTap, Waitlist, PricingTable, + APIKeys, } from './client-boundary/uiComponents'; /** diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 49c10a61589..3a757e9ec5d 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -2,6 +2,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` [ + "APIKeys", "AuthenticateWithRedirectCallback", "ClerkDegraded", "ClerkFailed", diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index f932a18115d..a905a3aea0b 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -10,6 +10,7 @@ export { GoogleOneTap, Waitlist, PricingTable, + APIKeys, } from './uiComponents'; export { diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index efba0fe8130..5e628d65789 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -1,5 +1,6 @@ import { logErrorInDevMode } from '@clerk/shared/utils'; import type { + APIKeysProps, CreateOrganizationProps, GoogleOneTapProps, OrganizationListProps, @@ -600,3 +601,35 @@ export const PricingTable = withClerk( }, { component: 'PricingTable', renderWhileLoading: true }, ); + +/** + * @experimental + * This component is in early access and may change in future releases. + */ +export const APIKeys = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'ApiKeys', renderWhileLoading: true }, +); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index a6da6cb703d..c92f3d57692 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -8,6 +8,8 @@ import type { __internal_PlanDetailsProps, __internal_UserVerificationModalProps, __internal_UserVerificationProps, + APIKeysNamespace, + APIKeysProps, AuthenticateWithCoinbaseWalletParams, AuthenticateWithGoogleOneTapParams, AuthenticateWithMetamaskParams, @@ -99,11 +101,13 @@ type IsomorphicLoadedClerk = Without< | '__internal_getCachedResources' | '__internal_reloadInitialResources' | 'billing' + | 'apiKeys' | '__internal_setComponentNavigationContext' | '__internal_setActiveInProgress' > & { client: ClientResource | undefined; billing: CommerceBillingNamespace | undefined; + apiKeys: APIKeysNamespace | undefined; }; export class IsomorphicClerk implements IsomorphicLoadedClerk { @@ -132,6 +136,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private premountMethodCalls = new Map, MethodCallback>(); private premountWaitlistNodes = new Map(); private premountPricingTableNodes = new Map(); + private premountApiKeysNodes = new Map(); private premountOAuthConsentNodes = new Map(); // A separate Map of `addListener` method calls to handle multiple listeners. private premountAddListenerCalls = new Map< @@ -611,6 +616,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.mountPricingTable(node, props); }); + this.premountApiKeysNodes.forEach((props, node) => { + clerkjs.mountApiKeys(node, props); + }); + this.premountOAuthConsentNodes.forEach((props, node) => { clerkjs.__internal_mountOAuthConsent(node, props); }); @@ -692,6 +701,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return this.clerkjs?.billing; } + get apiKeys(): APIKeysNamespace | undefined { + return this.clerkjs?.apiKeys; + } + __unstable__setEnvironment(...args: any): void { if (this.clerkjs && '__unstable__setEnvironment' in this.clerkjs) { (this.clerkjs as any).__unstable__setEnvironment(args); @@ -1056,6 +1069,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + mountApiKeys = (node: HTMLDivElement, props?: APIKeysProps): void => { + if (this.clerkjs && this.loaded) { + this.clerkjs.mountApiKeys(node, props); + } else { + this.premountApiKeysNodes.set(node, props); + } + }; + + unmountApiKeys = (node: HTMLDivElement): void => { + if (this.clerkjs && this.loaded) { + this.clerkjs.unmountApiKeys(node); + } else { + this.premountApiKeysNodes.delete(node); + } + }; + __internal_mountOAuthConsent = (node: HTMLDivElement, props?: __internal_OAuthConsentProps) => { if (this.clerkjs && this.loaded) { this.clerkjs.__internal_mountOAuthConsent(node, props); diff --git a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap index 2c197c925d5..e2ae044851f 100644 --- a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap @@ -2,6 +2,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` [ + "APIKeys", "AuthenticateWithRedirectCallback", "ClerkApp", "ClerkDegraded", diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index f0247688460..296c327d52e 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -14,6 +14,7 @@ exports[`errors public exports > should not change unexpectedly 1`] = ` exports[`root public exports > should not change unexpectedly 1`] = ` [ + "APIKeys", "AuthenticateWithRedirectCallback", "ClerkDegraded", "ClerkFailed", diff --git a/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts b/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts new file mode 100644 index 00000000000..5f341197475 --- /dev/null +++ b/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts @@ -0,0 +1,15 @@ +import type { EnhancedPage } from './app'; +import { common } from './common'; + +export const createAPIKeysPageObject = (testArgs: { page: EnhancedPage }) => { + const { page } = testArgs; + + const self = { + ...common(testArgs), + waitForMounted: (selector = '.cl-apiKeys-root') => { + return page.waitForSelector(selector, { state: 'attached' }); + }, + }; + + return self; +}; diff --git a/packages/testing/src/playwright/unstable/page-objects/index.ts b/packages/testing/src/playwright/unstable/page-objects/index.ts index bd2b7be3ac7..eba4b475581 100644 --- a/packages/testing/src/playwright/unstable/page-objects/index.ts +++ b/packages/testing/src/playwright/unstable/page-objects/index.ts @@ -1,5 +1,6 @@ import type { Page } from '@playwright/test'; +import { createAPIKeysPageObject } from './apiKeys'; import { createAppPageObject } from './app'; import { createCheckoutPageObject } from './checkout'; import { createClerkPageObject } from './clerk'; @@ -38,6 +39,7 @@ export const createPageObjects = ({ keylessPopover: createKeylessPopoverPageObject(testArgs), organizationSwitcher: createOrganizationSwitcherComponentPageObject(testArgs), pricingTable: createPricingTablePageObject(testArgs), + apiKeys: createAPIKeysPageObject(testArgs), sessionTask: createSessionTaskComponentPageObject(testArgs), signIn: createSignInComponentPageObject(testArgs), signUp: createSignUpComponentPageObject(testArgs), diff --git a/packages/types/src/apiKeys.ts b/packages/types/src/apiKeys.ts new file mode 100644 index 00000000000..64f74587b18 --- /dev/null +++ b/packages/types/src/apiKeys.ts @@ -0,0 +1,51 @@ +import type { CreateAPIKeyParams, GetAPIKeysParams, RevokeAPIKeyParams } from './clerk'; +import type { ClerkResource } from './resource'; + +export interface APIKeyResource extends ClerkResource { + id: string; + type: string; + name: string; + subject: string; + scopes: string[]; + claims: Record | null; + revoked: boolean; + revocationReason: string | null; + expired: boolean; + expiration: Date | null; + createdBy: string | null; + description: string | null; + lastUsedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface APIKeysNamespace { + /** + * @experimental + * This API is in early access and may change in future releases. + * + * Retrieves all API keys for the current user or organization. + */ + getAll(params?: GetAPIKeysParams): Promise; + /** + * @experimental + * This API is in early access and may change in future releases. + * + * Retrieves the secret for a given API key ID. + */ + getSecret(id: string): Promise; + /** + * @experimental + * This API is in early access and may change in future releases. + * + * Creates a new API key. + */ + create(params: CreateAPIKeyParams): Promise; + /** + * @experimental + * This API is in early access and may change in future releases. + * + * Revokes a given API key by ID. + */ + revoke(params: RevokeAPIKeyParams): Promise; +} diff --git a/packages/types/src/apiKeysSettings.ts b/packages/types/src/apiKeysSettings.ts new file mode 100644 index 00000000000..f5871c1ed2d --- /dev/null +++ b/packages/types/src/apiKeysSettings.ts @@ -0,0 +1,13 @@ +import type { ClerkResourceJSON } from './json'; +import type { ClerkResource } from './resource'; +import type { APIKeysSettingsJSONSnapshot } from './snapshots'; + +export interface APIKeysSettingsJSON extends ClerkResourceJSON { + enabled: boolean; +} + +export interface APIKeysSettingsResource extends ClerkResource { + enabled: boolean; + + __internal_toSnapshot: () => APIKeysSettingsJSONSnapshot; +} diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index ce49aaa02e3..98fd2a11d9f 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -846,6 +846,7 @@ export type WaitlistTheme = Theme; export type PricingTableTheme = Theme; export type CheckoutTheme = Theme; export type PlanDetailTheme = Theme; +export type APIKeysTheme = Theme; export type OAuthConsentTheme = Theme; type GlobalAppearanceOptions = { @@ -911,6 +912,10 @@ export type Appearance = T & * Theme overrides that only apply to the `` component */ checkout?: T; + /** + * Theme overrides that only apply to the `` component + */ + apiKeys?: T; /** * Theme overrides that only apply to the `` component */ diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 786dbb9c9b2..e25115150a9 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1,4 +1,6 @@ +import type { APIKeysNamespace } from './apiKeys'; import type { + APIKeysTheme, Appearance, CheckoutTheme, CreateOrganizationTheme, @@ -459,6 +461,27 @@ export interface Clerk { */ unmountPricingTable: (targetNode: HTMLDivElement) => void; + /** + * @experimental + * This API is in early access and may change in future releases. + * + * Mount a api keys component at the target element. + * @param targetNode Target to mount the APIKeys component. + * @param props Configuration parameters. + */ + mountApiKeys: (targetNode: HTMLDivElement, props?: APIKeysProps) => void; + + /** + * @experimental + * This API is in early access and may change in future releases. + * + * Unmount a api keys component from the target element. + * If there is no component mounted at the target node, results in a noop. + * + * @param targetNode Target node to unmount the ApiKeys component from. + */ + unmountApiKeys: (targetNode: HTMLDivElement) => void; + /** * Mounts a OAuth consent component at the target element. * @param targetNode Target node to mount the OAuth consent component. @@ -751,6 +774,13 @@ export interface Clerk { * initiated outside of the Clerk class. */ __internal_setActiveInProgress: boolean; + + /** + * API Keys Object + * @experimental + * This API is in early access and may change in future releases. + */ + apiKeys: APIKeysNamespace; } export type HandleOAuthCallbackParams = TransferableOption & @@ -1635,6 +1665,43 @@ type PortalRoot = HTMLElement | null | undefined; export type PricingTableProps = PricingTableBaseProps & PricingTableDefaultProps; +export type APIKeysProps = { + /** + * The type of API key to filter by. + * Currently, only 'api_key' is supported. + * @default 'api_key' + */ + type?: 'api_key'; + /** + * The number of API keys to show per page. + * @default 5 + */ + perPage?: number; + /** + * Customisation options to fully match the Clerk components to your own brand. + * These options serve as overrides and will be merged with the global `appearance` + * prop of ClerkProvider (if one is provided) + */ + appearance?: APIKeysTheme; +}; + +export type GetAPIKeysParams = { + subject?: string; +}; + +export type CreateAPIKeyParams = { + type?: 'api_key'; + name: string; + subject?: string; + secondsUntilExpiration?: number; + description?: string; +}; + +export type RevokeAPIKeyParams = { + apiKeyID: string; + revocationReason?: string; +}; + export type __internal_CheckoutProps = { appearance?: CheckoutTheme; planId?: string; diff --git a/packages/types/src/elementIds.ts b/packages/types/src/elementIds.ts index c5cf21acb76..ee1d5936768 100644 --- a/packages/types/src/elementIds.ts +++ b/packages/types/src/elementIds.ts @@ -21,7 +21,10 @@ export type FieldId = | 'enrollmentMode' | 'affiliationEmailAddress' | 'deleteExistingInvitationsSuggestions' - | 'legalAccepted'; + | 'legalAccepted' + | 'description' + | 'expirationDate' + | 'revokeConfirmation'; export type ProfileSectionId = | 'profile' | 'username' diff --git a/packages/types/src/environment.ts b/packages/types/src/environment.ts index 91076d2b119..b8e2d23cbc2 100644 --- a/packages/types/src/environment.ts +++ b/packages/types/src/environment.ts @@ -1,3 +1,4 @@ +import type { APIKeysSettingsResource } from './apiKeysSettings'; import type { AuthConfigResource } from './authConfig'; import type { CommerceSettingsResource } from './commerceSettings'; import type { DisplayConfigResource } from './displayConfig'; @@ -12,6 +13,7 @@ export interface EnvironmentResource extends ClerkResource { authConfig: AuthConfigResource; displayConfig: DisplayConfigResource; commerceSettings: CommerceSettingsResource; + apiKeysSettings: APIKeysSettingsResource; isSingleSession: () => boolean; isProduction: () => boolean; isDevelopmentOrStaging: () => boolean; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 143e5f7df9c..7db0228bbe8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -66,6 +66,8 @@ export * from './passkey'; export * from './customMenuItems'; export * from './samlConnection'; export * from './waitlist'; +export * from './apiKeys'; +export * from './apiKeysSettings'; export * from './snapshots'; export * from './authObject'; export * from './phoneCodeChannel'; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 2bbfeed77f4..845e36e0227 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -2,6 +2,7 @@ * Currently representing API DTOs in their JSON form. */ +import type { APIKeysSettingsJSON } from './apiKeysSettings'; import type { CommercePaymentChargeType, CommercePaymentSourceStatus, @@ -70,6 +71,7 @@ export interface ImageJSON { export interface EnvironmentJSON extends ClerkResourceJSON { auth_config: AuthConfigJSON; + api_keys_settings: APIKeysSettingsJSON; commerce_settings: CommerceSettingsJSON; display_config: DisplayConfigJSON; user_settings: UserSettingsJSON; @@ -729,3 +731,21 @@ export interface CommerceCheckoutJSON extends ClerkResourceJSON { totals: CommerceCheckoutTotalsJSON; is_immediate_plan_change: boolean; } + +export interface ApiKeyJSON extends ClerkResourceJSON { + id: string; + type: string; + name: string; + subject: string; + scopes: string[]; + claims: Record | null; + revoked: boolean; + revocation_reason: string | null; + expired: boolean; + expiration: number | null; + created_by: string | null; + description: string | null; + last_used_at: number | null; + created_at: number; + updated_at: number; +} diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 4208d30e653..c3e55912e41 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -54,6 +54,10 @@ type _LocalizationResource = { formFieldLabel__confirmDeletion: LocalizationValue; formFieldLabel__role: LocalizationValue; formFieldLabel__passkeyName: LocalizationValue; + formFieldLabel__apiKeyName: LocalizationValue; + formFieldLabel__apiKeyDescription: LocalizationValue; + formFieldLabel__apiKeyExpiration: LocalizationValue; + formFieldLabel__apiKeyExpirationDate: LocalizationValue; formFieldInputPlaceholder__emailAddress: LocalizationValue; formFieldInputPlaceholder__emailAddresses: LocalizationValue; formFieldInputPlaceholder__phoneNumber: LocalizationValue; @@ -68,6 +72,9 @@ type _LocalizationResource = { formFieldInputPlaceholder__organizationDomain: LocalizationValue; formFieldInputPlaceholder__organizationDomainEmailAddress: LocalizationValue; formFieldInputPlaceholder__confirmDeletionUserAccount: LocalizationValue; + formFieldInputPlaceholder__apiKeyName: LocalizationValue; + formFieldInputPlaceholder__apiKeyDescription: LocalizationValue; + formFieldInputPlaceholder__apiKeyExpirationDate: LocalizationValue; formFieldError__notMatchingPasswords: LocalizationValue; formFieldError__matchingPasswords: LocalizationValue; formFieldError__verificationLinkExpired: LocalizationValue; @@ -498,6 +505,7 @@ type _LocalizationResource = { account: LocalizationValue; security: LocalizationValue; billing: LocalizationValue; + apiKeys: LocalizationValue; }; start: { headerTitle__account: LocalizationValue; @@ -638,6 +646,10 @@ type _LocalizationResource = { successMessage: LocalizationValue; }; }; + apiKeysPage: { + title: LocalizationValue; + detailsTitle__emptyRow: LocalizationValue; + }; passkeyScreen: { title__rename: LocalizationValue; subtitle__rename: LocalizationValue; @@ -824,6 +836,7 @@ type _LocalizationResource = { general: LocalizationValue; members: LocalizationValue; billing: LocalizationValue; + apiKeys: LocalizationValue; }; badge__unverified: LocalizationValue; badge__automaticInvitation: LocalizationValue; @@ -1002,6 +1015,10 @@ type _LocalizationResource = { noPermissionsToManageBilling: LocalizationValue; }; }; + apiKeysPage: { + title: LocalizationValue; + detailsTitle__emptyRow: LocalizationValue; + }; }; createOrganization: { title: LocalizationValue; @@ -1044,6 +1061,30 @@ type _LocalizationResource = { message: LocalizationValue; }; }; + apiKeys: { + formTitle: LocalizationValue; + formHint: LocalizationValue; + formButtonPrimary__add: LocalizationValue; + menuAction__revoke: LocalizationValue; + action__search: LocalizationValue; + action__add: LocalizationValue; + detailsTitle__emptyRow: LocalizationValue; + revokeConfirmation: { + formTitle: LocalizationValue; + formHint: LocalizationValue; + formButtonPrimary__revoke: LocalizationValue; + }; + dates: { + lastUsed: { + seconds: LocalizationValue; + minutes: LocalizationValue; + hours: LocalizationValue; + days: LocalizationValue; + months: LocalizationValue; + years: LocalizationValue; + }; + }; + }; }; type WithParamName = T & diff --git a/packages/types/src/snapshots.ts b/packages/types/src/snapshots.ts index 69637433f06..99b53912eea 100644 --- a/packages/types/src/snapshots.ts +++ b/packages/types/src/snapshots.ts @@ -1,5 +1,6 @@ // this file contains the types returned by the __internal_toSnapshot method of the resources +import type { APIKeysSettingsJSON } from './apiKeysSettings'; import type { CommerceSettingsJSON } from './commerceSettings'; import type { DisplayConfigJSON } from './displayConfig'; import type { @@ -191,3 +192,5 @@ export type CommerceSettingsJSONSnapshot = CommerceSettingsJSON; export type CommercePlanJSONSnapshot = CommercePlanJSON; export type CommerceFeatureJSONSnapshot = CommerceFeatureJSON; + +export type APIKeysSettingsJSONSnapshot = APIKeysSettingsJSON;