Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,8 @@ void (async () => {
},
'/oauth-consent': () => {
const searchParams = new URLSearchParams(window.location.search);
const clientId = searchParams.get('client_id');
const scope = searchParams.get('scope');
const scopes = (searchParams.get('scopes')?.split(',') ?? []).map(scope => ({
scope,
description: scope === 'offline_access' ? null : `Grants access to your ${scope}`,
Expand All @@ -397,6 +399,7 @@ void (async () => {
Clerk.__internal_mountOAuthConsent(
app,
componentControls.oauthConsent.getProps() ?? {
...(clientId ? { clientId, scope: scope ?? undefined } : {}),
scopes,
oAuthApplicationName: searchParams.get('oauth-application-name'),
redirectUrl: searchParams.get('redirect_uri'),
Expand Down
22 changes: 22 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1331,6 +1331,28 @@ export class Clerk implements ClerkInterface {
void this.#clerkUI?.then(ui => ui.ensureMounted()).then(controls => controls.unmountComponent({ node }));
};

public __internal_fetchOAuthConsent = async (
clientId: string,
params?: { scope?: string },
): Promise<__internal_OAuthConsentProps> => {
const search: Record<string, string> = {};
if (params?.scope) {
search.scope = params.scope;
}

const response = await this.#fapiClient.request<__internal_OAuthConsentProps>({
method: 'GET',
path: `/me/oauth/consent/${encodeURIComponent(clientId)}`,
search,
});

if (!response.payload?.response) {
throw new Error('Failed to fetch OAuth consent details');
}

return response.payload.response;
};

/**
* @experimental This API is in early access and may change in future releases.
*
Expand Down
8 changes: 8 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,14 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
}
};

__internal_fetchOAuthConsent = async (
clientId: string,
params?: { scope?: string },
): Promise<__internal_OAuthConsentProps> => {
const clerkjs = await this.#waitForClerkJS();
return clerkjs.__internal_fetchOAuthConsent(clientId, params);
};

mountTaskChooseOrganization = (node: HTMLDivElement, props?: TaskChooseOrganizationProps): void => {
if (this.clerkjs && this.loaded) {
this.clerkjs.mountTaskChooseOrganization(node, props);
Expand Down
33 changes: 28 additions & 5 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,18 @@ export interface Clerk {
*/
__internal_unmountOAuthConsent: (targetNode: HTMLDivElement) => void;

/**
* Fetches OAuth consent details from the FAPI for the given client_id.
*
* @param clientId - The OAuth application client_id.
* @param params - Optional query parameters for the request.
* @param params.scope - Space-delimited list of OAuth scopes.
*/
__internal_fetchOAuthConsent: (
clientId: string,
params?: { scope?: string },
) => Promise<__internal_OAuthConsentProps>;

/**
* Mounts a TaskChooseOrganization component at the target element.
*
Expand Down Expand Up @@ -2271,10 +2283,21 @@ export type __experimental_SubscriptionDetailsButtonProps = {

export type __internal_OAuthConsentProps = {
appearance?: ClerkAppearanceTheme;
/**
* The OAuth application's client_id. When provided, the component will
* fetch consent details from the FAPI endpoint
* `/v1/me/oauth/consent/{clientId}`.
*/
clientId?: string;
/**
* Space-delimited list of OAuth scopes requested by the client.
* Sent as the `scope` query parameter when fetching consent details.
*/
scope?: string;
/**
* Name of the OAuth application.
*/
oAuthApplicationName: string;
oAuthApplicationName?: string;
/**
* Logo URL of the OAuth application.
*/
Expand All @@ -2286,23 +2309,23 @@ export type __internal_OAuthConsentProps = {
/**
* Scopes requested by the OAuth application.
*/
scopes: {
scopes?: {
scope: string;
description: string | null;
requires_consent: boolean;
}[];
/**
* Full URL or path to navigate to after the user allows access.
*/
redirectUrl: string;
redirectUrl?: string;
/**
* Called when user allows access.
*/
onAllow: () => void;
onAllow?: () => void;
/**
* Called when user denies access.
*/
onDeny: () => void;
onDeny?: () => void;
};

export interface HandleEmailLinkVerificationParams {
Expand Down
65 changes: 60 additions & 5 deletions packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useUser } from '@clerk/shared/react';
import { useClerk, useUser } from '@clerk/shared/react';
import type { __internal_OAuthConsentProps } from '@clerk/shared/types';
import type { ComponentProps } from 'react';
import { useState } from 'react';
import { useEffect, useState } from 'react';

import { useEnvironment, useOAuthConsentContext } from '@/ui/contexts';
import { Box, Button, Flex, Flow, Grid, Icon, Text } from '@/ui/customizables';
Expand All @@ -11,19 +12,55 @@ import { Header } from '@/ui/elements/Header';
import { Modal } from '@/ui/elements/Modal';
import { Tooltip } from '@/ui/elements/Tooltip';
import { LockDottedCircle } from '@/ui/icons';
import { Alert, Textarea } from '@/ui/primitives';
import { Alert, Spinner, Textarea } from '@/ui/primitives';
import type { ThemableCssProp } from '@/ui/styledSystem';
import { common } from '@/ui/styledSystem';
import { colors } from '@/ui/utils/colors';

const OFFLINE_ACCESS_SCOPE = 'offline_access';

export function OAuthConsentInternal() {
const { scopes, oAuthApplicationName, oAuthApplicationLogoUrl, oAuthApplicationUrl, redirectUrl, onDeny, onAllow } =
useOAuthConsentContext();
const ctx = useOAuthConsentContext();
const clerk = useClerk();
const { user } = useUser();
const { applicationName, logoImageUrl } = useEnvironment().displayConfig;
const [isUriModalOpen, setIsUriModalOpen] = useState(false);
const [fetchedData, setFetchedData] = useState<__internal_OAuthConsentProps | null>(null);
const [isLoading, setIsLoading] = useState(!!ctx.clientId);

useEffect(() => {
if (!ctx.clientId) {
return;
}

let cancelled = false;
setIsLoading(true);

clerk
.__internal_fetchOAuthConsent(ctx.clientId, { scope: ctx.scope })
.then(data => {
if (!cancelled) {
setFetchedData(data);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});

return () => {
cancelled = true;
};
}, [ctx.clientId, ctx.scope, clerk]);

const scopes = fetchedData?.scopes ?? ctx.scopes;
const oAuthApplicationName = fetchedData?.oAuthApplicationName ?? ctx.oAuthApplicationName ?? '';
const oAuthApplicationLogoUrl = fetchedData?.oAuthApplicationLogoUrl ?? ctx.oAuthApplicationLogoUrl;
const oAuthApplicationUrl = fetchedData?.oAuthApplicationUrl ?? ctx.oAuthApplicationUrl;
const redirectUrl = fetchedData?.redirectUrl ?? ctx.redirectUrl ?? '';
const onAllow = ctx.onAllow ?? (() => {});
const onDeny = ctx.onDeny ?? (() => {});

const primaryIdentifier = user?.primaryEmailAddress?.emailAddress || user?.primaryPhoneNumber?.phoneNumber;

Expand All @@ -40,6 +77,24 @@ export function OAuthConsentInternal() {
}
}

if (isLoading) {
return (
<Flow.Root flow='oauthConsent'>
<Card.Root>
<Card.Content>
<Flex
justify='center'
align='center'
sx={t => ({ padding: t.space.$10 })}
>
<Spinner />
</Flex>
</Card.Content>
</Card.Root>
</Flow.Root>
);
}

return (
<Flow.Root flow='oauthConsent'>
<Card.Root>
Expand Down
Loading