diff --git a/apps/kitchensink-react/src/App.tsx b/apps/kitchensink-react/src/App.tsx index 3cef4e7f..3e3ae34e 100644 --- a/apps/kitchensink-react/src/App.tsx +++ b/apps/kitchensink-react/src/App.tsx @@ -9,6 +9,10 @@ import {AppRoutes} from './AppRoutes' const theme = buildTheme({}) const sanityConfigs: SanityConfig[] = [ + { + projectId: 'project-id', + dataset: 'data-set', + }, { projectId: 'ppsg7ml5', dataset: 'test', diff --git a/packages/core/src/_exports/index.ts b/packages/core/src/_exports/index.ts index da6371da..f75a120a 100644 --- a/packages/core/src/_exports/index.ts +++ b/packages/core/src/_exports/index.ts @@ -19,7 +19,7 @@ export { type LoggedOutAuthState, type LoggingInAuthState, } from '../auth/authStore' -export {observeOrganizationVerificationState} from '../auth/getOrganizationVerificationState' +export {ConfigurationError} from '../auth/ConfigurationError' export {handleAuthCallback} from '../auth/handleAuthCallback' export {logout} from '../auth/logout' export type {ClientStoreState as ClientState} from '../client/clientStore' diff --git a/packages/react/src/components/auth/ConfigurationError.ts b/packages/core/src/auth/ConfigurationError.ts similarity index 100% rename from packages/react/src/components/auth/ConfigurationError.ts rename to packages/core/src/auth/ConfigurationError.ts diff --git a/packages/core/src/auth/authStore.ts b/packages/core/src/auth/authStore.ts index ca0080a4..f95a003c 100644 --- a/packages/core/src/auth/authStore.ts +++ b/packages/core/src/auth/authStore.ts @@ -1,12 +1,15 @@ import {type ClientConfig, createClient, type SanityClient} from '@sanity/client' import {type CurrentUser} from '@sanity/types' -import {type Subscription} from 'rxjs' +import {combineLatest, filter, map, type Subscription, switchMap} from 'rxjs' import {type AuthConfig, type AuthProvider} from '../config/authConfig' import {bindActionGlobally} from '../store/createActionBinder' +import {type SanityInstance} from '../store/createSanityInstance' import {createStateSourceAction} from '../store/createStateSourceAction' -import {defineStore} from '../store/defineStore' +import {defineStore, type StoreContext} from '../store/defineStore' +import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants' import {AuthStateType} from './authStateType' +import {ConfigurationError} from './ConfigurationError' import {refreshStampedToken} from './refreshStampedToken' import {checkForCookieAuth, getStudioTokenFromLocalStorage} from './studioModeAuth' import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser' @@ -89,6 +92,7 @@ export interface AuthStoreState { authMethod: AuthMethodOptions } dashboardContext?: DashboardContext + error?: unknown } export const authStore = defineStore({ @@ -211,6 +215,7 @@ export const authStore = defineStore({ initialize(context) { const subscriptions: Subscription[] = [] subscriptions.push(subscribeToStateAndFetchCurrentUser(context)) + subscriptions.push(listenToProjectIdsAndDashboard(context)) if (context.state.get().options?.storageArea) { subscriptions.push(subscribeToStorageEventsAndSetToken(context)) @@ -229,6 +234,77 @@ export const authStore = defineStore({ }, }) +function getProjectIdsFromInstanceAndParents(instance: SanityInstance | undefined): string[] { + if (!instance) return [] + + const projectIds: string[] = [] + if (instance.config?.projectId) { + projectIds.push(instance.config.projectId) + } + + const parentProjectIds = getProjectIdsFromInstanceAndParents(instance.getParent()) + return projectIds.concat(parentProjectIds) +} + +const listenToProjectIdsAndDashboard = ({instance, state}: StoreContext) => { + const { + options: {clientFactory, apiHost}, + } = state.get() + + const projectIds = getProjectIdsFromInstanceAndParents(instance) + + const token$ = state.observable.pipe( + map((i) => i.authState.type === AuthStateType.LOGGED_IN && i.authState.token), + filter((i) => typeof i === 'string'), + ) + + const client$ = token$.pipe( + map((token) => + clientFactory({ + apiVersion: DEFAULT_API_VERSION, + requestTagPrefix: REQUEST_TAG_PREFIX, + useProjectHostname: false, + useCdn: false, + token, + ...(apiHost && {apiHost}), + }), + ), + ) + + const orgId$ = state.observable.pipe( + map((i) => i.dashboardContext?.orgId), + filter(Boolean), + ) + + const organizationIdsFromProjects$ = combineLatest( + projectIds.map((projectId) => + client$.pipe( + switchMap((client) => client.observable.projects.getById(projectId)), + map((i) => ({projectId: i.id, organizationId: i.organizationId})), + ), + ), + ) + + return combineLatest([orgId$, organizationIdsFromProjects$]) + .pipe( + map(([orgId, orgIdsFromProjects]) => { + for (const {organizationId, projectId} of orgIdsFromProjects) { + if (orgId !== organizationId) { + throw new ConfigurationError( + new Error( + `Project ${projectId} is not part of organization ${orgId}. ` + + `The project belongs to organization ${organizationId} instead.`, + ), + ) + } + } + }), + ) + .subscribe({ + error: (error) => state.set('setError', {authState: {error, type: AuthStateType.ERROR}}), + }) +} + /** * @public */ @@ -270,7 +346,10 @@ export const getLoginUrlState = bindActionGlobally( */ export const getAuthState = bindActionGlobally( authStore, - createStateSourceAction(({state: {authState}}) => authState), + createStateSourceAction(({state: {authState}}) => { + if (authState.type === AuthStateType.ERROR) throw authState.error + return authState + }), ) /** diff --git a/packages/core/src/auth/getOrganizationVerificationState.test.ts b/packages/core/src/auth/getOrganizationVerificationState.test.ts deleted file mode 100644 index 1fc020b5..00000000 --- a/packages/core/src/auth/getOrganizationVerificationState.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import {type Observable} from 'rxjs' -import {TestScheduler} from 'rxjs/testing' -import {beforeEach, describe, expect, it, vi} from 'vitest' - -import { - compareProjectOrganization, - type OrgVerificationResult, -} from '../project/organizationVerification' -import {getProjectState} from '../project/project' -import {type SanityInstance} from '../store/createSanityInstance' -import {type StateSource} from '../store/createStateSourceAction' -import {getDashboardOrganizationId} from './dashboardUtils' -import {observeOrganizationVerificationState} from './getOrganizationVerificationState' - -// Mock dependencies -vi.mock('./dashboardUtils', () => ({ - getDashboardOrganizationId: vi.fn(), -})) -vi.mock('../project/project', () => ({ - getProjectState: vi.fn(), -})) -// Mock the comparison function to check its inputs -vi.mock('../project/organizationVerification', async (importOriginal) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const original = await importOriginal() - return { - ...original, - compareProjectOrganization: vi.fn(), - } -}) - -describe('observeOrganizationVerificationState', () => { - let testScheduler: TestScheduler - - // Mock instance (only config.projectId is used) - const mockInstance = { - config: {projectId: 'proj-1', dataset: 'd'}, - } as SanityInstance - - beforeEach(() => { - testScheduler = new TestScheduler((actual, expected) => { - expect(actual).toEqual(expected) - }) - vi.clearAllMocks() - }) - - // Helper to mock getDashboardOrganizationId - const mockDashboardOrgId = (observable: Observable) => { - vi.mocked(getDashboardOrganizationId).mockReturnValue({ - observable, - getCurrent: () => undefined, - subscribe: observable.subscribe.bind(observable), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) // Cast to any to bypass strict type checking in mock - } - - // Helper to mock getProjectState - const mockProjectOrgId = (observable: Observable<{organizationId: string | null} | null>) => { - vi.mocked(getProjectState).mockReturnValue({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - observable: observable as any, // Cast needed due to complex type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as StateSource) - } - - // Helper to mock compareProjectOrganization result - const mockComparisonResult = (result: OrgVerificationResult) => { - vi.mocked(compareProjectOrganization).mockReturnValue(result) - } - - it('should emit {error: null} if dashboardOrgId is null', () => { - testScheduler.run(({hot, expectObservable}) => { - const dashboardOrgId$ = hot('-a-', {a: null}) - const projectOrgId$ = hot('--b', {b: {organizationId: 'org-real'}}) - - mockDashboardOrgId(dashboardOrgId$) - mockProjectOrgId(projectOrgId$) - - const expectedMarble = '--a' // Corrected: combineLatest emits at frame 2 - const expectedValues = {a: {error: null}} - - const result$ = observeOrganizationVerificationState(mockInstance, [ - mockInstance.config.projectId!, - ]) - expectObservable(result$).toBe(expectedMarble, expectedValues) - }) - expect(compareProjectOrganization).not.toHaveBeenCalled() - }) - - it('should emit {error: null} if instance.config.projectId is missing', () => { - const instanceWithoutProjectId = { - config: {projectId: undefined, dataset: 'd'}, - // Add other required properties if SanityInstance type needs them - } as SanityInstance - - testScheduler.run(({hot, expectObservable}) => { - // Dashboard org ID doesn't matter much here, but provide one - const dashboardOrgId$ = hot('-a-', {a: 'org-dash'}) - // Project state fetch won't happen due to early return - const projectOrgId$: Observable<{organizationId: string | null} | null> = hot('--') - - mockDashboardOrgId(dashboardOrgId$) - mockProjectOrgId(projectOrgId$) - - // Should emit immediately (or based on dashboardOrgId$) due to missing projId - const expectedMarble = '-a' // Corrected: Emit at frame 1 - const expectedValues = {a: {error: null}} - - const result$ = observeOrganizationVerificationState(instanceWithoutProjectId, []) - expectObservable(result$).toBe(expectedMarble, expectedValues) - }) - // No project fetch or comparison should occur - expect(getProjectState).not.toHaveBeenCalled() - expect(compareProjectOrganization).not.toHaveBeenCalled() - }) - - it('should emit an error if project fetch returns null when dashboard orgId is present', () => { - const comparisonError = { - error: - 'Project proj-1 belongs to Organization unknown, but the Dashboard has Organization org-dash selected', - } - - testScheduler.run(({hot, expectObservable}) => { - const dashboardOrgId$ = hot('-a-', {a: 'org-dash'}) - const projectOrgId$ = hot('---n', {n: null}) // Project fetch returns null - - mockDashboardOrgId(dashboardOrgId$) - mockProjectOrgId(projectOrgId$) - // Mock the result specifically for this test's inputs - vi.mocked(compareProjectOrganization).mockImplementation((pId, projOrgId, dashOrgId) => { - if (pId === 'proj-1' && projOrgId === null && dashOrgId === 'org-dash') { - return comparisonError - } - return {error: 'Unexpected call to compareProjectOrganization'} // Fail test if called unexpectedly - }) - - // When project fetch returns null, orgId becomes null, and the comparison is skipped. - const expectedMarble = '---r' // Should emit { error: null } - const expectedValues = {r: {error: null}} // Expect null error - - const result$ = observeOrganizationVerificationState(mockInstance, [ - mockInstance.config.projectId!, - ]) - expectObservable(result$).toBe(expectedMarble, expectedValues) - }) - // Comparison should NOT be called because projectData.orgId is null - expect(compareProjectOrganization).not.toHaveBeenCalled() - // Reset mock for other tests - vi.mocked(compareProjectOrganization).mockReset() - }) - - it('should call compareProjectOrganization and emit its result when IDs match', () => { - testScheduler.run(({hot, expectObservable}) => { - const dashboardOrgId$ = hot('-a-', {a: 'org-match'}) - const projectOrgId$ = hot('--b', {b: {organizationId: 'org-match'}}) - const comparisonResult = {error: null} - - mockDashboardOrgId(dashboardOrgId$) - mockProjectOrgId(projectOrgId$) - mockComparisonResult(comparisonResult) - - const expectedMarble = '--r' // Emits when projectOrgId$ emits - const expectedValues = {r: comparisonResult} - - const result$ = observeOrganizationVerificationState(mockInstance, [ - mockInstance.config.projectId!, - ]) - expectObservable(result$).toBe(expectedMarble, expectedValues) - }) - - // Check that comparison was called with correct values after observables emit - expect(compareProjectOrganization).toHaveBeenCalledTimes(1) - expect(compareProjectOrganization).toHaveBeenCalledWith('proj-1', 'org-match', 'org-match') - }) - - it('should call compareProjectOrganization and emit its result when IDs mismatch', () => { - testScheduler.run(({hot, expectObservable}) => { - const dashboardOrgId$ = hot('-a-', {a: 'org-dash'}) - const projectOrgId$ = hot('--b', {b: {organizationId: 'org-proj'}}) - const comparisonResult = {error: 'Mismatch detected'} - - mockDashboardOrgId(dashboardOrgId$) - mockProjectOrgId(projectOrgId$) - mockComparisonResult(comparisonResult) - - const expectedMarble = '--r' - const expectedValues = {r: comparisonResult} - - const result$ = observeOrganizationVerificationState(mockInstance, [ - mockInstance.config.projectId!, - ]) - expectObservable(result$).toBe(expectedMarble, expectedValues) - }) - expect(compareProjectOrganization).toHaveBeenCalledTimes(1) - expect(compareProjectOrganization).toHaveBeenCalledWith('proj-1', 'org-proj', 'org-dash') - }) -}) diff --git a/packages/core/src/auth/getOrganizationVerificationState.ts b/packages/core/src/auth/getOrganizationVerificationState.ts deleted file mode 100644 index faf2e5c4..00000000 --- a/packages/core/src/auth/getOrganizationVerificationState.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {combineLatest, distinctUntilChanged, map, type Observable, of, switchMap} from 'rxjs' - -import { - compareProjectOrganization, - type OrgVerificationResult, -} from '../project/organizationVerification' -import {getProjectState} from '../project/project' -import {type SanityInstance} from '../store/createSanityInstance' -import {getDashboardOrganizationId} from './dashboardUtils' - -/** - * Creates an observable that emits the organization verification state for a given instance. - * It combines the dashboard organization ID (from auth context) with the - * project's actual organization ID (fetched via getProjectState) and compares them. - * @public - */ -export function observeOrganizationVerificationState( - instance: SanityInstance, - projectIds: string[], -): Observable { - // Observable for the dashboard org ID (potentially null) - const dashboardOrgId$ = - getDashboardOrganizationId(instance).observable.pipe(distinctUntilChanged()) - - // Create observables for each project's org ID - const projectOrgIdObservables = projectIds.map((id) => - getProjectState(instance, {projectId: id}).observable.pipe( - map((project) => ({projectId: id, orgId: project?.organizationId ?? null})), - // Ensure we only proceed if the orgId is loaded, distinct prevents unnecessary checks - distinctUntilChanged((prev, curr) => prev.orgId === curr.orgId), - ), - ) - - // Combine observables to get all project org IDs - const allProjectOrgIds$ = - projectOrgIdObservables.length > 0 ? combineLatest(projectOrgIdObservables) : of([]) - - // Combine the sources - return combineLatest([dashboardOrgId$, allProjectOrgIds$]).pipe( - switchMap(([dashboardOrgId, projectOrgDataArray]) => { - // If no dashboard org ID is set, or no project IDs provided, verification isn't applicable/possible - if (!dashboardOrgId || projectOrgDataArray.length === 0) { - return of({error: null}) // Return success (no error) - } - - // Iterate through all projects and check organization IDs - for (const projectData of projectOrgDataArray) { - // If a project doesn't have an orgId, we can't verify, treat as non-blocking for now - // (Matches original logic where null projectOrgId resulted in {error: null}) - if (!projectData.orgId) { - continue - } - - // Perform the comparison for the current project - const result = compareProjectOrganization( - projectData.projectId, - projectData.orgId, - dashboardOrgId, - ) - - // If any project fails verification, immediately return the error - if (result.error) { - return of(result) - } - } - - // If all projects passed verification (or had no orgId to check) - return of({error: null}) - }), - // Only emit when the overall error status actually changes - distinctUntilChanged((prev, curr) => prev.error === curr.error), - ) -} diff --git a/packages/core/src/project/project.ts b/packages/core/src/project/project.ts index a553794c..b2740f68 100644 --- a/packages/core/src/project/project.ts +++ b/packages/core/src/project/project.ts @@ -1,5 +1,6 @@ -import {switchMap} from 'rxjs' +import {catchError, switchMap} from 'rxjs' +import {ConfigurationError} from '../auth/ConfigurationError' import {getClientState} from '../client/clientStore' import {type ProjectHandle} from '../config/sanityConfig' import {createFetcherStore} from '../utils/createFetcherStore' @@ -11,7 +12,7 @@ const project = createFetcherStore({ getKey: (instance, options?: ProjectHandle) => { const projectId = options?.projectId ?? instance.config.projectId if (!projectId) { - throw new Error('A projectId is required to use the project API.') + throw new ConfigurationError(new Error('A projectId is required to use the project API.')) } return projectId }, @@ -31,6 +32,9 @@ const project = createFetcherStore({ (projectId ?? instance.config.projectId)!, ), ), + catchError((error) => { + throw new ConfigurationError(error) + }), ) }, }) diff --git a/packages/react/src/_exports/sdk-react.ts b/packages/react/src/_exports/sdk-react.ts index 0fa82995..5ae7712d 100644 --- a/packages/react/src/_exports/sdk-react.ts +++ b/packages/react/src/_exports/sdk-react.ts @@ -12,7 +12,6 @@ export {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganization export {useHandleAuthCallback} from '../hooks/auth/useHandleAuthCallback' export {useLoginUrl} from '../hooks/auth/useLoginUrl' export {useLogOut} from '../hooks/auth/useLogOut' -export {useVerifyOrgProjects} from '../hooks/auth/useVerifyOrgProjects' export {useClient} from '../hooks/client/useClient' export { type FrameConnection, diff --git a/packages/react/src/components/SDKError.tsx b/packages/react/src/components/SDKError.tsx new file mode 100644 index 00000000..53767329 --- /dev/null +++ b/packages/react/src/components/SDKError.tsx @@ -0,0 +1,28 @@ +import {ConfigurationError} from '@sanity/sdk' +import {type FallbackProps} from 'react-error-boundary' + +/** + * @alpha + */ +export type SDKErrorProps = FallbackProps + +/** + * Displays authentication error details and provides retry functionality. + * Only handles {@link ClientError} instances - rethrows other error types. + * + * @alpha + */ +export function SDKError({error}: SDKErrorProps): React.ReactNode { + if (!(error instanceof ConfigurationError)) { + throw error + } + + return ( +
+
+

Configuration Error

+

{error.message}

+
+
+ ) +} diff --git a/packages/react/src/components/auth/AuthBoundary.test.tsx b/packages/react/src/components/auth/AuthBoundary.test.tsx index 86bb883d..1b774294 100644 --- a/packages/react/src/components/auth/AuthBoundary.test.tsx +++ b/packages/react/src/components/auth/AuthBoundary.test.tsx @@ -7,7 +7,6 @@ import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest' import {ResourceProvider} from '../../context/ResourceProvider' import {useAuthState} from '../../hooks/auth/useAuthState' import {useLoginUrl} from '../../hooks/auth/useLoginUrl' -import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects' import {AuthBoundary} from './AuthBoundary' // Mock hooks @@ -104,7 +103,6 @@ describe('AuthBoundary', () => { let consoleErrorSpy: MockInstance const mockUseAuthState = vi.mocked(useAuthState) const mockUseLoginUrl = vi.mocked(useLoginUrl) - const mockUseVerifyOrgProjects = vi.mocked(useVerifyOrgProjects) const testProjectIds = ['proj-test'] // Example project ID for tests beforeEach(() => { @@ -114,8 +112,6 @@ describe('AuthBoundary', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN} as any) mockUseLoginUrl.mockReturnValue('http://example.com/login') - // Default mock for useVerifyOrgProjects - returns null (no error) - mockUseVerifyOrgProjects.mockImplementation(() => null) }) afterEach(() => { @@ -129,7 +125,7 @@ describe('AuthBoundary', () => { }) render( - Protected Content + Protected Content , ) @@ -146,7 +142,7 @@ describe('AuthBoundary', () => { }) const {container} = render( - Protected Content + Protected Content , ) @@ -163,7 +159,7 @@ describe('AuthBoundary', () => { }) render( - Protected Content + Protected Content , ) @@ -177,7 +173,7 @@ describe('AuthBoundary', () => { }) render( - Protected Content + Protected Content , ) @@ -194,7 +190,7 @@ describe('AuthBoundary', () => { it('renders children when logged in and org verification passes', () => { render( - Protected Content + Protected Content , ) expect(screen.getByText('Protected Content')).toBeInTheDocument() @@ -204,18 +200,10 @@ describe('AuthBoundary', () => { const orgErrorMessage = 'Organization mismatch!' // eslint-disable-next-line @typescript-eslint/no-explicit-any mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN} as any) - // Mock specific return value for this test - mockUseVerifyOrgProjects.mockImplementation((disabled, pIds) => { - // Expect verification to be enabled (disabled=false) and projectIds to match - if (!disabled && pIds === testProjectIds) { - return orgErrorMessage - } - return null // Default case - }) // Need to catch the error thrown during render. ErrorBoundary mock handles this. render( - +
Protected Content
, ) @@ -245,7 +233,7 @@ describe('AuthBoundary', () => { }) render( - +
Protected Content
, ) @@ -264,11 +252,9 @@ describe('AuthBoundary', () => { error: new Error(authErrorMessage), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) - mockUseVerifyOrgProjects.mockReturnValue(null) // Org verification passes or is irrelevant - mockUseVerifyOrgProjects.mockImplementation(() => null) render( - +
Protected Content
, ) diff --git a/packages/react/src/components/auth/AuthBoundary.tsx b/packages/react/src/components/auth/AuthBoundary.tsx index d439e28b..55b563bd 100644 --- a/packages/react/src/components/auth/AuthBoundary.tsx +++ b/packages/react/src/components/auth/AuthBoundary.tsx @@ -4,10 +4,8 @@ import {ErrorBoundary, type FallbackProps} from 'react-error-boundary' import {useAuthState} from '../../hooks/auth/useAuthState' import {useLoginUrl} from '../../hooks/auth/useLoginUrl' -import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects' import {isInIframe} from '../utils' import {AuthError} from './AuthError' -import {ConfigurationError} from './ConfigurationError' import {LoginCallback} from './LoginCallback' import {LoginError, type LoginErrorProps} from './LoginError' @@ -58,25 +56,11 @@ export interface AuthBoundaryProps { /** Header content to display */ header?: React.ReactNode - /** - * The project IDs to use for organization verification. - */ - projectIds?: string[] - /** Footer content to display */ footer?: React.ReactNode /** Protected content to render when authenticated */ children?: React.ReactNode - - /** - * Whether to verify that the project belongs to the organization specified in the dashboard context. - * By default, organization verification is enabled when running in a dashboard context. - * - * WARNING: Disabling organization verification is NOT RECOMMENDED and may cause your application - * to break in the future. This should never be disabled in production environments. - */ - verifyOrganization?: boolean } /** @@ -129,19 +113,10 @@ interface AuthSwitchProps { header?: React.ReactNode footer?: React.ReactNode children?: React.ReactNode - verifyOrganization?: boolean - projectIds?: string[] } -function AuthSwitch({ - CallbackComponent = LoginCallback, - children, - verifyOrganization = true, - projectIds, - ...props -}: AuthSwitchProps) { +function AuthSwitch({CallbackComponent = LoginCallback, children, ...props}: AuthSwitchProps) { const authState = useAuthState() - const orgError = useVerifyOrgProjects(!verifyOrganization, projectIds) const isLoggedOut = authState.type === AuthStateType.LOGGED_OUT && !authState.isDestroyingSession const loginUrl = useLoginUrl() @@ -153,11 +128,6 @@ function AuthSwitch({ } }, [isLoggedOut, loginUrl]) - // Only check the error if verification is enabled - if (verifyOrganization && orgError) { - throw new ConfigurationError({message: orgError}) - } - switch (authState.type) { case AuthStateType.ERROR: { throw new AuthError(authState.error) diff --git a/packages/react/src/components/auth/LoginError.tsx b/packages/react/src/components/auth/LoginError.tsx index 255d5160..e87e80ff 100644 --- a/packages/react/src/components/auth/LoginError.tsx +++ b/packages/react/src/components/auth/LoginError.tsx @@ -1,12 +1,9 @@ import {ClientError} from '@sanity/client' -import {AuthStateType} from '@sanity/sdk' -import {useCallback, useEffect, useState} from 'react' +import {useCallback, useEffect, useMemo} from 'react' import {type FallbackProps} from 'react-error-boundary' -import {useAuthState} from '../../hooks/auth/useAuthState' import {useLogOut} from '../../hooks/auth/useLogOut' -import {AuthError} from './AuthError' -import {ConfigurationError} from './ConfigurationError' +import {isAuthError} from '../utils' /** * @alpha */ @@ -19,13 +16,15 @@ export type LoginErrorProps = FallbackProps * @alpha */ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.ReactNode { - if (!(error instanceof AuthError || error instanceof ConfigurationError)) throw error + if (!(error instanceof ClientError)) { + throw error + } + const logout = useLogOut() - const authState = useAuthState() + // const authState = useAuthState() - const [authErrorMessage, setAuthErrorMessage] = useState( - 'Please try again or contact support if the problem persists.', - ) + // const authErrorMessage = error.message + // const [authErrorMessage, setAuthErrorMessage] = useState(error.message) const handleRetry = useCallback(async () => { await logout() @@ -33,29 +32,25 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React. }, [logout, resetErrorBoundary]) useEffect(() => { - if (authState.type === AuthStateType.ERROR && authState.error instanceof ClientError) { - if (authState.error.statusCode === 401) { - handleRetry() - } else if (authState.error.statusCode === 404) { - const errorMessage = authState.error.response.body.message || '' - if (errorMessage.startsWith('Session with sid') && errorMessage.endsWith('not found')) { - setAuthErrorMessage('The session ID is invalid or expired.') - } else { - setAuthErrorMessage('The login link is invalid or expired. Please try again.') - } - } + const needsRetry = error instanceof ClientError && isAuthError(error) + if (needsRetry) { + handleRetry() } - if (authState.type !== AuthStateType.ERROR && error instanceof ConfigurationError) { - setAuthErrorMessage(error.message) + }, [error, handleRetry]) + + const authErrorMessage = useMemo(() => { + const errMess = error.response.body.message || '' + if (errMess.startsWith('Session with sid') && errMess.endsWith('not found')) { + return 'The session ID is invalid or expired.' + } else { + return errMess } - }, [authState, handleRetry, error]) + }, [error]) return (
-

- {error instanceof AuthError ? 'Authentication Error' : 'Configuration Error'} -

+

Authentication Error

{authErrorMessage}

diff --git a/packages/react/src/components/utils.ts b/packages/react/src/components/utils.ts index 7a3b1fa1..d49bd108 100644 --- a/packages/react/src/components/utils.ts +++ b/packages/react/src/components/utils.ts @@ -1,3 +1,5 @@ +import {type ClientError} from '@sanity/client' + export function isInIframe(): boolean { return typeof window !== 'undefined' && window.self !== window.top } @@ -20,3 +22,27 @@ export function isLocalUrl(window: Window): boolean { url.startsWith('https://127.0.0.1') ) } + +/** + * @internal + * + * Checks if the ClientError is an authentication error that requires a logout. + * + * @param error - The ClientError to check + * @returns True if the error is an authentication error that requires a logout, false otherwise + */ +export function isAuthError(error: ClientError): boolean { + const SANITY_AUTH_ERROR_CODES = [ + 'SIO-401-ANF', // The token specified is not valid or has been deleted + 'SIO-401-AWH', // The token specified does not belong to the configured project + 'SIO-401-AEX', // The token is expired + ] as const + if (error.statusCode !== 401) { + return false + } + const errorCode = error.response.body.errorCode + if (!errorCode) { + return false + } + return SANITY_AUTH_ERROR_CODES.includes(errorCode) +} diff --git a/packages/react/src/context/ResourceProvider.tsx b/packages/react/src/context/ResourceProvider.tsx index 3fe0d663..23c5b26b 100644 --- a/packages/react/src/context/ResourceProvider.tsx +++ b/packages/react/src/context/ResourceProvider.tsx @@ -1,6 +1,8 @@ import {createSanityInstance, type SanityConfig, type SanityInstance} from '@sanity/sdk' import {Suspense, useContext, useEffect, useMemo, useRef} from 'react' +import {ErrorBoundary, type FallbackProps} from 'react-error-boundary' +import {SDKError} from '../components/SDKError' import {SanityInstanceContext} from './SanityInstanceContext' const DEFAULT_FALLBACK = ( @@ -20,6 +22,7 @@ export interface ResourceProviderProps extends SanityConfig { */ fallback: React.ReactNode children: React.ReactNode + ErrorComponent?: React.ComponentType } /** @@ -70,6 +73,7 @@ export interface ResourceProviderProps extends SanityConfig { export function ResourceProvider({ children, fallback, + ErrorComponent = SDKError, ...config }: ResourceProviderProps): React.ReactNode { const parent = useContext(SanityInstanceContext) @@ -105,7 +109,9 @@ export function ResourceProvider({ return ( - {children} + + {children} + ) } diff --git a/packages/react/src/hooks/auth/useVerifyOrgProjects.test.tsx b/packages/react/src/hooks/auth/useVerifyOrgProjects.test.tsx deleted file mode 100644 index fdb2d472..00000000 --- a/packages/react/src/hooks/auth/useVerifyOrgProjects.test.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import {observeOrganizationVerificationState, type OrgVerificationResult} from '@sanity/sdk' -import {act, renderHook, waitFor} from '@testing-library/react' -import {Subject} from 'rxjs' -import {describe, expect, it, vi} from 'vitest' - -import {useSanityInstance} from '../context/useSanityInstance' -import {useVerifyOrgProjects} from './useVerifyOrgProjects' - -// Mock dependencies -vi.mock('@sanity/sdk', async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - observeOrganizationVerificationState: vi.fn(), - } -}) -vi.mock('../context/useSanityInstance') - -describe('useVerifyOrgProjects', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mockInstance = {config: {}} as any // Dummy instance - const mockObserve = vi.mocked(observeOrganizationVerificationState) - const mockUseInstance = vi.mocked(useSanityInstance) - const testProjectIds = ['proj-1'] - - beforeEach(() => { - vi.clearAllMocks() - mockUseInstance.mockReturnValue(mockInstance) - }) - - it('should return null and not observe state if disabled', () => { - const {result} = renderHook(() => useVerifyOrgProjects(true, testProjectIds)) - - expect(result.current).toBeNull() - expect(mockObserve).not.toHaveBeenCalled() - }) - - it('should return null and not observe state if projectIds is missing or empty', () => { - const {result: resultUndefined} = renderHook(() => useVerifyOrgProjects(false, undefined)) - expect(resultUndefined.current).toBeNull() - expect(mockObserve).not.toHaveBeenCalled() - - const {result: resultEmpty} = renderHook(() => useVerifyOrgProjects(false, [])) - expect(resultEmpty.current).toBeNull() - expect(mockObserve).not.toHaveBeenCalled() - }) - - it('should return null initially when not disabled and projectIds provided', () => { - const subject = new Subject() - mockObserve.mockReturnValue(subject.asObservable()) - - const {result} = renderHook(() => useVerifyOrgProjects(false, testProjectIds)) - - expect(result.current).toBeNull() - expect(mockObserve).toHaveBeenCalledWith(mockInstance, testProjectIds) - }) - - it('should return null if observable emits { error: null }', async () => { - const subject = new Subject() - mockObserve.mockReturnValue(subject.asObservable()) - - const {result} = renderHook(() => useVerifyOrgProjects(false, testProjectIds)) - - act(() => { - subject.next({error: null}) - }) - - await waitFor(() => { - expect(result.current).toBeNull() - }) - }) - - it('should return error string if observable emits { error: string }', async () => { - const subject = new Subject() - const errorMessage = 'Org mismatch' - mockObserve.mockReturnValue(subject.asObservable()) - - const {result} = renderHook(() => useVerifyOrgProjects(false, testProjectIds)) - - act(() => { - subject.next({error: errorMessage}) - }) - - await waitFor(() => { - expect(result.current).toBe(errorMessage) - }) - }) - - it('should unsubscribe on unmount', () => { - const subject = new Subject() - const unsubscribeSpy = vi.spyOn(subject, 'unsubscribe') - mockObserve.mockReturnValue(subject) - - const {unmount} = renderHook(() => useVerifyOrgProjects(false, testProjectIds)) - - expect(unsubscribeSpy).not.toHaveBeenCalled() - unmount() - // Note: RxJS handles the inner subscription cleanup when the source (Subject) completes or errors, - // but testing library unmount should trigger the useEffect cleanup which calls unsubscribe. - // However, the spy might be on the Subject itself, not the final Subscription object. - // Let's adjust to spy on the returned subscription directly if possible, or accept this limitation. - // For now, we assume the useEffect cleanup calls unsubscribe correctly. - // We can validate subscription logic more deeply if needed. - // For this test, let's check if the observable reference still has observers. - expect(subject.observed).toBe(false) // Check if observers are gone after unmount - }) - - it('should clear the error if disabled becomes true', async () => { - const subject = new Subject() - const errorMessage = 'Org mismatch' - mockObserve.mockReturnValue(subject.asObservable()) - - const {result, rerender} = renderHook( - ({disabled, pIds}) => useVerifyOrgProjects(disabled, pIds), - { - initialProps: {disabled: false, pIds: testProjectIds}, - }, - ) - - // Set initial error - act(() => { - subject.next({error: errorMessage}) - }) - await waitFor(() => { - expect(result.current).toBe(errorMessage) - }) - - // Disable the hook - rerender({disabled: true, pIds: testProjectIds}) - - // Error should be cleared - await waitFor(() => { - expect(result.current).toBeNull() - }) - }) -}) diff --git a/packages/react/src/hooks/auth/useVerifyOrgProjects.tsx b/packages/react/src/hooks/auth/useVerifyOrgProjects.tsx deleted file mode 100644 index 04c8d25c..00000000 --- a/packages/react/src/hooks/auth/useVerifyOrgProjects.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import {observeOrganizationVerificationState, type OrgVerificationResult} from '@sanity/sdk' -import {useEffect, useState} from 'react' - -import {useSanityInstance} from '../context/useSanityInstance' - -/** - * Hook that verifies the current projects belongs to the organization ID specified in the dashboard context. - * - * @public - * @param disabled - When true, disables verification and skips project verification API calls - * @returns Error message if the project doesn't match the organization ID, or null if all match or verification isn't needed - * @category Projects - * @example - * ```tsx - * function OrgVerifier() { - * const error = useVerifyOrgProjects() - * - * if (error) { - * return
{error}
- * } - * - * return
Organization projects verified!
- * } - * ``` - */ -export function useVerifyOrgProjects(disabled = false, projectIds?: string[]): string | null { - const instance = useSanityInstance() - const [error, setError] = useState(null) - - useEffect(() => { - if (disabled || !projectIds || projectIds.length === 0) { - if (error !== null) setError(null) - return - } - - const verificationObservable$ = observeOrganizationVerificationState(instance, projectIds) - - const subscription = verificationObservable$.subscribe((result: OrgVerificationResult) => { - setError(result.error) - }) - - return () => { - subscription.unsubscribe() - } - }, [instance, disabled, error, projectIds]) - - return error -}