diff --git a/.changeset/mosaic-api-keys-table.md b/.changeset/mosaic-api-keys-table.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-api-keys-table.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx
new file mode 100644
index 00000000000..850e0f6f862
--- /dev/null
+++ b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx
@@ -0,0 +1,659 @@
+import type { ApiKeyJSON } from '@clerk/shared/types';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { describe, expect, it, onTestFinished, vi } from 'vitest';
+
+import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiApiKey,
+ fapiClient,
+ fapiEmailAddress,
+ fapiEnvironment,
+ fapiMembership,
+ fapiOrganization,
+ fapiSession,
+ fapiUser,
+} from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { MosaicProvider } from '../../../mosaic-provider';
+import type { APIKeysTableProps } from '../api-keys-table';
+import { APIKeysTable } from '../api-keys-table';
+
+const DAY = 24 * 60 * 60 * 1000;
+
+const acme = fapiOrganization({ id: 'org_1', name: 'Acme' });
+
+const alice = fapiUser({
+ id: 'user_1',
+ first_name: 'Alice',
+ email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })],
+ organization_memberships: [fapiMembership(acme)],
+});
+
+const userKey = (id: string, name: string, overrides: Partial = {}) =>
+ fapiApiKey({ id, name, subject: alice.id, created_at: Date.UTC(2026, 0, 5, 12), ...overrides });
+
+const webApp = userKey('ak_1234567890FKWO', 'Web app', { expiration: Date.UTC(2027, 11, 31, 12) });
+const ciPipeline = userKey('ak_2', 'CI pipeline', { last_used_at: Date.now() - 2 * 60_000 });
+
+const manyKeys = Array.from({ length: 11 }, (_, index) => userKey(`ak_${index + 1}`, `Key ${index + 1}`));
+
+function signedIn(apiKeys: ApiKeyJSON[] = [webApp, ciPipeline], overrides: FakeFapiSeed = {}): FakeFapiSeed {
+ return {
+ environment: fapiEnvironment({ api_keys_settings: { user_api_keys_enabled: true, orgs_api_keys_enabled: true } }),
+ client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: acme.id })]),
+ apiKeys,
+ ...overrides,
+ };
+}
+
+async function renderTable(seed: FakeFapiSeed = signedIn(), props: Partial = {}) {
+ const fapi = serveFapi(seed);
+ const view = await renderWithClerk(
+ ,
+ );
+ return { ...view, fapi, user: userEvent.setup() };
+}
+
+type User = ReturnType;
+
+const table = () => screen.getByRole('table', { name: 'API Keys' });
+
+async function expectFallback(seed: FakeFapiSeed, props: Partial = {}) {
+ await renderTable(seed, { fallback: Unavailable
, ...props });
+
+ expect(await screen.findByText('Unavailable')).toBeVisible();
+ expect(screen.queryByRole('table', { name: 'API Keys' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Create API key' })).toBeNull();
+}
+
+async function openCreate(user: User) {
+ await user.click(await screen.findByRole('button', { name: 'Create API key' }));
+ return screen.findByRole('dialog', { name: 'Add new API key' });
+}
+
+async function fillCreate(user: User, dialog: HTMLElement, name: string, expiration: string) {
+ await user.type(within(dialog).getByRole('textbox', { name: 'Secret key name' }), name);
+ await user.click(within(dialog).getByRole('combobox', { name: /^Expiration/ }));
+ await user.click(await screen.findByRole('option', { name: expiration }));
+}
+
+async function startRevoke(user: User, name: string) {
+ await user.click(await screen.findByRole('button', { name: `Manage ${name}` }));
+ await user.click(await screen.findByRole('menuitem', { name: 'Revoke key' }));
+ return screen.findByRole('dialog', { name: `Revoke ${name}?` });
+}
+
+async function openRevoke(user: User, name: string) {
+ const dialog = await startRevoke(user, name);
+ await user.type(within(dialog).getByRole('textbox'), name);
+ return dialog;
+}
+
+function failRevokeOnce(id: string) {
+ worker.use(
+ http.post(
+ fapiUrl(`/api_keys/${id}/revoke`),
+ () =>
+ HttpResponse.json(
+ {
+ errors: [
+ {
+ code: 'api_key_revoke_failed',
+ message: 'api_key_revoke_failed',
+ long_message: 'api_key_revoke_failed',
+ },
+ ],
+ },
+ { status: 400 },
+ ),
+ { once: true },
+ ),
+ );
+}
+
+describe('APIKeysTable', () => {
+ describe('listing keys', () => {
+ it("shows the user's own keys, not the active organization's", async () => {
+ await renderTable(
+ signedIn([webApp, ciPipeline, fapiApiKey({ id: 'ak_org', name: 'Org key', subject: acme.id })]),
+ );
+
+ expect(await within(table()).findByText('Web app')).toBeVisible();
+ expect(within(table()).getByText('CI pipeline')).toBeVisible();
+ expect(within(table()).queryByText('Org key')).toBeNull();
+ });
+
+ it('describes when each key was created, last used, and expires', async () => {
+ await renderTable();
+
+ await within(table()).findByText('Web app');
+ expect(within(table()).getAllByRole('cell', { name: 'Jan 5, 2026' })).toHaveLength(2);
+ expect(within(table()).getByText('Expires Dec 31, 2027')).toBeVisible();
+ expect(within(table()).getByText('Never expires')).toBeVisible();
+ expect(within(table()).getByRole('cell', { name: '2 minutes ago' })).toBeVisible();
+ expect(within(table()).getByRole('cell', { name: '-' })).toBeVisible();
+ expect(within(table()).getByText('ak_...FKWO', { exact: false })).toBeVisible();
+ });
+
+ it('shows loading until the first page arrives', async () => {
+ serveFapi(signedIn());
+ const list = holdRequests('get', '/api_keys');
+ await renderWithClerk( );
+
+ expect(await screen.findByRole('status')).toHaveTextContent('Loading API keys');
+ list.release();
+ expect(await within(table()).findByText('Web app')).toBeVisible();
+ expect(screen.queryByRole('status')).toBeNull();
+ });
+
+ it('explains when the user has no keys', async () => {
+ await renderTable(signedIn([]));
+
+ expect(await screen.findByText('No API Keys created')).toBeVisible();
+ expect(
+ screen.getByText('API keys allow apps and scripts to access your account without signing in.'),
+ ).toBeVisible();
+ });
+
+ it('treats a blank search as no search', async () => {
+ const { user } = await renderTable(signedIn([]));
+ await screen.findByText('No API Keys created');
+
+ await user.type(screen.getByRole('searchbox', { name: 'Search API keys' }), ' ');
+
+ expect(screen.getByText('No API Keys created')).toBeVisible();
+ expect(screen.queryByText(/did not return any results/)).toBeNull();
+ });
+
+ it('uses the messages it is given over its own', async () => {
+ await renderTable(signedIn([]), { messages: { noKeys: 'Nothing here yet' } });
+
+ expect(await screen.findByText('Nothing here yet')).toBeVisible();
+ });
+
+ it('uses the localization of its provider', async () => {
+ serveFapi(signedIn());
+ await renderWithClerk(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+
+ expect(await within(table()).findByText('Expiration: Dec 31, 2027')).toBeVisible();
+ expect(screen.getByRole('searchbox', { name: 'Find a key' })).toBeVisible();
+ await user.click(screen.getByRole('button', { name: 'Manage Web app' }));
+ await user.click(await screen.findByRole('menuitem', { name: 'Revoke key' }));
+ const dialog = await screen.findByRole('dialog', { name: 'Retirer Web app ?' });
+ await waitFor(() => expect(dialog).toBeVisible());
+ });
+
+ it('explains a failed load instead of showing no keys, and loads again on retry', { timeout: 20_000 }, async () => {
+ let failing = true;
+ serveFapi(signedIn());
+ worker.use(
+ http.get(fapiUrl('/api_keys'), () =>
+ failing
+ ? HttpResponse.json({ errors: [{ code: 'internal_error', message: 'internal_error' }] }, { status: 400 })
+ : undefined,
+ ),
+ );
+ await renderWithClerk( );
+ const user = userEvent.setup();
+
+ expect(await screen.findByText('Could not load API keys', undefined, { timeout: 15_000 })).toBeVisible();
+ expect(screen.queryByText('No API Keys created')).toBeNull();
+
+ failing = false;
+ await user.click(screen.getByRole('button', { name: 'Try again' }));
+ expect(await within(table()).findByText('Web app')).toBeVisible();
+ });
+ });
+
+ describe('availability', () => {
+ it('renders the fallback when signed out', async () => {
+ await expectFallback(signedIn([], { client: fapiClient([]) }));
+ });
+
+ it('renders the fallback when user API keys are disabled', async () => {
+ await expectFallback(
+ signedIn([], { environment: fapiEnvironment({ api_keys_settings: { orgs_api_keys_enabled: true } }) }),
+ );
+ });
+
+ it('renders the fallback for a user other than the signed-in one', async () => {
+ await expectFallback(signedIn(), { subject: 'user_2' });
+ });
+ });
+
+ describe('searching', () => {
+ it('lists only matching keys and explains when nothing matches', async () => {
+ const { user } = await renderTable();
+ await within(table()).findByText('CI pipeline');
+ const search = screen.getByRole('searchbox', { name: 'Search API keys' });
+
+ await user.type(search, 'web');
+ await waitFor(() => expect(within(table()).queryByText('CI pipeline')).toBeNull());
+ expect(within(table()).getByText('Web app')).toBeVisible();
+
+ await user.clear(search);
+ await user.type(search, 'nothing');
+ expect(await screen.findByText('Your search for "nothing" did not return any results.')).toBeVisible();
+ });
+
+ it('clears the search and keeps focus in it', async () => {
+ const { user } = await renderTable();
+ await within(table()).findByText('CI pipeline');
+ const search = screen.getByRole('searchbox', { name: 'Search API keys' });
+ await user.type(search, 'web');
+ await waitFor(() => expect(within(table()).queryByText('CI pipeline')).toBeNull());
+
+ await user.click(screen.getByRole('button', { name: 'Clear search' }));
+
+ expect(search).toHaveValue('');
+ expect(search).toHaveFocus();
+ expect(await within(table()).findByText('CI pipeline')).toBeVisible();
+ });
+
+ it('returns to the first page for a new search', async () => {
+ const { user } = await renderTable(signedIn(manyKeys));
+
+ await user.click(await screen.findByRole('button', { name: 'Next API keys page' }));
+ expect(await within(table()).findByText('Key 11')).toBeVisible();
+
+ await user.type(screen.getByRole('searchbox', { name: 'Search API keys' }), 'Key 1');
+ expect(await within(table()).findByText('Key 10')).toBeVisible();
+ expect(within(table()).getByText('Key 1')).toBeVisible();
+ });
+ });
+
+ describe('paging', () => {
+ it('moves between pages of keys', async () => {
+ const { user } = await renderTable(signedIn(manyKeys));
+
+ expect(await within(table()).findByText('Key 1')).toBeVisible();
+ expect(screen.getByText('1/2')).toBeVisible();
+
+ const next = holdRequests('get', '/api_keys');
+ await user.click(screen.getByRole('button', { name: 'Next API keys page' }));
+ await waitFor(() => expect(table()).toHaveAttribute('aria-busy', 'true'));
+ expect(within(table()).getByText('Key 1')).toBeVisible();
+ next.release();
+ expect(await within(table()).findByText('Key 11')).toBeVisible();
+ expect(within(table()).queryByText('Key 1')).toBeNull();
+
+ await user.click(screen.getByRole('button', { name: 'Previous API keys page' }));
+ expect(await within(table()).findByText('Key 1')).toBeVisible();
+ });
+
+ it.todo('uses a configured page size');
+ });
+
+ describe('creating a key', () => {
+ it('creates a key for the user, shows its secret once, and lists it', async () => {
+ const { fapi, user } = await renderTable();
+ const dialog = await openCreate(user);
+
+ await fillCreate(user, dialog, 'Deploy', '7 Days');
+ await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
+
+ const secret = await within(dialog).findByRole('textbox', { name: 'API key' });
+ const [created] = fapi.apiKeys;
+ expect(created).toMatchObject({ name: 'Deploy', subject: alice.id });
+ expect(created.expiration).toBeGreaterThan(Date.now() + 6 * DAY);
+ expect(secret).toHaveValue(`ak_secret_${created.id}`);
+
+ await user.click(within(dialog).getByRole('button', { name: 'Copy and close' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(await within(table()).findByText('Deploy')).toBeVisible();
+ });
+
+ it('shows the secret without waiting for the list to refresh', async () => {
+ const { fapi, user } = await renderTable();
+ const dialog = await openCreate(user);
+ await fillCreate(user, dialog, 'Deploy', 'Never');
+ const refresh = holdRequests('get', '/api_keys');
+
+ await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
+
+ expect(await within(dialog).findByRole('textbox', { name: 'API key' })).toHaveValue(
+ `ak_secret_${fapi.apiKeys[0].id}`,
+ );
+ refresh.release();
+ expect(await within(table()).findByText('Deploy')).toBeVisible();
+ });
+
+ it('copies the secret without closing the dialog', async () => {
+ await navigator.clipboard.writeText('');
+ const { fapi, user } = await renderTable();
+ const dialog = await openCreate(user);
+ await fillCreate(user, dialog, 'Deploy', 'Never');
+ await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
+
+ await user.click(await within(dialog).findByRole('button', { name: 'Copy API key' }));
+
+ await expect(navigator.clipboard.readText()).resolves.toBe(`ak_secret_${fapi.apiKeys[0].id}`);
+ expect(screen.getByRole('alertdialog', { name: 'Add new API key' })).toBeVisible();
+ });
+
+ it('keeps the secret open on an outside click but closes it on Escape', async () => {
+ const { user } = await renderTable();
+ const dialog = await openCreate(user);
+ await fillCreate(user, dialog, 'Deploy', 'Never');
+ await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
+ await within(dialog).findByRole('textbox', { name: 'API key' });
+ const backdrop = document.querySelector('.cl-dialog-backdrop');
+ if (!backdrop) {
+ throw new Error('Expected the dialog backdrop');
+ }
+
+ await user.click(backdrop);
+ expect(dialog).not.toHaveAttribute('data-closed');
+ expect(screen.getByRole('alertdialog', { name: 'Add new API key' })).toBeVisible();
+
+ await user.keyboard('{Escape}');
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ });
+
+ it('explains a failed copy', async () => {
+ const { user } = await renderTable();
+ const dialog = await openCreate(user);
+ await fillCreate(user, dialog, 'Deploy', 'Never');
+ await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
+ await within(dialog).findByRole('textbox', { name: 'API key' });
+ const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('blocked'));
+ onTestFinished(() => writeText.mockRestore());
+
+ await user.click(within(dialog).getByRole('button', { name: 'Copy and close' }));
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not copy the API key. Try again.');
+ expect(dialog).toBeVisible();
+ });
+
+ it('focuses the name and requires an expiration', async () => {
+ const { user } = await renderTable();
+ const dialog = await openCreate(user);
+ const name = within(dialog).getByRole('textbox', { name: 'Secret key name' });
+ const add = within(dialog).getByRole('button', { name: 'Add API Key' });
+
+ await waitFor(() => expect(name).toHaveFocus());
+ await user.type(name, 'Deploy');
+ expect(add).toBeDisabled();
+
+ await user.click(within(dialog).getByRole('combobox', { name: /^Expiration/ }));
+ expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual([
+ 'Never',
+ '1 Day',
+ '7 Days',
+ '30 Days',
+ '60 Days',
+ '90 Days',
+ '180 Days',
+ '1 Year',
+ ]);
+ await user.click(screen.getByRole('option', { name: 'Never' }));
+ await waitFor(() => expect(within(dialog).getByText('This key will never expire')).toBeVisible());
+ expect(add).toBeEnabled();
+ });
+
+ it('requires a name longer than two characters', async () => {
+ const { user } = await renderTable();
+ const dialog = await openCreate(user);
+ const add = within(dialog).getByRole('button', { name: 'Add API Key' });
+
+ await fillCreate(user, dialog, 'ab', 'Never');
+ expect(add).toBeDisabled();
+
+ await user.type(within(dialog).getByRole('textbox', { name: 'Secret key name' }), 'c');
+ expect(add).toBeEnabled();
+ });
+
+ it('holds the form while the key is created', async () => {
+ const { user } = await renderTable();
+ const dialog = await openCreate(user);
+ await fillCreate(user, dialog, 'Deploy', 'Never');
+ const create = holdRequests('post', '/api_keys');
+
+ await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
+ await waitFor(() => expect(within(dialog).getByRole('textbox', { name: 'Secret key name' })).toBeDisabled());
+ expect(within(dialog).getByRole('button', { name: 'Add API Key' })).toHaveAttribute('aria-busy', 'true');
+
+ create.release();
+ expect(await within(dialog).findByRole('textbox', { name: 'API key' })).toBeVisible();
+ });
+
+ it('explains a name that is already taken and keeps the form', async () => {
+ const { user } = await renderTable();
+ const dialog = await openCreate(user);
+
+ await fillCreate(user, dialog, 'Web app', 'Never');
+ await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
+
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('API Key name already exists.');
+ expect(within(dialog).getByRole('textbox', { name: 'Secret key name' })).toHaveValue('Web app');
+ });
+
+ it('explains when the usage limit is reached', async () => {
+ const { user } = await renderTable();
+ const dialog = await openCreate(user);
+ await fillCreate(user, dialog, 'Deploy', 'Never');
+ const create = holdRequests('post', '/api_keys');
+
+ await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
+ create.fail('token_quota_exceeded');
+
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent(
+ 'You have reached your usage limit. You can remove the limit by upgrading to a paid plan.',
+ );
+ });
+
+ it.todo('creates a key with an optional description when descriptions are enabled');
+ });
+
+ describe('revoking a key', () => {
+ it('revokes the key once its name is typed and removes it from the list', async () => {
+ const { fapi, user } = await renderTable();
+ const dialog = await openRevoke(user, 'Web app');
+
+ await user.click(within(dialog).getByRole('button', { name: 'Revoke key' }));
+
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ await waitFor(() => expect(within(table()).queryByText('Web app')).toBeNull());
+ expect(within(table()).getByText('CI pipeline')).toBeVisible();
+ expect(fapi.apiKeys.find(key => key.id === webApp.id)?.revoked).toBe(true);
+ });
+
+ it('revokes only once the exact name is typed, and returns focus on cancel', async () => {
+ const { fapi, user } = await renderTable();
+ const dialog = await startRevoke(user, 'Web app');
+
+ await user.type(
+ within(dialog).getByRole('textbox', { name: 'Type “Web app” below to continue' }),
+ 'web app{Enter}',
+ );
+ expect(within(dialog).getByRole('button', { name: 'Revoke key' })).toHaveAttribute('aria-disabled', 'true');
+
+ await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Web app' })).toHaveFocus());
+ expect(fapi.apiKeys.find(key => key.id === webApp.id)?.revoked).toBeFalsy();
+ });
+
+ it('holds the dialog while the key is revoked', async () => {
+ const { user } = await renderTable();
+ const dialog = await openRevoke(user, 'Web app');
+ const revoke = holdRequests('post', `/api_keys/${webApp.id}/revoke`);
+
+ await user.click(within(dialog).getByRole('button', { name: 'Revoke key' }));
+ await waitFor(() => expect(within(dialog).getByRole('textbox')).toBeDisabled());
+ expect(within(dialog).getByRole('button', { name: 'Revoke key' })).toHaveAttribute('aria-busy', 'true');
+
+ revoke.release();
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ });
+
+ it('explains a failed revoke, keeps the dialog open, and revokes on retry', async () => {
+ const { fapi, user } = await renderTable();
+ const dialog = await openRevoke(user, 'Web app');
+ failRevokeOnce(webApp.id);
+
+ await user.click(within(dialog).getByRole('button', { name: 'Revoke key' }));
+
+ await waitFor(() =>
+ expect(within(dialog).getByRole('textbox')).toHaveAccessibleDescription('api_key_revoke_failed'),
+ );
+ expect(within(dialog).getByRole('textbox')).toHaveValue('Web app');
+
+ await user.click(within(dialog).getByRole('button', { name: 'Revoke key' }));
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(fapi.apiKeys.find(key => key.id === webApp.id)?.revoked).toBe(true);
+ });
+
+ it('starts each revoke without the previous failure', async () => {
+ const { user } = await renderTable();
+ const first = await openRevoke(user, 'Web app');
+ failRevokeOnce(webApp.id);
+ await user.click(within(first).getByRole('button', { name: 'Revoke key' }));
+ await waitFor(() =>
+ expect(within(first).getByRole('textbox')).toHaveAccessibleDescription('api_key_revoke_failed'),
+ );
+ await user.click(within(first).getByRole('button', { name: 'Cancel' }));
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+
+ const second = await startRevoke(user, 'CI pipeline');
+
+ expect(within(second).getByRole('textbox')).toHaveValue('');
+ expect(within(second).getByRole('textbox')).not.toHaveAccessibleDescription('api_key_revoke_failed');
+ });
+
+ it('moves focus to the next key, then to create once no keys are left', async () => {
+ const { user } = await renderTable();
+
+ await user.click(within(await openRevoke(user, 'Web app')).getByRole('button', { name: 'Revoke key' }));
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage CI pipeline' })).toHaveFocus());
+
+ await user.click(within(await openRevoke(user, 'CI pipeline')).getByRole('button', { name: 'Revoke key' }));
+ expect(await screen.findByText('No API Keys created')).toBeVisible();
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Create API key' })).toHaveFocus());
+ });
+
+ it('returns to the previous page when the last key on a page is revoked', async () => {
+ const { user } = await renderTable(signedIn(manyKeys));
+ await user.click(await screen.findByRole('button', { name: 'Next API keys page' }));
+ const dialog = await openRevoke(user, 'Key 11');
+
+ await user.click(within(dialog).getByRole('button', { name: 'Revoke key' }));
+
+ expect(await within(table()).findByText('Key 1')).toBeVisible();
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Next API keys page' })).toBeNull());
+ });
+ });
+
+ describe('for an organization', () => {
+ const readKeys = 'org:sys_api_keys:read';
+ const manageKeys = 'org:sys_api_keys:manage';
+ const orgKey = fapiApiKey({
+ id: 'ak_org',
+ name: 'Org key',
+ subject: acme.id,
+ created_at: Date.UTC(2026, 0, 5, 12),
+ });
+
+ function memberOfAcme(permissions: string[], apiKeys: ApiKeyJSON[] = [orgKey, webApp]): FakeFapiSeed {
+ const member = fapiUser({ ...alice, organization_memberships: [fapiMembership(acme, { permissions })] });
+ return signedIn(apiKeys, {
+ client: fapiClient([fapiSession({ id: 'sess_1', user: member, last_active_organization_id: acme.id })]),
+ });
+ }
+
+ it("lists the organization's keys and lets a manager create one for it", async () => {
+ const { fapi, user } = await renderTable(memberOfAcme([readKeys, manageKeys]), { subject: acme.id });
+
+ expect(await within(table()).findByText('Org key')).toBeVisible();
+ expect(within(table()).queryByText('Web app')).toBeNull();
+
+ const dialog = await openCreate(user);
+ await fillCreate(user, dialog, 'Deploy', 'Never');
+ await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
+ await within(dialog).findByRole('textbox', { name: 'API key' });
+ expect(fapi.apiKeys[0]).toMatchObject({ name: 'Deploy', subject: acme.id });
+ });
+
+ it('is read-only without permission to manage keys', async () => {
+ await renderTable(memberOfAcme([readKeys]), { subject: acme.id });
+
+ expect(await within(table()).findByText('Org key')).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Create API key' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Manage Org key' })).toBeNull();
+ expect(screen.queryByRole('columnheader', { name: 'Actions' })).toBeNull();
+ });
+
+ it('lets a manager without permission to read keys create one', async () => {
+ const { fapi, user } = await renderTable(memberOfAcme([manageKeys]), { subject: acme.id });
+
+ expect(await screen.findByText('No API Keys created')).toBeVisible();
+ expect(within(table()).queryByText('Org key')).toBeNull();
+
+ const dialog = await openCreate(user);
+ await fillCreate(user, dialog, 'Deploy', 'Never');
+ await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
+ await within(dialog).findByRole('textbox', { name: 'API key' });
+ expect(fapi.apiKeys[0]).toMatchObject({ name: 'Deploy', subject: acme.id });
+ });
+
+ it('renders the fallback without permission to read or manage keys', async () => {
+ await expectFallback(memberOfAcme([]), { subject: acme.id });
+ });
+
+ it('renders the fallback for an organization other than the active one', async () => {
+ await expectFallback(memberOfAcme([readKeys, manageKeys]), { subject: 'org_2' });
+ });
+
+ it('renders the fallback when organization API keys are disabled', async () => {
+ await expectFallback(
+ {
+ ...memberOfAcme([readKeys, manageKeys]),
+ environment: fapiEnvironment({ api_keys_settings: { user_api_keys_enabled: true } }),
+ },
+ { subject: acme.id },
+ );
+ });
+
+ it('starts over on the first page with no search when the subject changes', async () => {
+ const orgKeys = Array.from({ length: 11 }, (_, index) =>
+ fapiApiKey({ id: `ak_org_${index + 1}`, name: `Org key ${index + 1}`, subject: acme.id }),
+ );
+ const { user, rerender } = await renderTable(memberOfAcme([readKeys], [...orgKeys, ...manyKeys]), {
+ subject: acme.id,
+ });
+ await user.click(await screen.findByRole('button', { name: 'Next API keys page' }));
+ expect(await within(table()).findByText('Org key 11')).toBeVisible();
+ await user.type(screen.getByRole('searchbox', { name: 'Search API keys' }), 'Org');
+
+ rerender( );
+
+ expect(await within(table()).findByText('Key 1')).toBeVisible();
+ expect(screen.getByRole('searchbox', { name: 'Search API keys' })).toHaveValue('');
+ expect(screen.getByText('1/2')).toBeVisible();
+ });
+
+ it('explains what organization keys are for', async () => {
+ await renderTable(memberOfAcme([readKeys], []), { subject: acme.id });
+
+ expect(
+ await screen.findByText('API keys allow apps and scripts to access your organization without signing in.'),
+ ).toBeVisible();
+ });
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-api-keys.format.ts b/packages/mosaic/src/features/api-keys/api-keys-table.format.ts
similarity index 80%
rename from packages/mosaic/src/features/user-profile/user-profile-api-keys.format.ts
rename to packages/mosaic/src/features/api-keys/api-keys-table.format.ts
index df58db0d464..97317f664dc 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-api-keys.format.ts
+++ b/packages/mosaic/src/features/api-keys/api-keys-table.format.ts
@@ -1,8 +1,8 @@
-import type { UserProfileAPIKeyExpiration } from './user-profile-create-api-key.dialog';
+import type { APIKeyExpiration } from './create-api-key.dialog';
const DAY_SECONDS = 24 * 60 * 60;
-const expirationDays: Record, number> = {
+const expirationDays: Record, number> = {
'1d': 1,
'7d': 7,
'30d': 30,
@@ -20,7 +20,7 @@ const relativeUnits = [
['minute', 60],
] as const;
-export function getExpirationDate(expiration: UserProfileAPIKeyExpiration, now: Date): Date | null {
+export function getExpirationDate(expiration: APIKeyExpiration, now: Date): Date | null {
if (expiration === 'never') {
return null;
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.messages.ts b/packages/mosaic/src/features/api-keys/api-keys-table.messages.ts
similarity index 75%
rename from packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.messages.ts
rename to packages/mosaic/src/features/api-keys/api-keys-table.messages.ts
index 26f31e94440..02393d372c8 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.messages.ts
+++ b/packages/mosaic/src/features/api-keys/api-keys-table.messages.ts
@@ -1,4 +1,7 @@
-export const userProfileApiKeysPanelMessages = {
+import type { MosaicMessages } from '../../localization';
+import type { APIKeysTableMessages, APIKeysTableSubjectKind } from './api-keys-table.types';
+
+export const apiKeysTableMessages = {
loading: 'Loading API keys',
loadError: 'Could not load API keys',
loadErrorDescription: 'Something went wrong. Please try again.',
@@ -6,7 +9,10 @@ export const userProfileApiKeysPanelMessages = {
empty: 'No API keys found',
emptyDescription: 'Your search for "{query}" did not return any results.',
noKeys: 'No API Keys created',
- noKeysDescription: 'API keys allow apps and scripts to access your account without signing in.',
+ noKeysDescription: {
+ user: 'API keys allow apps and scripts to access your account without signing in.',
+ organization: 'API keys allow apps and scripts to access your organization without signing in.',
+ },
clearSearch: 'Clear search',
pagination: 'API keys pages',
pageSize: 'Results per page',
@@ -60,3 +66,11 @@ export const userProfileApiKeysPanelMessages = {
manage: 'Manage {name}',
revoke: 'Revoke key',
} as const;
+
+export function resolveAPIKeysTableMessages(
+ catalog: MosaicMessages['apiKeysTable'],
+ subjectKind: APIKeysTableSubjectKind,
+ overrides?: Partial,
+): APIKeysTableMessages {
+ return { ...catalog, noKeysDescription: catalog.noKeysDescription[subjectKind], ...overrides };
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.styles.ts b/packages/mosaic/src/features/api-keys/api-keys-table.styles.ts
similarity index 77%
rename from packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.styles.ts
rename to packages/mosaic/src/features/api-keys/api-keys-table.styles.ts
index c61ec582db5..357f2a1cd39 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.styles.ts
+++ b/packages/mosaic/src/features/api-keys/api-keys-table.styles.ts
@@ -3,6 +3,7 @@ import * as stylex from '@stylexjs/stylex';
import { fontWeightVars, space } from '../../tokens.stylex';
export const styles = stylex.create({
+ root: { gap: space['6'], display: 'flex', flexDirection: 'column', width: '100%' },
name: { fontWeight: fontWeightVars['--cl-font-medium'] },
metadata: { gap: space['0.5'], display: 'flex', flexDirection: 'column', minWidth: '25ch' },
});
diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.tsx b/packages/mosaic/src/features/api-keys/api-keys-table.tsx
new file mode 100644
index 00000000000..8afc65bad00
--- /dev/null
+++ b/packages/mosaic/src/features/api-keys/api-keys-table.tsx
@@ -0,0 +1,198 @@
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+import { isOrganizationId } from '@clerk/shared/internal/clerk-js/organization';
+import { useAPIKeys, useClerk, useSession } from '@clerk/shared/react';
+import type { APIKeyResource } from '@clerk/shared/types';
+import type { ReactNode } from 'react';
+import { useEffect, useRef, useState } from 'react';
+
+import { FormSubmitError } from '../../components/form';
+import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment';
+import { useLocale, useMessages } from '../../localization';
+import { formatDate, formatRelativeTime } from './api-keys-table.format';
+import { resolveAPIKeysTableMessages } from './api-keys-table.messages';
+import type { APIKey, APIKeysTableMessages } from './api-keys-table.types';
+import { APIKeysTableView } from './api-keys-table.view';
+import type { CreateAPIKeyInput } from './create-api-key.controller';
+import { useCreateAPIKeyController } from './create-api-key.controller';
+
+const PAGE_SIZE = 10;
+const SEARCH_DEBOUNCE_MS = 500;
+const READ_PERMISSION = 'org:sys_api_keys:read';
+const MANAGE_PERMISSION = 'org:sys_api_keys:manage';
+
+export interface APIKeysTableProps {
+ subject: string;
+ messages?: Partial;
+ fallback?: ReactNode;
+}
+
+// -- Model --
+
+export function useAPIKeysAccess(subject: string | undefined) {
+ const { isLoaded, session } = useSession();
+ const settings = useMosaicEnvironment()?.apiKeysSettings;
+ const isOrganization = subject !== undefined && isOrganizationId(subject);
+ const can = (permission: string) => !isOrganization || (session?.checkAuthorization({ permission }) ?? false);
+ const canRead = can(READ_PERMISSION);
+ const canManage = can(MANAGE_PERMISSION);
+ const isAvailable =
+ subject !== undefined &&
+ (isOrganization
+ ? session?.lastActiveOrganizationId === subject &&
+ Boolean(settings?.orgs_api_keys_enabled) &&
+ (canRead || canManage)
+ : session?.user.id === subject && Boolean(settings?.user_api_keys_enabled));
+
+ return { isLoaded, isAvailable, canRead, canManage };
+}
+
+function useAPIKeysTableModel(subject: string, query: string, messages: APIKeysTableMessages) {
+ const clerk = useClerk();
+ const { isLoaded, isAvailable, canRead, canManage } = useAPIKeysAccess(subject);
+ const apiKeys = useAPIKeys({
+ subject,
+ query,
+ pageSize: PAGE_SIZE,
+ keepPreviousData: true,
+ enabled: isAvailable && canRead,
+ });
+
+ return {
+ isLoaded,
+ isAvailable,
+ ...apiKeys,
+ canManage,
+ create: async ({ name, expiresAt }: CreateAPIKeyInput) => {
+ try {
+ const created = await clerk.apiKeys.create({
+ name,
+ subject,
+ secondsUntilExpiration: expiresAt ? Math.floor((expiresAt.getTime() - Date.now()) / 1000) : undefined,
+ });
+ void apiKeys.revalidate();
+ return created.secret ?? '';
+ } catch (error) {
+ throw new FormSubmitError({ message: createErrorMessage(error, messages) });
+ }
+ },
+ revoke: async (id: string) => {
+ await clerk.apiKeys.revoke({ apiKeyID: id });
+ await apiKeys.revalidate();
+ },
+ };
+}
+
+function createErrorMessage(
+ error: unknown,
+ m: { createError: string; nameTakenError: string; quotaExceededError: string },
+): string {
+ if (isClerkAPIResponseError(error)) {
+ const code = error.errors[0]?.code;
+ if (code === 'token_quota_exceeded') {
+ return m.quotaExceededError;
+ }
+ if (code === 'token_creation_conflict') {
+ return m.nameTakenError;
+ }
+ }
+ return error instanceof Error ? error.message : m.createError;
+}
+
+// -- Controllers --
+
+function useDebouncedSearch() {
+ const [searchValue, setSearchValue] = useState('');
+ const [query, setQuery] = useState('');
+ const timer = useRef | undefined>(undefined);
+
+ useEffect(() => () => clearTimeout(timer.current), []);
+
+ return {
+ searchValue,
+ query,
+ onSearchChange: (value: string) => {
+ setSearchValue(value);
+ clearTimeout(timer.current);
+ timer.current = setTimeout(() => {
+ setQuery(value.trim());
+ }, SEARCH_DEBOUNCE_MS);
+ },
+ };
+}
+
+function useApiKeyLabels(apiKeys: APIKeyResource[]): APIKey[] {
+ const locale = useLocale();
+ const now = new Date();
+
+ return apiKeys.map(key => ({
+ id: key.id,
+ name: key.name,
+ createdAtLabel: formatDate(key.createdAt, locale),
+ expiresAtLabel: key.expiration ? formatDate(key.expiration, locale) : null,
+ lastUsedAtLabel: key.lastUsedAt ? formatRelativeTime(key.lastUsedAt, locale, now) : null,
+ }));
+}
+
+// -- View --
+
+export function APIKeysTable(props: APIKeysTableProps) {
+ return (
+
+ );
+}
+
+function SubjectAPIKeysTable({ subject, messages: overrides, fallback }: APIKeysTableProps) {
+ const messages = resolveAPIKeysTableMessages(
+ useMessages('apiKeysTable'),
+ isOrganizationId(subject) ? 'organization' : 'user',
+ overrides,
+ );
+ const search = useDebouncedSearch();
+ const model = useAPIKeysTableModel(subject, search.query, messages);
+ const create = useCreateAPIKeyController({ messages, onCreate: model.create });
+ const apiKeys = useApiKeyLabels(model.data);
+ const { isFetching, page, pageCount, fetchPage } = model;
+ const searchedQuery = useRef(search.query);
+
+ useEffect(() => {
+ if (searchedQuery.current !== search.query) {
+ searchedQuery.current = search.query;
+ fetchPage(1);
+ }
+ }, [search.query, fetchPage]);
+
+ useEffect(() => {
+ if (!isFetching && pageCount > 0 && page > pageCount) {
+ fetchPage(pageCount);
+ }
+ }, [isFetching, page, pageCount, fetchPage]);
+
+ if (!model.isLoaded || !model.isAvailable) {
+ return fallback ?? null;
+ }
+
+ const manage = model.canManage
+ ? { onCreate: create.onOpen, createDialog: create.dialog, onRevoke: model.revoke }
+ : undefined;
+
+ return (
+ void model.revalidate()}
+ onPageChange={model.fetchPage}
+ onSearchChange={search.onSearchChange}
+ {...manage}
+ />
+ );
+}
diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.types.ts b/packages/mosaic/src/features/api-keys/api-keys-table.types.ts
new file mode 100644
index 00000000000..1eedeb5181e
--- /dev/null
+++ b/packages/mosaic/src/features/api-keys/api-keys-table.types.ts
@@ -0,0 +1,47 @@
+import type { MouseEventHandler } from 'react';
+
+import type { MosaicMessages } from '../../localization';
+import type { CreateAPIKeyDialogProps } from './create-api-key.dialog';
+
+export interface APIKey {
+ id: string;
+ name: string;
+ createdAtLabel: string;
+ expiresAtLabel: string | null;
+ lastUsedAtLabel: string | null;
+}
+
+export interface APIKeysTableSort {
+ column: 'name' | 'createdAt' | 'lastUsed';
+ direction: 'ascending' | 'descending';
+}
+
+export type APIKeysTableSubjectKind = 'user' | 'organization';
+
+type Strings = { readonly [K in keyof T]: T[K] extends string ? string : Strings };
+
+export type APIKeysTableMessages = Strings> & {
+ readonly noKeysDescription: string;
+};
+
+export interface APIKeysTableViewProps {
+ messages: APIKeysTableMessages;
+ apiKeys: APIKey[];
+ totalCount: number;
+ page: number;
+ pageSize?: number;
+ searchValue: string;
+ isLoading: boolean;
+ isFetching?: boolean;
+ isError?: boolean;
+ onRetry?: () => void;
+ onPageChange: (page: number) => void;
+ onPageSizeChange?: (pageSize: number) => void;
+ onSearchChange: (value: string) => void;
+ onCreate?: MouseEventHandler;
+ createDialog?: CreateAPIKeyDialogProps;
+ onRevoke?: (id: string) => Promise;
+ onBulkAction?: (ids: string[]) => void;
+ sort?: APIKeysTableSort | null;
+ onSortChange?: (sort: APIKeysTableSort | null) => void;
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.view.tsx b/packages/mosaic/src/features/api-keys/api-keys-table.view.tsx
similarity index 90%
rename from packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.view.tsx
rename to packages/mosaic/src/features/api-keys/api-keys-table.view.tsx
index 6bfe57c8259..ba16708c493 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.view.tsx
+++ b/packages/mosaic/src/features/api-keys/api-keys-table.view.tsx
@@ -6,28 +6,24 @@ import { Button } from '../../components/button';
import { EmptyState } from '../../components/empty-state';
import { Menu } from '../../components/menu';
import { Pagination } from '../../components/pagination';
-import { Panel } from '../../components/panel';
import { Spinner } from '../../components/spinner';
import type { TableHeaderCellProps } from '../../components/table';
import { Table } from '../../components/table';
import { Text } from '../../components/text';
import { VisuallyHidden } from '../../components/visually-hidden';
import { useListRemovalFocus } from '../../hooks/use-list-removal-focus';
-import { fill, useMessages } from '../../localization';
+import { fill } from '../../localization';
import { useDataTable } from '../../primitives/hooks';
-import { themeProps } from '../../props';
+import { mergeStyleProps, themeProps } from '../../props';
import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visible';
-import { styles } from './user-profile-api-keys-panel.styles';
-import type {
- UserProfileAPIKey,
- UserProfileAPIKeySort,
- UserProfileApiKeysPanelViewProps,
-} from './user-profile-api-keys-panel.types';
-import { UserProfileCreateAPIKeyDialog } from './user-profile-create-api-key.dialog';
+import { styles } from './api-keys-table.styles';
+import type { APIKey, APIKeysTableMessages, APIKeysTableSort, APIKeysTableViewProps } from './api-keys-table.types';
+import { CreateAPIKeyDialog } from './create-api-key.dialog';
-const getRowId = (row: UserProfileAPIKey) => row.id;
+const getRowId = (row: APIKey) => row.id;
-export function UserProfileApiKeysPanelView({
+export function APIKeysTableView({
+ messages: m,
apiKeys,
totalCount,
page,
@@ -46,8 +42,7 @@ export function UserProfileApiKeysPanelView({
isFetching = false,
isError = false,
onRetry,
-}: UserProfileApiKeysPanelViewProps) {
- const m = useMessages('userProfileApiKeysPanel');
+}: APIKeysTableViewProps) {
const searchInput = useRef(null);
const createButton = useRef(null);
const removalFocus = useListRemovalFocus({
@@ -55,7 +50,7 @@ export function UserProfileApiKeysPanelView({
onRemove: onRevoke,
fallback: () => createButton.current ?? searchInput.current,
});
- const revokeKey = useMemo(() => Destructive.createHandle(), []);
+ const revokeKey = useMemo(() => Destructive.createHandle(), []);
const pagination = { pageIndex: page - 1, pageSize };
const table = useDataTable({
data: apiKeys,
@@ -89,7 +84,7 @@ export function UserProfileApiKeysPanelView({
onSearchChange(typeof update === 'function' ? update(searchValue) : update);
},
});
- const sortHeader = (column: UserProfileAPIKeySort['column']): Pick => {
+ const sortHeader = (column: APIKeysTableSort['column']): Pick => {
const active = table.sorting[0];
return {
sort: active?.id === column ? (active.desc ? 'descending' : 'ascending') : 'none',
@@ -112,8 +107,7 @@ export function UserProfileApiKeysPanelView({
: { label: m.noKeys, description: m.noKeysDescription };
return (
<>
- }>
- {m.title}
+
revokeKey.open(apiKey)}
@@ -261,8 +256,8 @@ export function UserProfileApiKeysPanelView({
onChange={next => table.setPagination(current => ({ ...current, pageIndex: next - 1 }))}
/>
) : null}
-
- {createDialog ? : null}
+
+ {createDialog ? : null}
{onRevoke ? (
(element: HTMLButtonElement | null) => void;
- onSelect: (key: UserProfileAPIKey) => void;
+ onSelect: (key: APIKey) => void;
}) {
- const m = useMessages('userProfileApiKeysPanel');
const [triggerRef] = useState(() => registerTrigger(apiKey.id));
return (
diff --git a/packages/mosaic/src/features/user-profile/user-profile-create-api-key.controller.ts b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts
similarity index 67%
rename from packages/mosaic/src/features/user-profile/user-profile-create-api-key.controller.ts
rename to packages/mosaic/src/features/api-keys/create-api-key.controller.ts
index 942661b4f0a..7a710737749 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-create-api-key.controller.ts
+++ b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts
@@ -2,33 +2,32 @@ import type { MouseEventHandler } from 'react';
import { useRef, useState } from 'react';
import { useForm } from '../../components/form';
-import { useLocale, useMessages } from '../../localization';
-import { formatDate, getExpirationDate } from './user-profile-api-keys.format';
-import type {
- UserProfileCreateAPIKeyDialogProps,
- UserProfileCreateAPIKeyValues,
-} from './user-profile-create-api-key.dialog';
+import { useLocale } from '../../localization';
+import { formatDate, getExpirationDate } from './api-keys-table.format';
+import type { APIKeysTableMessages } from './api-keys-table.types';
+import type { CreateAPIKeyDialogProps, CreateAPIKeyValues } from './create-api-key.dialog';
-const initialValues: UserProfileCreateAPIKeyValues = { name: '', expiration: null };
+const initialValues: CreateAPIKeyValues = { name: '', expiration: null };
-export interface UserProfileCreateAPIKeyInput {
+export interface CreateAPIKeyInput {
name: string;
expiresAt: Date | null;
}
-export interface UserProfileCreateAPIKeyControllerOptions {
- onCreate: (input: UserProfileCreateAPIKeyInput) => Promise;
+export interface CreateAPIKeyControllerOptions {
+ messages: APIKeysTableMessages;
+ onCreate: (input: CreateAPIKeyInput) => Promise;
}
-export interface UserProfileCreateAPIKeyController {
+export interface CreateAPIKeyController {
onOpen: MouseEventHandler;
- dialog: UserProfileCreateAPIKeyDialogProps;
+ dialog: CreateAPIKeyDialogProps;
}
-export function useUserProfileCreateAPIKeyController({
+export function useCreateAPIKeyController({
+ messages,
onCreate,
-}: UserProfileCreateAPIKeyControllerOptions): UserProfileCreateAPIKeyController {
- const m = useMessages('userProfileApiKeysPanel');
+}: CreateAPIKeyControllerOptions): CreateAPIKeyController {
const locale = useLocale();
const trigger = useRef(null);
const [open, setOpen] = useState(false);
@@ -58,6 +57,7 @@ export function useUserProfileCreateAPIKeyController({
setOpen(true);
},
dialog: {
+ messages,
open,
onOpenChange: next => {
if (!form.isSubmitting) {
@@ -73,7 +73,7 @@ export function useUserProfileCreateAPIKeyController({
try {
await navigator.clipboard.writeText(secret ?? '');
} catch (error) {
- setCopyError(m.copyError);
+ setCopyError(messages.copyError);
if (!close) {
throw error;
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-create-api-key.dialog.tsx b/packages/mosaic/src/features/api-keys/create-api-key.dialog.tsx
similarity index 84%
rename from packages/mosaic/src/features/user-profile/user-profile-create-api-key.dialog.tsx
rename to packages/mosaic/src/features/api-keys/create-api-key.dialog.tsx
index e51ebdcd625..0145cb8fe7c 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-create-api-key.dialog.tsx
+++ b/packages/mosaic/src/features/api-keys/create-api-key.dialog.tsx
@@ -14,30 +14,32 @@ import { Input } from '../../components/input';
import { InputGroup } from '../../components/input-group';
import { Select } from '../../components/select';
import { Text } from '../../components/text';
-import { fill, useMessages } from '../../localization';
+import { fill } from '../../localization';
import { truncationStyles } from '../../styles/typography.styles';
+import type { APIKeysTableMessages } from './api-keys-table.types';
const expirationValues = ['never', '1d', '7d', '30d', '60d', '90d', '180d', '1y'] as const;
-export type UserProfileAPIKeyExpiration = (typeof expirationValues)[number];
+export type APIKeyExpiration = (typeof expirationValues)[number];
-export interface UserProfileCreateAPIKeyValues {
+export interface CreateAPIKeyValues {
name: string;
- expiration: UserProfileAPIKeyExpiration | null;
+ expiration: APIKeyExpiration | null;
}
-export interface UserProfileCreateAPIKeyDialogProps {
+export interface CreateAPIKeyDialogProps {
+ messages: APIKeysTableMessages;
open: boolean;
onOpenChange: (open: boolean) => void;
finalFocus?: DialogFocusTarget;
- form: UseFormResult;
+ form: UseFormResult;
expirationDateLabel: string | null;
secret: string | null;
copyError: string | null;
onCopy: (close: boolean) => void | Promise;
}
-export function UserProfileCreateAPIKeyDialog(props: UserProfileCreateAPIKeyDialogProps) {
+export function CreateAPIKeyDialog(props: CreateAPIKeyDialogProps) {
const nameInput = useRef(null);
return (
@@ -78,8 +80,8 @@ export function UserProfileCreateAPIKeyDialog(props: UserProfileCreateAPIKeyDial
);
}
-function CreateKeyStep(props: UserProfileCreateAPIKeyDialogProps & { inputRef: Ref }) {
- const m = useMessages('userProfileApiKeysPanel');
+function CreateKeyStep(props: CreateAPIKeyDialogProps & { inputRef: Ref }) {
+ const m = props.messages;
const { form } = props;
const { ref, ...nameControl } = form.register('name');
const nameRef = useMergeRefs([ref, props.inputRef]);
@@ -125,13 +127,15 @@ function CreateKeyStep(props: UserProfileCreateAPIKeyDialogProps & { inputRef: R
- {expiration !== null ? (
-
- {props.expirationDateLabel
- ? fill(m.expirationCaption, { date: props.expirationDateLabel })
- : m.noExpiration}
-
- ) : null}
+
+
+ {expiration === null
+ ? null
+ : props.expirationDateLabel
+ ? fill(m.expirationCaption, { date: props.expirationDateLabel })
+ : m.noExpiration}
+
+
{form.error ? (
@@ -157,8 +161,8 @@ function CreateKeyStep(props: UserProfileCreateAPIKeyDialogProps & { inputRef: R
);
}
-function CopyKeyStep(props: UserProfileCreateAPIKeyDialogProps) {
- const m = useMessages('userProfileApiKeysPanel');
+function CopyKeyStep(props: CreateAPIKeyDialogProps) {
+ const m = props.messages;
const formId = useId();
return (
diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-api-keys-panel.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-api-keys-panel.feature.test.tsx
new file mode 100644
index 00000000000..987bfd8dd7b
--- /dev/null
+++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-api-keys-panel.feature.test.tsx
@@ -0,0 +1,49 @@
+import { screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { serveFapi } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiApiKey,
+ fapiClient,
+ fapiEnvironment,
+ fapiMembership,
+ fapiOrganization,
+ fapiSession,
+ fapiUser,
+} from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { OrganizationProfileApiKeysPanel } from '../organization-profile-api-keys-panel';
+
+const acme = fapiOrganization({ id: 'org_1', name: 'Acme' });
+
+const alice = fapiUser({
+ id: 'user_1',
+ first_name: 'Alice',
+ organization_memberships: [fapiMembership(acme, { permissions: ['org:sys_api_keys:read'] })],
+});
+
+function serve(apiKeysSettings: { user_api_keys_enabled: boolean; orgs_api_keys_enabled: boolean }) {
+ serveFapi({
+ environment: fapiEnvironment({ api_keys_settings: apiKeysSettings }),
+ client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: acme.id })]),
+ apiKeys: [fapiApiKey({ id: 'ak_org', name: 'Org key', subject: acme.id })],
+ });
+}
+
+describe('OrganizationProfileApiKeysPanel', () => {
+ it("lists the organization's keys when organization API keys are enabled", async () => {
+ serve({ user_api_keys_enabled: false, orgs_api_keys_enabled: true });
+ await renderWithClerk( );
+
+ expect(await screen.findByText('Org key')).toBeVisible();
+ });
+
+ it('renders the fallback when only user API keys are enabled', async () => {
+ serve({ user_api_keys_enabled: true, orgs_api_keys_enabled: false });
+ await renderWithClerk(Unavailable
} />);
+
+ expect(await screen.findByText('Unavailable')).toBeVisible();
+ expect(screen.queryByRole('table', { name: 'API Keys' })).toBeNull();
+ expect(screen.queryByText('API Keys')).toBeNull();
+ });
+});
diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx
deleted file mode 100644
index fb5d7b23c00..00000000000
--- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx
+++ /dev/null
@@ -1,498 +0,0 @@
-import { act, render, screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { useState } from 'react';
-import { describe, expect, it, vi } from 'vitest';
-
-import { deferred } from '../../../__tests__/async';
-import { MosaicProvider } from '../../../mosaic-provider';
-import type {
- OrganizationProfileAPIKey,
- OrganizationProfileApiKeysPanelViewProps,
-} from '../organization-profile-api-keys-panel.types';
-import { OrganizationProfileApiKeysPanelView } from '../organization-profile-api-keys-panel.view';
-import type { OrganizationProfileCreateAPIKeyDialogProps } from '../organization-profile-create-api-key.dialog';
-
-const apiKeys: OrganizationProfileAPIKey[] = [
- {
- id: 'primary',
- name: 'Primary API Key',
- createdAtLabel: 'Jan 5, 2026',
- expiresAtLabel: 'Dec 31, 2027',
- lastUsedAtLabel: '2 minutes ago',
- },
- { id: 'legacy', name: 'Legacy API Key', createdAtLabel: 'Jul 1, 2024', expiresAtLabel: null, lastUsedAtLabel: null },
-];
-
-function propsFor(
- overrides: Partial = {},
-): OrganizationProfileApiKeysPanelViewProps {
- return {
- apiKeys,
- totalCount: 2,
- page: 1,
- searchValue: '',
- isLoading: false,
- onSearchChange: vi.fn(),
- onPageChange: vi.fn(),
- onCreate: vi.fn(),
- onRevoke: vi.fn(async () => {}),
- ...overrides,
- };
-}
-
-function renderView(overrides: Partial = {}) {
- const props = propsFor(overrides);
- return {
- props,
- ...render(
-
-
- ,
- ),
- };
-}
-
-describe('OrganizationProfileApiKeysPanelView', () => {
- it('renders supplied metadata and only offers actions when callbacks are provided', async () => {
- const user = userEvent.setup();
- const { props, rerender } = renderView();
- const table = screen.getByRole('table', { name: 'API Keys' });
- expect(screen.getByRole('heading', { name: 'API Keys' })).toBeVisible();
- expect(within(table).getByRole('cell', { name: 'Jan 5, 2026' })).toBeVisible();
- expect(within(table).getByRole('cell', { name: 'Jul 1, 2024' })).toBeVisible();
- expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
- expect(screen.getByRole('searchbox', { name: 'Search API keys' })).toBeVisible();
- expect(
- within(table)
- .getAllByRole('columnheader')
- .map(header => header.textContent),
- ).toEqual(['Name', 'Date created', 'Last used', 'Actions']);
- expect(within(table).getByText('Expires Dec 31, 2027')).toBeVisible();
- expect(within(table).getByText('Never expires')).toBeVisible();
- expect(within(table).getByRole('cell', { name: '2 minutes ago' })).toBeVisible();
- expect(within(table).getByRole('cell', { name: '-' })).toBeVisible();
- expect(within(table).queryByRole('checkbox')).not.toBeInTheDocument();
- expect(within(table).queryByRole('button', { name: /sort/i })).not.toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'Create API key' }));
- expect(props.onCreate).toHaveBeenCalledOnce();
- await user.click(screen.getByRole('button', { name: 'Manage Primary API Key' }));
- expect(
- within(screen.getByRole('menu'))
- .getAllByRole('menuitem')
- .map(item => item.textContent),
- ).toEqual(['Revoke key']);
- await user.keyboard('{Escape}');
- rerender(
-
-
- ,
- );
- expect(screen.getByText('Primary API Key')).toBeVisible();
- expect(screen.getAllByRole('columnheader').map(header => header.textContent)).toEqual([
- 'Name',
- 'Date created',
- 'Last used',
- ]);
- expect(screen.queryByRole('button', { name: 'Create API key' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /Manage/ })).not.toBeInTheDocument();
- });
- it.each([
- ['', 'No API Keys created', 'API keys allow apps and scripts to access your organization without signing in.'],
- [' ', 'No API Keys created', 'API keys allow apps and scripts to access your organization without signing in.'],
- ['Special Key', 'No API keys found', 'Your search for "Special Key" did not return any results.'],
- ])('shows the empty state for search "%s"', (searchValue, label, description) => {
- renderView({ apiKeys: [], totalCount: 0, searchValue });
-
- expect(screen.getByText(label)).toBeVisible();
- expect(screen.getByText(description)).toBeVisible();
- });
-
- it('localizes the shared confirmation for each selected key', async () => {
- const user = userEvent.setup();
- const onRevoke = vi.fn<(id: string) => Promise>().mockRejectedValueOnce(null);
- render(
-
-
- ,
- );
- expect(screen.getByRole('combobox', { name: 'Résultats par page 10' })).toBeVisible();
- await user.click(screen.getByRole('button', { name: 'Manage Primary API Key' }));
- await user.click(screen.getByRole('menuitem', { name: 'Revoke key' }));
- expect(screen.getByRole('dialog')).toHaveAccessibleName('Retirer Primary API Key ?');
- await user.type(
- screen.getByRole('textbox', { name: 'Saisissez « Primary API Key » pour continuer' }),
- 'Primary API Key',
- );
- await user.click(screen.getByRole('button', { name: 'Revoke key' }));
- expect(await screen.findByText('Impossible de révoquer cette clé.')).toBeVisible();
- await user.click(screen.getByRole('button', { name: 'Annuler' }));
- await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
- await user.click(screen.getByRole('button', { name: 'Manage Legacy API Key' }));
- await user.click(screen.getByRole('menuitem', { name: 'Revoke key' }));
- expect(screen.getByRole('dialog')).toHaveAccessibleName('Retirer Legacy API Key ?');
- expect(screen.getByRole('textbox', { name: 'Saisissez « Legacy API Key » pour continuer' })).toHaveValue('');
- expect(screen.queryByText('Impossible de révoquer cette clé.')).not.toBeInTheDocument();
- expect(onRevoke).toHaveBeenCalledExactlyOnceWith('primary');
- });
- it('localizes complete sentences and renders supplied rows even when their names do not match search', () => {
- render(
-
-
- ,
- );
- expect(screen.getByText('ak_...FKWO', { exact: false })).toBeVisible();
- expect(screen.getByText('Expiration: Jul 1, 2025')).toBeVisible();
- expect(screen.getByText('Primary API Key')).toBeVisible();
- expect(screen.queryByText('Expired')).not.toBeInTheDocument();
- expect(screen.getByRole('searchbox', { name: 'Find a key' })).toHaveValue('unmatched');
- });
-
- it.each([true, false])(
- 'restores focus after the last row is removed with creation available: %s',
- async hasCreate => {
- const user = userEvent.setup();
- const pending = deferred();
- function Example() {
- const [items, setItems] = useState([apiKeys[0]]);
- return (
-
- {
- await pending.promise;
- setItems([]);
- }}
- />
-
- );
- }
- render( );
- await user.click(screen.getByRole('button', { name: 'Manage Primary API Key' }));
- await user.click(screen.getByRole('menuitem', { name: 'Revoke key' }));
- await user.type(screen.getByRole('textbox'), 'Primary API Key');
- await user.click(screen.getByRole('button', { name: 'Revoke key' }));
- await act(async () => {
- pending.resolve();
- await pending.promise;
- });
- await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
- expect(screen.getByText('No API Keys created')).toBeVisible();
- expect(
- hasCreate ? screen.getByRole('button', { name: 'Create API key' }) : screen.getByRole('searchbox'),
- ).toHaveFocus();
- },
- );
-
- it('holds the selected identity while pending, explains failure, and retries successfully', async () => {
- const user = userEvent.setup();
- const attempt = deferred();
- const onRevoke = vi
- .fn<(id: string) => Promise>()
- .mockImplementationOnce(() => attempt.promise)
- .mockResolvedValueOnce(undefined);
- renderView({ onRevoke });
- await user.click(screen.getByRole('button', { name: 'Manage Legacy API Key' }));
- await user.click(screen.getByRole('menuitem', { name: 'Revoke key' }));
- const dialog = screen.getByRole('dialog', { name: 'Revoke Legacy API Key?' });
- const confirm = within(dialog).getByRole('button', { name: 'Revoke key' });
- const input = within(dialog).getByRole('textbox');
- await user.type(input, 'Legacy API Key');
- await user.click(confirm);
- expect(input).toBeDisabled();
- expect(confirm).toHaveAttribute('aria-busy', 'true');
- expect(onRevoke).toHaveBeenCalledExactlyOnceWith('legacy');
- expect(dialog).toBeInTheDocument();
- await act(async () => {
- attempt.reject(new Error('Could not revoke this key. Try again.'));
- await attempt.promise.catch(() => {});
- });
- expect(await screen.findByText('Could not revoke this key. Try again.')).toBeVisible();
- expect(dialog).toHaveAccessibleName('Revoke Legacy API Key?');
- expect(input).toHaveValue('Legacy API Key');
- expect(input).toHaveAttribute('aria-invalid', 'true');
- expect(input).toHaveAccessibleDescription('Could not revoke this key. Try again.');
- await user.click(confirm);
- await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
- expect(onRevoke.mock.calls).toEqual([['legacy'], ['legacy']]);
- });
- it('requires each key name, cancels safely, and revokes multiple keys through one dialog with removal focus', async () => {
- const user = userEvent.setup();
- const onRevoke = vi.fn<(id: string) => Promise>(async () => {});
- function Example() {
- const [items, setItems] = useState(apiKeys);
- return (
-
- {
- await onRevoke(id);
- setItems(current => current.filter(item => item.id !== id));
- }}
- />
-
- );
- }
- render( );
- await user.click(screen.getByRole('button', { name: 'Manage Primary API Key' }));
- await user.click(screen.getByRole('menuitem', { name: 'Revoke key' }));
- let dialog = screen.getByRole('dialog', { name: 'Revoke Primary API Key?' });
- expect(onRevoke).not.toHaveBeenCalled();
- expect(within(dialog).getByRole('button', { name: 'Revoke key' })).toHaveAttribute('aria-disabled', 'true');
- await user.type(within(dialog).getByRole('textbox'), 'Primary API Key');
- await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
- expect(onRevoke).not.toHaveBeenCalled();
- await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Primary API Key' })).toHaveFocus());
- for (const name of ['Primary API Key', 'Legacy API Key']) {
- await user.click(screen.getByRole('button', { name: `Manage ${name}` }));
- await user.click(screen.getByRole('menuitem', { name: 'Revoke key' }));
- dialog = screen.getByRole('dialog', { name: `Revoke ${name}?` });
- expect(screen.getAllByRole('dialog')).toHaveLength(1);
- const input = within(dialog).getByRole('textbox', { name: `Type “${name}” below to continue` });
- const previousCalls = onRevoke.mock.calls.length;
- expect(input).toHaveValue('');
- await user.type(input, `${name.toLowerCase()}{Enter}`);
- expect(onRevoke).toHaveBeenCalledTimes(previousCalls);
- await user.clear(input);
- await user.type(input, name);
- await user.click(within(dialog).getByRole('button', { name: 'Revoke key' }));
- await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
- expect(screen.queryByText(name)).not.toBeInTheDocument();
- if (name === 'Primary API Key') {
- expect(screen.getByRole('button', { name: 'Manage Legacy API Key' })).toHaveFocus();
- }
- }
- expect(onRevoke.mock.calls).toEqual([['primary'], ['legacy']]);
- expect(screen.getByText('No API Keys created')).toBeVisible();
- expect(screen.getByRole('searchbox', { name: 'Search API keys' })).toHaveFocus();
- });
- it('only offers selection with an injected bulk action and has no bulk action button', async () => {
- const user = userEvent.setup();
- const onBulkAction = vi.fn();
- const { props, rerender } = renderView({ onBulkAction });
- expect(screen.queryByRole('button', { name: 'Bulk actions' })).not.toBeInTheDocument();
- await user.click(screen.getByRole('checkbox', { name: 'Select Primary API Key' }));
- expect(screen.getByRole('checkbox', { name: 'Select all API keys' })).toBePartiallyChecked();
- expect(screen.getByRole('checkbox', { name: 'Select Primary API Key' })).toBeChecked();
- await user.click(screen.getByRole('checkbox', { name: 'Select all API keys' }));
- expect(screen.getByRole('checkbox', { name: 'Select all API keys' })).toBeChecked();
- rerender(
-
-
- ,
- );
- expect(screen.getByRole('checkbox', { name: 'Select Legacy API Key' })).toBeChecked();
- expect(onBulkAction).not.toHaveBeenCalled();
- rerender(
-
-
- ,
- );
- expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Bulk actions' })).not.toBeInTheDocument();
- });
- it('navigates actual pages and clears controlled search without changing page size', async () => {
- const user = userEvent.setup();
- const items: OrganizationProfileAPIKey[] = Array.from({ length: 11 }, (_, index) => ({
- ...apiKeys[0],
- id: `key-${index}`,
- name: `Key ${index + 1}`,
- }));
- function Example() {
- const [page, setPage] = useState(1);
- const [searchValue, setSearchValue] = useState('');
- const filtered = items.filter(item => item.name.includes(searchValue));
- return (
-
- {
- setSearchValue(value);
- setPage(1);
- }}
- />
-
- );
- }
- render( );
- expect(screen.getByText('1/2')).toBeVisible();
- await user.click(screen.getByRole('button', { name: 'Next API keys page' }));
- expect(screen.getByText('Key 11')).toBeVisible();
- expect(screen.queryByText('Key 1')).not.toBeInTheDocument();
- expect(screen.getByText('2/2')).toBeVisible();
- await user.click(screen.getByRole('button', { name: 'Previous API keys page' }));
- expect(screen.getByText('Key 1')).toBeVisible();
- const input = screen.getByRole('searchbox', { name: 'Search API keys' });
- await user.type(input, 'Key 11');
- expect(input).toHaveValue('Key 11');
- expect(screen.getAllByRole('row')).toHaveLength(2);
- expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'Clear search' }));
- expect(input).toHaveValue('');
- expect(input).toHaveFocus();
- expect(screen.getByText('1/2')).toBeVisible();
- });
- it('distinguishes initial loading from retained rows and empty read-only results', () => {
- const props = propsFor({ apiKeys: [], totalCount: 0, isLoading: true, onCreate: undefined, onRevoke: undefined });
- const view = render(
-
-
- ,
- );
- expect(screen.getByRole('status')).toHaveTextContent('Loading API keys');
- expect(screen.queryByText('No API Keys created')).not.toBeInTheDocument();
- view.rerender(
-
-
- ,
- );
- expect(screen.getByText('Primary API Key')).toBeVisible();
- expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'true');
- expect(screen.queryByRole('status')).not.toBeInTheDocument();
- expect(screen.queryByRole('columnheader', { name: 'Actions' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Create API key' })).not.toBeInTheDocument();
- view.rerender(
-
-
- ,
- );
- expect(screen.getByText('No API Keys created')).toBeVisible();
- expect(screen.getAllByRole('columnheader')).toHaveLength(3);
- });
-});
-
-function dialogPropsFor(
- overrides: Partial = {},
-): OrganizationProfileCreateAPIKeyDialogProps {
- return {
- open: true,
- onOpenChange: vi.fn(),
- name: '',
- onNameChange: vi.fn(),
- expiration: null,
- expirationDateLabel: null,
- onExpirationChange: vi.fn(),
- secret: null,
- isPending: false,
- error: null,
- onSubmit: vi.fn(),
- onCopy: vi.fn(),
- ...overrides,
- };
-}
-
-function dialogView(createDialog: OrganizationProfileCreateAPIKeyDialogProps) {
- return (
-
-
-
- );
-}
-
-describe('organization API key creation', () => {
- it('focuses the name and requires a trimmed name and explicit expiration before submitting', async () => {
- const user = userEvent.setup();
- const props = dialogPropsFor();
- const view = render(dialogView(props));
- const dialog = screen.getByRole('dialog', { name: 'Add new API key' });
- const submit = within(dialog).getByRole('button', { name: 'Add API Key' });
- await waitFor(() => expect(within(dialog).getByRole('textbox', { name: 'Secret key name' })).toHaveFocus());
- expect(submit).toBeDisabled();
- expect(within(dialog).queryByText('This key will never expire')).not.toBeInTheDocument();
- await user.type(within(dialog).getByRole('textbox', { name: 'Secret key name' }), 'A');
- expect(props.onNameChange).toHaveBeenCalledWith('A');
- view.rerender(dialogView({ ...props, name: 'A' }));
- await user.keyboard('{Enter}');
- expect(props.onSubmit).not.toHaveBeenCalled();
- await user.click(within(dialog).getByRole('combobox', { name: /^Expiration/ }));
- expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual([
- 'Never',
- '1 Day',
- '7 Days',
- '30 Days',
- '60 Days',
- '90 Days',
- '180 Days',
- '1 Year',
- ]);
- await user.click(screen.getByRole('option', { name: 'Never' }));
- expect(props.onExpirationChange).toHaveBeenCalledWith('never');
- view.rerender(dialogView({ ...props, name: ' ', expiration: 'never' }));
- expect(submit).toBeDisabled();
- view.rerender(dialogView({ ...props, name: 'A', expiration: 'never' }));
- expect(within(dialog).getByText('This key will never expire')).toBeVisible();
- await user.click(submit);
- expect(props.onSubmit).toHaveBeenCalledOnce();
- });
-
- it('holds the form while pending and displays errors without losing input before retry', async () => {
- const user = userEvent.setup();
- const props = dialogPropsFor({ name: 'Deploy', expiration: '7d', expirationDateLabel: 'Sep 28, 2026' });
- const view = render(dialogView(props));
- expect(screen.getByText('This key will expire on Sep 28, 2026')).toBeVisible();
- await user.click(screen.getByRole('button', { name: 'Add API Key' }));
- view.rerender(dialogView({ ...props, isPending: true }));
- expect(screen.getByRole('textbox', { name: 'Secret key name' })).toBeDisabled();
- expect(screen.getByRole('combobox', { name: /^Expiration/ })).toBeDisabled();
- expect(screen.getByRole('button', { name: 'Add API Key' })).toHaveAttribute('aria-disabled', 'true');
- await user.click(screen.getByRole('button', { name: 'Add API Key' }));
- expect(props.onSubmit).toHaveBeenCalledOnce();
- await user.keyboard('{Escape}');
- expect(props.onOpenChange).not.toHaveBeenCalled();
- view.rerender(dialogView({ ...props, error: 'Creation failed. Try again.' }));
- expect(screen.getByRole('alert')).toHaveTextContent('Creation failed. Try again.');
- expect(screen.getByRole('textbox', { name: 'Secret key name' })).toHaveValue('Deploy');
- await user.click(screen.getByRole('button', { name: 'Add API Key' }));
- expect(props.onSubmit).toHaveBeenCalledTimes(2);
- });
-});
diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx
new file mode 100644
index 00000000000..b9a99f16665
--- /dev/null
+++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+
+import { getAvailableOrganizationProfilePages } from '../organization-profile.layout';
+import type { OrganizationProfilePages } from '../organization-profile.types';
+
+const general: OrganizationProfilePages['general'] = { name: 'Acme', slug: 'acme', memberCount: 1 };
+
+describe('getAvailableOrganizationProfilePages', () => {
+ it('lists api keys given content', () => {
+ expect(getAvailableOrganizationProfilePages({ general, apiKeys: API keys
})).toEqual(['general', 'apiKeys']);
+ });
+
+ it.each([null, false])('drops api keys given %s content', apiKeys => {
+ expect(getAvailableOrganizationProfilePages({ general, apiKeys })).toEqual(['general']);
+ });
+});
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.messages.ts b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.messages.ts
deleted file mode 100644
index f314fe9b838..00000000000
--- a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.messages.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-export const organizationProfileApiKeysPanelMessages = {
- loading: 'Loading API keys',
- empty: 'No API keys found',
- emptyDescription: 'Your search for "{query}" did not return any results.',
- noKeys: 'No API Keys created',
- noKeysDescription: 'API keys allow apps and scripts to access your organization without signing in.',
- clearSearch: 'Clear search',
- pagination: 'API keys pages',
- pageSize: 'Results per page',
- previousPage: 'Previous API keys page',
- nextPage: 'Next API keys page',
- selectAll: 'Select all API keys',
- select: 'Select {name}',
- revokeTitle: 'Revoke {name}?',
- revokeDescription: 'This API key will stop working immediately. This action cannot be undone.',
- revokeFieldLabel: 'Type “{name}” below to continue',
- revokeError: 'Something went wrong. Please try again.',
- cancel: 'Cancel',
- title: 'API Keys',
- search: 'Search API keys',
- create: 'Create API key',
- createError: 'Could not create the API key. Try again.',
- createTitle: 'Add new API key',
- createDescription: 'Provide a name to generate a new key. You’ll be able to revoke it anytime.',
- nameLabel: 'Secret key name',
- expirationLabel: 'Expiration',
- noExpiration: 'This key will never expire',
- expirationPlaceholder: 'Select expiration',
- expirationCaption: 'This key will expire on {date}',
- expirationOptions: {
- never: 'Never',
- '1d': '1 Day',
- '7d': '7 Days',
- '30d': '30 Days',
- '60d': '60 Days',
- '90d': '90 Days',
- '180d': '180 Days',
- '1y': '1 Year',
- },
- add: 'Add API Key',
- copyTitle: 'Copy your API Key',
- copyDescription: "For security reasons, we won't allow you to view it again later.",
- secretLabel: 'API key',
- copy: 'Copy API key',
- copied: 'Copied',
- copyError: 'Could not copy the API key. Try again.',
- copyAndClose: 'Copy and close',
- name: 'Name',
- createdAt: 'Date created',
- lastUsed: 'Last used',
- actions: 'Actions',
- expires: 'Expires {expiresDate}',
- neverExpires: 'Never expires',
- neverUsed: '-',
- manage: 'Manage {name}',
- revoke: 'Revoke key',
-} as const;
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.styles.ts b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.styles.ts
deleted file mode 100644
index c61ec582db5..00000000000
--- a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.styles.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import * as stylex from '@stylexjs/stylex';
-
-import { fontWeightVars, space } from '../../tokens.stylex';
-
-export const styles = stylex.create({
- name: { fontWeight: fontWeightVars['--cl-font-medium'] },
- metadata: { gap: space['0.5'], display: 'flex', flexDirection: 'column', minWidth: '25ch' },
-});
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.tsx
new file mode 100644
index 00000000000..c3cc8731b32
--- /dev/null
+++ b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.tsx
@@ -0,0 +1,31 @@
+import { useOrganization } from '@clerk/shared/react';
+import type { ReactNode } from 'react';
+
+import { Panel } from '../../components/panel';
+import { useMessages } from '../../localization';
+import { themeProps } from '../../props';
+import { APIKeysTable, useAPIKeysAccess } from '../api-keys/api-keys-table';
+
+export interface OrganizationProfileApiKeysPanelProps {
+ fallback?: ReactNode;
+}
+
+export function OrganizationProfileApiKeysPanel({ fallback }: OrganizationProfileApiKeysPanelProps) {
+ const { organization } = useOrganization();
+ const { isAvailable } = useAPIKeysAccess(organization?.id);
+ const m = useMessages('organizationProfile');
+
+ if (!organization || !isAvailable) {
+ return fallback ?? null;
+ }
+
+ return (
+ }>
+ {m.pages.apiKeys}
+
+
+ );
+}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.types.ts b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.types.ts
deleted file mode 100644
index 31df1dfed88..00000000000
--- a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.types.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { MouseEventHandler } from 'react';
-
-import type { OrganizationProfileCreateAPIKeyDialogProps } from './organization-profile-create-api-key.dialog';
-
-export interface OrganizationProfileAPIKey {
- id: string;
- name: string;
- createdAtLabel: string;
- expiresAtLabel: string | null;
- lastUsedAtLabel: string | null;
-}
-
-export interface OrganizationProfileAPIKeySort {
- column: 'name' | 'createdAt' | 'lastUsed';
- direction: 'ascending' | 'descending';
-}
-
-export interface OrganizationProfileApiKeysPanelViewProps {
- apiKeys: OrganizationProfileAPIKey[];
- totalCount: number;
- page: number;
- pageSize?: number;
- searchValue: string;
- isLoading: boolean;
- isFetching?: boolean;
- onPageChange: (page: number) => void;
- onPageSizeChange?: (pageSize: number) => void;
- onSearchChange: (value: string) => void;
- onCreate?: MouseEventHandler;
- createDialog?: OrganizationProfileCreateAPIKeyDialogProps;
- onRevoke?: (id: string) => Promise;
- onBulkAction?: (ids: string[]) => void;
- sort?: OrganizationProfileAPIKeySort | null;
- onSortChange?: (sort: OrganizationProfileAPIKeySort | null) => void;
-}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx
deleted file mode 100644
index 014a5b77c5c..00000000000
--- a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx
+++ /dev/null
@@ -1,297 +0,0 @@
-import * as stylex from '@stylexjs/stylex';
-import { useMemo, useRef, useState } from 'react';
-
-import { Destructive } from '../../blocks/destructive';
-import { Button } from '../../components/button';
-import { EmptyState } from '../../components/empty-state';
-import { Menu } from '../../components/menu';
-import { Pagination } from '../../components/pagination';
-import { Panel } from '../../components/panel';
-import { Spinner } from '../../components/spinner';
-import type { TableHeaderCellProps } from '../../components/table';
-import { Table } from '../../components/table';
-import { Text } from '../../components/text';
-import { VisuallyHidden } from '../../components/visually-hidden';
-import { useListRemovalFocus } from '../../hooks/use-list-removal-focus';
-import { fill, useMessages } from '../../localization';
-import { useDataTable } from '../../primitives/hooks';
-import { themeProps } from '../../props';
-import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visible';
-import { styles } from './organization-profile-api-keys-panel.styles';
-import type {
- OrganizationProfileAPIKey,
- OrganizationProfileAPIKeySort,
- OrganizationProfileApiKeysPanelViewProps,
-} from './organization-profile-api-keys-panel.types';
-import { OrganizationProfileCreateAPIKeyDialog } from './organization-profile-create-api-key.dialog';
-
-const getRowId = (row: OrganizationProfileAPIKey) => row.id;
-
-export function OrganizationProfileApiKeysPanelView({
- apiKeys,
- totalCount,
- page,
- pageSize = 10,
- onPageChange,
- onPageSizeChange,
- searchValue,
- onSearchChange,
- onCreate,
- createDialog,
- onRevoke,
- onBulkAction,
- sort,
- onSortChange,
- isLoading,
- isFetching = false,
-}: OrganizationProfileApiKeysPanelViewProps) {
- const m = useMessages('organizationProfileApiKeysPanel');
- const searchInput = useRef(null);
- const createButton = useRef(null);
- const removalFocus = useListRemovalFocus({
- ids: apiKeys.map(getRowId),
- onRemove: onRevoke,
- fallback: () => createButton.current ?? searchInput.current,
- });
- const revokeKey = useMemo(() => Destructive.createHandle(), []);
- const pagination = { pageIndex: page - 1, pageSize };
- const table = useDataTable({
- data: apiKeys,
- totalCount,
- getRowId,
- sorting: sort ? [{ id: sort.column, desc: sort.direction === 'descending' }] : [],
- onSortingChange: onSortChange
- ? update => {
- const next = typeof update === 'function' ? update(table.sorting) : update;
- const active = next[0];
- table.setRowSelection({});
- onSortChange(
- active && (active.id === 'name' || active.id === 'createdAt' || active.id === 'lastUsed')
- ? { column: active.id, direction: active.desc ? 'descending' : 'ascending' }
- : null,
- );
- }
- : undefined,
- pagination,
- onPaginationChange: update => {
- const next = typeof update === 'function' ? update(pagination) : update;
- table.setRowSelection({});
- if (next.pageSize !== pageSize) {
- onPageSizeChange?.(next.pageSize);
- }
- onPageChange(next.pageIndex + 1);
- },
- globalFilter: searchValue,
- onGlobalFilterChange: update => {
- table.setRowSelection({});
- onSearchChange(typeof update === 'function' ? update(searchValue) : update);
- },
- });
- const sortHeader = (
- column: OrganizationProfileAPIKeySort['column'],
- ): Pick => {
- const active = table.sorting[0];
- return {
- sort: active?.id === column ? (active.desc ? 'descending' : 'ascending') : 'none',
- onSort: onSortChange
- ? () =>
- table.setSorting(current => {
- const active = current[0];
- if (active?.id !== column) {
- return [{ id: column, desc: false }];
- }
- return active.desc ? [] : [{ id: column, desc: true }];
- })
- : undefined,
- };
- };
- const columnCount = 3 + Number(Boolean(onRevoke)) + Number(Boolean(onBulkAction));
- const query = searchValue.trim();
- const emptyState = query
- ? { label: m.empty, description: fill(m.emptyDescription, { query }) }
- : { label: m.noKeys, description: m.noKeysDescription };
- return (
- <>
- }>
- {m.title}
-
-
- {onCreate ? (
-
- {m.create}
-
- ) : null}
-
-
-
-
- {onBulkAction ? (
-
- ) : null}
- {m.name}
- {m.createdAt}
- {m.lastUsed}
- {onRevoke ? (
-
- {m.actions}
-
- ) : null}
-
-
-
- {isLoading ? (
-
-
-
- {m.loading}
-
-
- ) : table.rows.length === 0 ? (
-
-
-
- {emptyState.label}
- {emptyState.description}
-
-
- ) : (
- table.rows.map(row => (
-
- {onBulkAction ? (
-
- ) : null}
-
-
- {row.original.name}
-
- {truncateWithEndVisible(row.original.id, 10, 4)} ·{' '}
- }
- size='xs'
- color={row.original.expiresAtLabel === null ? 'foreground-secondary' : 'warning'}
- >
- {row.original.expiresAtLabel === null
- ? m.neverExpires
- : fill(m.expires, {
- expiresDate: row.original.expiresAtLabel,
- })}
-
-
-
-
-
- {row.original.createdAtLabel}
-
-
- {row.original.lastUsedAtLabel ?? m.neverUsed}
-
- {onRevoke ? (
-
- revokeKey.open(apiKey)}
- />
-
- ) : null}
-
- ))
- )}
-
-
- {table.getPageCount() > 1 || (totalCount > 0 && onPageSizeChange) ? (
- table.setPagination({ pageIndex: 0, pageSize: next }) : undefined
- }
- label={m.pagination}
- pageSizeLabel={m.pageSize}
- previousPageLabel={m.previousPage}
- nextPageLabel={m.nextPage}
- onChange={next => table.setPagination(current => ({ ...current, pageIndex: next - 1 }))}
- />
- ) : null}
-
- {createDialog ? : null}
- {onRevoke ? (
- fill(m.revokeTitle, { name: apiKey.name })}
- description={m.revokeDescription}
- fieldLabel={apiKey => fill(m.revokeFieldLabel, { name: apiKey.name })}
- confirmationValue={apiKey => apiKey.name}
- actionLabel={m.revoke}
- cancelLabel={m.cancel}
- onDelete={async apiKey => {
- try {
- await removalFocus.remove(apiKey.id);
- } catch (error) {
- throw error instanceof Error ? error : new Error(m.revokeError);
- }
- }}
- finalFocus={removalFocus.finalFocus}
- />
- ) : null}
- >
- );
-}
-
-function APIKeyActions({
- apiKey,
- registerTrigger,
- onSelect,
-}: {
- apiKey: OrganizationProfileAPIKey;
- registerTrigger: (id: string) => (element: HTMLButtonElement | null) => void;
- onSelect: (key: OrganizationProfileAPIKey) => void;
-}) {
- const m = useMessages('organizationProfileApiKeysPanel');
- const [triggerRef] = useState(() => registerTrigger(apiKey.id));
- return (
-
-
-
- onSelect(apiKey)}
- >
- {m.revoke}
-
-
-
- );
-}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-create-api-key.dialog.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-create-api-key.dialog.tsx
deleted file mode 100644
index 939d1cc44c8..00000000000
--- a/packages/mosaic/src/features/organization-profile/organization-profile-create-api-key.dialog.tsx
+++ /dev/null
@@ -1,225 +0,0 @@
-import type { Ref } from 'react';
-import { useId, useRef } from 'react';
-
-import { SubmitButton } from '../../components/button';
-import { Card } from '../../components/card';
-import { CopyButton } from '../../components/copy-button';
-import type { DialogFocusTarget } from '../../components/dialog';
-import { Dialog } from '../../components/dialog';
-import { Field } from '../../components/field';
-import { Flow, useFlowAutoFocus } from '../../components/flow';
-import { Input } from '../../components/input';
-import { InputGroup } from '../../components/input-group';
-import { Select } from '../../components/select';
-import { Text } from '../../components/text';
-import { fill, useMessages } from '../../localization';
-import { truncationStyles } from '../../styles/typography.styles';
-
-const expirationValues = ['never', '1d', '7d', '30d', '60d', '90d', '180d', '1y'] as const;
-
-export interface OrganizationProfileCreateAPIKeyDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- finalFocus?: DialogFocusTarget;
- name: string;
- onNameChange: (name: string) => void;
- expiration: (typeof expirationValues)[number] | null;
- expirationDateLabel: string | null;
- onExpirationChange: (expiration: (typeof expirationValues)[number] | null) => void;
- secret: string | null;
- isPending: boolean;
- error: string | null;
- onSubmit: () => void | Promise;
- onCopy: (close: boolean) => void | Promise;
-}
-
-export function OrganizationProfileCreateAPIKeyDialog(props: OrganizationProfileCreateAPIKeyDialogProps) {
- const nameInput = useRef(null);
-
- return (
- {
- if (!props.isPending) {
- props.onOpenChange(open);
- }
- }}
- >
-
-
-
- {current => (
- <>
-
-
-
-
-
-
- >
- )}
-
-
-
-
- );
-}
-
-function CreateKeyStep(props: OrganizationProfileCreateAPIKeyDialogProps & { inputRef: Ref }) {
- const m = useMessages('organizationProfileApiKeysPanel');
- const formId = useId();
-
- return (
- <>
-
- {m.createTitle}
- {m.createDescription}
-
- {
- event.preventDefault();
- if (!props.isPending && props.name.trim() && props.expiration !== null) {
- void props.onSubmit();
- }
- }}
- />
- }
- >
-
- {m.nameLabel}
- props.onNameChange(event.target.value)}
- />
-
-
- {m.expirationLabel}
- ({ value, label: m.expirationOptions[value] }))}
- value={props.expiration ?? undefined}
- onValueChange={value => props.onExpirationChange(expirationValues.find(option => option === value) ?? null)}
- >
-
-
-
- {props.expiration !== null ? (
-
- {props.expirationDateLabel
- ? fill(m.expirationCaption, { date: props.expirationDateLabel })
- : m.noExpiration}
-
- ) : null}
-
-
- {props.error ? (
-
- {props.error}
-
- ) : null}
-
-
-
- {m.add}
-
-
- >
- );
-}
-
-function CopyKeyStep(props: OrganizationProfileCreateAPIKeyDialogProps) {
- const m = useMessages('organizationProfileApiKeysPanel');
- const formId = useId();
-
- return (
- <>
-
- {m.copyTitle}
- {m.copyDescription}
-
- {
- event.preventDefault();
- if (!props.isPending) {
- void props.onCopy(true);
- }
- }}
- />
- }
- >
-
- {m.secretLabel}
-
-
-
- ()}
- value={props.secret ?? ''}
- label={m.copy}
- copiedLabel={m.copied}
- disabled={props.isPending}
- onCopy={() => props.onCopy(false)}
- />
-
-
-
-
- {props.error ? (
-
- {props.error}
-
- ) : null}
-
-
-
- {m.copyAndClose}
-
-
- >
- );
-}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts
index d9e9747fada..231ab9effab 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts
@@ -27,7 +27,10 @@ export type OrganizationProfileNavEntry =
| { id: string; custom: CustomOrganizationProfilePage };
export function getAvailableOrganizationProfilePages(pages: OrganizationProfilePages): OrganizationProfilePageId[] {
- return ORGANIZATION_PROFILE_PAGE_IDS.filter(id => pages[id] !== undefined);
+ return ORGANIZATION_PROFILE_PAGE_IDS.filter(id => {
+ const content = pages[id];
+ return content !== undefined && content !== null && content !== false;
+ });
}
export function resolveOrganizationProfilePages(
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts
index a811681c353..e4d135e55ec 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts
@@ -1,6 +1,5 @@
import type { ReactNode } from 'react';
-import type { OrganizationProfileApiKeysPanelViewProps } from './organization-profile-api-keys-panel.types';
import type { OrganizationProfileGeneralPanelViewProps } from './organization-profile-general-panel.view';
import type { OrganizationProfileMembersPanelViewProps } from './organization-profile-members-panel.view';
@@ -11,7 +10,7 @@ export interface OrganizationProfilePages {
members?: OrganizationProfileMembersPanelViewProps;
security?: Record;
billing?: Record;
- apiKeys?: OrganizationProfileApiKeysPanelViewProps;
+ apiKeys?: ReactNode;
}
export interface CustomOrganizationProfilePage {
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx
index 38f93bb66ee..7caa9cde280 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx
@@ -14,7 +14,6 @@ import type {
OrganizationProfilePageId,
OrganizationProfilePages,
} from './organization-profile.types';
-import { OrganizationProfileApiKeysPanelView } from './organization-profile-api-keys-panel.view';
import { OrganizationProfileGeneralPanelView } from './organization-profile-general-panel.view';
import { OrganizationProfileMembersPanelView } from './organization-profile-members-panel.view';
import { OrganizationProfilePlaceholderPanelView } from './organization-profile-placeholder-panel.view';
@@ -34,12 +33,12 @@ function BuiltInPage({
}: {
id: OrganizationProfilePageId;
pages: OrganizationProfilePages;
-}): React.ReactElement | null {
+}): React.ReactNode {
if (id === 'general') {
return ;
}
if (id === 'apiKeys') {
- return pages.apiKeys ? : null;
+ return pages.apiKeys;
}
if (id === 'members') {
return pages.members?.members || pages.members?.invitations || pages.members?.requests ? (
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.feature.test.tsx
index a5786edd0af..a80a8732f13 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.feature.test.tsx
@@ -1,357 +1,35 @@
-import type { ApiKeyJSON } from '@clerk/shared/types';
-import { screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { http, HttpResponse } from 'msw';
-import { describe, expect, it, onTestFinished, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
-import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
-import {
- fapiApiKey,
- fapiClient,
- fapiEmailAddress,
- fapiEnvironment,
- fapiMembership,
- fapiOrganization,
- fapiSession,
- fapiUser,
-} from '../../../__tests__/feature/fapi';
+import { serveFapi } from '../../../__tests__/feature/fake-fapi';
+import { fapiApiKey, fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { UserProfileApiKeysPanel } from '../user-profile-api-keys-panel';
-const DAY = 24 * 60 * 60 * 1000;
+const alice = fapiUser({ id: 'user_1', first_name: 'Alice' });
-const acme = fapiOrganization({ id: 'org_1', name: 'Acme' });
-
-const alice = fapiUser({
- id: 'user_1',
- first_name: 'Alice',
- email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })],
- organization_memberships: [fapiMembership(acme)],
-});
-
-const userKey = (id: string, name: string, overrides: Partial = {}) =>
- fapiApiKey({ id, name, subject: alice.id, created_at: Date.UTC(2026, 0, 5, 12), ...overrides });
-
-const webApp = userKey('ak_1', 'Web app', { expiration: Date.UTC(2027, 11, 31, 12) });
-const ciPipeline = userKey('ak_2', 'CI pipeline', { last_used_at: Date.now() - 2 * 60_000 });
-
-const manyKeys = Array.from({ length: 11 }, (_, index) => userKey(`ak_${index + 1}`, `Key ${index + 1}`));
-
-function signedIn(apiKeys: ApiKeyJSON[] = [webApp, ciPipeline], overrides: FakeFapiSeed = {}): FakeFapiSeed {
- return {
- environment: fapiEnvironment({ api_keys_settings: { user_api_keys_enabled: true } }),
- client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: acme.id })]),
- apiKeys,
- ...overrides,
- };
-}
-
-async function renderPanel(seed: FakeFapiSeed = signedIn()) {
- const fapi = serveFapi(seed);
- const view = await renderWithClerk( );
- return { ...view, fapi, user: userEvent.setup() };
-}
-
-type User = ReturnType;
-
-const table = () => screen.getByRole('table', { name: 'API Keys' });
-
-async function openCreate(user: User) {
- await user.click(await screen.findByRole('button', { name: 'Create API key' }));
- return screen.findByRole('dialog', { name: 'Add new API key' });
-}
-
-async function fillCreate(user: User, dialog: HTMLElement, name: string, expiration: string) {
- await user.type(within(dialog).getByRole('textbox', { name: 'Secret key name' }), name);
- await user.click(within(dialog).getByRole('combobox', { name: /^Expiration/ }));
- await user.click(await screen.findByRole('option', { name: expiration }));
-}
-
-async function openRevoke(user: User, name: string) {
- await user.click(await screen.findByRole('button', { name: `Manage ${name}` }));
- await user.click(await screen.findByRole('menuitem', { name: 'Revoke key' }));
- const dialog = await screen.findByRole('dialog', { name: `Revoke ${name}?` });
- await user.type(within(dialog).getByRole('textbox'), name);
- return dialog;
+function serve(userAPIKeysEnabled: boolean) {
+ serveFapi({
+ environment: fapiEnvironment({ api_keys_settings: { user_api_keys_enabled: userAPIKeysEnabled } }),
+ client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]),
+ apiKeys: [fapiApiKey({ id: 'ak_1', name: 'Web app', subject: alice.id })],
+ });
}
describe('UserProfileApiKeysPanel', () => {
- describe('listing keys', () => {
- it("shows the user's own keys, not the active organization's", async () => {
- await renderPanel(
- signedIn([webApp, ciPipeline, fapiApiKey({ id: 'ak_org', name: 'Org key', subject: acme.id })]),
- );
-
- expect(await within(table()).findByText('Web app')).toBeVisible();
- expect(within(table()).getByText('CI pipeline')).toBeVisible();
- expect(within(table()).queryByText('Org key')).toBeNull();
- });
-
- it('describes when each key was created, last used, and expires', async () => {
- await renderPanel();
+ it("lists the user's keys when user API keys are enabled", async () => {
+ serve(true);
+ await renderWithClerk( );
- await within(table()).findByText('Web app');
- expect(within(table()).getAllByRole('cell', { name: 'Jan 5, 2026' })).toHaveLength(2);
- expect(within(table()).getByText('Expires Dec 31, 2027')).toBeVisible();
- expect(within(table()).getByText('Never expires')).toBeVisible();
- expect(within(table()).getByRole('cell', { name: '2 minutes ago' })).toBeVisible();
- expect(within(table()).getByRole('cell', { name: '-' })).toBeVisible();
- });
-
- it('shows loading until the first page arrives', async () => {
- serveFapi(signedIn());
- const list = holdRequests('get', '/api_keys');
- await renderWithClerk( );
-
- expect(await screen.findByRole('status')).toHaveTextContent('Loading API keys');
- list.release();
- expect(await within(table()).findByText('Web app')).toBeVisible();
- expect(screen.queryByRole('status')).toBeNull();
- });
-
- it('explains when the user has no keys', async () => {
- await renderPanel(signedIn([]));
-
- expect(await screen.findByText('No API Keys created')).toBeVisible();
- });
-
- it('explains a failed load instead of showing no keys, and loads again on retry', { timeout: 20_000 }, async () => {
- let failing = true;
- serveFapi(signedIn());
- worker.use(
- http.get(fapiUrl('/api_keys'), () =>
- failing
- ? HttpResponse.json({ errors: [{ code: 'internal_error', message: 'internal_error' }] }, { status: 400 })
- : undefined,
- ),
- );
- await renderWithClerk( );
- const user = userEvent.setup();
-
- expect(await screen.findByText('Could not load API keys', undefined, { timeout: 15_000 })).toBeVisible();
- expect(screen.queryByText('No API Keys created')).toBeNull();
-
- failing = false;
- await user.click(screen.getByRole('button', { name: 'Try again' }));
- expect(await within(table()).findByText('Web app')).toBeVisible();
- });
+ expect(await screen.findByText('Web app')).toBeVisible();
});
- describe('searching', () => {
- it('lists only matching keys and explains when nothing matches', async () => {
- const { user } = await renderPanel();
- await within(table()).findByText('CI pipeline');
- const search = screen.getByRole('searchbox', { name: 'Search API keys' });
-
- await user.type(search, 'web');
- await waitFor(() => expect(within(table()).queryByText('CI pipeline')).toBeNull());
- expect(within(table()).getByText('Web app')).toBeVisible();
-
- await user.clear(search);
- await user.type(search, 'nothing');
- expect(await screen.findByText('Your search for "nothing" did not return any results.')).toBeVisible();
- });
-
- it('returns to the first page for a new search', async () => {
- const { user } = await renderPanel(signedIn(manyKeys));
-
- await user.click(await screen.findByRole('button', { name: 'Next API keys page' }));
- expect(await within(table()).findByText('Key 11')).toBeVisible();
-
- await user.type(screen.getByRole('searchbox', { name: 'Search API keys' }), 'Key 1');
- expect(await within(table()).findByText('Key 10')).toBeVisible();
- expect(within(table()).getByText('Key 1')).toBeVisible();
- });
- });
-
- describe('paging', () => {
- it('moves between pages of keys', async () => {
- const { user } = await renderPanel(signedIn(manyKeys));
-
- expect(await within(table()).findByText('Key 1')).toBeVisible();
- expect(screen.getByText('1/2')).toBeVisible();
-
- await user.click(screen.getByRole('button', { name: 'Next API keys page' }));
- expect(await within(table()).findByText('Key 11')).toBeVisible();
- expect(within(table()).queryByText('Key 1')).toBeNull();
-
- await user.click(screen.getByRole('button', { name: 'Previous API keys page' }));
- expect(await within(table()).findByText('Key 1')).toBeVisible();
- });
-
- it.todo('uses a configured page size');
- });
-
- describe('creating a key', () => {
- it('creates a key for the user, shows its secret once, and lists it', async () => {
- const { fapi, user } = await renderPanel();
- const dialog = await openCreate(user);
-
- await fillCreate(user, dialog, 'Deploy', '7 Days');
- await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
-
- const secret = await within(dialog).findByRole('textbox', { name: 'API key' });
- const [created] = fapi.apiKeys;
- expect(created).toMatchObject({ name: 'Deploy', subject: alice.id });
- expect(created.expiration).toBeGreaterThan(Date.now() + 6 * DAY);
- expect(secret).toHaveValue(`ak_secret_${created.id}`);
-
- await user.click(within(dialog).getByRole('button', { name: 'Copy and close' }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
- expect(await within(table()).findByText('Deploy')).toBeVisible();
- });
-
- it('shows the secret without waiting for the list to refresh', async () => {
- const { fapi, user } = await renderPanel();
- const dialog = await openCreate(user);
- await fillCreate(user, dialog, 'Deploy', 'Never');
- const refresh = holdRequests('get', '/api_keys');
-
- await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
-
- expect(await within(dialog).findByRole('textbox', { name: 'API key' })).toHaveValue(
- `ak_secret_${fapi.apiKeys[0].id}`,
- );
- refresh.release();
- expect(await within(table()).findByText('Deploy')).toBeVisible();
- });
-
- it('copies the secret without closing the dialog', async () => {
- await navigator.clipboard.writeText('');
- const { fapi, user } = await renderPanel();
- const dialog = await openCreate(user);
- await fillCreate(user, dialog, 'Deploy', 'Never');
- await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
-
- await user.click(await within(dialog).findByRole('button', { name: 'Copy API key' }));
-
- await expect(navigator.clipboard.readText()).resolves.toBe(`ak_secret_${fapi.apiKeys[0].id}`);
- expect(screen.getByRole('alertdialog', { name: 'Add new API key' })).toBeVisible();
- });
-
- it('keeps the secret open on an outside click but closes it on Escape', async () => {
- const { user } = await renderPanel();
- const dialog = await openCreate(user);
- await fillCreate(user, dialog, 'Deploy', 'Never');
- await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
- await within(dialog).findByRole('textbox', { name: 'API key' });
- const backdrop = document.querySelector('.cl-dialog-backdrop');
- if (!backdrop) {
- throw new Error('Expected the dialog backdrop');
- }
-
- await user.click(backdrop);
- expect(dialog).not.toHaveAttribute('data-closed');
- expect(screen.getByRole('alertdialog', { name: 'Add new API key' })).toBeVisible();
-
- await user.keyboard('{Escape}');
- await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
- });
-
- it('explains a failed copy', async () => {
- const { user } = await renderPanel();
- const dialog = await openCreate(user);
- await fillCreate(user, dialog, 'Deploy', 'Never');
- await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
- await within(dialog).findByRole('textbox', { name: 'API key' });
- const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('blocked'));
- onTestFinished(() => writeText.mockRestore());
-
- await user.click(within(dialog).getByRole('button', { name: 'Copy and close' }));
- expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not copy the API key. Try again.');
- expect(dialog).toBeVisible();
- });
-
- it('requires a name longer than two characters', async () => {
- const { user } = await renderPanel();
- const dialog = await openCreate(user);
- const add = within(dialog).getByRole('button', { name: 'Add API Key' });
-
- await fillCreate(user, dialog, 'ab', 'Never');
- expect(add).toBeDisabled();
-
- await user.type(within(dialog).getByRole('textbox', { name: 'Secret key name' }), 'c');
- expect(add).toBeEnabled();
- });
-
- it('holds the form while the key is created', async () => {
- const { user } = await renderPanel();
- const dialog = await openCreate(user);
- await fillCreate(user, dialog, 'Deploy', 'Never');
- const create = holdRequests('post', '/api_keys');
-
- await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
- await waitFor(() => expect(within(dialog).getByRole('textbox', { name: 'Secret key name' })).toBeDisabled());
- expect(within(dialog).getByRole('button', { name: 'Add API Key' })).toHaveAttribute('aria-busy', 'true');
-
- create.release();
- expect(await within(dialog).findByRole('textbox', { name: 'API key' })).toBeVisible();
- });
-
- it('explains a name that is already taken and keeps the form', async () => {
- const { user } = await renderPanel();
- const dialog = await openCreate(user);
-
- await fillCreate(user, dialog, 'Web app', 'Never');
- await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
-
- expect(await within(dialog).findByRole('alert')).toHaveTextContent('API Key name already exists.');
- expect(within(dialog).getByRole('textbox', { name: 'Secret key name' })).toHaveValue('Web app');
- });
-
- it('explains when the usage limit is reached', async () => {
- const { user } = await renderPanel();
- const dialog = await openCreate(user);
- await fillCreate(user, dialog, 'Deploy', 'Never');
- const create = holdRequests('post', '/api_keys');
-
- await user.click(within(dialog).getByRole('button', { name: 'Add API Key' }));
- create.fail('token_quota_exceeded');
-
- expect(await within(dialog).findByRole('alert')).toHaveTextContent(
- 'You have reached your usage limit. You can remove the limit by upgrading to a paid plan.',
- );
- });
-
- it.todo('creates a key with an optional description when descriptions are enabled');
- });
-
- describe('revoking a key', () => {
- it('revokes the key once its name is typed and removes it from the list', async () => {
- const { fapi, user } = await renderPanel();
- const dialog = await openRevoke(user, 'Web app');
-
- await user.click(within(dialog).getByRole('button', { name: 'Revoke key' }));
-
- await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
- await waitFor(() => expect(within(table()).queryByText('Web app')).toBeNull());
- expect(within(table()).getByText('CI pipeline')).toBeVisible();
- expect(fapi.apiKeys.find(key => key.id === webApp.id)?.revoked).toBe(true);
- });
-
- it('explains a failed revoke and keeps the dialog open', async () => {
- const { user } = await renderPanel();
- const dialog = await openRevoke(user, 'Web app');
- const revoke = holdRequests('post', `/api_keys/${webApp.id}/revoke`);
-
- await user.click(within(dialog).getByRole('button', { name: 'Revoke key' }));
- revoke.fail('api_key_revoke_failed');
-
- await waitFor(() =>
- expect(within(dialog).getByRole('textbox')).toHaveAccessibleDescription('api_key_revoke_failed'),
- );
- expect(screen.getByRole('dialog', { name: 'Revoke Web app?' })).toBeVisible();
- });
-
- it('returns to the previous page when the last key on a page is revoked', async () => {
- const { user } = await renderPanel(signedIn(manyKeys));
- await user.click(await screen.findByRole('button', { name: 'Next API keys page' }));
- const dialog = await openRevoke(user, 'Key 11');
-
- await user.click(within(dialog).getByRole('button', { name: 'Revoke key' }));
+ it('renders the fallback when user API keys are disabled', async () => {
+ serve(false);
+ await renderWithClerk(Unavailable} />);
- expect(await within(table()).findByText('Key 1')).toBeVisible();
- await waitFor(() => expect(screen.queryByRole('button', { name: 'Next API keys page' })).toBeNull());
- });
+ expect(await screen.findByText('Unavailable')).toBeVisible();
+ expect(screen.queryByRole('table', { name: 'API Keys' })).toBeNull();
+ expect(screen.queryByText('API Keys')).toBeNull();
});
});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.view.test.tsx
deleted file mode 100644
index 36ba8e9817f..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.view.test.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { render, screen, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { describe, expect, it, vi } from 'vitest';
-
-import { MosaicProvider } from '../../../mosaic-provider';
-import type { UserProfileAPIKey, UserProfileApiKeysPanelViewProps } from '../user-profile-api-keys-panel.types';
-import { UserProfileApiKeysPanelView } from '../user-profile-api-keys-panel.view';
-
-const apiKeys: UserProfileAPIKey[] = [
- {
- id: 'primary',
- name: 'Primary API Key',
- createdAtLabel: 'Jan 5, 2026',
- expiresAtLabel: 'Dec 31, 2027',
- lastUsedAtLabel: '2 minutes ago',
- },
- { id: 'legacy', name: 'Legacy API Key', createdAtLabel: 'Jul 1, 2024', expiresAtLabel: null, lastUsedAtLabel: null },
-];
-
-function renderView(overrides: Partial = {}) {
- const props: UserProfileApiKeysPanelViewProps = {
- apiKeys,
- totalCount: 2,
- page: 1,
- searchValue: '',
- isLoading: false,
- onSearchChange: vi.fn(),
- onPageChange: vi.fn(),
- ...overrides,
- };
- render(
-
-
- ,
- );
- return props;
-}
-
-const columnHeaders = () =>
- within(screen.getByRole('table', { name: 'API Keys' }))
- .getAllByRole('columnheader')
- .map(header => header.textContent);
-
-describe('UserProfileApiKeysPanelView', () => {
- it('renders read-only without create or revoke callbacks', () => {
- renderView();
-
- expect(screen.getByText('Primary API Key')).toBeVisible();
- expect(columnHeaders()).toEqual(['Name', 'Date created', 'Last used']);
- expect(screen.queryByRole('button', { name: 'Create API key' })).toBeNull();
- expect(screen.queryByRole('button', { name: /^Manage/ })).toBeNull();
- });
-
- it('offers create and revoke when their callbacks are given', () => {
- renderView({ onCreate: vi.fn(), onRevoke: vi.fn(async () => {}) });
-
- expect(columnHeaders()).toEqual(['Name', 'Date created', 'Last used', 'Actions']);
- expect(screen.getByRole('button', { name: 'Create API key' })).toBeVisible();
- expect(screen.getByRole('button', { name: 'Manage Primary API Key' })).toBeVisible();
- });
-
- it('only offers selection with a bulk action', async () => {
- const user = userEvent.setup();
- const onBulkAction = vi.fn();
- renderView({ onBulkAction });
-
- await user.click(screen.getByRole('checkbox', { name: 'Select Primary API Key' }));
- expect(screen.getByRole('checkbox', { name: 'Select all API keys' })).toBePartiallyChecked();
- await user.click(screen.getByRole('checkbox', { name: 'Select all API keys' }));
- expect(screen.getByRole('checkbox', { name: 'Select Legacy API Key' })).toBeChecked();
- expect(onBulkAction).not.toHaveBeenCalled();
- });
-
- it('has no selection without a bulk action', () => {
- renderView();
-
- expect(screen.queryByRole('checkbox')).toBeNull();
- });
-
- it('changes the page size and returns to the first page', async () => {
- const user = userEvent.setup();
- const props = renderView({ page: 2, totalCount: 25, onPageSizeChange: vi.fn() });
-
- await user.click(screen.getByRole('combobox', { name: /Results per page/ }));
- await user.click(screen.getByRole('option', { name: '20' }));
-
- expect(props.onPageSizeChange).toHaveBeenCalledWith(20);
- expect(props.onPageChange).toHaveBeenCalledWith(1);
- });
-
- it('shows a load error instead of the empty state', async () => {
- const user = userEvent.setup();
- const onRetry = vi.fn();
- renderView({ apiKeys: [], totalCount: 0, isError: true, onRetry });
-
- expect(screen.getByText('Could not load API keys')).toBeVisible();
- expect(screen.queryByText('No API Keys created')).toBeNull();
- await user.click(screen.getByRole('button', { name: 'Try again' }));
- expect(onRetry).toHaveBeenCalledOnce();
- });
-});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.tsx b/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.tsx
index 55741ba40b8..a12c25df961 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.tsx
@@ -1,156 +1,31 @@
-import { isClerkAPIResponseError } from '@clerk/shared/error';
-import { useAPIKeys, useClerk, useUser } from '@clerk/shared/react';
-import type { APIKeyResource } from '@clerk/shared/types';
+import { useUser } from '@clerk/shared/react';
import type { ReactNode } from 'react';
-import { useEffect, useRef, useState } from 'react';
-import { FormSubmitError } from '../../components/form';
-import { useLocale, useMessages } from '../../localization';
-import { formatDate, formatRelativeTime } from './user-profile-api-keys.format';
-import type { UserProfileAPIKey } from './user-profile-api-keys-panel.types';
-import { UserProfileApiKeysPanelView } from './user-profile-api-keys-panel.view';
-import type { UserProfileCreateAPIKeyInput } from './user-profile-create-api-key.controller';
-import { useUserProfileCreateAPIKeyController } from './user-profile-create-api-key.controller';
-
-const PAGE_SIZE = 10;
-const SEARCH_DEBOUNCE_MS = 500;
+import { Panel } from '../../components/panel';
+import { useMessages } from '../../localization';
+import { themeProps } from '../../props';
+import { APIKeysTable, useAPIKeysAccess } from '../api-keys/api-keys-table';
export interface UserProfileApiKeysPanelProps {
fallback?: ReactNode;
}
-// -- Model --
-
-function useUserProfileApiKeysModel(query: string) {
- const clerk = useClerk();
- const { isLoaded, user } = useUser();
- const m = useMessages('userProfileApiKeysPanel');
- const subject = user?.id ?? '';
- const apiKeys = useAPIKeys({
- subject,
- query,
- pageSize: PAGE_SIZE,
- keepPreviousData: true,
- enabled: Boolean(user),
- });
-
- return {
- isLoaded,
- ...apiKeys,
- create: async ({ name, expiresAt }: UserProfileCreateAPIKeyInput) => {
- try {
- const created = await clerk.apiKeys.create({
- name,
- subject,
- secondsUntilExpiration: expiresAt ? Math.floor((expiresAt.getTime() - Date.now()) / 1000) : undefined,
- });
- void apiKeys.revalidate();
- return created.secret ?? '';
- } catch (error) {
- throw new FormSubmitError({ message: createErrorMessage(error, m) });
- }
- },
- revoke: async (id: string) => {
- await clerk.apiKeys.revoke({ apiKeyID: id });
- await apiKeys.revalidate();
- },
- };
-}
-
-function createErrorMessage(
- error: unknown,
- m: { createError: string; nameTakenError: string; quotaExceededError: string },
-): string {
- if (isClerkAPIResponseError(error)) {
- const code = error.errors[0]?.code;
- if (code === 'token_quota_exceeded') {
- return m.quotaExceededError;
- }
- if (code === 'token_creation_conflict') {
- return m.nameTakenError;
- }
- }
- return error instanceof Error ? error.message : m.createError;
-}
-
-// -- Controllers --
-
-function useDebouncedSearch() {
- const [searchValue, setSearchValue] = useState('');
- const [query, setQuery] = useState('');
- const timer = useRef | undefined>(undefined);
-
- useEffect(() => () => clearTimeout(timer.current), []);
-
- return {
- searchValue,
- query,
- onSearchChange: (value: string) => {
- setSearchValue(value);
- clearTimeout(timer.current);
- timer.current = setTimeout(() => {
- setQuery(value.trim());
- }, SEARCH_DEBOUNCE_MS);
- },
- };
-}
-
-function useApiKeyLabels(apiKeys: APIKeyResource[]): UserProfileAPIKey[] {
- const locale = useLocale();
- const now = new Date();
-
- return apiKeys.map(key => ({
- id: key.id,
- name: key.name,
- createdAtLabel: formatDate(key.createdAt, locale),
- expiresAtLabel: key.expiration ? formatDate(key.expiration, locale) : null,
- lastUsedAtLabel: key.lastUsedAt ? formatRelativeTime(key.lastUsedAt, locale, now) : null,
- }));
-}
-
-// -- View --
-
export function UserProfileApiKeysPanel({ fallback }: UserProfileApiKeysPanelProps) {
- const search = useDebouncedSearch();
- const model = useUserProfileApiKeysModel(search.query);
- const create = useUserProfileCreateAPIKeyController({ onCreate: model.create });
- const apiKeys = useApiKeyLabels(model.data);
- const { isFetching, page, pageCount, fetchPage } = model;
- const searchedQuery = useRef(search.query);
-
- useEffect(() => {
- if (searchedQuery.current !== search.query) {
- searchedQuery.current = search.query;
- fetchPage(1);
- }
- }, [search.query, fetchPage]);
-
- useEffect(() => {
- if (!isFetching && pageCount > 0 && page > pageCount) {
- fetchPage(pageCount);
- }
- }, [isFetching, page, pageCount, fetchPage]);
+ const { user } = useUser();
+ const { isAvailable } = useAPIKeysAccess(user?.id);
+ const m = useMessages('userProfile');
- if (!model.isLoaded) {
+ if (!user || !isAvailable) {
return fallback ?? null;
}
return (
- void model.revalidate()}
- onPageChange={model.fetchPage}
- onSearchChange={search.onSearchChange}
- onCreate={create.onOpen}
- createDialog={create.dialog}
- onRevoke={model.revoke}
- />
+ }>
+ {m.pages.apiKeys}
+
+
);
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.types.ts b/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.types.ts
deleted file mode 100644
index 98f516a2c77..00000000000
--- a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.types.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { MouseEventHandler } from 'react';
-
-import type { UserProfileCreateAPIKeyDialogProps } from './user-profile-create-api-key.dialog';
-
-export interface UserProfileAPIKey {
- id: string;
- name: string;
- createdAtLabel: string;
- expiresAtLabel: string | null;
- lastUsedAtLabel: string | null;
-}
-
-export interface UserProfileAPIKeySort {
- column: 'name' | 'createdAt' | 'lastUsed';
- direction: 'ascending' | 'descending';
-}
-
-export interface UserProfileApiKeysPanelViewProps {
- apiKeys: UserProfileAPIKey[];
- totalCount: number;
- page: number;
- pageSize?: number;
- searchValue: string;
- isLoading: boolean;
- isFetching?: boolean;
- isError?: boolean;
- onRetry?: () => void;
- onPageChange: (page: number) => void;
- onPageSizeChange?: (pageSize: number) => void;
- onSearchChange: (value: string) => void;
- onCreate?: MouseEventHandler;
- createDialog?: UserProfileCreateAPIKeyDialogProps;
- onRevoke?: (id: string) => Promise;
- onBulkAction?: (ids: string[]) => void;
- sort?: UserProfileAPIKeySort | null;
- onSortChange?: (sort: UserProfileAPIKeySort | null) => void;
-}
diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts
index d92f36608ab..f7dc074d2cd 100644
--- a/packages/mosaic/src/localization/registry.ts
+++ b/packages/mosaic/src/localization/registry.ts
@@ -1,8 +1,8 @@
import { formMessages } from '../components/form/form.messages';
+import { apiKeysTableMessages } from '../features/api-keys/api-keys-table.messages';
import { invitationsTableTabMessages } from '../features/organization-profile/invitations-table-tab.messages';
import { membersTableTabMessages } from '../features/organization-profile/members-table-tab.messages';
import { organizationProfileMessages } from '../features/organization-profile/organization-profile.messages';
-import { organizationProfileApiKeysPanelMessages } from '../features/organization-profile/organization-profile-api-keys-panel.messages';
import { organizationProfileDangerSectionMessages } from '../features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.messages';
import { organizationProfileInviteMembersMessages } from '../features/organization-profile/organization-profile-invite-members.messages';
import { organizationProfileWorkspaceSectionMessages } from '../features/organization-profile/organization-profile-workspace-section/organization-profile-workspace-section.messages';
@@ -18,7 +18,6 @@ import { userProfileVerifyEmailSsoMessages } from '../features/user-profile/user
import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices.messages';
import { userProfileAddAuthenticatorMessages } from '../features/user-profile/user-profile-add-authenticator.messages';
import { userProfileAddSmsMessages } from '../features/user-profile/user-profile-add-sms.messages';
-import { userProfileApiKeysPanelMessages } from '../features/user-profile/user-profile-api-keys-panel.messages';
import { userProfileAuthenticatorSetupMessages } from '../features/user-profile/user-profile-authenticator-setup.messages';
import { userProfileBackupCodesMessages } from '../features/user-profile/user-profile-backup-codes.messages';
import { userProfileConnectedAccountsMessages } from '../features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.messages';
@@ -30,6 +29,7 @@ import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-pr
import { errorMessages } from './errors.messages';
export const mosaicMessages = {
+ apiKeysTable: apiKeysTableMessages,
errors: errorMessages,
form: formMessages,
membersTableTab: membersTableTabMessages,
@@ -38,9 +38,7 @@ export const mosaicMessages = {
organizationProfile: organizationProfileMessages,
organizationProfileDangerSection: organizationProfileDangerSectionMessages,
organizationProfileWorkspaceSection: organizationProfileWorkspaceSectionMessages,
- organizationProfileApiKeysPanel: organizationProfileApiKeysPanelMessages,
organizationProfileInviteMembers: organizationProfileInviteMembersMessages,
- userProfileApiKeysPanel: userProfileApiKeysPanelMessages,
reverification: reverificationMessages,
userButton: userButtonMessages,
userProfile: userProfileMessages,
diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts
index b069dfb7618..68774fa4bc1 100644
--- a/packages/mosaic/src/styles/index.ts
+++ b/packages/mosaic/src/styles/index.ts
@@ -227,10 +227,7 @@ export { mergeStyleProps, themeProps } from '../props';
export { UserProfileMfaSectionView } from '../features/user-profile/user-profile-mfa-section.view';
export { UserProfileMfaSetupView } from '../features/user-profile/user-profile-mfa-setup.view';
-export { UserProfileApiKeysPanelView } from '../features/user-profile/user-profile-api-keys-panel.view';
-
-export { OrganizationProfileApiKeysPanelView } from '../features/organization-profile/organization-profile-api-keys-panel.view';
-export { OrganizationProfileCreateAPIKeyDialog } from '../features/organization-profile/organization-profile-create-api-key.dialog';
+export { APIKeysTableView } from '../features/api-keys/api-keys-table.view';
export { MembersTableTabView } from '../features/organization-profile/members-table-tab.view';
export { InvitationsTableTabView } from '../features/organization-profile/invitations-table-tab.view';
diff --git a/packages/swingset/src/app/(clerk)/live/api-keys/page.tsx b/packages/swingset/src/app/(clerk)/live/api-keys/page.tsx
index 81db3e73cff..1bb66492cdc 100644
--- a/packages/swingset/src/app/(clerk)/live/api-keys/page.tsx
+++ b/packages/swingset/src/app/(clerk)/live/api-keys/page.tsx
@@ -1,5 +1,6 @@
'use client';
+import { OrganizationProfileApiKeysPanel } from '@clerk/mosaic/features/organization-profile/organization-profile-api-keys-panel';
import { UserProfileApiKeysPanel } from '@clerk/mosaic/features/user-profile/user-profile-api-keys-panel';
import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
import { useUser } from '@clerk/nextjs';
@@ -14,8 +15,8 @@ export default function ApiKeysLivePage() {
API keys
- The user profile API keys panel, listing, creating, and revoking the signed-in user's real API keys.
- User API keys must be enabled for the application.
+ The user and organization profile API keys panels, listing, creating, and revoking real API keys for the
+ signed-in user and their active organization. API keys must be enabled for the application.
{!isLoaded ? Loading…
: null}
@@ -30,7 +31,12 @@ export default function ApiKeysLivePage() {
to use the live harness.
) : null}
- {isLoaded && isSignedIn ? : null}
+ {isLoaded && isSignedIn ? (
+ <>
+
+
+ >
+ ) : null}
);
diff --git a/packages/swingset/src/components/Composition.tsx b/packages/swingset/src/components/Composition.tsx
index 0a525e5a687..38c7efa9d57 100644
--- a/packages/swingset/src/components/Composition.tsx
+++ b/packages/swingset/src/components/Composition.tsx
@@ -17,6 +17,7 @@ const LAYER_ORDER = [
'User Button',
'User Profile',
'Organization Profile',
+ 'API Keys',
'Blocks',
'Components',
'Styles',
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index e006bd926a5..3031f104ab5 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -49,6 +49,9 @@ const docModules: Record> = {
'organization-profile-danger-section': dynamic(() => import('../stories/organization-profile-danger-section.mdx')),
'organization-profile-api-keys-panel': dynamic(() => import('../stories/organization-profile-api-keys-panel.mdx')),
},
+ 'api-keys': {
+ 'api-keys-table': dynamic(() => import('../stories/api-keys-table.mdx')),
+ },
reverification: {
reverification: dynamic(() => import('../stories/reverification.mdx')),
},
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 4814ea9f80e..3f604fdfd56 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -6,6 +6,13 @@ import {
Inline as ActionBarInline,
meta as actionBarMeta,
} from '../stories/action-bar.stories';
+import {
+ Default as APIKeysTableDefault,
+ Empty as APIKeysTableEmpty,
+ meta as apiKeysTableMeta,
+ Organization as APIKeysTableOrganization,
+ ProposedTable as APIKeysTableProposedTable,
+} from '../stories/api-keys-table.stories';
import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
import {
Fallback as AvatarFallbackStory,
@@ -145,7 +152,6 @@ import {
Default as OrganizationProfileApiKeysPanelDefault,
Empty as OrganizationProfileApiKeysPanelEmpty,
meta as organizationProfileApiKeysPanelMeta,
- ProposedTable as OrganizationProfileApiKeysPanelProposedTable,
} from '../stories/organization-profile-api-keys-panel.stories';
import {
Default as OrganizationProfileDangerSectionDefault,
@@ -328,7 +334,6 @@ import {
Default as UserProfileApiKeysPanelDefault,
Empty as UserProfileApiKeysPanelEmpty,
meta as userProfileApiKeysPanelMeta,
- ProposedTable as UserProfileApiKeysPanelProposedTable,
} from '../stories/user-profile-api-keys-panel.stories';
import {
Default as UserProfileBillingHistorySectionDefault,
@@ -733,7 +738,6 @@ const toastModule: StoryModule = { meta: toastMeta, Default: ToastDefault, Ancho
const userProfileApiKeysPanelModule: StoryModule = {
meta: userProfileApiKeysPanelMeta,
Default: UserProfileApiKeysPanelDefault,
- ProposedTable: UserProfileApiKeysPanelProposedTable,
Empty: UserProfileApiKeysPanelEmpty,
};
const userProfileModule: StoryModule = {
@@ -857,6 +861,14 @@ const destructiveModule: StoryModule = {
WithError: DestructiveWithError,
};
+const apiKeysTableModule: StoryModule = {
+ meta: apiKeysTableMeta,
+ Default: APIKeysTableDefault,
+ Organization: APIKeysTableOrganization,
+ ProposedTable: APIKeysTableProposedTable,
+ Empty: APIKeysTableEmpty,
+};
+
const reverificationModule: StoryModule = {
meta: reverificationMeta,
Default: ReverificationDefault,
@@ -881,7 +893,6 @@ const reverificationModule: StoryModule = {
const organizationProfileApiKeysPanelModule: StoryModule = {
meta: organizationProfileApiKeysPanelMeta,
Default: OrganizationProfileApiKeysPanelDefault,
- ProposedTable: OrganizationProfileApiKeysPanelProposedTable,
Empty: OrganizationProfileApiKeysPanelEmpty,
};
@@ -924,6 +935,8 @@ export const registry: StoryModule[] = [
// Organization Profile · Sections
organizationProfileWorkspaceSectionModule,
organizationProfileDangerSectionModule,
+ // API Keys
+ apiKeysTableModule,
// Reverification
reverificationModule,
// Blocks — flows assembled from components, wired by the caller's machine.
diff --git a/packages/swingset/src/lib/slug.ts b/packages/swingset/src/lib/slug.ts
index 44bfb16458d..1314d746872 100644
--- a/packages/swingset/src/lib/slug.ts
+++ b/packages/swingset/src/lib/slug.ts
@@ -1,5 +1,6 @@
export function toSlug(str: string): string {
return str
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
diff --git a/packages/swingset/src/stories/api-keys-table.mdx b/packages/swingset/src/stories/api-keys-table.mdx
new file mode 100644
index 00000000000..0af92ad0cf7
--- /dev/null
+++ b/packages/swingset/src/stories/api-keys-table.mdx
@@ -0,0 +1,49 @@
+import * as Stories from './api-keys-table.stories';
+
+# APIKeysTable
+
+A table of the API keys that belong to a user or an organization, with search, page navigation, creation, and revocation confirmed by typing the key name. `APIKeysTable` takes the `subject` whose keys it lists, reads them from Clerk, and offers creation and revocation only when the signed-in user may manage them. The user and organization profile API keys panels render it under their own title. Pass `messages` to replace any of its strings.
+
+## Example
+
+
+
+## Examples
+
+### Organization
+
+An organization subject explains organization keys in its empty state.
+
+
+
+### Empty
+
+
+
+### Proposed table
+
+This example enables sorting and row selection through `onSortChange` and `onBulkAction`. Click Name, Date created, or Last used to cycle through ascending, descending, and unsorted order. The caller sorts all results before pagination and resets to page 1. Keys that have never been used sort last.
+
+The bulk callback is a placeholder that enables selection checkboxes. Sorting clears the current selection.
+
+
diff --git a/packages/swingset/src/stories/api-keys-table.stories.tsx b/packages/swingset/src/stories/api-keys-table.stories.tsx
new file mode 100644
index 00000000000..0684f3050c1
--- /dev/null
+++ b/packages/swingset/src/stories/api-keys-table.stories.tsx
@@ -0,0 +1,46 @@
+import { APIKeysTableView } from '@clerk/mosaic/features/api-keys/api-keys-table.view';
+
+import type { StoryMeta } from '@/lib/types';
+
+import { useAPIKeysTableFixture } from './fixtures/api-keys-table';
+
+export { default as __source } from './api-keys-table.stories?raw';
+
+export const meta: StoryMeta = {
+ group: 'API Keys',
+ status: 'wip',
+ title: 'APIKeysTable',
+ label: 'API keys table',
+ source: 'packages/mosaic/src/features/api-keys/api-keys-table.view.tsx',
+};
+
+export function Default() {
+ const props = useAPIKeysTableFixture();
+ return ;
+}
+
+export function Organization() {
+ const props = useAPIKeysTableFixture({ subjectKind: 'organization', initialKeys: [] });
+ return ;
+}
+
+export function Empty() {
+ const props = useAPIKeysTableFixture({ initialKeys: [] });
+ return (
+
+ );
+}
+
+export function ProposedTable() {
+ const props = useAPIKeysTableFixture({ enableSorting: true });
+ return (
+ undefined}
+ />
+ );
+}
diff --git a/packages/swingset/src/stories/fixtures/user-profile-api-keys.ts b/packages/swingset/src/stories/fixtures/api-keys-table.tsx
similarity index 78%
rename from packages/swingset/src/stories/fixtures/user-profile-api-keys.ts
rename to packages/swingset/src/stories/fixtures/api-keys-table.tsx
index 06bb26301f5..4d13749693c 100644
--- a/packages/swingset/src/stories/fixtures/user-profile-api-keys.ts
+++ b/packages/swingset/src/stories/fixtures/api-keys-table.tsx
@@ -1,9 +1,13 @@
+import { Panel } from '@clerk/mosaic/components/panel';
+import { resolveAPIKeysTableMessages } from '@clerk/mosaic/features/api-keys/api-keys-table.messages';
import type {
- UserProfileAPIKeySort,
- UserProfileApiKeysPanelViewProps,
-} from '@clerk/mosaic/features/user-profile/user-profile-api-keys-panel.types';
-import { useUserProfileCreateAPIKeyController } from '@clerk/mosaic/features/user-profile/user-profile-create-api-key.controller';
-import { useLocale } from '@clerk/mosaic/localization';
+ APIKeysTableSort,
+ APIKeysTableSubjectKind,
+ APIKeysTableViewProps,
+} from '@clerk/mosaic/features/api-keys/api-keys-table.types';
+import { APIKeysTableView } from '@clerk/mosaic/features/api-keys/api-keys-table.view';
+import { useCreateAPIKeyController } from '@clerk/mosaic/features/api-keys/create-api-key.controller';
+import { useLocale, useMessages } from '@clerk/mosaic/localization';
import { useEffect, useState } from 'react';
import { useChaosFixture } from '@/components/ChaosProvider';
@@ -40,7 +44,7 @@ export const exampleAPIKeys: FixtureAPIKey[] = [
lastUsedAt: index % 3 === 0 ? exampleTime - (index + 2) * 60_000 : null,
}));
-function sortAPIKeys(items: FixtureAPIKey[], sort: UserProfileAPIKeySort | null) {
+function sortAPIKeys(items: FixtureAPIKey[], sort: APIKeysTableSort | null) {
if (!sort) {
return items;
}
@@ -71,14 +75,17 @@ async function revokeExampleAPIKey() {
await new Promise(resolve => setTimeout(resolve, 600));
}
-export function useUserProfileAPIKeysFixture({
+export function useAPIKeysTableFixture({
initialKeys = exampleAPIKeys,
enableSorting = false,
+ subjectKind = 'user',
}: {
initialKeys?: FixtureAPIKey[];
enableSorting?: boolean;
-} = {}): UserProfileApiKeysPanelViewProps {
+ subjectKind?: APIKeysTableSubjectKind;
+} = {}): APIKeysTableViewProps {
const locale = useLocale();
+ const messages = resolveAPIKeysTableMessages(useMessages('apiKeysTable'), subjectKind);
const seed = useChaosFixture(initialKeys, items =>
chaosRows(items).map(key => ({ ...key, name: chaosText(key.name) })),
);
@@ -86,7 +93,7 @@ export function useUserProfileAPIKeysFixture({
const [searchValue, setSearchValue] = useState('');
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
- const [sort, setSort] = useState(null);
+ const [sort, setSort] = useState(null);
const [pageSize, setPageSize] = useState(10);
const dateLabel = (date: Date | number) =>
new Intl.DateTimeFormat(locale, {
@@ -97,7 +104,8 @@ export function useUserProfileAPIKeysFixture({
}).format(date);
const relativeTime = new Intl.RelativeTimeFormat(locale);
- const create = useUserProfileCreateAPIKeyController({
+ const create = useCreateAPIKeyController({
+ messages,
onCreate: async ({ name, expiresAt }) => {
const result = await createExampleAPIKey();
setItems(current => [
@@ -136,6 +144,7 @@ export function useUserProfileAPIKeysFixture({
}, [page, pageCount]);
return {
+ messages,
apiKeys: sorted.slice((page - 1) * pageSize, page * pageSize).map(item => ({
id: item.id,
name: item.name,
@@ -169,3 +178,12 @@ export function useUserProfileAPIKeysFixture({
},
};
}
+
+export function APIKeysPanelExample(props: APIKeysTableViewProps) {
+ return (
+
+ API Keys
+
+
+ );
+}
diff --git a/packages/swingset/src/stories/fixtures/organization-profile-api-keys.ts b/packages/swingset/src/stories/fixtures/organization-profile-api-keys.ts
deleted file mode 100644
index 641a889bb01..00000000000
--- a/packages/swingset/src/stories/fixtures/organization-profile-api-keys.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-import type {
- OrganizationProfileAPIKeySort,
- OrganizationProfileApiKeysPanelViewProps,
-} from '@clerk/mosaic/features/organization-profile/organization-profile-api-keys-panel.types';
-import type { OrganizationProfileCreateAPIKeyDialogProps } from '@clerk/mosaic/features/organization-profile/organization-profile-create-api-key.dialog';
-import { useLocale, useMessages } from '@clerk/mosaic/localization';
-import { useEffect, useRef, useState } from 'react';
-
-import { useChaosFixture } from '@/components/ChaosProvider';
-import { chaosRows, chaosText } from '@/lib/chaos';
-
-interface FixtureAPIKey {
- id: string;
- name: string;
- createdAt: number;
- expiresAt: number | null;
- lastUsedAt: number | null;
-}
-
-const exampleTime = Date.now();
-
-export const exampleAPIKeys: FixtureAPIKey[] = [
- 'Web app',
- 'Mobile app',
- 'CI pipeline',
- 'Analytics',
- 'Billing service',
- 'Integrations',
- 'Reports',
- 'Support tools',
- 'Monitoring',
- 'Backups',
- 'Staging',
- 'Local development',
-].map((name, index) => ({
- id: `ak_example${String(index + 1).padStart(16, '0')}`,
- name,
- createdAt: Date.UTC(2026, 0, index + 5),
- expiresAt: index % 2 === 0 ? Date.UTC(2027, 11, 31) : null,
- lastUsedAt: index % 3 === 0 ? exampleTime - (index + 2) * 60_000 : null,
-}));
-
-function getExpirationDate(expiration: OrganizationProfileCreateAPIKeyDialogProps['expiration'], now = new Date()) {
- if (expiration === null || expiration === 'never') {
- return null;
- }
- const date = new Date(now);
- if (expiration === '1y') {
- date.setFullYear(date.getFullYear() + 1);
- } else {
- const days = { '1d': 1, '7d': 7, '30d': 30, '60d': 60, '90d': 90, '180d': 180 };
- date.setDate(date.getDate() + days[expiration]);
- }
- return date;
-}
-
-function sortAPIKeys(items: FixtureAPIKey[], sort: OrganizationProfileAPIKeySort | null) {
- if (!sort) {
- return items;
- }
- const direction = sort.direction === 'ascending' ? 1 : -1;
- return [...items].sort((a, b) => {
- if (sort.column === 'name') {
- return direction * a.name.localeCompare(b.name);
- }
- if (sort.column === 'createdAt') {
- return direction * (a.createdAt - b.createdAt);
- }
- if (a.lastUsedAt === null) {
- return b.lastUsedAt === null ? 0 : 1;
- }
- if (b.lastUsedAt === null) {
- return -1;
- }
- return direction * (a.lastUsedAt - b.lastUsedAt);
- });
-}
-
-async function createExampleAPIKey() {
- await new Promise(resolve => setTimeout(resolve, 600));
- return { id: `ak_demo_${crypto.randomUUID()}`, secret: `ak_demo_${crypto.randomUUID()}` };
-}
-
-async function revokeExampleAPIKey() {
- await new Promise(resolve => setTimeout(resolve, 600));
-}
-
-export function useOrganizationProfileAPIKeysFixture({
- initialKeys = exampleAPIKeys,
- enableSorting = false,
-}: {
- initialKeys?: FixtureAPIKey[];
- enableSorting?: boolean;
-} = {}): OrganizationProfileApiKeysPanelViewProps {
- const m = useMessages('organizationProfileApiKeysPanel');
- const locale = useLocale();
- const seed = useChaosFixture(initialKeys, items =>
- chaosRows(items).map(key => ({ ...key, name: chaosText(key.name) })),
- );
- const [items, setItems] = useState(seed);
- const [searchValue, setSearchValue] = useState('');
- const [query, setQuery] = useState('');
- const [page, setPage] = useState(1);
- const [sort, setSort] = useState(null);
- const createTrigger = useRef(null);
- const [open, setOpen] = useState(false);
- const [name, setName] = useState('');
- const [expiration, setExpiration] = useState(null);
- const [secret, setSecret] = useState(null);
- const [isPending, setIsPending] = useState(false);
- const [error, setError] = useState(null);
- const [pageSize, setPageSize] = useState(10);
- const dateLabel = (date: Date | number) =>
- new Intl.DateTimeFormat(locale, {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- timeZone: 'UTC',
- }).format(date);
- const relativeTime = new Intl.RelativeTimeFormat(locale);
-
- const expirationDate = getExpirationDate(expiration);
-
- useEffect(() => {
- const next = searchValue.trim();
- if (next === query) {
- return;
- }
- const timer = setTimeout(() => {
- setQuery(next);
- setPage(1);
- }, 500);
- return () => clearTimeout(timer);
- }, [searchValue, query]);
-
- const filtered = items.filter(item => item.name.toLowerCase().includes(query.toLowerCase()));
- const sorted = sortAPIKeys(filtered, sort);
- const pageCount = Math.ceil(filtered.length / pageSize);
- useEffect(() => {
- if (page > Math.max(1, pageCount)) {
- setPage(Math.max(1, pageCount));
- }
- }, [page, pageCount]);
-
- return {
- apiKeys: sorted.slice((page - 1) * pageSize, page * pageSize).map(item => ({
- id: item.id,
- name: item.name,
- createdAtLabel: dateLabel(item.createdAt),
- expiresAtLabel: item.expiresAt === null ? null : dateLabel(item.expiresAt),
- lastUsedAtLabel:
- item.lastUsedAt === null
- ? null
- : relativeTime.format(Math.round((item.lastUsedAt - exampleTime) / 60_000), 'minute'),
- })),
- totalCount: filtered.length,
- page,
- pageSize,
- searchValue,
- isLoading: false,
- onCreate: event => {
- createTrigger.current = event.currentTarget;
- setName('');
- setExpiration(null);
- setSecret(null);
- setError(null);
- setOpen(true);
- },
- createDialog: {
- open,
- onOpenChange: setOpen,
- finalFocus: createTrigger,
- name,
- onNameChange: setName,
- expiration,
- expirationDateLabel: expirationDate ? dateLabel(expirationDate) : null,
- onExpirationChange: setExpiration,
- secret,
- isPending,
- error,
- onSubmit: async () => {
- if (!name.trim() || expiration === null || isPending) {
- return;
- }
- setIsPending(true);
- setError(null);
- try {
- const result = await createExampleAPIKey();
- const createdAt = new Date();
- const expiresAt = getExpirationDate(expiration, createdAt);
- setItems(current => [
- {
- id: result.id,
- name: name.trim(),
- createdAt: createdAt.getTime(),
- expiresAt: expiresAt?.getTime() ?? null,
- lastUsedAt: null,
- },
- ...current,
- ]);
- setSecret(result.secret);
- } catch (error) {
- setError(error instanceof Error ? error.message : m.createError);
- } finally {
- setIsPending(false);
- }
- },
- onCopy: async close => {
- if (!secret || isPending) {
- return;
- }
- setIsPending(true);
- setError(null);
- try {
- await navigator.clipboard.writeText(secret);
- if (close) {
- setOpen(false);
- }
- } catch (error) {
- setError(m.copyError);
- if (!close) {
- throw error;
- }
- } finally {
- setIsPending(false);
- }
- },
- },
- onSearchChange: setSearchValue,
- onPageChange: setPage,
- onPageSizeChange: setPageSize,
- sort: enableSorting ? sort : undefined,
- onSortChange: enableSorting
- ? next => {
- setSort(next);
- setPage(1);
- }
- : undefined,
- onRevoke: async id => {
- await revokeExampleAPIKey();
- setItems(current => current.filter(item => item.id !== id));
- },
- };
-}
diff --git a/packages/swingset/src/stories/fixtures/organization-profile.ts b/packages/swingset/src/stories/fixtures/organization-profile.tsx
similarity index 93%
rename from packages/swingset/src/stories/fixtures/organization-profile.ts
rename to packages/swingset/src/stories/fixtures/organization-profile.tsx
index d0f9a4bc942..75442b616d5 100644
--- a/packages/swingset/src/stories/fixtures/organization-profile.ts
+++ b/packages/swingset/src/stories/fixtures/organization-profile.tsx
@@ -5,9 +5,9 @@ import { useState } from 'react';
import { useChaosFixture } from '@/components/ChaosProvider';
import { chaosName } from '@/lib/chaos';
+import { APIKeysPanelExample, useAPIKeysTableFixture } from './api-keys-table';
import { useInvitationsTableFixture } from './invitations-table-tab';
import { useMembersTableFixture } from './members-table-tab';
-import { useOrganizationProfileAPIKeysFixture } from './organization-profile-api-keys';
import { useInviteMembersFixture } from './organization-profile-invite-members';
import { useRequestsTableFixture } from './requests-table-tab';
import { usePreviewImage } from './use-preview-image';
@@ -31,7 +31,7 @@ export function useOrganizationProfileFixture({ failWith }: OrganizationProfileF
const [name, setName] = useState(organization.name);
const [slug, setSlug] = useState(organization.slug);
const { imageUrl, showFile, clearImage } = usePreviewImage();
- const apiKeys = useOrganizationProfileAPIKeysFixture();
+ const apiKeys = useAPIKeysTableFixture({ subjectKind: 'organization' });
const members = useMembersTableFixture();
const invitations = useInvitationsTableFixture();
const requests = useRequestsTableFixture();
@@ -65,7 +65,7 @@ export function useOrganizationProfileFixture({ failWith }: OrganizationProfileF
members: { members, invitations, requests, onInvite, inviteDialog },
security: {},
billing: {},
- apiKeys,
+ apiKeys: ,
};
return { activePage, setActivePage, pages, general, name };
diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx
index 3132c03f8fc..fa3e7a5b8ab 100644
--- a/packages/swingset/src/stories/fixtures/user-profile.tsx
+++ b/packages/swingset/src/stories/fixtures/user-profile.tsx
@@ -1,6 +1,5 @@
import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller';
import type { UserProfileViewProps } from '@clerk/mosaic/features/user-profile/user-profile.view';
-import { UserProfileApiKeysPanelView } from '@clerk/mosaic/features/user-profile/user-profile-api-keys-panel.view';
import type {
UserProfilePaymentMethod,
UserProfileSubscription,
@@ -18,11 +17,11 @@ import { useRef, useState } from 'react';
import { useChaosFixture } from '@/components/ChaosProvider';
import { chaosEmail, chaosRows, chaosText } from '@/lib/chaos';
+import { APIKeysPanelExample, useAPIKeysTableFixture } from './api-keys-table';
import { usePreviewImage } from './use-preview-image';
import { useUserProfileActiveDevicesFixture } from './user-profile-active-devices';
import { createUserProfileAddEmailFixture } from './user-profile-add-email';
import { createUserProfileAddPhoneFixture } from './user-profile-add-phone';
-import { useUserProfileAPIKeysFixture } from './user-profile-api-keys';
import { useConnectedAccountsFixture } from './user-profile-connected-accounts';
import { useUserProfileEditNameFixture } from './user-profile-edit-name';
import { useUserProfileEditPasswordFixture } from './user-profile-edit-password';
@@ -105,7 +104,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions
const [subscription, setSubscription] = useState(seedSubscription);
const [paymentMethods, setPaymentMethods] = useState(seedPaymentMethods);
const [historyPageSize, setHistoryPageSize] = useState(10);
- const apiKeys = useUserProfileAPIKeysFixture();
+ const apiKeys = useAPIKeysTableFixture();
const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4');
const addEmail = (value: string) =>
setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: false }]);
@@ -209,7 +208,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions
onBillingHistoryPageSizeChange: setHistoryPageSize,
onViewInvoice: () => undefined,
},
- apiKeys: ,
+ apiKeys: ,
};
return { activePage, setActivePage, pages, addEmail, devices: activeDevices.devices };
diff --git a/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx b/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx
index 417daf3ada2..8a5e3bdbbd5 100644
--- a/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx
+++ b/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx
@@ -2,7 +2,7 @@ import * as Stories from './organization-profile-api-keys-panel.stories';
# OrganizationProfileApiKeysPanel
-A controlled organization API keys table with search, page navigation, and confirmation before revocation. The caller supplies the current page and owns data updates; the panel requires typing the key's exact name before revoking it.
+The API keys panel lists the keys that belong to the active organization under the panel title. It renders `APIKeysTable` with the active organization as the subject.
## Example
@@ -10,22 +10,8 @@ A controlled organization API keys table with search, page navigation, and confi
name='Default'
storyModule={Stories}
composition={[
- { name: 'Table', href: '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/components/table', layer: 'Components' },
- { name: 'EmptyState', href: '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/components/empty-state', layer: 'Components' },
- { name: 'useDataTable', href: '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/hooks/use-data-table', layer: 'Hooks' },
- { name: 'Pagination', href: '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/components/pagination', layer: 'Components' },
- { name: 'Menu', href: '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/components/menu', layer: 'Components' },
- { name: 'Button', href: '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/components/button', layer: 'Components' },
- { name: 'Text', href: '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/components/text', layer: 'Components' },
- { name: 'Spinner', href: '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/components/spinner', layer: 'Components' },
- { name: 'Profile', href: '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/components/profile', layer: 'Components' },
- { name: 'Dialog', href: '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/components/dialog', layer: 'Components' },
- { name: 'Flow', href: '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/components/flow', layer: 'Components' },
- { name: 'Card', href: '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/components/card', layer: 'Components' },
- { name: 'Field', href: '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/components/field', layer: 'Components' },
- { name: 'Input', href: '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/components/input', layer: 'Components' },
- { name: 'Select', href: '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/components/select', layer: 'Components' },
- { name: 'Destructive', href: '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/blocks/destructive', layer: 'Blocks' },
+ { name: 'Panel', href: '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/components/panel', layer: 'Components' },
+ { name: 'APIKeysTable', href: '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/api-keys/api-keys-table', layer: 'API Keys' },
]}
/>
@@ -34,11 +20,3 @@ A controlled organization API keys table with search, page navigation, and confi
### Empty
-
-### Proposed table
-
-This example enables sorting and row selection through `onSortChange` and `onBulkAction`. Click Name, Date created, or Last used to cycle through ascending, descending, and unsorted order. The caller sorts all results before pagination and resets to page 1. Keys that have never been used sort last.
-
-The bulk callback is a placeholder that enables selection checkboxes. Sorting clears the current selection.
-
-
diff --git a/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx
index 111efb789c7..07569a06d8a 100644
--- a/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx
+++ b/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx
@@ -1,8 +1,6 @@
-import { OrganizationProfileApiKeysPanelView } from '@clerk/mosaic/features/organization-profile/organization-profile-api-keys-panel.view';
-
import type { StoryMeta } from '@/lib/types';
-import { useOrganizationProfileAPIKeysFixture } from './fixtures/organization-profile-api-keys';
+import { APIKeysPanelExample, useAPIKeysTableFixture } from './fixtures/api-keys-table';
export { default as __source } from './organization-profile-api-keys-panel.stories?raw';
@@ -12,31 +10,15 @@ export const meta: StoryMeta = {
title: 'OrganizationProfileApiKeysPanel',
label: 'API keys panel',
navigation: { category: 'Panels' },
- source: 'packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx',
+ source: 'packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.tsx',
};
export function Default() {
- const props = useOrganizationProfileAPIKeysFixture();
- return ;
+ const props = useAPIKeysTableFixture({ subjectKind: 'organization' });
+ return ;
}
export function Empty() {
- const props = useOrganizationProfileAPIKeysFixture({ initialKeys: [] });
- return (
-
- );
-}
-
-export function ProposedTable() {
- const props = useOrganizationProfileAPIKeysFixture({ enableSorting: true });
- return (
- undefined}
- />
- );
+ const props = useAPIKeysTableFixture({ subjectKind: 'organization', initialKeys: [] });
+ return ;
}
diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx
index 77352fc8d51..54fd6002b8d 100644
--- a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx
+++ b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx
@@ -2,7 +2,7 @@ import * as Stories from './user-profile-api-keys-panel.stories';
# UserProfileApiKeysPanel
-A controlled API keys table with search, page navigation, and confirmation by typing the key name before revocation. The caller supplies the current page and owns data updates; the panel shares one destructive dialog across all key menus.
+The API keys panel lists the keys that belong to the signed-in user under the panel title. It renders `APIKeysTable` with the signed-in user as the subject.
## Example
@@ -10,22 +10,8 @@ A controlled API keys table with search, page navigation, and confirmation by ty
name='Default'
storyModule={Stories}
composition={[
- { name: 'Table', href: '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/components/table', layer: 'Components' },
- { name: 'EmptyState', href: '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/components/empty-state', layer: 'Components' },
- { name: 'useDataTable', href: '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/hooks/use-data-table', layer: 'Hooks' },
- { name: 'Pagination', href: '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/components/pagination', layer: 'Components' },
- { name: 'Menu', href: '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/components/menu', layer: 'Components' },
- { name: 'Button', href: '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/components/button', layer: 'Components' },
- { name: 'Text', href: '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/components/text', layer: 'Components' },
- { name: 'Spinner', href: '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/components/spinner', layer: 'Components' },
- { name: 'Profile', href: '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/components/profile', layer: 'Components' },
- { name: 'Dialog', href: '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/components/dialog', layer: 'Components' },
- { name: 'Flow', href: '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/components/flow', layer: 'Components' },
- { name: 'Card', href: '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/components/card', layer: 'Components' },
- { name: 'Field', href: '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/components/field', layer: 'Components' },
- { name: 'Input', href: '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/components/input', layer: 'Components' },
- { name: 'Select', href: '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/components/select', layer: 'Components' },
- { name: 'Destructive', href: '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/blocks/destructive', layer: 'Blocks' },
+ { name: 'Panel', href: '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/components/panel', layer: 'Components' },
+ { name: 'APIKeysTable', href: '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/api-keys/api-keys-table', layer: 'API Keys' },
]}
/>
@@ -34,11 +20,3 @@ A controlled API keys table with search, page navigation, and confirmation by ty
### Empty
-
-### Proposed table
-
-This example enables sorting and row selection through `onSortChange` and `onBulkAction`. Click Name, Date created, or Last used to cycle through ascending, descending, and unsorted order. The caller sorts all results before pagination and resets to page 1. Keys that have never been used sort last.
-
-The bulk callback is a placeholder that enables selection checkboxes. Sorting clears the current selection.
-
-
diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx b/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx
index 3fe45da3e0b..c204e83adcc 100644
--- a/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx
+++ b/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx
@@ -1,8 +1,6 @@
-import { UserProfileApiKeysPanelView } from '@clerk/mosaic/features/user-profile/user-profile-api-keys-panel.view';
-
import type { StoryMeta } from '@/lib/types';
-import { useUserProfileAPIKeysFixture } from './fixtures/user-profile-api-keys';
+import { APIKeysPanelExample, useAPIKeysTableFixture } from './fixtures/api-keys-table';
export { default as __source } from './user-profile-api-keys-panel.stories?raw';
@@ -12,31 +10,15 @@ export const meta: StoryMeta = {
title: 'UserProfileApiKeysPanel',
label: 'API keys panel',
navigation: { category: 'Panels' },
- source: 'packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.view.tsx',
+ source: 'packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.tsx',
};
export function Default() {
- const props = useUserProfileAPIKeysFixture();
- return ;
+ const props = useAPIKeysTableFixture();
+ return ;
}
export function Empty() {
- const props = useUserProfileAPIKeysFixture({ initialKeys: [] });
- return (
-
- );
-}
-
-export function ProposedTable() {
- const props = useUserProfileAPIKeysFixture({ enableSorting: true });
- return (
- undefined}
- />
- );
+ const props = useAPIKeysTableFixture({ initialKeys: [] });
+ return ;
}