From 3f6e82fddc95364fc09bad0dd8baf932742ae701 Mon Sep 17 00:00:00 2001 From: gabrielmeloc22 Date: Fri, 11 Sep 2026 10:44:55 -0300 Subject: [PATCH 1/2] 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]; From 230eed091b9101c4cef461943ca91e537342ea94 Mon Sep 17 00:00:00 2001 From: gabrielmeloc22 Date: Mon, 14 Sep 2026 10:33:42 -0300 Subject: [PATCH 2/2] fix(shared): ignore a directory from another organization when reading sync status An organization admin opening Directory Sync could be shown another organization's sync result: whether it last synced, when, and whether it failed. That is the state they use to judge whether provisioning is working, so showing a neighbouring organization's is both wrong and confusing. It takes the caller passing a directory it kept from a previously active organization. The hook takes the directory from the caller but the organization from context, and keys the cache on both, so such a directory would file its result under the current organization. No caller does this today, which makes this a guard rather than a fix for an observed bug. The hook now reads status only for a directory the active organization owns. Part of ORGS-1842 --- ...seOrganizationDirectorySyncStatus.spec.tsx | 19 +++++++++++++++++-- .../useOrganizationDirectorySyncStatus.tsx | 7 ++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncStatus.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncStatus.spec.tsx index fcd230e2eeb..1cd8e443ca6 100644 --- a/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncStatus.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationDirectorySyncStatus.spec.tsx @@ -13,8 +13,13 @@ 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 createDirectory = (id: string, organizationId = 'org_1') => + ({ + id, + organizationId, + enterpriseConnectionId: 'ent_1', + getSyncStatus: getSyncStatusSpy, + }) as unknown as DirectorySyncResource; const defaultQueryClient = createMockQueryClient(); @@ -77,6 +82,16 @@ describe('useOrganizationDirectorySyncStatus', () => { await waitFor(() => expect(getSyncStatusSpy.mock.calls.length).toBeGreaterThan(callsAfterFirstLoad)); }); + it('refuses a directory belonging to another organization', async () => { + const { result } = renderStatus({ directory: createDirectory('dir_other', 'org_2') }); + + // The cache key is built from the context organization, so reading a + // foreign directory would file its result under this organization. + expect(getSyncStatusSpy).not.toHaveBeenCalled(); + expect(result.current.data).toBeUndefined(); + expect(result.current.isPolling).toBe(false); + }); + it('does not carry one directory status onto another', async () => { const { result, rerender } = renderStatus({ directory: createDirectory('dir_1') }); diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySyncStatus.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySyncStatus.tsx index 1c8bda0b5f9..c0c09a0e237 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySyncStatus.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySyncStatus.tsx @@ -76,7 +76,12 @@ function useOrganizationDirectorySyncStatus( stableKeys: stableKey, }); - const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(directory); + // The directory comes from the caller while the organization comes from + // context, and the cache key is built from both. A directory belonging to + // another organization would therefore cache that organization's sync result + // under this one's key, so refuse to read it. + const belongsToActiveOrganization = Boolean(organization) && directory?.organizationId === organization?.id; + const queryEnabled = enabled && clerk.loaded && belongsToActiveOrganization && Boolean(directory); const query = useClerkQuery({ queryKey,