- 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;
}