From 6a681e3ebfc3a5879d77dbba421c9df398b1d20d Mon Sep 17 00:00:00 2001 From: gabrielmeloc22 Date: Fri, 11 Sep 2026 10:44:55 -0300 Subject: [PATCH] feat(shared): add directory sync credential and status hooks An organization admin cannot set up a Google Workspace directory from the organization profile today. The Directory Sync setup flow sends those connections to the Clerk Dashboard instead, which is the Clerk customer's account, not theirs, so the setup simply dead-ends. Closing that needs the setup view to store a credential, start a sync, and report how the last one went. The resource can already do all three, but nothing in React can reach it, so this puts hooks in front: credential and sync mutations on the directory hook, and a sync-status hook whose polling is opt-in so a view watching a run does not keep polling for the rest of the session. Status deliberately carries no placeholder data across directories: showing one directory's last run against another would misreport whether it has ever synced, and never-synced drives different UI from synced-recently. Part of ORGS-1842 --- .changeset/dir-sync-google-hooks.md | 5 + ...seOrganizationDirectorySyncStatus.spec.tsx | 92 +++++++++++++++ packages/shared/src/react/hooks/index.ts | 5 + .../useOrganizationDirectorySync.shared.ts | 25 ++++ .../hooks/useOrganizationDirectorySync.tsx | 32 +++++ .../useOrganizationDirectorySyncStatus.tsx | 111 ++++++++++++++++++ packages/shared/src/react/stable-keys.ts | 2 + 7 files changed, 272 insertions(+) create mode 100644 .changeset/dir-sync-google-hooks.md create mode 100644 packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncStatus.spec.tsx create mode 100644 packages/shared/src/react/hooks/useOrganizationDirectorySyncStatus.tsx 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];