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
2 changes: 1 addition & 1 deletion packages/clerk-js/sandbox/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@
<script
type="text/javascript"
src="/<%= htmlRspackPlugin.files.js[0] %>"
data-clerk-publishable-key="pk_test_c3VtbWFyeS1tYW4tOTkuY2xlcmsuYWNjb3VudHNzdGFnZS5kZXYk"
data-clerk-publishable-key="pk_test_bW9kZWwtbWFzdGlmZi04Ny5jbGVyay5hY2NvdW50c3N0YWdlLmRldiQ"
></script>
<script
type="text/javascript"
Expand Down
187 changes: 57 additions & 130 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { __internal_useUserEnterpriseConnections, useOrganization, useUser } from '@clerk/shared/react';
import { __internal_useUserEnterpriseConnections, useUser } from '@clerk/shared/react';
import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types';
import React from 'react';

import { useEnvironment, withCoreUserGuard } from '@/contexts';
import { Box, Col, descriptors, Flex, Flow, Icon, localizationKeys, Text, useAppearance } from '@/customizables';
import { ApplicationLogo } from '@/elements/ApplicationLogo';
import { withCoreUserGuard } from '@/contexts';
import { Col, descriptors, Flow } from '@/customizables';
import { withCardStateProvider } from '@/elements/contexts';
import { NavBar, NavbarContextProvider } from '@/elements/Navbar';
import { ProfileCard } from '@/elements/ProfileCard';
import { BoxIcon } from '@/icons';
import { Route, Switch } from '@/router';

import { ConfigureSSOFlowProvider } from './ConfigureSSOContext';
import { ConfigureSSOFooter } from './ConfigureSSOFooter';
import { ConfigureSSOHeader } from './ConfigureSSOHeader';
import { ConfigureSSONavbar } from './ConfigureSSONavbar';
import { ConfigureSSOSkeleton } from './ConfigureSSOSkeleton';
import { Wizard } from './elements/Wizard';
import { ConfigureCreateApp, ConfirmationStep, ProvideEmail, TestConfigurationStep, VerifyDomainStep } from './steps';
import { ConfigureSSOWizard } from './wizard';

const ConfigureSSOInternal = () => {
return (
Expand All @@ -29,73 +30,12 @@ const ConfigureSSOInternal = () => {

const AuthenticatedContent = withCoreUserGuard(() => {
const contentRef = React.useRef<HTMLDivElement>(null);
const { applicationName, logoImageUrl } = useEnvironment().displayConfig;
const { organizationSettings } = useEnvironment();
const { parsedOptions } = useAppearance();
const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl);

const { data: enterpriseConnections, isLoading: isLoadingEnterpriseConnections } =
__internal_useUserEnterpriseConnections({ enabled: true });
// Currently FAPI only supports one enterprise connection per user
const enterpriseConnection = enterpriseConnections?.[0];

return (
<ProfileCard.Root
sx={t => ({ display: 'grid', gridTemplateColumns: '1fr 3fr', height: t.sizes.$176, overflow: 'hidden' })}
>
<NavbarContextProvider contentRef={contentRef}>
<NavBar
header={
<Flex
align='center'
sx={t => ({
gap: t.space.$2,
padding: `${t.space.$none} ${t.space.$3}`,
maxWidth: '100%',
})}
>
{hasLogo ? (
<ApplicationLogo
sx={t => ({ width: t.space.$9, height: t.space.$9, borderRadius: t.radii.$md, overflow: 'hidden' })}
/>
) : (
<Box
sx={t => ({
width: t.space.$9,
height: t.space.$9,
flexShrink: 0,
borderRadius: t.radii.$md,
backgroundColor: t.colors.$primary500,
color: t.colors.$colorPrimaryForeground,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
aria-hidden
>
<Icon
icon={BoxIcon}
sx={t => ({ width: t.sizes.$4, height: t.sizes.$4 })}
/>
</Box>
)}

<Col sx={{ minWidth: 0 }}>
<Text
as='p'
truncate
>
{applicationName}
</Text>
{organizationSettings.enabled && <OrganizationSidebarSubtitle />}
</Col>
</Flex>
}
titleSx={t => ({ fontSize: t.fontSizes.$lg })}
title={localizationKeys('configureSSO.navbar.title')}
routes={[]}
contentRef={contentRef}
/>
<ConfigureSSONavbar contentRef={contentRef}>
<Col
ref={contentRef}
elementDescriptor={descriptors.scrollBox}
Expand All @@ -108,100 +48,87 @@ const AuthenticatedContent = withCoreUserGuard(() => {
borderWidth: t.borderWidths.$normal,
borderStyle: t.borderStyles.$solid,
borderColor: t.colors.$borderAlpha150,
marginBlock: '-1px',
marginInlineEnd: '-1px',
flex: 1,
})}
>
<ConfigureSSOFlowProvider
enterpriseConnection={enterpriseConnection}
isLoading={isLoadingEnterpriseConnections}
>
<ConfigureSSOSteps />
</ConfigureSSOFlowProvider>
<ConfigureSSOCardContent />
</Col>
</NavbarContextProvider>
</ConfigureSSONavbar>
</ProfileCard.Root>
);
});

const ConfigureSSOCardContent = () => {
const { data: enterpriseConnections, isLoading } = __internal_useUserEnterpriseConnections({ enabled: true });
// Currently FAPI only supports one enterprise connection per user
const enterpriseConnection = enterpriseConnections?.[0];

// Initial-load gate at root — wizard never sees isLoading
if (isLoading && !enterpriseConnection) {
return <ConfigureSSOSkeleton />;
}

return (
<ConfigureSSOFlowProvider enterpriseConnection={enterpriseConnection}>
<Wizard>
<ConfigureSSOHeader />

<ConfigureSSOSteps />

<ConfigureSSOFooter />
</Wizard>
</ConfigureSSOFlowProvider>
);
};

const ConfigureSSOSteps = () => {
const { user } = useUser();

const primaryEmailAddress = user?.primaryEmailAddress;

return (
<ConfigureSSOWizard>
<ConfigureSSOWizard.Step
<>
<Wizard.Step
id='verify-email-domain'
path='verify-email-domain'
label='Verify domain'
>
<ConfigureSSOWizard>
<Wizard>
{!primaryEmailAddress && (
<ConfigureSSOWizard.Step
id='provide-email'
path='provide-email'
>
<Wizard.Step id='provide-email'>
<ProvideEmail />
</ConfigureSSOWizard.Step>
</Wizard.Step>
)}
<ConfigureSSOWizard.Step
id='verify-domain'
path='verify-domain'
>
<Wizard.Step id='verify-domain'>
<VerifyDomainStep />
</ConfigureSSOWizard.Step>
</ConfigureSSOWizard>
</ConfigureSSOWizard.Step>
<ConfigureSSOWizard.Step
</Wizard.Step>
</Wizard>
</Wizard.Step>

<Wizard.Step
id='configure'
path='configure'
label='Configure'
>
<ConfigureSSOWizard>
{/* TODO: Implement configure steps */}
<ConfigureSSOWizard.Step
id='create-app'
path='create-app'
>
<Wizard>
<Wizard.Step id='create-app'>
<ConfigureCreateApp />
</ConfigureSSOWizard.Step>
</ConfigureSSOWizard>
</ConfigureSSOWizard.Step>
<ConfigureSSOWizard.Step
</Wizard.Step>
</Wizard>
</Wizard.Step>

<Wizard.Step
id='test'
path='test'
label='Test'
>
<TestConfigurationStep />
</ConfigureSSOWizard.Step>
<ConfigureSSOWizard.Step
</Wizard.Step>

<Wizard.Step
id='confirmation'
path='confirmation'
label='Confirmation'
>
<ConfirmationStep />
</ConfigureSSOWizard.Step>
</ConfigureSSOWizard>
);
};

const OrganizationSidebarSubtitle = () => {
const { organization } = useOrganization();

if (!organization) {
return null;
}

return (
<Text
as='span'
truncate
sx={t => ({ color: t.colors.$colorMutedForeground })}
>
{organization?.name}
</Text>
</Wizard.Step>
</>
);
};

Expand Down
19 changes: 4 additions & 15 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,39 +11,28 @@ export interface ConfigureSSOData {
enterpriseConnection: EnterpriseConnectionResource | undefined;
}

export interface ConfigureSSOContextValue extends ConfigureSSOData {
/**
* `true` while the parent is still fetching the user's enterprise
* connection
*/
isLoading: boolean;
}

interface ConfigureSSOFlowProviderProps {
enterpriseConnection: EnterpriseConnectionResource | undefined;
isLoading: boolean;
}

const ConfigureSSOFlowContext = React.createContext<ConfigureSSOContextValue | null>(null);
const ConfigureSSOFlowContext = React.createContext<ConfigureSSOData | null>(null);
ConfigureSSOFlowContext.displayName = 'ConfigureSSOFlowContext';

export const ConfigureSSOFlowProvider = ({
enterpriseConnection,
isLoading,
children,
}: PropsWithChildren<ConfigureSSOFlowProviderProps>): JSX.Element => {
const value = React.useMemo<ConfigureSSOContextValue>(
const value = React.useMemo<ConfigureSSOData>(
() => ({
enterpriseConnection,
isLoading,
}),
[enterpriseConnection, isLoading],
[enterpriseConnection],
);

return <ConfigureSSOFlowContext.Provider value={value}>{children}</ConfigureSSOFlowContext.Provider>;
};

export const useConfigureSSOFlow = (): ConfigureSSOContextValue => {
export const useConfigureSSOFlow = (): ConfigureSSOData => {
const ctx = React.useContext(ConfigureSSOFlowContext);
if (!ctx) {
throw new Error('useConfigureSSOFlow called outside <ConfigureSSOFlowProvider>.');
Expand Down
91 changes: 91 additions & 0 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSOFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Button, descriptors, Icon, useLocalizations } from '@/customizables';
import { CaretLeft, CaretRight } from '@/icons';

import { ProfileCardFooter } from './elements/ProfileCard';
import { useFooterActions } from './elements/Wizard';

interface ConfigureSSOFooterProps {
/** Override label for the Previous button */
previousLabel?: string;
/** Override label for the Continue button (also overridable per step via `useRegisterContinueAction({ label })`) */
continueLabel?: string;
/** Hides the Previous button entirely */
hidePrevious?: boolean;
/** Force-disables both Previous and Continue regardless of wizard state */
isDisabled?: boolean;
}

/**
* Shared Previous / Continue footer for the ConfigureSSO surface.
* Dispatches to the deepest mounted wizard so Previous from a nested
* sub-step lands on its own previous sibling instead of jumping out
* to the parent wizard's previous main step
*/
export const ConfigureSSOFooter = ({
previousLabel = 'Previous',
continueLabel = 'Continue',
hidePrevious = false,
isDisabled = false,
}: ConfigureSSOFooterProps): JSX.Element => {
const { continueAction, deepestWizard } = useFooterActions();
const { t } = useLocalizations();

const isForceDisabled = isDisabled;
const isFirstStep = deepestWizard?.isFirstStep ?? true;
const isLastStep = deepestWizard?.isLastStep ?? true;

const continueLabelToShow =
typeof continueAction?.label === 'string'
? continueAction.label
: continueAction?.label
? t(continueAction.label)
: continueLabel;

const handleContinue = () => {
if (continueAction?.handler) {
void continueAction.handler();
return;
}
void deepestWizard?.goNext();
};

const handlePrevious = () => {
void deepestWizard?.goPrev();
};

return (
<ProfileCardFooter>
{!hidePrevious && (
<Button
elementDescriptor={descriptors.configureSSOWizardFooterPreviousButton}
variant='outline'
size='sm'
isDisabled={isForceDisabled || isFirstStep}
onClick={handlePrevious}
>
<Icon
icon={CaretLeft}
size='sm'
sx={theme => ({ marginInlineEnd: theme.space.$1 })}
/>
{previousLabel}
</Button>
)}
<Button
elementDescriptor={descriptors.configureSSOWizardFooterContinueButton}
variant='solid'
size='sm'
isDisabled={isForceDisabled || continueAction?.isDisabled || isLastStep}
isLoading={continueAction?.isLoading}
onClick={handleContinue}
>
{continueLabelToShow}
<Icon
icon={CaretRight}
size='sm'
sx={theme => ({ marginInlineStart: theme.space.$1 })}
/>
</Button>
</ProfileCardFooter>
);
};
Loading
Loading