From 712123e2ff22bd17d3cd6944e9f22eca9ef54174 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 10:51:20 -0400 Subject: [PATCH 1/7] feat(mosaic): extract APIKeysTable and use it in both profile API keys panels --- .changeset/mosaic-api-keys-table.md | 2 + .../api-keys-table.feature.test.tsx} | 161 ++++++++-- .../__tests__/api-keys-table.view.test.tsx} | 166 ++++------ .../api-keys-table.format.ts} | 6 +- .../api-keys-table.messages.ts} | 18 +- .../api-keys-table.styles.ts} | 1 + .../src/features/api-keys/api-keys-table.tsx | 182 +++++++++++ .../features/api-keys/api-keys-table.types.ts | 47 +++ .../api-keys-table.view.tsx} | 42 ++- .../create-api-key.controller.ts} | 32 +- .../create-api-key.dialog.tsx} | 24 +- .../organization-profile.layout.test.tsx | 16 + ...ization-profile-api-keys-panel.messages.ts | 57 ---- ...anization-profile-api-keys-panel.styles.ts | 8 - .../organization-profile-api-keys-panel.tsx | 30 ++ ...ganization-profile-api-keys-panel.types.ts | 35 --- ...ganization-profile-api-keys-panel.view.tsx | 297 ------------------ ...nization-profile-create-api-key.dialog.tsx | 225 ------------- .../organization-profile.layout.ts | 5 +- .../organization-profile.types.ts | 3 +- .../organization-profile.view.tsx | 5 +- .../user-profile-api-keys-panel.view.test.tsx | 101 ------ .../user-profile-api-keys-panel.tsx | 156 +-------- .../user-profile-api-keys-panel.types.ts | 37 --- packages/mosaic/src/localization/registry.ts | 6 +- packages/mosaic/src/styles/index.ts | 5 +- .../src/app/(clerk)/live/api-keys/page.tsx | 12 +- .../swingset/src/components/Composition.tsx | 1 + .../swingset/src/components/DocsViewer.tsx | 5 +- packages/swingset/src/lib/registry.ts | 42 +-- packages/swingset/src/lib/slug.ts | 1 + ...-api-keys-panel.mdx => api-keys-table.mdx} | 13 +- .../src/stories/api-keys-table.stories.tsx | 46 +++ ...profile-api-keys.ts => api-keys-table.tsx} | 38 ++- .../fixtures/organization-profile-api-keys.ts | 244 -------------- ...on-profile.ts => organization-profile.tsx} | 6 +- .../src/stories/fixtures/user-profile.tsx | 7 +- ...ization-profile-api-keys-panel.stories.tsx | 42 --- .../src/stories/organization-profile.mdx | 2 +- .../stories/user-profile-api-keys-panel.mdx | 44 --- .../user-profile-api-keys-panel.stories.tsx | 42 --- .../swingset/src/stories/user-profile.mdx | 2 +- 42 files changed, 681 insertions(+), 1533 deletions(-) create mode 100644 .changeset/mosaic-api-keys-table.md rename packages/mosaic/src/features/{user-profile/__tests__/user-profile-api-keys-panel.feature.test.tsx => api-keys/__tests__/api-keys-table.feature.test.tsx} (70%) rename packages/mosaic/src/features/{organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx => api-keys/__tests__/api-keys-table.view.test.tsx} (74%) rename packages/mosaic/src/features/{user-profile/user-profile-api-keys.format.ts => api-keys/api-keys-table.format.ts} (80%) rename packages/mosaic/src/features/{user-profile/user-profile-api-keys-panel.messages.ts => api-keys/api-keys-table.messages.ts} (75%) rename packages/mosaic/src/features/{user-profile/user-profile-api-keys-panel.styles.ts => api-keys/api-keys-table.styles.ts} (77%) create mode 100644 packages/mosaic/src/features/api-keys/api-keys-table.tsx create mode 100644 packages/mosaic/src/features/api-keys/api-keys-table.types.ts rename packages/mosaic/src/features/{user-profile/user-profile-api-keys-panel.view.tsx => api-keys/api-keys-table.view.tsx} (90%) rename packages/mosaic/src/features/{user-profile/user-profile-create-api-key.controller.ts => api-keys/create-api-key.controller.ts} (67%) rename packages/mosaic/src/features/{user-profile/user-profile-create-api-key.dialog.tsx => api-keys/create-api-key.dialog.tsx} (89%) create mode 100644 packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx delete mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.messages.ts delete mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.styles.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.tsx delete mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.types.ts delete mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx delete mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-create-api-key.dialog.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.view.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.types.ts rename packages/swingset/src/stories/{organization-profile-api-keys-panel.mdx => api-keys-table.mdx} (72%) create mode 100644 packages/swingset/src/stories/api-keys-table.stories.tsx rename packages/swingset/src/stories/fixtures/{user-profile-api-keys.ts => api-keys-table.tsx} (78%) delete mode 100644 packages/swingset/src/stories/fixtures/organization-profile-api-keys.ts rename packages/swingset/src/stories/fixtures/{organization-profile.ts => organization-profile.tsx} (93%) delete mode 100644 packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx delete mode 100644 packages/swingset/src/stories/user-profile-api-keys-panel.mdx delete mode 100644 packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx 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/user-profile/__tests__/user-profile-api-keys-panel.feature.test.tsx b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx similarity index 70% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.feature.test.tsx rename to packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx index a5786edd0af..1ed8fee3c41 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.feature.test.tsx +++ b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx @@ -16,7 +16,8 @@ import { fapiUser, } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; -import { UserProfileApiKeysPanel } from '../user-profile-api-keys-panel'; +import type { APIKeysTableProps } from '../api-keys-table'; +import { APIKeysTable } from '../api-keys-table'; const DAY = 24 * 60 * 60 * 1000; @@ -46,9 +47,14 @@ function signedIn(apiKeys: ApiKeyJSON[] = [webApp, ciPipeline], overrides: FakeF }; } -async function renderPanel(seed: FakeFapiSeed = signedIn()) { +async function renderTable(seed: FakeFapiSeed = signedIn(), props: Partial = {}) { const fapi = serveFapi(seed); - const view = await renderWithClerk(); + const view = await renderWithClerk( + , + ); return { ...view, fapi, user: userEvent.setup() }; } @@ -75,10 +81,10 @@ async function openRevoke(user: User, name: string) { return dialog; } -describe('UserProfileApiKeysPanel', () => { +describe('APIKeysTable', () => { describe('listing keys', () => { it("shows the user's own keys, not the active organization's", async () => { - await renderPanel( + await renderTable( signedIn([webApp, ciPipeline, fapiApiKey({ id: 'ak_org', name: 'Org key', subject: acme.id })]), ); @@ -88,7 +94,7 @@ describe('UserProfileApiKeysPanel', () => { }); it('describes when each key was created, last used, and expires', async () => { - await renderPanel(); + await renderTable(); await within(table()).findByText('Web app'); expect(within(table()).getAllByRole('cell', { name: 'Jan 5, 2026' })).toHaveLength(2); @@ -101,7 +107,7 @@ describe('UserProfileApiKeysPanel', () => { it('shows loading until the first page arrives', async () => { serveFapi(signedIn()); const list = holdRequests('get', '/api_keys'); - await renderWithClerk(); + await renderWithClerk(); expect(await screen.findByRole('status')).toHaveTextContent('Loading API keys'); list.release(); @@ -110,9 +116,18 @@ describe('UserProfileApiKeysPanel', () => { }); it('explains when the user has no keys', async () => { - await renderPanel(signedIn([])); + 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('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('explains a failed load instead of showing no keys, and loads again on retry', { timeout: 20_000 }, async () => { @@ -125,7 +140,7 @@ describe('UserProfileApiKeysPanel', () => { : undefined, ), ); - await renderWithClerk(); + await renderWithClerk(); const user = userEvent.setup(); expect(await screen.findByText('Could not load API keys', undefined, { timeout: 15_000 })).toBeVisible(); @@ -139,7 +154,7 @@ describe('UserProfileApiKeysPanel', () => { describe('searching', () => { it('lists only matching keys and explains when nothing matches', async () => { - const { user } = await renderPanel(); + const { user } = await renderTable(); await within(table()).findByText('CI pipeline'); const search = screen.getByRole('searchbox', { name: 'Search API keys' }); @@ -153,7 +168,7 @@ describe('UserProfileApiKeysPanel', () => { }); it('returns to the first page for a new search', async () => { - const { user } = await renderPanel(signedIn(manyKeys)); + 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(); @@ -166,7 +181,7 @@ describe('UserProfileApiKeysPanel', () => { describe('paging', () => { it('moves between pages of keys', async () => { - const { user } = await renderPanel(signedIn(manyKeys)); + const { user } = await renderTable(signedIn(manyKeys)); expect(await within(table()).findByText('Key 1')).toBeVisible(); expect(screen.getByText('1/2')).toBeVisible(); @@ -184,7 +199,7 @@ describe('UserProfileApiKeysPanel', () => { 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 { fapi, user } = await renderTable(); const dialog = await openCreate(user); await fillCreate(user, dialog, 'Deploy', '7 Days'); @@ -202,7 +217,7 @@ describe('UserProfileApiKeysPanel', () => { }); it('shows the secret without waiting for the list to refresh', async () => { - const { fapi, user } = await renderPanel(); + const { fapi, user } = await renderTable(); const dialog = await openCreate(user); await fillCreate(user, dialog, 'Deploy', 'Never'); const refresh = holdRequests('get', '/api_keys'); @@ -218,7 +233,7 @@ describe('UserProfileApiKeysPanel', () => { it('copies the secret without closing the dialog', async () => { await navigator.clipboard.writeText(''); - const { fapi, user } = await renderPanel(); + 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' })); @@ -230,7 +245,7 @@ describe('UserProfileApiKeysPanel', () => { }); it('keeps the secret open on an outside click but closes it on Escape', async () => { - const { user } = await renderPanel(); + 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' })); @@ -249,7 +264,7 @@ describe('UserProfileApiKeysPanel', () => { }); it('explains a failed copy', async () => { - const { user } = await renderPanel(); + 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' })); @@ -262,8 +277,34 @@ describe('UserProfileApiKeysPanel', () => { 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' })); + 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 renderPanel(); + const { user } = await renderTable(); const dialog = await openCreate(user); const add = within(dialog).getByRole('button', { name: 'Add API Key' }); @@ -275,7 +316,7 @@ describe('UserProfileApiKeysPanel', () => { }); it('holds the form while the key is created', async () => { - const { user } = await renderPanel(); + const { user } = await renderTable(); const dialog = await openCreate(user); await fillCreate(user, dialog, 'Deploy', 'Never'); const create = holdRequests('post', '/api_keys'); @@ -289,7 +330,7 @@ describe('UserProfileApiKeysPanel', () => { }); it('explains a name that is already taken and keeps the form', async () => { - const { user } = await renderPanel(); + const { user } = await renderTable(); const dialog = await openCreate(user); await fillCreate(user, dialog, 'Web app', 'Never'); @@ -300,7 +341,7 @@ describe('UserProfileApiKeysPanel', () => { }); it('explains when the usage limit is reached', async () => { - const { user } = await renderPanel(); + const { user } = await renderTable(); const dialog = await openCreate(user); await fillCreate(user, dialog, 'Deploy', 'Never'); const create = holdRequests('post', '/api_keys'); @@ -318,7 +359,7 @@ describe('UserProfileApiKeysPanel', () => { 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 { fapi, user } = await renderTable(); const dialog = await openRevoke(user, 'Web app'); await user.click(within(dialog).getByRole('button', { name: 'Revoke key' })); @@ -330,7 +371,7 @@ describe('UserProfileApiKeysPanel', () => { }); it('explains a failed revoke and keeps the dialog open', async () => { - const { user } = await renderPanel(); + const { user } = await renderTable(); const dialog = await openRevoke(user, 'Web app'); const revoke = holdRequests('post', `/api_keys/${webApp.id}/revoke`); @@ -344,7 +385,7 @@ describe('UserProfileApiKeysPanel', () => { }); it('returns to the previous page when the last key on a page is revoked', async () => { - const { user } = await renderPanel(signedIn(manyKeys)); + 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'); @@ -354,4 +395,76 @@ describe('UserProfileApiKeysPanel', () => { 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(); + }); + + it('lists nothing without permission to read keys', async () => { + await renderTable(memberOfAcme([]), { subject: acme.id }); + + expect(await screen.findByText('No API Keys created')).toBeVisible(); + expect(within(table()).queryByText('Org key')).toBeNull(); + }); + + 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/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.view.test.tsx similarity index 74% rename from packages/mosaic/src/features/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx rename to packages/mosaic/src/features/api-keys/__tests__/api-keys-table.view.test.tsx index fb5d7b23c00..539511d0829 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx +++ b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.view.test.tsx @@ -4,15 +4,13 @@ import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { deferred } from '../../../__tests__/async'; +import { useMessages } from '../../../localization'; 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'; +import { resolveAPIKeysTableMessages } from '../api-keys-table.messages'; +import type { APIKey, APIKeysTableViewProps } from '../api-keys-table.types'; +import { APIKeysTableView } from '../api-keys-table.view'; -const apiKeys: OrganizationProfileAPIKey[] = [ +const apiKeys: APIKey[] = [ { id: 'primary', name: 'Primary API Key', @@ -23,9 +21,19 @@ const apiKeys: OrganizationProfileAPIKey[] = [ { id: 'legacy', name: 'Legacy API Key', createdAtLabel: 'Jul 1, 2024', expiresAtLabel: null, lastUsedAtLabel: null }, ]; -function propsFor( - overrides: Partial = {}, -): OrganizationProfileApiKeysPanelViewProps { +type ViewProps = Omit; + +function View(props: ViewProps) { + const messages = resolveAPIKeysTableMessages(useMessages('apiKeysTable'), 'organization'); + return ( + + ); +} + +function propsFor(overrides: Partial = {}): ViewProps { return { apiKeys, totalCount: 2, @@ -40,24 +48,23 @@ function propsFor( }; } -function renderView(overrides: Partial = {}) { +function renderView(overrides: Partial = {}) { const props = propsFor(overrides); return { props, ...render( - + , ), }; } -describe('OrganizationProfileApiKeysPanelView', () => { +describe('APIKeysTableView', () => { 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(); @@ -84,7 +91,7 @@ describe('OrganizationProfileApiKeysPanelView', () => { await user.keyboard('{Escape}'); rerender( - { - + , ); expect(screen.getByRole('combobox', { name: 'Résultats par page 10' })).toBeVisible(); @@ -153,12 +160,12 @@ describe('OrganizationProfileApiKeysPanelView', () => { - { const [items, setItems] = useState([apiKeys[0]]); return ( - { const [items, setItems] = useState(apiKeys); return ( - { expect(screen.getByRole('checkbox', { name: 'Select all API keys' })).toBeChecked(); rerender( - { expect(onBulkAction).not.toHaveBeenCalled(); rerender( - @@ -329,7 +336,7 @@ describe('OrganizationProfileApiKeysPanelView', () => { }); 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) => ({ + const items: APIKey[] = Array.from({ length: 11 }, (_, index) => ({ ...apiKeys[0], id: `key-${index}`, name: `Key ${index + 1}`, @@ -340,7 +347,7 @@ describe('OrganizationProfileApiKeysPanelView', () => { const filtered = items.filter(item => item.name.includes(searchValue)); return ( - { 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.queryByRole('button', { name: 'Create API key' })).not.toBeInTheDocument(); view.rerender( - @@ -409,90 +416,23 @@ describe('OrganizationProfileApiKeysPanelView', () => { 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 () => { + it('changes the page size and returns to the first page', 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(); + 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('holds the form while pending and displays errors without losing input before retry', async () => { + it('shows a load error with retry instead of the empty state', 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); + 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')).not.toBeInTheDocument(); + 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.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..45746142184 --- /dev/null +++ b/packages/mosaic/src/features/api-keys/api-keys-table.tsx @@ -0,0 +1,182 @@ +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 { 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 -- + +function useAPIKeysTableModel(subject: string, query: string, messages: APIKeysTableMessages) { + const clerk = useClerk(); + const { isLoaded, session } = useSession(); + const isOrganization = isOrganizationId(subject); + const can = (permission: string) => !isOrganization || (session?.checkAuthorization({ permission }) ?? false); + const canRead = can(READ_PERMISSION); + const canManage = can(MANAGE_PERMISSION); + const apiKeys = useAPIKeys({ + subject, + query, + pageSize: PAGE_SIZE, + keepPreviousData: true, + enabled: Boolean(session) && canRead, + }); + + return { + isLoaded, + ...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) { + 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 89% 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..66dc4131ec0 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]); @@ -157,8 +159,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.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..223dbf3d53b --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.tsx @@ -0,0 +1,30 @@ +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 } from '../api-keys/api-keys-table'; + +export interface OrganizationProfileApiKeysPanelProps { + fallback?: ReactNode; +} + +export function OrganizationProfileApiKeysPanel({ fallback }: OrganizationProfileApiKeysPanelProps) { + const { organization } = useOrganization(); + const m = useMessages('organizationProfile'); + + if (!organization) { + 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 ? ( - - ) : 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.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..819c955e706 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,30 @@ -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 } 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 m = useMessages('userProfile'); - if (!model.isLoaded) { + if (!user) { 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..4fc686a28ca 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -17,7 +17,6 @@ const docModules: Record> = { 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), 'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')), 'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')), - 'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')), 'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')), 'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')), 'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')), @@ -47,7 +46,9 @@ const docModules: Record> = { () => import('../stories/organization-profile-workspace-section.mdx'), ), '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..c687a767fdf 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -141,12 +141,6 @@ import { meta as organizationProfileMeta, Overlay as OrganizationProfileOverlay, } from '../stories/organization-profile.stories'; -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, LeaveOnly as OrganizationProfileDangerSectionLeaveOnly, @@ -325,11 +319,12 @@ import { SignOutError as UserProfileActiveDevicesSectionSignOutError, } from '../stories/user-profile-active-devices-section.stories'; import { - Default as UserProfileApiKeysPanelDefault, - Empty as UserProfileApiKeysPanelEmpty, - meta as userProfileApiKeysPanelMeta, - ProposedTable as UserProfileApiKeysPanelProposedTable, -} from '../stories/user-profile-api-keys-panel.stories'; + Default as APIKeysTableDefault, + Empty as APIKeysTableEmpty, + meta as apiKeysTableMeta, + Organization as APIKeysTableOrganization, + ProposedTable as APIKeysTableProposedTable, +} from '../stories/api-keys-table.stories'; import { Default as UserProfileBillingHistorySectionDefault, Empty as UserProfileBillingHistorySectionEmpty, @@ -730,12 +725,6 @@ const tabsComponentModule: StoryModule = { // Planned but not yet implemented; the entry reserves its sidebar slot with a todo dot. const toastModule: StoryModule = { meta: toastMeta, Default: ToastDefault, Anchored: ToastAnchored }; -const userProfileApiKeysPanelModule: StoryModule = { - meta: userProfileApiKeysPanelMeta, - Default: UserProfileApiKeysPanelDefault, - ProposedTable: UserProfileApiKeysPanelProposedTable, - Empty: UserProfileApiKeysPanelEmpty, -}; const userProfileModule: StoryModule = { meta: userProfileMeta, Default: UserProfileDefault, @@ -857,6 +846,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, @@ -878,13 +875,6 @@ const reverificationModule: StoryModule = { MethodPickerPending: ReverificationMethodPickerPending, Help: ReverificationHelp, }; -const organizationProfileApiKeysPanelModule: StoryModule = { - meta: organizationProfileApiKeysPanelMeta, - Default: OrganizationProfileApiKeysPanelDefault, - ProposedTable: OrganizationProfileApiKeysPanelProposedTable, - Empty: OrganizationProfileApiKeysPanelEmpty, -}; - const organizationProfileMembersPanelModule: StoryModule = { meta: organizationProfileMembersPanelMeta, Legacy: OrganizationProfileMembersPanelLegacy, @@ -901,7 +891,6 @@ export const registry: StoryModule[] = [ userProfileProfilePanelModule, userProfileSecurityPanelModule, userProfileBillingPanelModule, - userProfileApiKeysPanelModule, // User Profile · Sections userProfileAccountSectionModule, userProfilePasswordSectionModule, @@ -919,11 +908,12 @@ export const registry: StoryModule[] = [ organizationProfileModule, // Organization Profile · Panels organizationProfileGeneralPanelModule, - organizationProfileApiKeysPanelModule, organizationProfileMembersPanelModule, // 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/organization-profile-api-keys-panel.mdx b/packages/swingset/src/stories/api-keys-table.mdx similarity index 72% rename from packages/swingset/src/stories/organization-profile-api-keys-panel.mdx rename to packages/swingset/src/stories/api-keys-table.mdx index 417daf3ada2..0af92ad0cf7 100644 --- a/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx +++ b/packages/swingset/src/stories/api-keys-table.mdx @@ -1,8 +1,8 @@ -import * as Stories from './organization-profile-api-keys-panel.stories'; +import * as Stories from './api-keys-table.stories'; -# OrganizationProfileApiKeysPanel +# APIKeysTable -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. +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 @@ -18,7 +18,6 @@ A controlled organization API keys table with search, page navigation, and confi { 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' }, @@ -31,6 +30,12 @@ A controlled organization API keys table with search, page navigation, and confi ## Examples +### Organization + +An organization subject explains organization keys in its empty state. + + + ### Empty 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..149958660bc 100644 --- a/packages/swingset/src/stories/fixtures/organization-profile.ts +++ b/packages/swingset/src/stories/fixtures/organization-profile.tsx @@ -7,7 +7,7 @@ import { chaosName } from '@/lib/chaos'; import { useInvitationsTableFixture } from './invitations-table-tab'; import { useMembersTableFixture } from './members-table-tab'; -import { useOrganizationProfileAPIKeysFixture } from './organization-profile-api-keys'; +import { APIKeysPanelExample, useAPIKeysTableFixture } from './api-keys-table'; 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..d4a3f237e00 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, @@ -22,7 +21,7 @@ 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 { APIKeysPanelExample, useAPIKeysTableFixture } from './api-keys-table'; 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.stories.tsx b/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx deleted file mode 100644 index 111efb789c7..00000000000 --- a/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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'; - -export { default as __source } from './organization-profile-api-keys-panel.stories?raw'; - -export const meta: StoryMeta = { - group: 'Organization Profile', - status: 'wip', - title: 'OrganizationProfileApiKeysPanel', - label: 'API keys panel', - navigation: { category: 'Panels' }, - source: 'packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx', -}; - -export function Default() { - const props = useOrganizationProfileAPIKeysFixture(); - return ; -} - -export function Empty() { - const props = useOrganizationProfileAPIKeysFixture({ initialKeys: [] }); - return ( - - ); -} - -export function ProposedTable() { - const props = useOrganizationProfileAPIKeysFixture({ enableSorting: true }); - return ( - undefined} - /> - ); -} diff --git a/packages/swingset/src/stories/organization-profile.mdx b/packages/swingset/src/stories/organization-profile.mdx index 19d70d381d3..cf8e69792d7 100644 --- a/packages/swingset/src/stories/organization-profile.mdx +++ b/packages/swingset/src/stories/organization-profile.mdx @@ -13,7 +13,7 @@ Billing hold their place with a placeholder. composition={[ { name: 'General panel', href: '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/organization-profile/organization-profile-general-panel', layer: 'Organization Profile' }, { name: 'Members panel', href: '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/organization-profile/organization-profile-members-panel', layer: 'Organization Profile' }, - { name: 'API keys panel', href: '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/organization-profile/organization-profile-api-keys-panel', layer: 'Organization Profile' }, + { name: 'APIKeysTable', href: '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/api-keys/api-keys-table', layer: 'API Keys' }, ]} /> diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx deleted file mode 100644 index 77352fc8d51..00000000000 --- a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx +++ /dev/null @@ -1,44 +0,0 @@ -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. - -## Example - - - -## Examples - -### 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 deleted file mode 100644 index 3fe45da3e0b..00000000000 --- a/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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'; - -export { default as __source } from './user-profile-api-keys-panel.stories?raw'; - -export const meta: StoryMeta = { - group: 'User Profile', - status: 'wip', - title: 'UserProfileApiKeysPanel', - label: 'API keys panel', - navigation: { category: 'Panels' }, - source: 'packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.view.tsx', -}; - -export function Default() { - const props = useUserProfileAPIKeysFixture(); - return ; -} - -export function Empty() { - const props = useUserProfileAPIKeysFixture({ initialKeys: [] }); - return ( - - ); -} - -export function ProposedTable() { - const props = useUserProfileAPIKeysFixture({ enableSorting: true }); - return ( - undefined} - /> - ); -} diff --git a/packages/swingset/src/stories/user-profile.mdx b/packages/swingset/src/stories/user-profile.mdx index 5fd35d80134..87bbd845487 100644 --- a/packages/swingset/src/stories/user-profile.mdx +++ b/packages/swingset/src/stories/user-profile.mdx @@ -15,7 +15,7 @@ height. { name: 'Profile panel', href: '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/user-profile/user-profile-profile-panel', layer: 'User Profile' }, { name: 'Security panel', href: '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/user-profile/user-profile-security-panel', layer: 'User Profile' }, { name: 'Billing panel', href: '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/user-profile/user-profile-billing-panel', layer: 'User Profile' }, - { name: 'API keys panel', href: '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/user-profile/user-profile-api-keys-panel', layer: 'User Profile' }, + { name: 'APIKeysTable', href: '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/api-keys/api-keys-table', layer: 'API Keys' }, ]} /> From f33875b7e4d142f751fbc4987032e6022040bd8f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 11:09:50 -0400 Subject: [PATCH 2/7] chore(swingset): sort imports --- packages/swingset/src/lib/registry.ts | 14 +++++++------- .../src/stories/fixtures/organization-profile.tsx | 2 +- .../swingset/src/stories/fixtures/user-profile.tsx | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index c687a767fdf..465d9952fe3 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, @@ -318,13 +325,6 @@ import { meta as userProfileActiveDevicesSectionMeta, SignOutError as UserProfileActiveDevicesSectionSignOutError, } from '../stories/user-profile-active-devices-section.stories'; -import { - Default as APIKeysTableDefault, - Empty as APIKeysTableEmpty, - meta as apiKeysTableMeta, - Organization as APIKeysTableOrganization, - ProposedTable as APIKeysTableProposedTable, -} from '../stories/api-keys-table.stories'; import { Default as UserProfileBillingHistorySectionDefault, Empty as UserProfileBillingHistorySectionEmpty, diff --git a/packages/swingset/src/stories/fixtures/organization-profile.tsx b/packages/swingset/src/stories/fixtures/organization-profile.tsx index 149958660bc..75442b616d5 100644 --- a/packages/swingset/src/stories/fixtures/organization-profile.tsx +++ 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 { APIKeysPanelExample, useAPIKeysTableFixture } from './api-keys-table'; import { useInviteMembersFixture } from './organization-profile-invite-members'; import { useRequestsTableFixture } from './requests-table-tab'; import { usePreviewImage } from './use-preview-image'; diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index d4a3f237e00..fa3e7a5b8ab 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -17,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 { APIKeysPanelExample, useAPIKeysTableFixture } from './api-keys-table'; import { useConnectedAccountsFixture } from './user-profile-connected-accounts'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; import { useUserProfileEditPasswordFixture } from './user-profile-edit-password'; From e2df575c32934eef5f5cfc96e9cd6b616fe86d9b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 11:19:28 -0400 Subject: [PATCH 3/7] chore(swingset): restore API keys panel examples --- .../swingset/src/components/DocsViewer.tsx | 2 ++ packages/swingset/src/lib/registry.ts | 23 ++++++++++++++++++ .../organization-profile-api-keys-panel.mdx | 22 +++++++++++++++++ ...ization-profile-api-keys-panel.stories.tsx | 24 +++++++++++++++++++ .../src/stories/organization-profile.mdx | 2 +- .../stories/user-profile-api-keys-panel.mdx | 22 +++++++++++++++++ .../user-profile-api-keys-panel.stories.tsx | 24 +++++++++++++++++++ .../swingset/src/stories/user-profile.mdx | 2 +- 8 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 packages/swingset/src/stories/organization-profile-api-keys-panel.mdx create mode 100644 packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx create mode 100644 packages/swingset/src/stories/user-profile-api-keys-panel.mdx create mode 100644 packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 4fc686a28ca..3031f104ab5 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -17,6 +17,7 @@ const docModules: Record> = { 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), 'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')), 'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')), + 'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')), 'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')), 'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')), 'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')), @@ -46,6 +47,7 @@ const docModules: Record> = { () => import('../stories/organization-profile-workspace-section.mdx'), ), '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')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 465d9952fe3..3f604fdfd56 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -148,6 +148,11 @@ import { meta as organizationProfileMeta, Overlay as OrganizationProfileOverlay, } from '../stories/organization-profile.stories'; +import { + Default as OrganizationProfileApiKeysPanelDefault, + Empty as OrganizationProfileApiKeysPanelEmpty, + meta as organizationProfileApiKeysPanelMeta, +} from '../stories/organization-profile-api-keys-panel.stories'; import { Default as OrganizationProfileDangerSectionDefault, LeaveOnly as OrganizationProfileDangerSectionLeaveOnly, @@ -325,6 +330,11 @@ import { meta as userProfileActiveDevicesSectionMeta, SignOutError as UserProfileActiveDevicesSectionSignOutError, } from '../stories/user-profile-active-devices-section.stories'; +import { + Default as UserProfileApiKeysPanelDefault, + Empty as UserProfileApiKeysPanelEmpty, + meta as userProfileApiKeysPanelMeta, +} from '../stories/user-profile-api-keys-panel.stories'; import { Default as UserProfileBillingHistorySectionDefault, Empty as UserProfileBillingHistorySectionEmpty, @@ -725,6 +735,11 @@ const tabsComponentModule: StoryModule = { // Planned but not yet implemented; the entry reserves its sidebar slot with a todo dot. const toastModule: StoryModule = { meta: toastMeta, Default: ToastDefault, Anchored: ToastAnchored }; +const userProfileApiKeysPanelModule: StoryModule = { + meta: userProfileApiKeysPanelMeta, + Default: UserProfileApiKeysPanelDefault, + Empty: UserProfileApiKeysPanelEmpty, +}; const userProfileModule: StoryModule = { meta: userProfileMeta, Default: UserProfileDefault, @@ -875,6 +890,12 @@ const reverificationModule: StoryModule = { MethodPickerPending: ReverificationMethodPickerPending, Help: ReverificationHelp, }; +const organizationProfileApiKeysPanelModule: StoryModule = { + meta: organizationProfileApiKeysPanelMeta, + Default: OrganizationProfileApiKeysPanelDefault, + Empty: OrganizationProfileApiKeysPanelEmpty, +}; + const organizationProfileMembersPanelModule: StoryModule = { meta: organizationProfileMembersPanelMeta, Legacy: OrganizationProfileMembersPanelLegacy, @@ -891,6 +912,7 @@ export const registry: StoryModule[] = [ userProfileProfilePanelModule, userProfileSecurityPanelModule, userProfileBillingPanelModule, + userProfileApiKeysPanelModule, // User Profile · Sections userProfileAccountSectionModule, userProfilePasswordSectionModule, @@ -908,6 +930,7 @@ export const registry: StoryModule[] = [ organizationProfileModule, // Organization Profile · Panels organizationProfileGeneralPanelModule, + organizationProfileApiKeysPanelModule, organizationProfileMembersPanelModule, // Organization Profile · Sections organizationProfileWorkspaceSectionModule, diff --git a/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx b/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx new file mode 100644 index 00000000000..8a5e3bdbbd5 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx @@ -0,0 +1,22 @@ +import * as Stories from './organization-profile-api-keys-panel.stories'; + +# OrganizationProfileApiKeysPanel + +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 + + + +## Examples + +### Empty + + 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 new file mode 100644 index 00000000000..07569a06d8a --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx @@ -0,0 +1,24 @@ +import type { StoryMeta } from '@/lib/types'; + +import { APIKeysPanelExample, useAPIKeysTableFixture } from './fixtures/api-keys-table'; + +export { default as __source } from './organization-profile-api-keys-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + title: 'OrganizationProfileApiKeysPanel', + label: 'API keys panel', + navigation: { category: 'Panels' }, + source: 'packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.tsx', +}; + +export function Default() { + const props = useAPIKeysTableFixture({ subjectKind: 'organization' }); + return ; +} + +export function Empty() { + const props = useAPIKeysTableFixture({ subjectKind: 'organization', initialKeys: [] }); + return ; +} diff --git a/packages/swingset/src/stories/organization-profile.mdx b/packages/swingset/src/stories/organization-profile.mdx index cf8e69792d7..19d70d381d3 100644 --- a/packages/swingset/src/stories/organization-profile.mdx +++ b/packages/swingset/src/stories/organization-profile.mdx @@ -13,7 +13,7 @@ Billing hold their place with a placeholder. composition={[ { name: 'General panel', href: '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/organization-profile/organization-profile-general-panel', layer: 'Organization Profile' }, { name: 'Members panel', href: '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/organization-profile/organization-profile-members-panel', layer: 'Organization Profile' }, - { name: 'APIKeysTable', href: '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/api-keys/api-keys-table', layer: 'API Keys' }, + { name: 'API keys panel', href: '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/organization-profile/organization-profile-api-keys-panel', layer: 'Organization Profile' }, ]} /> diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx new file mode 100644 index 00000000000..54fd6002b8d --- /dev/null +++ b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx @@ -0,0 +1,22 @@ +import * as Stories from './user-profile-api-keys-panel.stories'; + +# UserProfileApiKeysPanel + +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 + + + +## Examples + +### Empty + + 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 new file mode 100644 index 00000000000..c204e83adcc --- /dev/null +++ b/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx @@ -0,0 +1,24 @@ +import type { StoryMeta } from '@/lib/types'; + +import { APIKeysPanelExample, useAPIKeysTableFixture } from './fixtures/api-keys-table'; + +export { default as __source } from './user-profile-api-keys-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + status: 'wip', + title: 'UserProfileApiKeysPanel', + label: 'API keys panel', + navigation: { category: 'Panels' }, + source: 'packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.tsx', +}; + +export function Default() { + const props = useAPIKeysTableFixture(); + return ; +} + +export function Empty() { + const props = useAPIKeysTableFixture({ initialKeys: [] }); + return ; +} diff --git a/packages/swingset/src/stories/user-profile.mdx b/packages/swingset/src/stories/user-profile.mdx index 87bbd845487..5fd35d80134 100644 --- a/packages/swingset/src/stories/user-profile.mdx +++ b/packages/swingset/src/stories/user-profile.mdx @@ -15,7 +15,7 @@ height. { name: 'Profile panel', href: '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/user-profile/user-profile-profile-panel', layer: 'User Profile' }, { name: 'Security panel', href: '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/user-profile/user-profile-security-panel', layer: 'User Profile' }, { name: 'Billing panel', href: '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/user-profile/user-profile-billing-panel', layer: 'User Profile' }, - { name: 'APIKeysTable', href: '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/api-keys/api-keys-table', layer: 'API Keys' }, + { name: 'API keys panel', href: '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/user-profile/user-profile-api-keys-panel', layer: 'User Profile' }, ]} /> From 2c818d92c8cb88e09ccd33fa7eac4cc75d580201 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 11:19:29 -0400 Subject: [PATCH 4/7] fix(mosaic): animate the API key expiration hint --- .../__tests__/api-keys-table.feature.test.tsx | 2 +- .../features/api-keys/create-api-key.dialog.tsx | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) 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 index 1ed8fee3c41..e48bee0d740 100644 --- 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 @@ -299,7 +299,7 @@ describe('APIKeysTable', () => { '1 Year', ]); await user.click(screen.getByRole('option', { name: 'Never' })); - expect(within(dialog).getByText('This key will never expire')).toBeVisible(); + await waitFor(() => expect(within(dialog).getByText('This key will never expire')).toBeVisible()); expect(add).toBeEnabled(); }); diff --git a/packages/mosaic/src/features/api-keys/create-api-key.dialog.tsx b/packages/mosaic/src/features/api-keys/create-api-key.dialog.tsx index 66dc4131ec0..0145cb8fe7c 100644 --- a/packages/mosaic/src/features/api-keys/create-api-key.dialog.tsx +++ b/packages/mosaic/src/features/api-keys/create-api-key.dialog.tsx @@ -127,13 +127,15 @@ function CreateKeyStep(props: CreateAPIKeyDialogProps & { inputRef: Ref - {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 ? ( From f5835401df40f75350cd409d3e0513450099b923 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 11:42:17 -0400 Subject: [PATCH 5/7] feat(mosaic): hide API keys panels when that key type is disabled --- ...on-profile-api-keys-panel.feature.test.tsx | 48 +++++++++++++++++++ .../organization-profile-api-keys-panel.tsx | 4 +- ...er-profile-api-keys-panel.feature.test.tsx | 34 +++++++++++++ .../user-profile-api-keys-panel.tsx | 4 +- 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 packages/mosaic/src/features/organization-profile/__tests__/organization-profile-api-keys-panel.feature.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.feature.test.tsx 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..3e35712c35c --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-api-keys-panel.feature.test.tsx @@ -0,0 +1,48 @@ +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(); + }); +}); 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 index 223dbf3d53b..403b2971377 100644 --- 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 @@ -2,6 +2,7 @@ import { useOrganization } from '@clerk/shared/react'; import type { ReactNode } from 'react'; import { Panel } from '../../components/panel'; +import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { useMessages } from '../../localization'; import { themeProps } from '../../props'; import { APIKeysTable } from '../api-keys/api-keys-table'; @@ -12,9 +13,10 @@ export interface OrganizationProfileApiKeysPanelProps { export function OrganizationProfileApiKeysPanel({ fallback }: OrganizationProfileApiKeysPanelProps) { const { organization } = useOrganization(); + const enabled = useMosaicEnvironment()?.apiKeysSettings.orgs_api_keys_enabled; const m = useMessages('organizationProfile'); - if (!organization) { + if (!organization || !enabled) { return fallback ?? null; } 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 new file mode 100644 index 00000000000..6942d2ff21a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-api-keys-panel.feature.test.tsx @@ -0,0 +1,34 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +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 alice = fapiUser({ id: 'user_1', first_name: 'Alice' }); + +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', () => { + it("lists the user's keys when user API keys are enabled", async () => { + serve(true); + await renderWithClerk(); + + expect(await screen.findByText('Web app')).toBeVisible(); + }); + + it('renders the fallback when user API keys are disabled', async () => { + serve(false); + await renderWithClerk(Unavailable

} />); + + expect(await screen.findByText('Unavailable')).toBeVisible(); + expect(screen.queryByRole('table', { name: 'API Keys' })).toBeNull(); + }); +}); 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 819c955e706..121dca9dbe4 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 @@ -2,6 +2,7 @@ import { useUser } from '@clerk/shared/react'; import type { ReactNode } from 'react'; import { Panel } from '../../components/panel'; +import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { useMessages } from '../../localization'; import { themeProps } from '../../props'; import { APIKeysTable } from '../api-keys/api-keys-table'; @@ -12,9 +13,10 @@ export interface UserProfileApiKeysPanelProps { export function UserProfileApiKeysPanel({ fallback }: UserProfileApiKeysPanelProps) { const { user } = useUser(); + const enabled = useMosaicEnvironment()?.apiKeysSettings.user_api_keys_enabled; const m = useMessages('userProfile'); - if (!user) { + if (!user || !enabled) { return fallback ?? null; } From bf6d7142cec885f5f3c3d3eb38a69dc6e1fb88c1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 12:01:42 -0400 Subject: [PATCH 6/7] test(mosaic): cover organization members who can manage but not read API keys --- .../__tests__/api-keys-table.feature.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 index e48bee0d740..f96ad1a7065 100644 --- 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 @@ -434,6 +434,19 @@ describe('APIKeysTable', () => { expect(screen.queryByRole('button', { name: 'Manage Org key' })).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('lists nothing without permission to read keys', async () => { await renderTable(memberOfAcme([]), { subject: acme.id }); From ecadbb2fbaba80186389fa31dd6901f2fccdeaf3 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 14:03:55 -0400 Subject: [PATCH 7/7] refactor(mosaic): decide API keys availability once in the table model Move API keys eligibility (signed-in subject, active organization, key type enabled, read or manage permission) into useAPIKeysAccess, shared by APIKeysTable and both panels, and port the remaining view test scenarios to feature tests. --- .../__tests__/api-keys-table.feature.test.tsx | 198 +++++++- .../__tests__/api-keys-table.view.test.tsx | 438 ------------------ .../src/features/api-keys/api-keys-table.tsx | 26 +- ...on-profile-api-keys-panel.feature.test.tsx | 1 + .../organization-profile-api-keys-panel.tsx | 7 +- ...er-profile-api-keys-panel.feature.test.tsx | 1 + .../user-profile-api-keys-panel.tsx | 7 +- 7 files changed, 216 insertions(+), 462 deletions(-) delete mode 100644 packages/mosaic/src/features/api-keys/__tests__/api-keys-table.view.test.tsx 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 index f96ad1a7065..850e0f6f862 100644 --- 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 @@ -16,6 +16,7 @@ import { 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'; @@ -33,14 +34,14 @@ const alice = fapiUser({ 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 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 } }), + 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, @@ -62,6 +63,14 @@ 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' }); @@ -73,14 +82,40 @@ async function fillCreate(user: User, dialog: HTMLElement, name: string, expirat await user.click(await screen.findByRole('option', { name: expiration })); } -async function openRevoke(user: User, name: string) { +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' })); - const dialog = await screen.findByRole('dialog', { name: `Revoke ${name}?` }); + 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 () => { @@ -102,6 +137,7 @@ describe('APIKeysTable', () => { 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 () => { @@ -124,12 +160,47 @@ describe('APIKeysTable', () => { ).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()); @@ -152,6 +223,22 @@ describe('APIKeysTable', () => { }); }); + 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(); @@ -167,6 +254,20 @@ describe('APIKeysTable', () => { 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)); @@ -186,7 +287,11 @@ describe('APIKeysTable', () => { 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(); @@ -370,18 +475,77 @@ describe('APIKeysTable', () => { expect(fapi.apiKeys.find(key => key.id === webApp.id)?.revoked).toBe(true); }); - it('explains a failed revoke and keeps the dialog open', async () => { + 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' })); - revoke.fail('api_key_revoke_failed'); + 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(screen.getByRole('dialog', { name: 'Revoke Web app?' })).toBeVisible(); + 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 () => { @@ -432,6 +596,7 @@ describe('APIKeysTable', () => { 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 () => { @@ -447,11 +612,22 @@ describe('APIKeysTable', () => { expect(fapi.apiKeys[0]).toMatchObject({ name: 'Deploy', subject: acme.id }); }); - it('lists nothing without permission to read keys', async () => { - await renderTable(memberOfAcme([]), { subject: acme.id }); + it('renders the fallback without permission to read or manage keys', async () => { + await expectFallback(memberOfAcme([]), { subject: acme.id }); + }); - expect(await screen.findByText('No API Keys created')).toBeVisible(); - expect(within(table()).queryByText('Org key')).toBeNull(); + 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 () => { diff --git a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.view.test.tsx b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.view.test.tsx deleted file mode 100644 index 539511d0829..00000000000 --- a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.view.test.tsx +++ /dev/null @@ -1,438 +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 { useMessages } from '../../../localization'; -import { MosaicProvider } from '../../../mosaic-provider'; -import { resolveAPIKeysTableMessages } from '../api-keys-table.messages'; -import type { APIKey, APIKeysTableViewProps } from '../api-keys-table.types'; -import { APIKeysTableView } from '../api-keys-table.view'; - -const apiKeys: APIKey[] = [ - { - 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 }, -]; - -type ViewProps = Omit; - -function View(props: ViewProps) { - const messages = resolveAPIKeysTableMessages(useMessages('apiKeysTable'), 'organization'); - return ( - - ); -} - -function propsFor(overrides: Partial = {}): ViewProps { - 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('APIKeysTableView', () => { - 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(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: APIKey[] = 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); - }); - - 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 with retry 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')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Try again' })); - expect(onRetry).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.tsx b/packages/mosaic/src/features/api-keys/api-keys-table.tsx index 45746142184..8afc65bad00 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.tsx +++ b/packages/mosaic/src/features/api-keys/api-keys-table.tsx @@ -6,6 +6,7 @@ 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'; @@ -27,23 +28,38 @@ export interface APIKeysTableProps { // -- Model -- -function useAPIKeysTableModel(subject: string, query: string, messages: APIKeysTableMessages) { - const clerk = useClerk(); +export function useAPIKeysAccess(subject: string | undefined) { const { isLoaded, session } = useSession(); - const isOrganization = isOrganizationId(subject); + 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: Boolean(session) && canRead, + enabled: isAvailable && canRead, }); return { isLoaded, + isAvailable, ...apiKeys, canManage, create: async ({ name, expiresAt }: CreateAPIKeyInput) => { @@ -154,7 +170,7 @@ function SubjectAPIKeysTable({ subject, messages: overrides, fallback }: APIKeys } }, [isFetching, page, pageCount, fetchPage]); - if (!model.isLoaded) { + if (!model.isLoaded || !model.isAvailable) { return fallback ?? null; } 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 index 3e35712c35c..987bfd8dd7b 100644 --- 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 @@ -44,5 +44,6 @@ describe('OrganizationProfileApiKeysPanel', () => { 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/organization-profile-api-keys-panel.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.tsx index 403b2971377..c3cc8731b32 100644 --- 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 @@ -2,10 +2,9 @@ import { useOrganization } from '@clerk/shared/react'; import type { ReactNode } from 'react'; import { Panel } from '../../components/panel'; -import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { useMessages } from '../../localization'; import { themeProps } from '../../props'; -import { APIKeysTable } from '../api-keys/api-keys-table'; +import { APIKeysTable, useAPIKeysAccess } from '../api-keys/api-keys-table'; export interface OrganizationProfileApiKeysPanelProps { fallback?: ReactNode; @@ -13,10 +12,10 @@ export interface OrganizationProfileApiKeysPanelProps { export function OrganizationProfileApiKeysPanel({ fallback }: OrganizationProfileApiKeysPanelProps) { const { organization } = useOrganization(); - const enabled = useMosaicEnvironment()?.apiKeysSettings.orgs_api_keys_enabled; + const { isAvailable } = useAPIKeysAccess(organization?.id); const m = useMessages('organizationProfile'); - if (!organization || !enabled) { + if (!organization || !isAvailable) { return fallback ?? null; } 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 6942d2ff21a..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 @@ -30,5 +30,6 @@ describe('UserProfileApiKeysPanel', () => { 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/user-profile-api-keys-panel.tsx b/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.tsx index 121dca9dbe4..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 @@ -2,10 +2,9 @@ import { useUser } from '@clerk/shared/react'; import type { ReactNode } from 'react'; import { Panel } from '../../components/panel'; -import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { useMessages } from '../../localization'; import { themeProps } from '../../props'; -import { APIKeysTable } from '../api-keys/api-keys-table'; +import { APIKeysTable, useAPIKeysAccess } from '../api-keys/api-keys-table'; export interface UserProfileApiKeysPanelProps { fallback?: ReactNode; @@ -13,10 +12,10 @@ export interface UserProfileApiKeysPanelProps { export function UserProfileApiKeysPanel({ fallback }: UserProfileApiKeysPanelProps) { const { user } = useUser(); - const enabled = useMosaicEnvironment()?.apiKeysSettings.user_api_keys_enabled; + const { isAvailable } = useAPIKeysAccess(user?.id); const m = useMessages('userProfile'); - if (!user || !enabled) { + if (!user || !isAvailable) { return fallback ?? null; }