(items: T[], from: number, to: number): T[] => {
+ if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
+ return items;
+ }
+ const next = items.slice();
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ return next;
+};
diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/RoleMappingStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/RoleMappingStep.tsx
new file mode 100644
index 00000000000..5520763728b
--- /dev/null
+++ b/packages/ui/src/components/ConfigureDirectorySync/steps/RoleMappingStep.tsx
@@ -0,0 +1,527 @@
+import React from 'react';
+
+import {
+ Box,
+ Button,
+ Col,
+ descriptors,
+ Flex,
+ Icon,
+ type LocalizationKey,
+ localizationKeys,
+ Spinner,
+ Table,
+ Tbody,
+ Td,
+ Text,
+ Th,
+ Thead,
+ Tr,
+ useLocalizations,
+} from '@/customizables';
+import { useCardState } from '@/elements/contexts';
+import { Switch } from '@/elements/Switch';
+import { ArrowRight, Drag, Globe } from '@/icons';
+import { Alert } from '@/ui/elements/Alert';
+import type { ThemableCssProp } from '@/ui/styledSystem';
+import { handleError } from '@/utils/errorHandler';
+
+import { Step } from '../../ConfigureSSO/elements/Step';
+import { useWizard } from '../../ConfigureSSO/elements/Wizard';
+import { RoleSelect } from '../../OrganizationProfile/MemberListTable';
+import { useConfigureDirectorySync } from '../ConfigureDirectorySyncContext';
+import { DIRECTORY_SYNC_PROVIDERS } from '../providerMeta';
+import type { UnmappedGroup } from '../roleMapping';
+import { NO_ROLE_KEY } from '../roleMapping';
+import { RoleSyncDialog } from '../RoleSyncDialog';
+
+const HeaderText = ({ localizationKey }: { localizationKey: LocalizationKey }): JSX.Element => (
+
+);
+
+const selectTriggerSx: ThemableCssProp = t => ({
+ width: '100%',
+ justifyContent: 'space-between',
+ color: t.colors.$colorForeground,
+ backgroundColor: t.colors.$colorBackground,
+ textWrap: 'nowrap',
+ '& span': { fontSize: t.fontSizes.$md, fontWeight: t.fontWeights.$medium },
+});
+
+const GroupCell = ({ name, localizationKey }: { name?: string; localizationKey?: LocalizationKey }): JSX.Element => (
+
+ {name}
+
+);
+
+const PriorityCell = ({ children }: { children?: React.ReactNode }): JSX.Element => (
+ | ({ width: t.sizes.$16 })}>
+
+ {children}
+
+ |
+);
+
+const ArrowCell = (): JSX.Element => (
+ ({ width: t.sizes.$8, paddingInline: 0, textAlign: 'center' })}>
+
+ |
+);
+
+const RoleCell = ({ children }: { children: React.ReactNode }): JSX.Element => (
+ ({ width: '42%', paddingInlineStart: t.space.$2 })}>{children} |
+);
+
+const useGroupRoleOptions = () => {
+ const { roleMapping } = useConfigureDirectorySync();
+ const { t } = useLocalizations();
+ const unassignedLabel = t(localizationKeys('configureDirectorySync.roleMappingStep.roleOption__unassigned'));
+ return React.useMemo(
+ () => [
+ { value: NO_ROLE_KEY, label: unassignedLabel },
+ ...[...roleMapping.roles].sort((a, b) => a.label.localeCompare(b.label)),
+ ],
+ [roleMapping.roles, unassignedLabel],
+ );
+};
+
+type MappedRowProps = {
+ index: number;
+ count: number;
+ group: UnmappedGroup;
+ roleKey: string;
+ isDragging: boolean;
+ isDropTarget: boolean;
+ onDragStart: (index: number) => void;
+ onDragOver: (index: number) => void;
+ onDrop: (index: number) => void;
+ onDragEnd: () => void;
+};
+
+const MappedRow = ({
+ index,
+ count,
+ group,
+ roleKey,
+ isDragging,
+ isDropTarget,
+ onDragStart,
+ onDragOver,
+ onDrop,
+ onDragEnd,
+}: MappedRowProps): JSX.Element => {
+ const { roleMapping } = useConfigureDirectorySync();
+ const roleOptions = useGroupRoleOptions();
+ const { t } = useLocalizations();
+
+ const onHandleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'ArrowUp' && index > 0) {
+ e.preventDefault();
+ roleMapping.move(index, index - 1);
+ } else if (e.key === 'ArrowDown' && index < count - 1) {
+ e.preventDefault();
+ roleMapping.move(index, index + 1);
+ }
+ };
+
+ return (
+ {
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', String(index));
+ onDragStart(index);
+ }}
+ onDragOver={e => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ onDragOver(index);
+ }}
+ onDrop={e => {
+ e.preventDefault();
+ onDrop(index);
+ }}
+ onDragEnd={onDragEnd}
+ sx={t => ({
+ opacity: isDragging ? 0.4 : 1,
+ boxShadow: isDropTarget ? `inset 0 2px 0 0 ${t.colors.$primary500}` : 'none',
+ transition: 'opacity 120ms',
+ })}
+ >
+
+
+ ({ fontSize: t.fontSizes.$md, fontVariantNumeric: 'tabular-nums' })}
+ >
+ {index + 1}
+
+
+ |
+
+ |
+
+
+ roleMapping.setRole(group, key)}
+ triggerSx={selectTriggerSx}
+ />
+
+
+ );
+};
+
+const UnmappedRow = ({ group }: { group: UnmappedGroup }): JSX.Element => {
+ const { roleMapping } = useConfigureDirectorySync();
+ const roleOptions = useGroupRoleOptions();
+
+ return (
+
+
+ |
+
+ |
+
+
+ roleMapping.setRole(group, key)}
+ triggerSx={selectTriggerSx}
+ />
+
+
+ );
+};
+
+const EveryoneElseRow = (): JSX.Element => {
+ const { roleMapping } = useConfigureDirectorySync();
+ const { defaultRole } = roleMapping;
+
+ return (
+ ({ backgroundColor: t.colors.$neutralAlpha25 })}
+ >
+
+
+
+ |
+
+ |
+
+
+ {}}
+ isDisabled
+ triggerSx={selectTriggerSx}
+ />
+
+
+ );
+};
+
+const MappingTable = (): JSX.Element => {
+ const { roleMapping } = useConfigureDirectorySync();
+ const { mappings, unmappedGroups } = roleMapping;
+
+ const [dragIndex, setDragIndex] = React.useState(null);
+ const [overIndex, setOverIndex] = React.useState(null);
+
+ const endDrag = () => {
+ setDragIndex(null);
+ setOverIndex(null);
+ };
+
+ return (
+ ({
+ flex: '0 1 auto',
+ minHeight: 0,
+ overflowY: 'auto',
+ borderWidth: t.borderWidths.$normal,
+ borderStyle: t.borderStyles.$solid,
+ borderColor: t.colors.$borderAlpha150,
+ borderRadius: t.radii.$lg,
+ })}
+ >
+ ({
+ borderWidth: 0,
+ borderRadius: 0,
+ 'thead th': {
+ position: 'sticky',
+ top: 0,
+ zIndex: 1,
+ backgroundColor: t.colors.$colorBackground,
+ boxShadow: `inset 0 -1px 0 0 ${t.colors.$borderAlpha150}`,
+ },
+ 'thead::after': { display: 'none' },
+ tbody: { borderTopWidth: 0 },
+ 'tbody > tr:first-of-type > td': { borderTopWidth: 0 },
+ 'tr > th': { paddingBlock: t.space.$3, paddingInline: t.space.$4 },
+ 'tr > th:first-of-type': { paddingInlineStart: t.space.$8 },
+ 'tr > td': { paddingBlock: t.space.$3, verticalAlign: 'middle' },
+ 'tr > td:first-of-type': { paddingInlineStart: t.space.$8, paddingInlineEnd: 0 },
+ })}
+ >
+
+
+ |
+
+ |
+
+
+ |
+ |
+
+
+ |
+
+
+
+ {mappings.map((mapping, index) => (
+ {
+ if (dragIndex !== null) {
+ roleMapping.move(dragIndex, to);
+ }
+ endDrag();
+ }}
+ onDragEnd={endDrag}
+ />
+ ))}
+ {unmappedGroups.map(group => (
+
+ ))}
+
+
+
+
+ );
+};
+
+const EmptyGroups = (): JSX.Element => {
+ const { providerMeta } = useConfigureDirectorySync();
+ const { t } = useLocalizations();
+ const providerName = t((providerMeta ?? DIRECTORY_SYNC_PROVIDERS.custom).name);
+
+ return (
+ ({
+ gap: t.space.$1,
+ padding: t.space.$8,
+ borderRadius: t.radii.$md,
+ borderWidth: t.borderWidths.$normal,
+ borderStyle: 'dashed',
+ borderColor: t.colors.$borderAlpha150,
+ textAlign: 'center',
+ })}
+ >
+ ({ fontSize: t.fontSizes.$sm, fontWeight: t.fontWeights.$medium })}
+ localizationKey={localizationKeys('configureDirectorySync.roleMappingStep.empty__noGroups.title')}
+ />
+ ({ fontSize: t.fontSizes.$sm })}
+ localizationKey={localizationKeys('configureDirectorySync.roleMappingStep.empty__noGroups.subtitle', {
+ provider: providerName,
+ })}
+ />
+
+ );
+};
+
+const RoleMappingContent = (): JSX.Element => {
+ const { roleMapping } = useConfigureDirectorySync();
+ const { isLoading, error, mappings, unmappedGroups } = roleMapping;
+
+ if (isLoading) {
+ return (
+ ({ padding: t.space.$8 })}
+ >
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return mappings.length === 0 && unmappedGroups.length === 0 ? : ;
+};
+
+export const RoleMappingStep = (): JSX.Element => {
+ const { goPrev } = useWizard();
+ const { roleMapping, contentRef, onExit } = useConfigureDirectorySync();
+ const card = useCardState();
+ const [pendingAction, setPendingAction] = React.useState<'enable' | 'disable' | null>(null);
+
+ const complete = async () => {
+ if (card.isLoading) {
+ return;
+ }
+ card.setError(undefined);
+ card.setLoading();
+ try {
+ await roleMapping.save();
+ onExit?.();
+ } catch (err) {
+ handleError(err as Error, [], card.setError);
+ } finally {
+ card.setIdle();
+ }
+ };
+
+ return (
+ <>
+
+ ({ flexShrink: 0, height: `calc(${t.fontSizes.$lg} * ${t.lineHeights.$medium})` })}
+ >
+ setPendingAction(enabled ? 'enable' : 'disable')}
+ />
+
+
+
+ setPendingAction(null)}
+ onConfirm={() => roleMapping.setEnabled(pendingAction === 'enable')}
+ contentRef={contentRef}
+ />
+
+
+ ({ gap: t.space.$4, minHeight: 0 })}
+ >
+ {card.error && (
+
+ )}
+
+
+
+
+
+ goPrev()} />
+
+ >
+ );
+};
diff --git a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx
index a032153c5a9..277a7ee9843 100644
--- a/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx
+++ b/packages/ui/src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx
@@ -5,17 +5,7 @@ import {
import type { DirectorySyncUserResource } from '@clerk/shared/types';
import { useEffect, useState } from 'react';
-import {
- Badge,
- Button,
- Col,
- descriptors,
- Flex,
- localizationKeys,
- Spinner,
- Text,
- useLocalizations,
-} from '@/customizables';
+import { Badge, Col, descriptors, Flex, localizationKeys, Spinner, Text, useLocalizations } from '@/customizables';
import { Alert } from '@/ui/elements/Alert';
import { Step } from '../../ConfigureSSO/elements/Step';
@@ -89,8 +79,8 @@ const ProvisionedUserRow = ({ user }: { user: DirectorySyncUserResource }): JSX.
};
export const TestSyncStep = (): JSX.Element => {
- const { goPrev } = useWizard();
- const { providerMeta, directory, onExit, syncDirectory } = useConfigureDirectorySync();
+ const { goNext, goPrev } = useWizard();
+ const { providerMeta, directory, syncDirectory } = useConfigureDirectorySync();
const { t } = useLocalizations();
const isPull = providerMeta?.mode === 'pull';
// The list doubles as a live feed while the admin pushes test users from the
@@ -240,13 +230,7 @@ export const TestSyncStep = (): JSX.Element => {
goPrev()} />
-
>
);
diff --git a/packages/ui/src/components/ConfigureSSO/RemoveDomainDialog.tsx b/packages/ui/src/components/ConfigureSSO/RemoveDomainDialog.tsx
index 90644d60e87..836dd9d7b29 100644
--- a/packages/ui/src/components/ConfigureSSO/RemoveDomainDialog.tsx
+++ b/packages/ui/src/components/ConfigureSSO/RemoveDomainDialog.tsx
@@ -94,6 +94,7 @@ const RemoveDomainDialogContent = withCardStateProvider((props: RemoveDomainDial
& { isDisabled?:
};
export const RoleSelect = (props: {
- roles: { label: string; value: string }[] | undefined;
+ roles: { label: string; value: string; description?: string }[] | undefined;
value: string;
fallbackLabel?: string;
+ showDescriptions?: boolean;
onChange: (params: string) => unknown;
isDisabled?: boolean;
triggerSx?: ThemableCssProp;
@@ -166,6 +167,7 @@ export const RoleSelect = (props: {
triggerSx,
optionListSx,
prefixLocalizationKey,
+ showDescriptions,
formatLabel = label => label,
} = props;
@@ -181,8 +183,9 @@ export const RoleSelect = (props: {
fetchedRoles.map(role => ({
value: role.value,
label: formatLabel(localizeCustomRole(role.value) || role.label, role),
+ description: showDescriptions ? role.description : undefined,
})),
- [fetchedRoles, localizeCustomRole, formatLabel],
+ [fetchedRoles, localizeCustomRole, formatLabel, showDescriptions],
);
return (
@@ -254,6 +257,7 @@ type RolesListItemProps = PropsOfComponent & {
option?: {
label: string;
value: string;
+ description?: string;
};
};
@@ -277,7 +281,17 @@ const RolesListItem = memo((props: RolesListItemProps) => {
]}
{...rest}
>
- {option?.label}
+
+ {option?.label}
+ {option?.description && (
+ ({ fontSize: t.fontSizes.$sm })}
+ >
+ {option.description}
+
+ )}
+
);
});
diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx
index ccf3b63e763..a8095b7c1dd 100644
--- a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx
+++ b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx
@@ -113,6 +113,7 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
return (
}
+ contentRef={contentRef}
onExit={exitToOverview}
/>
);
diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts
index e1ff3112d55..d079b494d08 100644
--- a/packages/ui/src/customizables/elementDescriptors.ts
+++ b/packages/ui/src/customizables/elementDescriptors.ts
@@ -707,6 +707,17 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'configureDirectorySyncSyncNowButton',
'configureDirectorySyncStatusBadge',
'configureDirectorySyncLastSyncedAt',
+ 'configureDirectorySyncRoleMappingToggle',
+ 'configureDirectorySyncRoleMappingTable',
+ 'configureDirectorySyncRoleMappingHeader',
+ 'configureDirectorySyncRoleMappingRow',
+ 'configureDirectorySyncRoleMappingReorderButton',
+ 'configureDirectorySyncRoleMappingPriority',
+ 'configureDirectorySyncRoleMappingGroupName',
+ 'configureDirectorySyncRoleMappingEmpty',
+ 'configureDirectorySyncRoleSyncDialog',
+ 'configureDirectorySyncRoleSyncDialogCancelButton',
+ 'configureDirectorySyncRoleSyncDialogSubmitButton',
'web3SolanaWalletButtonsRoot',
'web3SolanaWalletButtons',
diff --git a/packages/ui/src/elements/Switch.tsx b/packages/ui/src/elements/Switch.tsx
index 53650afc543..d15c943ee5a 100644
--- a/packages/ui/src/elements/Switch.tsx
+++ b/packages/ui/src/elements/Switch.tsx
@@ -11,6 +11,8 @@ interface SwitchProps {
onChange?: (checked: boolean) => void;
isDisabled?: boolean;
label?: string | LocalizationKey;
+ labelVariant?: 'caption' | 'subtitle';
+ labelPosition?: 'start' | 'end';
'aria-label'?: string;
'aria-labelledby'?: string;
}
@@ -24,6 +26,8 @@ export const Switch = forwardRef(
onChange,
isDisabled = false,
label,
+ labelVariant = 'caption',
+ labelPosition = 'end',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
},
@@ -49,7 +53,7 @@ export const Switch = forwardRef(
({
@@ -117,11 +121,11 @@ export const Switch = forwardRef(
{hasInternalLabel ? (
({
- paddingInlineStart: t.sizes.$2,
+ [labelPosition === 'start' ? 'paddingInlineEnd' : 'paddingInlineStart']: t.sizes.$2,
cursor: isDisabled ? 'not-allowed' : 'pointer',
userSelect: 'none',
})}
diff --git a/packages/ui/src/hooks/useFetchRoles.ts b/packages/ui/src/hooks/useFetchRoles.ts
index 73aaead6de8..c46e1c185f5 100644
--- a/packages/ui/src/hooks/useFetchRoles.ts
+++ b/packages/ui/src/hooks/useFetchRoles.ts
@@ -31,7 +31,7 @@ export const useFetchRoles = (enabled = true) => {
return {
isLoading,
hasRoleSetMigration: !!data?.has_role_set_migration,
- options: data?.data?.map(role => ({ value: role.key, label: role.name })),
+ options: data?.data?.map(role => ({ value: role.key, label: role.name, description: role.description })),
};
};
diff --git a/packages/ui/src/icons/drag.svg b/packages/ui/src/icons/drag.svg
new file mode 100644
index 00000000000..a0c1ae25e7c
--- /dev/null
+++ b/packages/ui/src/icons/drag.svg
@@ -0,0 +1,8 @@
+
diff --git a/packages/ui/src/icons/globe.svg b/packages/ui/src/icons/globe.svg
new file mode 100644
index 00000000000..1982db19554
--- /dev/null
+++ b/packages/ui/src/icons/globe.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts
index f0959e25fd4..adae01acd2b 100644
--- a/packages/ui/src/icons/index.ts
+++ b/packages/ui/src/icons/index.ts
@@ -34,6 +34,7 @@ export { default as CreditCard } from './credit-card.svg';
export { default as DeviceLaptop } from './device-laptop.svg';
export { default as DeviceMobile } from './device-mobile.svg';
export { default as ThreeDotsCircle } from './three-dots-circle.svg';
+export { default as Drag } from './drag.svg';
export { default as DuotoneAtSymbol } from './duotone-at-symbol.svg';
export { default as DuotoneShieldCheck } from './duotone-shield-check.svg';
export { default as Envelope } from './envelope.svg';
@@ -43,6 +44,7 @@ export { default as Eye } from './eye.svg';
export { default as EyeSlash } from './eye-slash.svg';
export { default as Files } from './files.svg';
export { default as Fingerprint } from './fingerprint.svg';
+export { default as Globe } from './globe.svg';
export { default as InformationCircle } from './information-circle.svg';
export { default as Link } from './link.svg';
export { default as Lock } from './lock.svg';
diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts
index 09fc4ffad1a..23199b6bbaa 100644
--- a/packages/ui/src/internal/appearance.ts
+++ b/packages/ui/src/internal/appearance.ts
@@ -843,6 +843,17 @@ export type ElementsConfig = {
configureDirectorySyncSyncNowButton: WithOptions;
configureDirectorySyncStatusBadge: WithOptions;
configureDirectorySyncLastSyncedAt: WithOptions;
+ configureDirectorySyncRoleMappingToggle: WithOptions;
+ configureDirectorySyncRoleMappingTable: WithOptions;
+ configureDirectorySyncRoleMappingHeader: WithOptions;
+ configureDirectorySyncRoleMappingRow: WithOptions;
+ configureDirectorySyncRoleMappingReorderButton: WithOptions;
+ configureDirectorySyncRoleMappingPriority: WithOptions;
+ configureDirectorySyncRoleMappingGroupName: WithOptions;
+ configureDirectorySyncRoleMappingEmpty: WithOptions;
+ configureDirectorySyncRoleSyncDialog: WithOptions;
+ configureDirectorySyncRoleSyncDialogCancelButton: WithOptions;
+ configureDirectorySyncRoleSyncDialogSubmitButton: WithOptions;
web3SolanaWalletButtonsRoot: WithOptions;
web3SolanaWalletButtons: WithOptions;