Skip to content

Commit 4427c47

Browse files
authored
chore(nextjs): Display keyless prompt until .clerk/ is removed (#4940)
1 parent 7e3416c commit 4427c47

File tree

9 files changed

+169
-68
lines changed

9 files changed

+169
-68
lines changed

.changeset/thin-wolves-camp.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/nextjs': minor
4+
'@clerk/types': minor
5+
---
6+
7+
Display keyless prompt until the developer manually dismisses it.

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@
1818
{ "path": "./dist/userverification*.js", "maxSize": "5KB" },
1919
{ "path": "./dist/onetap*.js", "maxSize": "1KB" },
2020
{ "path": "./dist/waitlist*.js", "maxSize": "1.3KB" },
21-
{ "path": "./dist/keylessPrompt*.js", "maxSize": "4.9KB" }
21+
{ "path": "./dist/keylessPrompt*.js", "maxSize": "5.5KB" }
2222
]
2323
}

packages/clerk-js/src/core/clerk.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2081,12 +2081,14 @@ export class Clerk implements ClerkInterface {
20812081
};
20822082

20832083
#handleKeylessPrompt = () => {
2084-
if (this.#options.__internal_claimKeylessApplicationUrl) {
2084+
if (this.#options.__internal_keyless_claimKeylessApplicationUrl) {
20852085
void this.#componentControls?.ensureMounted().then(controls => {
2086+
// TODO(@pantelis): Investigate if this resets existing props
20862087
controls.updateProps({
20872088
options: {
2088-
__internal_claimKeylessApplicationUrl: this.#options.__internal_claimKeylessApplicationUrl,
2089-
__internal_copyInstanceKeysUrl: this.#options.__internal_copyInstanceKeysUrl,
2089+
__internal_keyless_claimKeylessApplicationUrl: this.#options.__internal_keyless_claimKeylessApplicationUrl,
2090+
__internal_keyless_copyInstanceKeysUrl: this.#options.__internal_keyless_copyInstanceKeysUrl,
2091+
__internal_keyless_dismissPrompt: this.#options.__internal_keyless_dismissPrompt,
20902092
},
20912093
});
20922094
});

packages/clerk-js/src/ui/Components.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -517,14 +517,16 @@ const Components = (props: ComponentsProps) => {
517517
</LazyImpersonationFabProvider>
518518
)}
519519

520-
{state.options?.__internal_claimKeylessApplicationUrl && state.options?.__internal_copyInstanceKeysUrl && (
521-
<LazyImpersonationFabProvider globalAppearance={state.appearance}>
522-
<KeylessPrompt
523-
claimUrl={state.options.__internal_claimKeylessApplicationUrl}
524-
copyKeysUrl={state.options.__internal_copyInstanceKeysUrl}
525-
/>
526-
</LazyImpersonationFabProvider>
527-
)}
520+
{state.options?.__internal_keyless_claimKeylessApplicationUrl &&
521+
state.options?.__internal_keyless_copyInstanceKeysUrl && (
522+
<LazyImpersonationFabProvider globalAppearance={state.appearance}>
523+
<KeylessPrompt
524+
claimUrl={state.options.__internal_keyless_claimKeylessApplicationUrl}
525+
copyKeysUrl={state.options.__internal_keyless_copyInstanceKeysUrl}
526+
onDismiss={state.options.__internal_keyless_dismissPrompt}
527+
/>
528+
</LazyImpersonationFabProvider>
529+
)}
528530

529531
<Suspense>{state.organizationSwitcherPrefetch && <OrganizationSwitcherPrefetch />}</Suspense>
530532
</LazyProviders>

packages/clerk-js/src/ui/components/KeylessPrompt/index.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// eslint-disable-next-line no-restricted-imports
22
import { css } from '@emotion/react';
33
import type { PropsWithChildren } from 'react';
4-
import { useEffect, useState } from 'react';
4+
import { useEffect, useMemo, useState } from 'react';
55
import { createPortal } from 'react-dom';
66

77
import { Flex } from '../../customizables';
@@ -14,6 +14,7 @@ import { useRevalidateEnvironment } from './use-revalidate-environment';
1414
type KeylessPromptProps = {
1515
claimUrl: string;
1616
copyKeysUrl: string;
17+
onDismiss: (() => Promise<unknown>) | undefined;
1718
};
1819

1920
const buttonIdentifierPrefix = `--clerk-keyless-prompt`;
@@ -25,11 +26,22 @@ const _KeylessPrompt = (_props: KeylessPromptProps) => {
2526
const environment = useRevalidateEnvironment();
2627
const claimed = Boolean(environment.authConfig.claimedAt);
2728

28-
const success = false;
29+
const success = typeof _props.onDismiss === 'function' && claimed;
2930
const appName = environment.displayConfig.applicationName;
3031

3132
const isForcedExpanded = claimed || success || isExpanded;
3233

34+
const urlToDashboard = useMemo(() => {
35+
if (claimed) {
36+
return _props.copyKeysUrl;
37+
}
38+
39+
const url = new URL(_props.claimUrl);
40+
// Clerk Dashboard accepts a `return_url` query param when visiting `/apps/claim`.
41+
url.searchParams.append('return_url', window.location.href);
42+
return url.href;
43+
}, [claimed, _props.copyKeysUrl, _props.claimUrl]);
44+
3345
const baseElementStyles = css`
3446
box-sizing: border-box;
3547
padding: 0;
@@ -71,6 +83,7 @@ const _KeylessPrompt = (_props: KeylessPromptProps) => {
7183
text-shadow: 0px 1px 2px rgba(0, 0, 0, 0.32);
7284
white-space: nowrap;
7385
user-select: none;
86+
cursor: pointer;
7487
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30.5%, rgba(0, 0, 0, 0.05) 100%), #454545;
7588
box-shadow:
7689
0px 0px 0px 1px rgba(255, 255, 255, 0.04) inset,
@@ -279,6 +292,7 @@ const _KeylessPrompt = (_props: KeylessPromptProps) => {
279292
color: #8c8c8c;
280293
transition: color 130ms ease-out;
281294
display: ${isExpanded && !claimed && !success ? 'block' : 'none'};
295+
cursor: pointer;
282296
283297
:hover {
284298
color: #eeeeee;
@@ -374,6 +388,10 @@ const _KeylessPrompt = (_props: KeylessPromptProps) => {
374388
(success ? (
375389
<button
376390
type='button'
391+
onClick={async () => {
392+
await _props.onDismiss?.();
393+
window.location.reload();
394+
}}
377395
css={css`
378396
${mainCTAStyles};
379397
&:hover {
@@ -386,7 +404,7 @@ const _KeylessPrompt = (_props: KeylessPromptProps) => {
386404
</button>
387405
) : (
388406
<a
389-
href={claimed ? _props.copyKeysUrl : _props.claimUrl}
407+
href={urlToDashboard}
390408
target='_blank'
391409
rel='noopener noreferrer'
392410
data-expanded={isForcedExpanded}

packages/nextjs/src/app-router/keyless-actions.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,12 @@ export async function createOrReadKeylessAction(): Promise<null | Omit<Accountle
5454
apiKeysUrl,
5555
};
5656
}
57+
58+
export async function deleteKeylessAction() {
59+
if (!canUseKeyless) {
60+
return;
61+
}
62+
63+
await import('../server/keyless-node.js').then(m => m.removeKeyless());
64+
return;
65+
}

packages/nextjs/src/app-router/server/ClerkProvider.tsx

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import React from 'react';
44

55
import { PromisifiedAuthProvider } from '../../client-boundary/PromisifiedAuthProvider';
66
import { getDynamicAuthData } from '../../server/buildClerkProps';
7+
import { safeParseClerkFile } from '../../server/keyless-node';
78
import type { NextClerkProviderProps } from '../../types';
89
import { canUseKeyless } from '../../utils/feature-flags';
910
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
1011
import { isNext13 } from '../../utils/sdk-versions';
1112
import { ClientClerkProvider } from '../client/ClerkProvider';
13+
import { deleteKeylessAction } from '../keyless-actions';
1214
import { buildRequestLike, getScriptNonceFromHeader } from './utils';
1315

1416
const getDynamicClerkState = React.cache(async function getDynamicClerkState() {
@@ -69,30 +71,36 @@ export async function ClerkProvider(
6971
</ClientClerkProvider>
7072
);
7173

72-
const shouldRunAsKeyless = !propsWithEnvs.publishableKey && canUseKeyless;
74+
const runningWithClaimedKeys = propsWithEnvs.publishableKey === safeParseClerkFile()?.publishableKey;
75+
const shouldRunAsKeyless = (!propsWithEnvs.publishableKey || runningWithClaimedKeys) && canUseKeyless;
7376

7477
if (shouldRunAsKeyless) {
7578
// NOTE: Create or read keys on every render. Usually this means only on hard refresh or hard navigations.
7679
const newOrReadKeys = await import('../../server/keyless-node.js').then(mod => mod.createOrReadKeyless());
7780

7881
if (newOrReadKeys) {
7982
const KeylessCookieSync = await import('../client/keyless-cookie-sync.js').then(mod => mod.KeylessCookieSync);
80-
output = (
81-
<KeylessCookieSync {...newOrReadKeys}>
82-
<ClientClerkProvider
83-
{...mergeNextClerkPropsWithEnv({
84-
...rest,
85-
publishableKey: newOrReadKeys.publishableKey,
86-
__internal_claimKeylessApplicationUrl: newOrReadKeys.claimUrl,
87-
__internal_copyInstanceKeysUrl: newOrReadKeys.apiKeysUrl,
88-
})}
89-
nonce={await generateNonce()}
90-
initialState={await generateStatePromise()}
91-
>
92-
{children}
93-
</ClientClerkProvider>
94-
</KeylessCookieSync>
83+
const clientProvider = (
84+
<ClientClerkProvider
85+
{...mergeNextClerkPropsWithEnv({
86+
...rest,
87+
publishableKey: newOrReadKeys.publishableKey,
88+
__internal_keyless_claimKeylessApplicationUrl: newOrReadKeys.claimUrl,
89+
__internal_keyless_copyInstanceKeysUrl: newOrReadKeys.apiKeysUrl,
90+
__internal_keyless_dismissPrompt: runningWithClaimedKeys ? deleteKeylessAction : undefined,
91+
})}
92+
nonce={await generateNonce()}
93+
initialState={await generateStatePromise()}
94+
>
95+
{children}
96+
</ClientClerkProvider>
9597
);
98+
99+
if (runningWithClaimedKeys) {
100+
output = clientProvider;
101+
} else {
102+
output = <KeylessCookieSync {...newOrReadKeys}>{clientProvider}</KeylessCookieSync>;
103+
}
96104
}
97105
}
98106

packages/nextjs/src/server/keyless-node.ts

Lines changed: 84 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,29 @@ const throwMissingFsModule = () => {
2525
throw "Clerk: fsModule.fs is missing. This is an internal error. Please contact Clerk's support.";
2626
};
2727

28-
/**
29-
* The `.clerk/` is NOT safe to be commited as it may include sensitive information about a Clerk instance.
30-
* It may include an instance's secret key and the secret token for claiming that instance.
31-
*/
32-
function updateGitignore() {
28+
const safeNodeRuntimeFs = () => {
3329
if (!nodeRuntime.fs) {
3430
throwMissingFsModule();
3531
}
36-
const { existsSync, writeFileSync, readFileSync, appendFileSync } = nodeRuntime.fs;
32+
return nodeRuntime.fs;
33+
};
3734

35+
const safeNodeRuntimePath = () => {
3836
if (!nodeRuntime.path) {
3937
throwMissingFsModule();
4038
}
41-
const gitignorePath = nodeRuntime.path.join(process.cwd(), '.gitignore');
39+
return nodeRuntime.path;
40+
};
41+
42+
/**
43+
* The `.clerk/` directory is NOT safe to be committed as it may include sensitive information about a Clerk instance.
44+
* It may include an instance's secret key and the secret token for claiming that instance.
45+
*/
46+
function updateGitignore() {
47+
const { existsSync, writeFileSync, readFileSync, appendFileSync } = safeNodeRuntimeFs();
48+
49+
const path = safeNodeRuntimePath();
50+
const gitignorePath = path.join(process.cwd(), '.gitignore');
4251
if (!existsSync(gitignorePath)) {
4352
writeFileSync(gitignorePath, '');
4453
}
@@ -52,10 +61,8 @@ function updateGitignore() {
5261
}
5362

5463
const generatePath = (...slugs: string[]) => {
55-
if (!nodeRuntime.path) {
56-
throwMissingFsModule();
57-
}
58-
return nodeRuntime.path.join(process.cwd(), CLERK_HIDDEN, ...slugs);
64+
const path = safeNodeRuntimePath();
65+
return path.join(process.cwd(), CLERK_HIDDEN, ...slugs);
5966
};
6067

6168
const _TEMP_DIR_NAME = '.tmp';
@@ -64,11 +71,8 @@ const getKeylessReadMePath = () => generatePath(_TEMP_DIR_NAME, 'README.md');
6471

6572
let isCreatingFile = false;
6673

67-
function safeParseClerkFile(): AccountlessApplication | undefined {
68-
if (!nodeRuntime.fs) {
69-
throwMissingFsModule();
70-
}
71-
const { readFileSync } = nodeRuntime.fs;
74+
export function safeParseClerkFile(): AccountlessApplication | undefined {
75+
const { readFileSync } = safeNodeRuntimeFs();
7276
try {
7377
const CONFIG_PATH = getKeylessConfigurationPath();
7478
let fileAsString;
@@ -87,20 +91,11 @@ const createMessage = (keys: AccountlessApplication) => {
8791
return `\n\x1b[35m\n[Clerk]:\x1b[0m You are running in keyless mode.\nYou can \x1b[35mclaim your keys\x1b[0m by visiting ${keys.claimUrl}\n`;
8892
};
8993

90-
async function createOrReadKeyless(): Promise<AccountlessApplication | undefined> {
91-
if (!nodeRuntime.fs) {
92-
// This should never happen.
93-
throwMissingFsModule();
94-
}
95-
const { existsSync, writeFileSync, mkdirSync, rmSync } = nodeRuntime.fs;
96-
97-
/**
98-
* If another request is already in the process of acquiring keys return early.
99-
* Using both an in-memory and file system lock seems to be the most effective solution.
100-
*/
101-
if (isCreatingFile || existsSync(CLERK_LOCK)) {
102-
return undefined;
103-
}
94+
/**
95+
* Using both an in-memory and file system lock seems to be the most effective solution.
96+
*/
97+
const lockFileWriting = () => {
98+
const { writeFileSync } = safeNodeRuntimeFs();
10499

105100
isCreatingFile = true;
106101

@@ -114,6 +109,37 @@ async function createOrReadKeyless(): Promise<AccountlessApplication | undefined
114109
flag: 'w',
115110
},
116111
);
112+
};
113+
114+
const unlockFileWriting = () => {
115+
const { rmSync } = safeNodeRuntimeFs();
116+
117+
try {
118+
rmSync(CLERK_LOCK, { force: true, recursive: true });
119+
} catch (e) {
120+
// Simply ignore if the removal of the directory/file fails
121+
}
122+
123+
isCreatingFile = false;
124+
};
125+
126+
const isFileWritingLocked = () => {
127+
const { existsSync } = safeNodeRuntimeFs();
128+
return isCreatingFile || existsSync(CLERK_LOCK);
129+
};
130+
131+
async function createOrReadKeyless(): Promise<AccountlessApplication | undefined> {
132+
const { writeFileSync, mkdirSync } = safeNodeRuntimeFs();
133+
134+
/**
135+
* If another request is already in the process of acquiring keys return early.
136+
* Using both an in-memory and file system lock seems to be the most effective solution.
137+
*/
138+
if (isFileWritingLocked()) {
139+
return undefined;
140+
}
141+
142+
lockFileWriting();
117143

118144
const CONFIG_PATH = getKeylessConfigurationPath();
119145
const README_PATH = getKeylessReadMePath();
@@ -126,8 +152,7 @@ async function createOrReadKeyless(): Promise<AccountlessApplication | undefined
126152
*/
127153
const envVarsMap = safeParseClerkFile();
128154
if (envVarsMap?.publishableKey && envVarsMap?.secretKey) {
129-
isCreatingFile = false;
130-
rmSync(CLERK_LOCK, { force: true, recursive: true });
155+
unlockFileWriting();
131156

132157
/**
133158
* Notify developers.
@@ -169,10 +194,34 @@ This directory is auto-generated from \`@clerk/nextjs\` because you are running
169194
/**
170195
* Clean up locks.
171196
*/
172-
rmSync(CLERK_LOCK, { force: true, recursive: true });
173-
isCreatingFile = false;
197+
unlockFileWriting();
174198

175199
return accountlessApplication;
176200
}
177201

178-
export { createOrReadKeyless };
202+
function removeKeyless() {
203+
const { rmSync } = safeNodeRuntimeFs();
204+
205+
/**
206+
* If another request is already in the process of acquiring keys return early.
207+
* Using both an in-memory and file system lock seems to be the most effective solution.
208+
*/
209+
if (isFileWritingLocked()) {
210+
return undefined;
211+
}
212+
213+
lockFileWriting();
214+
215+
try {
216+
rmSync(generatePath(), { force: true, recursive: true });
217+
} catch (e) {
218+
// Simply ignore if the removal of the directory/file fails
219+
}
220+
221+
/**
222+
* Clean up locks.
223+
*/
224+
unlockFileWriting();
225+
}
226+
227+
export { createOrReadKeyless, removeKeyless };

0 commit comments

Comments
 (0)