Skip to content

Commit

Permalink
Merge branch 'main' into brk.feat/render-ui-in-clerk-js
Browse files Browse the repository at this point in the history
  • Loading branch information
dstaley committed Oct 16, 2024
2 parents 163ef01 + 0fdc56c commit 5035d98
Show file tree
Hide file tree
Showing 47 changed files with 755 additions and 167 deletions.
26 changes: 26 additions & 0 deletions .changeset/clean-mugs-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@clerk/clerk-react": minor
---

Introducing experimental `asProvider`, `asStandalone`, and `<X.Outlet />` for `<UserButton />` and `<OrganizationSwitcher />` components.
- `asProvider` converts `<UserButton />` and `<OrganizationSwitcher />` to a provider that defers rendering until `<Outlet />` is mounted.
- `<Outlet />` also accepts a `asStandalone` prop. It will skip the trigger of these components and display only the UI which was previously inside the popover. This allows developers to create their own triggers.

Example usage:
```tsx
<UserButton __experimental_asProvider afterSignOutUrl='/'>
<UserButton.UserProfilePage label="Custom Page" url="/custom-page">
<h1> This is my page available to all children </h1>
</UserButton.UserProfilePage>
<UserButton.__experimental_Outlet __experimental_asStandalone />
</UserButton>
```

```tsx
<OrganizationSwitcher __experimental_asProvider afterSignOutUrl='/'>
<OrganizationSwitcher.OrganizationProfilePage label="Custom Page" url="/custom-page">
<h1> This is my page available to all children </h1>
</OrganizationSwitcher.OrganizationProfilePage>
<OrganizationSwitcher.__experimental_Outlet __experimental_asStandalone />
</OrganizationSwitcher>
```
7 changes: 7 additions & 0 deletions .changeset/fluffy-goats-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@clerk/clerk-js": minor
"@clerk/types": minor
---

Drop `maxAgeMinutes` from `__experimental_startVerification`.
Drop types `__experimental_SessionVerificationConfig` and `__experimental_SessionVerificationMaxAgeMinutes`.
11 changes: 11 additions & 0 deletions .changeset/gorgeous-teachers-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@clerk/express": minor
---

Export [`authenticateRequest` method](https://clerk.com/docs/references/backend/authenticate-request) from `@clerk/express` (in case you want to go low-level and implement flows to your specific needs). You can use it like so:

```ts
import { authenticateRequest } from "@clerk/express"
```

This function is adapted to Express' Request wrapper and as such notably different to the exported function from `@clerk/backend`. If you need to use it, be sure to import from `@clerk/express`.
7 changes: 7 additions & 0 deletions .changeset/new-bananas-complain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@clerk/localizations": minor
"@clerk/clerk-js": minor
"@clerk/types": minor
---

The "Restricted access" screen has been improved for visual consistency and the ability to contact support. The displayed texts have been made more clear and the sign-in button has been moved to the bottom.
12 changes: 12 additions & 0 deletions .changeset/shaggy-kids-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Add experimental standalone mode for `<UserButton />` and `<OrganizationSwitcher />`.
When `__experimental_asStandalone: true` the component will not render its trigger, and instead it will render only the contents of the popover in place.

APIs that changed:
- (For internal usage) Added `__experimental_prefetchOrganizationSwitcher` as a way to mount an internal component that will render the `useOrganizationList()` hook and prefetch the necessary data for the popover of `<OrganizationSwitcher />`. This enhances the UX since no loading state will be visible and keeps CLS to the minimum.
- New property for `mountOrganizationSwitcher(node, { __experimental_asStandalone: true })`
- New property for `mountUserButton(node, { __experimental_asStandalone: true })`
7 changes: 7 additions & 0 deletions .changeset/violet-games-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@clerk/clerk-js": minor
"@clerk/backend": minor
"@clerk/types": minor
---

Use EIP-4361 message spec for Web3 wallets sign in signature requests
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { UserButton } from '@clerk/clerk-react';
import { PropsWithChildren, useContext, useState } from 'react';
import { PageContext, PageContextProvider } from '../PageContext.tsx';

function Page1() {
const { counter, setCounter } = useContext(PageContext);

return (
<>
<h1 data-page={1}>Page 1</h1>
<p data-page={1}>Counter: {counter}</p>
<button
data-page={1}
onClick={() => setCounter(a => a + 1)}
>
Update
</button>
</>
);
}

function ToggleChildren(props: PropsWithChildren) {
const [isMounted, setMounted] = useState(false);

return (
<>
<button
data-toggle-btn
onClick={() => setMounted(v => !v)}
>
Toggle
</button>
{isMounted ? props.children : null}
</>
);
}

export default function Page() {
return (
<PageContextProvider>
<UserButton __experimental_asProvider>
<UserButton.UserProfilePage
label={'Page 1'}
labelIcon={<p data-label-icon={'page-1'}>🙃</p>}
url='page-1'
>
<Page1 />
</UserButton.UserProfilePage>
<UserButton.UserProfilePage label={'security'} />
<UserButton.UserProfilePage
label={'Page 2'}
labelIcon={<p data-label-icon={'page-2'}>🙃</p>}
url='page-2'
>
<h1>Page 2</h1>
</UserButton.UserProfilePage>
<p data-leaked-child>This is leaking</p>
<UserButton.UserProfileLink
url={'https://clerk.com'}
label={'Visit Clerk'}
labelIcon={<p data-label-icon={'page-3'}>🌐</p>}
/>
<UserButton.MenuItems>
<UserButton.Action
label={'page-1'}
labelIcon={<span>🙃</span>}
open={'page-1'}
/>
<UserButton.Action label={'manageAccount'} />
<UserButton.Action label={'signOut'} />
<UserButton.Link
href={'http://clerk.com'}
label={'Visit Clerk'}
labelIcon={<span>🌐</span>}
/>

<UserButton.Link
href={'/user'}
label={'Visit User page'}
labelIcon={<span>🌐</span>}
/>

<UserButton.Action
label={'Custom Alert'}
labelIcon={<span>🔔</span>}
onClick={() => alert('custom-alert')}
/>
</UserButton.MenuItems>
<UserButton.UserProfileLink
url={'/user'}
label={'Visit User page'}
labelIcon={<p data-label-icon={'page-4'}>🌐</p>}
/>
<ToggleChildren>
<UserButton.__experimental_Outlet __experimental_asStandalone />
</ToggleChildren>
</UserButton>
</PageContextProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default function Page() {
>
<h1>Page 2</h1>
</UserButton.UserProfilePage>
🌐
<p data-leaked-child>This is leaking</p>
<UserButton.UserProfileLink
url={'https://clerk.com'}
label={'Visit Clerk'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default function Page() {
>
<h1>Page 2</h1>
</UserProfile.Page>
🌐
<p data-leaked-child>This is leaking</p>
<UserProfile.Link
url={'https://clerk.com'}
label={'Visit Clerk'}
Expand Down
5 changes: 5 additions & 0 deletions integration/templates/react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import SignUp from './sign-up';
import UserProfile from './user';
import UserProfileCustom from './custom-user-profile';
import UserButtonCustom from './custom-user-button';
import UserButtonCustomTrigger from './custom-user-button-trigger';

const Root = () => {
const navigate = useNavigate();
Expand Down Expand Up @@ -64,6 +65,10 @@ const router = createBrowserRouter([
path: '/custom-user-button',
element: <UserButtonCustom />,
},
{
path: '/custom-user-button-trigger',
element: <UserButtonCustomTrigger />,
},
],
},
]);
Expand Down
68 changes: 67 additions & 1 deletion integration/tests/custom-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createTestUtils, testAgainstRunningApps } from '../testUtils';

const CUSTOM_PROFILE_PAGE = '/custom-user-profile';
const CUSTOM_BUTTON_PAGE = '/custom-user-button';
const CUSTOM_BUTTON_TRIGGER_PAGE = '/custom-user-button-trigger';

async function waitForMountedComponent(
component: 'UserButton' | 'UserProfile',
Expand Down Expand Up @@ -106,11 +107,29 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })(
await u.page.waitForSelector('p[data-page="1"]', { state: 'attached' });

await expect(u.page.locator('p[data-page="1"]')).toHaveText('Counter: 0');
u.page.locator('button[data-page="1"]').click();
await u.page.locator('button[data-page="1"]').click();

await expect(u.page.locator('p[data-page="1"]')).toHaveText('Counter: 1');
});

test('renders only custom pages and does not display unrelated child components', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

await waitForMountedComponent(component, u);

const buttons = await u.page.locator('button.cl-navbarButton__custom-page-0').all();
expect(buttons.length).toBe(1);
const [profilePage] = buttons;
await expect(profilePage.locator('div.cl-navbarButtonIcon__custom-page-0')).toHaveText('🙃');
await profilePage.click();

await expect(u.page.locator('p[data-leaked-child]')).toBeHidden();
});

test('user profile custom external absolute link', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
Expand Down Expand Up @@ -149,6 +168,53 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })(
});
});

test.describe('User Button with experimental asStandalone and asProvider', () => {
test('items at the specified order', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative(CUSTOM_BUTTON_TRIGGER_PAGE);
const toggleButton = await u.page.waitForSelector('button[data-toggle-btn]');
await toggleButton.click();

await u.po.userButton.waitForPopover();
await u.po.userButton.triggerManageAccount();
await u.po.userProfile.waitForMounted();

const pagesContainer = u.page.locator('div.cl-navbarButtons').first();

const buttons = await pagesContainer.locator('button').all();

expect(buttons.length).toBe(6);

const expectedTexts = ['Profile', '🙃Page 1', 'Security', '🙃Page 2', '🌐Visit Clerk', '🌐Visit User page'];
for (let i = 0; i < buttons.length; i++) {
await expect(buttons[i]).toHaveText(expectedTexts[i]);
}
});

test('children should be leaking when used with asProvider', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative(CUSTOM_BUTTON_TRIGGER_PAGE);
const toggleButton = await u.page.waitForSelector('button[data-toggle-btn]');
await toggleButton.click();

await u.po.userButton.waitForPopover();
await u.po.userButton.triggerManageAccount();
await u.po.userProfile.waitForMounted();

await expect(u.page.locator('p[data-leaked-child]')).toBeVisible();
});
});

test.describe('User Button custom items', () => {
test('items at the specified order', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
Expand Down
4 changes: 2 additions & 2 deletions integration/tests/restricted-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ export default function Page() {
await u.po.signUp.goTo();
await u.po.signUp.waitForMounted();

await expect(u.page.getByText(/Restricted access/i).first()).toBeVisible();
const backToSignIn = u.page.getByRole('link', { name: /Back to sign in/i });
await expect(u.page.getByText(/Access restricted/i).first()).toBeVisible();
const backToSignIn = u.page.getByRole('link', { name: /Sign in/i });
await backToSignIn.click();

await u.po.signUp.waitForMounted();
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/api/resources/JSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ export interface VerificationJSON extends ClerkResourceJSON {
verified_at_client?: string;
external_verification_redirect_url?: string | null;
nonce?: string | null;
message?: string | null;
}

export interface Web3WalletJSON extends ClerkResourceJSON {
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/api/resources/Verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export class Verification {
readonly attempts: number | null = null,
readonly expireAt: number | null = null,
readonly nonce: string | null = null,
readonly message: string | null = null,
) {}

static fromJSON(data: VerificationJSON): Verification {
Expand Down
7 changes: 7 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,13 @@ export class Clerk implements ClerkInterface {
void this.#componentControls?.ensureMounted().then(controls => controls.unmountComponent({ node }));
};

public __experimental_prefetchOrganizationSwitcher = () => {
this.assertComponentsReady(this.#componentControls);
void this.#componentControls
?.ensureMounted({ preloadHint: 'OrganizationSwitcher' })
.then(controls => controls.prefetch('organizationSwitcher'));
};

public mountOrganizationList = (node: HTMLDivElement, props?: OrganizationListProps) => {
this.assertComponentsReady(this.#componentControls);
if (disabledOrganizationsFeature(this, this.environment)) {
Expand Down
2 changes: 0 additions & 2 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,13 @@ export class Session extends BaseResource implements SessionResource {

__experimental_startVerification = async ({
level,
maxAgeMinutes,
}: __experimental_SessionVerifyCreateParams): Promise<__experimental_SessionVerificationResource> => {
const json = (
await BaseResource._fetch({
method: 'POST',
path: `/client/sessions/${this.id}/verify`,
body: {
level,
maxAgeMinutes,
} as any,
})
)?.response as unknown as __experimental_SessionVerificationJSON;
Expand Down
8 changes: 4 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,14 +250,14 @@ export class SignIn extends BaseResource implements SignInResource {

await this.prepareFirstFactor(web3FirstFactor);

const { nonce } = this.firstFactorVerification;
if (!nonce) {
const { message } = this.firstFactorVerification;
if (!message) {
clerkVerifyWeb3WalletCalledBeforeCreate('SignIn');
}

let signature: string;
try {
signature = await generateSignature({ identifier, nonce, provider });
signature = await generateSignature({ identifier, nonce: message, provider });
} catch (err) {
// There is a chance that as a user when you try to setup and use the Coinbase Wallet with an existing
// Passkey in order to authenticate, the initial generate signature request to be rejected. For this
Expand All @@ -266,7 +266,7 @@ export class SignIn extends BaseResource implements SignInResource {
// error code 4001 means the user rejected the request
// Reference: https://docs.cdp.coinbase.com/wallet-sdk/docs/errors
if (provider === 'coinbase_wallet' && err.code === 4001) {
signature = await generateSignature({ identifier, nonce, provider });
signature = await generateSignature({ identifier, nonce: message, provider });
} else {
throw err;
}
Expand Down
Loading

0 comments on commit 5035d98

Please sign in to comment.