diff --git a/.changeset/cdd-cross-project-auth.md b/.changeset/cdd-cross-project-auth.md new file mode 100644 index 0000000000..3364fc5327 --- /dev/null +++ b/.changeset/cdd-cross-project-auth.md @@ -0,0 +1,5 @@ +--- +'@sanity/cross-dataset-duplicator': patch +--- + +Fix duplicating to a Workspace in another Project failing with "Session does not match project host". The destination client now comes from the destination Workspace's own auth store instead of re-pointing the origin client, whose credentials are scoped to the origin Project. diff --git a/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx b/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx index 52bc8d2fae..2634f6f459 100644 --- a/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx +++ b/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx @@ -32,6 +32,7 @@ import { } from 'sanity' import {stickyStyles, createInitialMessage} from '../helpers' +import {getDestinationClient} from '../helpers/getDestinationClient' import {getDocumentsInArray} from '../helpers/getDocumentsInArray' import {isAllowedMigrationTarget} from '../helpers/migrationFilters' import type {PluginConfig} from '../types' @@ -62,6 +63,13 @@ type Message = { tone: CardTone } +function createNotLoggedInMessage(destination: WorkspaceOption): Message { + return { + tone: 'critical', + text: `You are not logged in to the "${destination.title ?? destination.name}" Workspace. Open it and log in, then try again.`, + } +} + function isAssetDocument(doc: SanityDocument): doc is SanityDocument & SanityAssetDocument { return isAssetId(doc._id) } @@ -654,10 +662,21 @@ export default function Duplicator(props: DuplicatorProps) { } const payloadIds = payloadActual.map(({doc}) => doc._id) - const destinationClient = originClient.withConfig({ - dataset: dest.dataset, - projectId: dest.projectId, + const destinationClient = await getDestinationClient({ + originClient, + destination: dest, + apiVersion: pluginConfig.apiVersion, }) + + if (!destinationClient) { + // Still list the payload so it can be duplicated elsewhere, or here once + // logged in, just without destination statuses + setPayload(payloadActual.map((item) => ({...item, status: undefined}))) + setMessage(createNotLoggedInMessage(dest)) + + return + } + const destinationData = await destinationClient.fetch( `*[_id in $payloadIds]{ _id, _updatedAt }`, {payloadIds}, @@ -758,6 +777,21 @@ export default function Duplicator(props: DuplicatorProps) { return } + const resolvedClient = await getDestinationClient({ + originClient, + destination, + apiVersion: pluginConfig.apiVersion, + }) + + if (!resolvedClient) { + setMessage(createNotLoggedInMessage(destination)) + + return + } + + // Narrowed for use inside the hoisted `processDoc` below + const destinationClient: SanityClient = resolvedClient + setIsDuplicating(true) const assetsCount = payload.filter(({doc, include}) => include && isAssetId(doc._id)).length @@ -766,12 +800,6 @@ export default function Duplicator(props: DuplicatorProps) { setMessage({text: 'Duplicating...', tone: `transparent`}) - const destinationClient = originClient.withConfig({ - apiVersion: pluginConfig.apiVersion, - dataset: destination.dataset, - projectId: destination.projectId, - }) - const transactionDocs: SanityDocument[] = [] const svgMaps: {old: string; new: string}[] = [] @@ -942,6 +970,8 @@ export default function Duplicator(props: DuplicatorProps) { if (targeted) { setDestination(targeted) + // Clear any message about the previous destination + setMessage(null) updatePayloadStatuses(payload, targeted).catch(console.error) } } diff --git a/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.test.ts b/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.test.ts new file mode 100644 index 0000000000..cc01ecf4e8 --- /dev/null +++ b/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.test.ts @@ -0,0 +1,96 @@ +import type {SanityClient} from '@sanity/client' +import type {AuthState, AuthStore} from 'sanity' +import {describe, expect, test, vi} from 'vitest' + +import {getDestinationClient} from './getDestinationClient' + +type ClientConfig = {projectId: string; dataset?: string; apiVersion?: string; token?: string} + +function createClient(config: ClientConfig): SanityClient { + const client = { + config: () => config, + withConfig: (next: Partial) => createClient({...config, ...next}), + } + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- only config() and withConfig() are called + return client as unknown as SanityClient +} + +// Minimal auth store whose `state` emits synchronously on subscribe, like the +// Studio's replayed auth state +function createAuth(state: Partial) { + const unsubscribe = vi.fn() + const store = { + state: { + subscribe: ({next}: {next: (value: AuthState) => void}) => { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- only authenticated and client are read + next(state as AuthState) + return {unsubscribe} + }, + }, + } + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- only state.subscribe() is called + const auth = store as unknown as AuthStore + + return {auth, unsubscribe} +} + +const originClient = createClient({ + projectId: 'projA', + dataset: 'production', + token: 'projA-token', +}) + +describe('getDestinationClient', () => { + test('re-points the origin client for a destination in the same project', async () => { + const {auth} = createAuth({authenticated: false}) + const client = await getDestinationClient({ + originClient, + destination: {projectId: 'projA', dataset: 'staging', auth}, + apiVersion: '2025-02-19', + }) + + expect(client?.config()).toEqual({ + projectId: 'projA', + dataset: 'staging', + apiVersion: '2025-02-19', + token: 'projA-token', + }) + }) + + test("uses the destination workspace's client for a destination in another project", async () => { + const {auth, unsubscribe} = createAuth({ + authenticated: true, + client: createClient({projectId: 'projB', dataset: 'production', token: 'projB-token'}), + }) + const client = await getDestinationClient({ + originClient, + destination: {projectId: 'projB', dataset: 'staging', auth}, + apiVersion: '2025-02-19', + }) + + expect(client?.config()).toEqual({ + projectId: 'projB', + dataset: 'staging', + apiVersion: '2025-02-19', + token: 'projB-token', + }) + + await Promise.resolve() + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + test('returns null when not logged in to a destination in another project', async () => { + const {auth} = createAuth({ + authenticated: false, + client: createClient({projectId: 'projB'}), + }) + const client = await getDestinationClient({ + originClient, + destination: {projectId: 'projB', dataset: 'production', auth}, + }) + + expect(client).toBeNull() + }) +}) diff --git a/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.ts b/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.ts new file mode 100644 index 0000000000..a43e483579 --- /dev/null +++ b/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.ts @@ -0,0 +1,56 @@ +import type {SanityClient} from '@sanity/client' +import type {AuthState, AuthStore, WorkspaceSummary} from 'sanity' + +type GetDestinationClientOptions = { + originClient: SanityClient + destination: Pick + apiVersion?: string +} + +// Resolve with the first auth state the store emits. The subscription may emit +// synchronously, so unsubscribe on the next microtask rather than inside `next`. +function getCurrentAuthState(auth: AuthStore): Promise { + return new Promise((resolve, reject) => { + const subscription = auth.state.subscribe({ + next: (state) => { + resolve(state) + queueMicrotask(() => subscription.unsubscribe()) + }, + error: reject, + }) + }) +} + +/** + * Get a client that can read from and write to the destination Workspace. + * + * Within the same Project the origin client's credentials are valid, so it is + * re-pointed at the destination Dataset. Credentials are scoped to a Project + * though (e.g. the token stored by the default `dual` login method), so for a + * destination in another Project the client comes from that Workspace's own + * auth store instead. + * + * Returns `null` if the user is not logged in to the destination Workspace. + */ +export async function getDestinationClient( + options: GetDestinationClientOptions, +): Promise { + const {originClient, destination, apiVersion} = options + const config = apiVersion ? {apiVersion} : {} + + if (destination.projectId === originClient.config().projectId) { + return originClient.withConfig({ + ...config, + dataset: destination.dataset, + projectId: destination.projectId, + }) + } + + const {authenticated, client} = await getCurrentAuthState(destination.auth) + + if (!authenticated) { + return null + } + + return client.withConfig({...config, dataset: destination.dataset}) +}