diff --git a/.changeset/thin-wolves-camp.md b/.changeset/thin-wolves-camp.md new file mode 100644 index 00000000000..20e9f51b749 --- /dev/null +++ b/.changeset/thin-wolves-camp.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/nextjs': minor +'@clerk/types': minor +--- + +Display keyless prompt until the developer manually dismisses it. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 5bab893c45a..7335d9c08dd 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -18,6 +18,6 @@ { "path": "./dist/userverification*.js", "maxSize": "5KB" }, { "path": "./dist/onetap*.js", "maxSize": "1KB" }, { "path": "./dist/waitlist*.js", "maxSize": "1.3KB" }, - { "path": "./dist/keylessPrompt*.js", "maxSize": "4.9KB" } + { "path": "./dist/keylessPrompt*.js", "maxSize": "5.5KB" } ] } diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 8862630bca0..dc1701b95cd 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2081,12 +2081,14 @@ export class Clerk implements ClerkInterface { }; #handleKeylessPrompt = () => { - if (this.#options.__internal_claimKeylessApplicationUrl) { + if (this.#options.__internal_keyless_claimKeylessApplicationUrl) { void this.#componentControls?.ensureMounted().then(controls => { + // TODO(@pantelis): Investigate if this resets existing props controls.updateProps({ options: { - __internal_claimKeylessApplicationUrl: this.#options.__internal_claimKeylessApplicationUrl, - __internal_copyInstanceKeysUrl: this.#options.__internal_copyInstanceKeysUrl, + __internal_keyless_claimKeylessApplicationUrl: this.#options.__internal_keyless_claimKeylessApplicationUrl, + __internal_keyless_copyInstanceKeysUrl: this.#options.__internal_keyless_copyInstanceKeysUrl, + __internal_keyless_dismissPrompt: this.#options.__internal_keyless_dismissPrompt, }, }); }); diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index 0e35b0a7236..eac3243a9b5 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -517,14 +517,16 @@ const Components = (props: ComponentsProps) => { )} - {state.options?.__internal_claimKeylessApplicationUrl && state.options?.__internal_copyInstanceKeysUrl && ( - - - - )} + {state.options?.__internal_keyless_claimKeylessApplicationUrl && + state.options?.__internal_keyless_copyInstanceKeysUrl && ( + + + + )} {state.organizationSwitcherPrefetch && } diff --git a/packages/clerk-js/src/ui/components/KeylessPrompt/index.tsx b/packages/clerk-js/src/ui/components/KeylessPrompt/index.tsx index 9f13df41e1f..125a1a1bd50 100644 --- a/packages/clerk-js/src/ui/components/KeylessPrompt/index.tsx +++ b/packages/clerk-js/src/ui/components/KeylessPrompt/index.tsx @@ -1,7 +1,7 @@ // eslint-disable-next-line no-restricted-imports import { css } from '@emotion/react'; import type { PropsWithChildren } from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { Flex } from '../../customizables'; @@ -14,6 +14,7 @@ import { useRevalidateEnvironment } from './use-revalidate-environment'; type KeylessPromptProps = { claimUrl: string; copyKeysUrl: string; + onDismiss: (() => Promise) | undefined; }; const buttonIdentifierPrefix = `--clerk-keyless-prompt`; @@ -25,11 +26,22 @@ const _KeylessPrompt = (_props: KeylessPromptProps) => { const environment = useRevalidateEnvironment(); const claimed = Boolean(environment.authConfig.claimedAt); - const success = false; + const success = typeof _props.onDismiss === 'function' && claimed; const appName = environment.displayConfig.applicationName; const isForcedExpanded = claimed || success || isExpanded; + const urlToDashboard = useMemo(() => { + if (claimed) { + return _props.copyKeysUrl; + } + + const url = new URL(_props.claimUrl); + // Clerk Dashboard accepts a `return_url` query param when visiting `/apps/claim`. + url.searchParams.append('return_url', window.location.href); + return url.href; + }, [claimed, _props.copyKeysUrl, _props.claimUrl]); + const baseElementStyles = css` box-sizing: border-box; padding: 0; @@ -71,6 +83,7 @@ const _KeylessPrompt = (_props: KeylessPromptProps) => { text-shadow: 0px 1px 2px rgba(0, 0, 0, 0.32); white-space: nowrap; user-select: none; + cursor: pointer; background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30.5%, rgba(0, 0, 0, 0.05) 100%), #454545; box-shadow: 0px 0px 0px 1px rgba(255, 255, 255, 0.04) inset, @@ -279,6 +292,7 @@ const _KeylessPrompt = (_props: KeylessPromptProps) => { color: #8c8c8c; transition: color 130ms ease-out; display: ${isExpanded && !claimed && !success ? 'block' : 'none'}; + cursor: pointer; :hover { color: #eeeeee; @@ -374,6 +388,10 @@ const _KeylessPrompt = (_props: KeylessPromptProps) => { (success ? ( ) : ( m.removeKeyless()); + return; +} diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 6f7b8f8c97d..541d8c405f8 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -4,11 +4,13 @@ import React from 'react'; import { PromisifiedAuthProvider } from '../../client-boundary/PromisifiedAuthProvider'; import { getDynamicAuthData } from '../../server/buildClerkProps'; +import { safeParseClerkFile } from '../../server/keyless-node'; import type { NextClerkProviderProps } from '../../types'; import { canUseKeyless } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; import { isNext13 } from '../../utils/sdk-versions'; import { ClientClerkProvider } from '../client/ClerkProvider'; +import { deleteKeylessAction } from '../keyless-actions'; import { buildRequestLike, getScriptNonceFromHeader } from './utils'; const getDynamicClerkState = React.cache(async function getDynamicClerkState() { @@ -69,7 +71,8 @@ export async function ClerkProvider( ); - const shouldRunAsKeyless = !propsWithEnvs.publishableKey && canUseKeyless; + const runningWithClaimedKeys = propsWithEnvs.publishableKey === safeParseClerkFile()?.publishableKey; + const shouldRunAsKeyless = (!propsWithEnvs.publishableKey || runningWithClaimedKeys) && canUseKeyless; if (shouldRunAsKeyless) { // NOTE: Create or read keys on every render. Usually this means only on hard refresh or hard navigations. @@ -77,22 +80,27 @@ export async function ClerkProvider( if (newOrReadKeys) { const KeylessCookieSync = await import('../client/keyless-cookie-sync.js').then(mod => mod.KeylessCookieSync); - output = ( - - - {children} - - + const clientProvider = ( + + {children} + ); + + if (runningWithClaimedKeys) { + output = clientProvider; + } else { + output = {clientProvider}; + } } } diff --git a/packages/nextjs/src/server/keyless-node.ts b/packages/nextjs/src/server/keyless-node.ts index 28226dba7a6..21c9dee3987 100644 --- a/packages/nextjs/src/server/keyless-node.ts +++ b/packages/nextjs/src/server/keyless-node.ts @@ -25,20 +25,29 @@ const throwMissingFsModule = () => { throw "Clerk: fsModule.fs is missing. This is an internal error. Please contact Clerk's support."; }; -/** - * The `.clerk/` is NOT safe to be commited as it may include sensitive information about a Clerk instance. - * It may include an instance's secret key and the secret token for claiming that instance. - */ -function updateGitignore() { +const safeNodeRuntimeFs = () => { if (!nodeRuntime.fs) { throwMissingFsModule(); } - const { existsSync, writeFileSync, readFileSync, appendFileSync } = nodeRuntime.fs; + return nodeRuntime.fs; +}; +const safeNodeRuntimePath = () => { if (!nodeRuntime.path) { throwMissingFsModule(); } - const gitignorePath = nodeRuntime.path.join(process.cwd(), '.gitignore'); + return nodeRuntime.path; +}; + +/** + * The `.clerk/` directory is NOT safe to be committed as it may include sensitive information about a Clerk instance. + * It may include an instance's secret key and the secret token for claiming that instance. + */ +function updateGitignore() { + const { existsSync, writeFileSync, readFileSync, appendFileSync } = safeNodeRuntimeFs(); + + const path = safeNodeRuntimePath(); + const gitignorePath = path.join(process.cwd(), '.gitignore'); if (!existsSync(gitignorePath)) { writeFileSync(gitignorePath, ''); } @@ -52,10 +61,8 @@ function updateGitignore() { } const generatePath = (...slugs: string[]) => { - if (!nodeRuntime.path) { - throwMissingFsModule(); - } - return nodeRuntime.path.join(process.cwd(), CLERK_HIDDEN, ...slugs); + const path = safeNodeRuntimePath(); + return path.join(process.cwd(), CLERK_HIDDEN, ...slugs); }; const _TEMP_DIR_NAME = '.tmp'; @@ -64,11 +71,8 @@ const getKeylessReadMePath = () => generatePath(_TEMP_DIR_NAME, 'README.md'); let isCreatingFile = false; -function safeParseClerkFile(): AccountlessApplication | undefined { - if (!nodeRuntime.fs) { - throwMissingFsModule(); - } - const { readFileSync } = nodeRuntime.fs; +export function safeParseClerkFile(): AccountlessApplication | undefined { + const { readFileSync } = safeNodeRuntimeFs(); try { const CONFIG_PATH = getKeylessConfigurationPath(); let fileAsString; @@ -87,20 +91,11 @@ const createMessage = (keys: AccountlessApplication) => { 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`; }; -async function createOrReadKeyless(): Promise { - if (!nodeRuntime.fs) { - // This should never happen. - throwMissingFsModule(); - } - const { existsSync, writeFileSync, mkdirSync, rmSync } = nodeRuntime.fs; - - /** - * If another request is already in the process of acquiring keys return early. - * Using both an in-memory and file system lock seems to be the most effective solution. - */ - if (isCreatingFile || existsSync(CLERK_LOCK)) { - return undefined; - } +/** + * Using both an in-memory and file system lock seems to be the most effective solution. + */ +const lockFileWriting = () => { + const { writeFileSync } = safeNodeRuntimeFs(); isCreatingFile = true; @@ -114,6 +109,37 @@ async function createOrReadKeyless(): Promise { + const { rmSync } = safeNodeRuntimeFs(); + + try { + rmSync(CLERK_LOCK, { force: true, recursive: true }); + } catch (e) { + // Simply ignore if the removal of the directory/file fails + } + + isCreatingFile = false; +}; + +const isFileWritingLocked = () => { + const { existsSync } = safeNodeRuntimeFs(); + return isCreatingFile || existsSync(CLERK_LOCK); +}; + +async function createOrReadKeyless(): Promise { + const { writeFileSync, mkdirSync } = safeNodeRuntimeFs(); + + /** + * If another request is already in the process of acquiring keys return early. + * Using both an in-memory and file system lock seems to be the most effective solution. + */ + if (isFileWritingLocked()) { + return undefined; + } + + lockFileWriting(); const CONFIG_PATH = getKeylessConfigurationPath(); const README_PATH = getKeylessReadMePath(); @@ -126,8 +152,7 @@ async function createOrReadKeyless(): Promise Promise; }; export interface NavigateOptions {