Skip to content

Commit 03a3651

Browse files
authored
chore(clerk-js): Switch to modal approach in API keys copying (#7134)
1 parent 5ec60e9 commit 03a3651

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+418
-238
lines changed

integration/tests/machine-auth/component.test.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,22 @@ testAgainstRunningApps({
126126
const apiKeyData = await createResponse.json();
127127
const secret = apiKeyData.secret;
128128

129-
// Wait for the alert to appear with the copy input and copy API key
130-
const copyButton = page.locator('.cl-formFieldInputCopyToClipboardButton');
131-
await copyButton.waitFor({ state: 'attached' });
132-
await copyButton.click();
129+
// Wait for the copy modal to appear
130+
const copyModal = page.locator('.cl-apiKeysCopyModal');
131+
await copyModal.waitFor({ state: 'attached' });
133132

134-
// Read clipboard contents
133+
// Grant clipboard permissions before clicking the button
135134
await context.grantPermissions(['clipboard-read']);
135+
136+
// Click "Copy & Close" button which will copy the secret and close the modal
137+
const copyAndCloseButton = copyModal.locator('.cl-apiKeysCopyModalSubmitButton');
138+
await copyAndCloseButton.waitFor({ state: 'attached' });
139+
await copyAndCloseButton.click();
140+
141+
// Wait for modal to close
142+
await copyModal.waitFor({ state: 'detached' });
143+
144+
// Read clipboard contents to verify the secret was copied
136145
const clipboardText = await page.evaluate('navigator.clipboard.readText()');
137146
await context.clearPermissions();
138147
expect(clipboardText).toBe(secret);

packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import { mqu } from '@/ui/styledSystem';
2626
import { isOrganizationId } from '@/utils';
2727

2828
import { ApiKeysTable } from './ApiKeysTable';
29-
import { CopyApiKeyAlert } from './CopyApiKeyAlert';
3029
import type { OnCreateParams } from './CreateApiKeyForm';
3130
import { CreateApiKeyForm } from './CreateApiKeyForm';
3231
import { useApiKeys } from './useApiKeys';
@@ -43,6 +42,12 @@ const RevokeAPIKeyConfirmationModal = lazy(() =>
4342
})),
4443
);
4544

45+
const CopyApiKeyModal = lazy(() =>
46+
import(/* webpackChunkName: "copy-api-key-modal"*/ './CopyApiKeyModal').then(module => ({
47+
default: module.CopyApiKeyModal,
48+
})),
49+
);
50+
4651
export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPageProps) => {
4752
const isOrg = isOrganizationId(subject);
4853
const canReadAPIKeys = useProtect({ permission: 'org:sys_api_keys:read' });
@@ -72,7 +77,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
7277
const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false);
7378
const [selectedApiKeyId, setSelectedApiKeyId] = useState('');
7479
const [selectedApiKeyName, setSelectedApiKeyName] = useState('');
75-
const [showCopyAlert, setShowCopyAlert] = useState(false);
80+
const [isCopyModalOpen, setIsCopyModalOpen] = useState(false);
7681

7782
const handleCreateApiKey = async (params: OnCreateParams, closeCardFn: () => void) => {
7883
try {
@@ -82,7 +87,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
8287
});
8388
closeCardFn();
8489
card.setError(undefined);
85-
setShowCopyAlert(true);
90+
setIsCopyModalOpen(true);
8691
} catch (err: any) {
8792
if (isClerkAPIResponseError(err)) {
8893
if (err.status === 409) {
@@ -154,12 +159,6 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
154159
</Action.Open>
155160
</Action.Root>
156161

157-
{showCopyAlert ? (
158-
<CopyApiKeyAlert
159-
apiKeyName={createdApiKey?.name ?? ''}
160-
apiKeySecret={createdApiKey?.secret ?? ''}
161-
/>
162-
) : null}
163162
<ApiKeysTable
164163
rows={apiKeys}
165164
isLoading={isLoading}
@@ -189,6 +188,14 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
189188
apiKeyName={selectedApiKeyName}
190189
modalRoot={revokeModalRoot}
191190
/>
191+
<CopyApiKeyModal
192+
isOpen={isCopyModalOpen}
193+
onOpen={() => setIsCopyModalOpen(true)}
194+
onClose={() => setIsCopyModalOpen(false)}
195+
apiKeyName={createdApiKey?.name ?? ''}
196+
apiKeySecret={createdApiKey?.secret ?? ''}
197+
modalRoot={revokeModalRoot}
198+
/>
192199
</Col>
193200
);
194201
};

packages/clerk-js/src/ui/components/ApiKeys/CopyApiKeyAlert.tsx

Lines changed: 0 additions & 54 deletions
This file was deleted.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { descriptors } from '@/ui/customizables';
2+
import { Card } from '@/ui/elements/Card';
3+
import { ClipboardInput } from '@/ui/elements/ClipboardInput';
4+
import { Form } from '@/ui/elements/Form';
5+
import { FormButtons } from '@/ui/elements/FormButtons';
6+
import { FormContainer } from '@/ui/elements/FormContainer';
7+
import { Modal } from '@/ui/elements/Modal';
8+
import { useClipboard } from '@/ui/hooks';
9+
import { Check, ClipboardOutline } from '@/ui/icons';
10+
import { localizationKeys } from '@/ui/localization';
11+
import { useFormControl } from '@/ui/utils/useFormControl';
12+
13+
import { getApiKeyModalContainerStyles } from './utils';
14+
15+
type CopyApiKeyModalProps = {
16+
isOpen: boolean;
17+
onOpen: () => void;
18+
onClose: () => void;
19+
apiKeyName: string;
20+
apiKeySecret: string;
21+
modalRoot?: React.MutableRefObject<HTMLElement | null>;
22+
};
23+
24+
export const CopyApiKeyModal = ({
25+
isOpen,
26+
onOpen,
27+
onClose,
28+
apiKeyName,
29+
apiKeySecret,
30+
modalRoot,
31+
}: CopyApiKeyModalProps) => {
32+
const apiKeyField = useFormControl('name', apiKeySecret, {
33+
type: 'text',
34+
label: localizationKeys('formFieldLabel__apiKey'),
35+
isRequired: false,
36+
});
37+
38+
const { onCopy } = useClipboard(apiKeySecret);
39+
40+
const handleSubmit = () => {
41+
onCopy();
42+
onClose();
43+
};
44+
45+
if (!isOpen) {
46+
return null;
47+
}
48+
49+
return (
50+
<Modal
51+
handleOpen={onOpen}
52+
handleClose={onClose}
53+
canCloseModal={false}
54+
portalRoot={modalRoot}
55+
containerSx={getApiKeyModalContainerStyles(modalRoot)}
56+
>
57+
<Card.Root
58+
role='alertdialog'
59+
elementDescriptor={descriptors.apiKeysCopyModal}
60+
>
61+
<Card.Content
62+
sx={t => ({
63+
textAlign: 'left',
64+
padding: `${t.sizes.$4} ${t.sizes.$5} ${t.sizes.$4} ${t.sizes.$6}`,
65+
})}
66+
>
67+
<FormContainer
68+
headerTitle={localizationKeys('apiKeys.copySecret.formTitle', { name: apiKeyName })}
69+
headerSubtitle={localizationKeys('apiKeys.copySecret.formHint')}
70+
>
71+
<Form.Root onSubmit={handleSubmit}>
72+
<Form.ControlRow
73+
elementDescriptor={descriptors.apiKeysCopyModalInput}
74+
sx={{ flex: 1 }}
75+
>
76+
<Form.CommonInputWrapper {...apiKeyField.props}>
77+
<ClipboardInput
78+
value={apiKeySecret}
79+
readOnly
80+
sx={{ width: '100%' }}
81+
copyIcon={ClipboardOutline}
82+
copiedIcon={Check}
83+
/>
84+
</Form.CommonInputWrapper>
85+
</Form.ControlRow>
86+
<FormButtons
87+
submitLabel={localizationKeys('apiKeys.copySecret.formButtonPrimary__copyAndClose')}
88+
hideReset
89+
elementDescriptor={descriptors.apiKeysCopyModalSubmitButton}
90+
/>
91+
</Form.Root>
92+
</FormContainer>
93+
</Card.Content>
94+
</Card.Root>
95+
</Modal>
96+
);
97+
};

packages/clerk-js/src/ui/components/ApiKeys/RevokeAPIKeyConfirmationModal.tsx

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { Modal } from '@/ui/elements/Modal';
1010
import { localizationKeys, useLocalizations } from '@/ui/localization';
1111
import { useFormControl } from '@/ui/utils/useFormControl';
1212

13+
import { getApiKeyModalContainerStyles } from './utils';
14+
1315
type RevokeAPIKeyConfirmationModalProps = {
1416
subject: string;
1517
isOpen: boolean;
@@ -70,24 +72,7 @@ export const RevokeAPIKeyConfirmationModal = ({
7072
handleClose={handleClose}
7173
canCloseModal={false}
7274
portalRoot={modalRoot}
73-
containerSx={[
74-
{ alignItems: 'center' },
75-
modalRoot
76-
? t => ({
77-
position: 'absolute',
78-
right: 0,
79-
bottom: 0,
80-
backgroundColor: 'inherit',
81-
backdropFilter: `blur(${t.sizes.$2})`,
82-
display: 'flex',
83-
justifyContent: 'center',
84-
minHeight: '100%',
85-
height: '100%',
86-
width: '100%',
87-
borderRadius: t.radii.$lg,
88-
})
89-
: {},
90-
]}
75+
containerSx={getApiKeyModalContainerStyles(modalRoot)}
9176
>
9277
<Card.Root
9378
role='alertdialog'
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { ThemableCssProp } from '@/ui/styledSystem';
2+
3+
/**
4+
* Shared container styles for API key modals when used with a custom modal root.
5+
* These styles handle the modal positioning and backdrop when the modal is rendered
6+
* within a custom container (e.g., within UserProfile or OrganizationProfile).
7+
*/
8+
export const getApiKeyModalContainerStyles = (
9+
modalRoot?: React.MutableRefObject<HTMLElement | null>,
10+
): ThemableCssProp => {
11+
return [
12+
{ alignItems: 'center' },
13+
modalRoot
14+
? t => ({
15+
position: 'absolute',
16+
right: 0,
17+
bottom: 0,
18+
backgroundColor: 'inherit',
19+
backdropFilter: `blur(${t.sizes.$2})`,
20+
display: 'flex',
21+
justifyContent: 'center',
22+
minHeight: '100%',
23+
height: '100%',
24+
width: '100%',
25+
borderRadius: t.radii.$lg,
26+
})
27+
: {},
28+
];
29+
};

packages/clerk-js/src/ui/customizables/elementDescriptors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,9 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
495495
'apiKeysRevokeModal',
496496
'apiKeysRevokeModalInput',
497497
'apiKeysRevokeModalSubmitButton',
498+
'apiKeysCopyModal',
499+
'apiKeysCopyModalInput',
500+
'apiKeysCopyModalSubmitButton',
498501

499502
'subscriptionDetailsCard',
500503
'subscriptionDetailsCardHeader',

packages/clerk-js/src/ui/elements/ClipboardInput.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ type ClipboardInputProps = PropsOfComponent<typeof Input> & {
1111
};
1212

1313
export const ClipboardInput = (props: ClipboardInputProps) => {
14-
const { id, value, copyIcon = Clipboard, copiedIcon = TickShield, ...rest } = props;
14+
const { id, value, copyIcon = Clipboard, copiedIcon = TickShield, sx, ...rest } = props;
1515
const { onCopy, hasCopied } = useClipboard(value as string);
1616

1717
return (
1818
<Flex
1919
direction='col'
2020
justify='center'
21-
sx={{ position: 'relative' }}
21+
sx={[{ position: 'relative' }, sx]}
2222
>
2323
<Input
2424
{...rest}

packages/clerk-js/src/ui/elements/Form.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ export const Form = {
318318
Checkbox,
319319
SubmitButton: FormSubmit,
320320
ResetButton: FormReset,
321+
CommonInputWrapper,
321322
};
322323

323324
export { useFormState };

packages/localizations/src/ar-SA.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ export const arSA: LocalizationResource = {
1717
apiKeys: {
1818
action__add: undefined,
1919
action__search: undefined,
20-
copyAlert: {
21-
subtitle: undefined,
22-
title: undefined,
20+
copySecret: {
21+
formButtonPrimary__copyAndClose: undefined,
22+
formHint: undefined,
23+
formTitle: undefined,
2324
},
2425
createdAndExpirationStatus__expiresOn: undefined,
2526
createdAndExpirationStatus__never: undefined,
@@ -225,6 +226,7 @@ export const arSA: LocalizationResource = {
225226
formFieldInputPlaceholder__phoneNumber: undefined,
226227
formFieldInputPlaceholder__username: undefined,
227228
formFieldInput__emailAddress_format: undefined,
229+
formFieldLabel__apiKey: undefined,
228230
formFieldLabel__apiKeyDescription: undefined,
229231
formFieldLabel__apiKeyExpiration: undefined,
230232
formFieldLabel__apiKeyName: undefined,

0 commit comments

Comments
 (0)