diff --git a/.changeset/dir-sync-google-hooks.md b/.changeset/dir-sync-google-hooks.md new file mode 100644 index 00000000000..a864d345307 --- /dev/null +++ b/.changeset/dir-sync-google-hooks.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Add credential and sync mutations to `__internal_useOrganizationDirectorySync`, and a `__internal_useOrganizationDirectorySyncStatus` hook that reports a directory's last sync result with opt-in polling. diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncStatus.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncStatus.spec.tsx new file mode 100644 index 00000000000..fcd230e2eeb --- /dev/null +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncStatus.spec.tsx @@ -0,0 +1,92 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DirectorySyncResource } from '@/types/directorySync'; + +import { __internal_useOrganizationDirectorySyncStatus } from '../useOrganizationDirectorySyncStatus'; +import { createMockClerk, createMockQueryClient } from './mocks/clerk'; +import { wrapper } from './wrapper'; + +const POLL_INTERVAL_MS = 20; + +const getSyncStatusSpy = vi.fn(() => + Promise.resolve({ lastSyncedAt: new Date(1700000000000), lastSyncStatus: 'succeeded', lastSyncError: null }), +); + +const createDirectory = (id: string) => + ({ id, enterpriseConnectionId: 'ent_1', getSyncStatus: getSyncStatusSpy }) as unknown as DirectorySyncResource; + +const defaultQueryClient = createMockQueryClient(); + +const mockClerk = createMockClerk({ + queryClient: defaultQueryClient, + __internal_lastEmittedResources: { + user: null, + session: null, + organization: { id: 'org_1' }, + client: null, + }, +}); + +vi.mock('../../contexts', () => ({ + useAssertWrappedByClerkProvider: () => {}, + useClerkInstanceContext: () => mockClerk, + useInitialStateContext: () => undefined, +})); + +type RenderProps = { directory: DirectorySyncResource | null; poll?: boolean }; + +const renderStatus = (initialProps: RenderProps) => + renderHook( + ({ directory, poll }: RenderProps) => + __internal_useOrganizationDirectorySyncStatus({ directory, poll, pollIntervalMs: POLL_INTERVAL_MS }), + { wrapper, initialProps }, + ); + +describe('useOrganizationDirectorySyncStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + defaultQueryClient.client.clear(); + mockClerk.loaded = true; + }); + + it('stays dormant without a directory', () => { + const { result } = renderStatus({ directory: null, poll: true }); + + expect(getSyncStatusSpy).not.toHaveBeenCalled(); + expect(result.current.data).toBeUndefined(); + expect(result.current.isPolling).toBe(false); + }); + + it('reads the last sync result once a directory is present', async () => { + const { result } = renderStatus({ directory: createDirectory('dir_1') }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(getSyncStatusSpy).toHaveBeenCalled(); + expect(result.current.data?.lastSyncStatus).toBe('succeeded'); + expect(result.current.isPolling).toBe(false); + }); + + it('polls while armed', async () => { + const { result } = renderStatus({ directory: createDirectory('dir_1'), poll: true }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.isPolling).toBe(true); + + const callsAfterFirstLoad = getSyncStatusSpy.mock.calls.length; + await waitFor(() => expect(getSyncStatusSpy.mock.calls.length).toBeGreaterThan(callsAfterFirstLoad)); + }); + + it('does not carry one directory status onto another', async () => { + const { result, rerender } = renderStatus({ directory: createDirectory('dir_1') }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + + // A different directory must not momentarily report the previous one's run. + // "Never synced" and "synced an hour ago" drive different UI. + getSyncStatusSpy.mockImplementationOnce(() => new Promise(() => {})); + rerender({ directory: createDirectory('dir_2') }); + + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index 5fe796fc0f4..4e7db0052cf 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -59,6 +59,11 @@ export type { UseOrganizationDirectorySyncUsersParams, UseOrganizationDirectorySyncUsersReturn, } from './useOrganizationDirectorySyncUsers'; +export { __internal_useOrganizationDirectorySyncStatus } from './useOrganizationDirectorySyncStatus'; +export type { + UseOrganizationDirectorySyncStatusParams, + UseOrganizationDirectorySyncStatusReturn, +} from './useOrganizationDirectorySyncStatus'; export { __internal_useOrganizationEnterpriseConnectionTestRuns } from './useOrganizationEnterpriseConnectionTestRuns'; export type { UseOrganizationEnterpriseConnectionTestRunsParams, diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts b/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts index 26d51bd0d32..9f85edf1910 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts @@ -54,3 +54,28 @@ export function useOrganizationDirectorySyncUsersCacheKeys(params: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [organizationId, enterpriseConnectionId, directoryId, JSON.stringify(args)]); } + +/** + * @internal + */ +export function useOrganizationDirectorySyncStatusCacheKeys(params: { + organizationId: string | null; + enterpriseConnectionId: string | null; + directoryId: string | null; +}) { + const { organizationId, enterpriseConnectionId, directoryId } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_STATUS_KEY, + authenticated: Boolean(organizationId), + tracked: { + organizationId: organizationId ?? null, + enterpriseConnectionId: enterpriseConnectionId ?? null, + directoryId: directoryId ?? null, + }, + untracked: { + args: {}, + }, + }); + }, [organizationId, enterpriseConnectionId, directoryId]); +} diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx index 99688aebfda..266cb79951d 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.tsx @@ -5,6 +5,7 @@ import type { DeletedObjectResource } from '../../types/deletedObject'; import type { CreateDirectorySyncParams, DirectorySyncResource, + SetDirectorySyncCredentialsParams, UpdateDirectorySyncParams, } from '../../types/directorySync'; import { useClerkInstanceContext } from '../contexts'; @@ -29,6 +30,16 @@ export type UseOrganizationDirectorySyncReturn = { /** Resolves `undefined` until `data` has loaded, since the mutations act on the loaded directory. */ updateDirectorySync: (params: UpdateDirectorySyncParams) => Promise; rotateDirectorySyncToken: () => Promise; + /** + * Stores the credential a pull-based directory reads the identity provider with, activating it. + * Rejects with the provider's own validation message when the credential is refused; surface that + * message, it is what tells the admin how to fix their setup. + */ + setDirectorySyncCredentials: ( + params: SetDirectorySyncCredentialsParams, + ) => Promise; + /** Starts a sync for a pull-based directory rather than waiting for the next scheduled one. */ + syncDirectory: () => Promise; deleteDirectorySync: () => Promise; revalidate: () => Promise; }; @@ -117,6 +128,25 @@ function useOrganizationDirectorySync(params: UseOrganizationDirectorySyncParams return rotated; }, [directory, revalidate]); + const setDirectorySyncCredentials = useCallback( + async (credentialsParams: SetDirectorySyncCredentialsParams) => { + if (!directory) { + return undefined; + } + const updated = await directory.setCredentials(credentialsParams); + await revalidate(); + return updated; + }, + [directory, revalidate], + ); + + const syncDirectory = useCallback(async () => { + if (!directory) { + return; + } + await directory.sync(); + }, [directory]); + const deleteDirectorySync = useCallback(async () => { if (!directory) { return undefined; @@ -134,6 +164,8 @@ function useOrganizationDirectorySync(params: UseOrganizationDirectorySyncParams createDirectorySync, updateDirectorySync, rotateDirectorySyncToken, + setDirectorySyncCredentials, + syncDirectory, deleteDirectorySync, revalidate, }; diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySyncStatus.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySyncStatus.tsx new file mode 100644 index 00000000000..1c8bda0b5f9 --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySyncStatus.tsx @@ -0,0 +1,111 @@ +import { useCallback } from 'react'; + +import type { DirectorySyncResource, DirectorySyncStatusResource } from '../../types/directorySync'; +import { useClerkInstanceContext } from '../contexts'; +import { useClerkQueryClient } from '../query/use-clerk-query-client'; +import { useClerkQuery } from '../query/useQuery'; +import { useOrganizationBase } from './base/useOrganizationBase'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { useOrganizationDirectorySyncStatusCacheKeys } from './useOrganizationDirectorySync.shared'; + +const DEFAULT_POLL_INTERVAL_MS = 2_000; + +export type UseOrganizationDirectorySyncStatusParams = { + /** The directory to read status for, e.g. `data` from `useOrganizationDirectorySync`. Nothing is fetched while `null` or `undefined`. */ + directory: DirectorySyncResource | null | undefined; + /** + * Poll for changes while `true`. Tie this to the view that needs the live + * status so polling stops when that view goes away. + * + * @default false + */ + poll?: boolean; + /** + * Polling interval (ms) used while `poll` is `true`. + * + * @default 2000 + */ + pollIntervalMs?: number; + /** + * If `false`, nothing is fetched and polling is paused. + * + * @default true + */ + enabled?: boolean; +}; + +export type UseOrganizationDirectorySyncStatusReturn = { + /** `undefined` while loading and while the hook is disabled. Every field is `null` before the first sync completes. */ + data: DirectorySyncStatusResource | undefined; + error: Error | null; + isLoading: boolean; + isFetching: boolean; + /** `true` while the hook is polling. */ + isPolling: boolean; + revalidate: () => Promise; +}; + +/** + * The result of a Directory Sync directory's most recent sync. + * + * Only pull-based directories sync, so this stays dormant for push providers, + * which are driven by the identity provider and have no sync to report. + * + * @internal + */ +function useOrganizationDirectorySyncStatus( + params: UseOrganizationDirectorySyncStatusParams, +): UseOrganizationDirectorySyncStatusReturn { + const { directory, poll = false, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, enabled = true } = params; + + const clerk = useClerkInstanceContext(); + const organization = useOrganizationBase(); + const [queryClient] = useClerkQueryClient(); + const enterpriseConnectionId = directory?.enterpriseConnectionId ?? null; + const directoryId = directory?.id ?? null; + + const { queryKey, invalidationKey, stableKey, authenticated } = useOrganizationDirectorySyncStatusCacheKeys({ + organizationId: organization?.id ?? null, + enterpriseConnectionId, + directoryId, + }); + + useClearQueriesOnSignOut({ + isSignedOut: organization === null, + authenticated, + stableKeys: stableKey, + }); + + const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(directory); + + const query = useClerkQuery({ + queryKey, + queryFn: () => { + if (!directory) { + throw new Error('directory is required to fetch sync status'); + } + return directory.getSyncStatus(); + }, + refetchInterval: () => (poll ? pollIntervalMs : false), + enabled: queryEnabled, + refetchIntervalInBackground: false, + // No placeholderData: a stale run result shown against a different directory + // would misreport whether that directory has ever synced. + }); + + const revalidate = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: invalidationKey }); + }, [queryClient, invalidationKey]); + + return { + // A disabled query still exposes whatever is cached under its key; report none until it can run. + data: queryEnabled ? query.data : undefined, + error: query.error ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + isPolling: queryEnabled && poll, + revalidate, + }; +} + +export { useOrganizationDirectorySyncStatus as __internal_useOrganizationDirectorySyncStatus }; diff --git a/packages/shared/src/react/stable-keys.ts b/packages/shared/src/react/stable-keys.ts index e7ae049abe6..f75ebfedbb5 100644 --- a/packages/shared/src/react/stable-keys.ts +++ b/packages/shared/src/react/stable-keys.ts @@ -85,6 +85,7 @@ const ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'organizationEnterprise const ORGANIZATION_DOMAINS_KEY = 'organizationDomains'; const ORGANIZATION_DIRECTORY_SYNC_KEY = 'organizationDirectorySync'; const ORGANIZATION_DIRECTORY_SYNC_USERS_KEY = 'organizationDirectorySyncUsers'; +const ORGANIZATION_DIRECTORY_SYNC_STATUS_KEY = 'organizationDirectorySyncStatus'; const CREDIT_HISTORY_KEY = 'billing-credit-history'; @@ -100,6 +101,7 @@ export const INTERNAL_STABLE_KEYS = { ORGANIZATION_DOMAINS_KEY, ORGANIZATION_DIRECTORY_SYNC_KEY, ORGANIZATION_DIRECTORY_SYNC_USERS_KEY, + ORGANIZATION_DIRECTORY_SYNC_STATUS_KEY, } as const; export type __internal_ResourceCacheStableKey = (typeof INTERNAL_STABLE_KEYS)[keyof typeof INTERNAL_STABLE_KEYS];