From 4aa3acdd86039c4a88f009ea3ad05f309b33feb0 Mon Sep 17 00:00:00 2001 From: Rico Kahler Date: Fri, 9 May 2025 15:28:35 -0500 Subject: [PATCH 1/6] feat(auth): introduce ConfigurationError class and enhance error handling in auth flow - Added ConfigurationError class for better error management during configuration issues. - Updated authStore to listen for project IDs and handle errors more effectively. - Enhanced error handling in various components and hooks to throw ConfigurationError when necessary. - Refactored useVerifyOrgProjects to improve error handling and logging. - Updated App.tsx with new sanity configuration. --- apps/kitchensink-react/src/App.tsx | 4 ++ packages/core/src/_exports/index.ts | 1 + .../src}/auth/ConfigurationError.ts | 0 packages/core/src/auth/authStore.ts | 63 ++++++++++++++++++- packages/core/src/auth/dashboardUtils.ts | 19 +++++- .../auth/getOrganizationVerificationState.ts | 2 +- packages/core/src/project/project.ts | 6 +- .../src/components/auth/AuthBoundary.tsx | 13 +--- .../react/src/components/auth/LoginError.tsx | 52 ++++++++------- .../src/hooks/auth/useVerifyOrgProjects.tsx | 11 +++- 10 files changed, 126 insertions(+), 45 deletions(-) rename packages/{react/src/components => core/src}/auth/ConfigurationError.ts (100%) diff --git a/apps/kitchensink-react/src/App.tsx b/apps/kitchensink-react/src/App.tsx index ac2f0907..16a55935 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 32a619ed..517047db 100644 --- a/packages/core/src/_exports/index.ts +++ b/packages/core/src/_exports/index.ts @@ -19,6 +19,7 @@ export { type LoggedOutAuthState, type LoggingInAuthState, } from '../auth/authStore' +export {ConfigurationError} from '../auth/ConfigurationError' export {observeOrganizationVerificationState} from '../auth/getOrganizationVerificationState' export {handleAuthCallback} from '../auth/handleAuthCallback' export {logout} from '../auth/logout' 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..35a8ae39 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} from 'rxjs' import {type AuthConfig, type AuthProvider} from '../config/authConfig' +import {getProjectState} from '../project/project' 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 {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,55 @@ 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 projectIds = getProjectIdsFromInstanceAndParents(instance) + + const orgId$ = state.observable.pipe( + map((i) => i.dashboardContext?.orgId), + filter(Boolean), + ) + + const organizationIdsFromProjects$ = combineLatest( + projectIds.map((projectId) => + getProjectState(instance, {projectId}).observable.pipe( + filter(Boolean), + 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 +324,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/dashboardUtils.ts b/packages/core/src/auth/dashboardUtils.ts index 0148a59f..df1ec511 100644 --- a/packages/core/src/auth/dashboardUtils.ts +++ b/packages/core/src/auth/dashboardUtils.ts @@ -1,12 +1,29 @@ import {bindActionGlobally} from '../store/createActionBinder' +import {type SanityInstance} from '../store/createSanityInstance' import {createStateSourceAction} from '../store/createStateSourceAction' import {authStore} from './authStore' +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) +} + /** * Gets the dashboard organization ID from the auth store * @internal */ export const getDashboardOrganizationId = bindActionGlobally( authStore, - createStateSourceAction(({state: {dashboardContext}}) => dashboardContext?.orgId), + createStateSourceAction(({instance, state: {dashboardContext}}) => { + const projectIds = getProjectIdsFromInstanceAndParents(instance) + console.log({projectIds}) + return dashboardContext?.orgId + }), ) diff --git a/packages/core/src/auth/getOrganizationVerificationState.ts b/packages/core/src/auth/getOrganizationVerificationState.ts index faf2e5c4..bdeb7596 100644 --- a/packages/core/src/auth/getOrganizationVerificationState.ts +++ b/packages/core/src/auth/getOrganizationVerificationState.ts @@ -60,7 +60,7 @@ export function observeOrganizationVerificationState( // If any project fails verification, immediately return the error if (result.error) { - return of(result) + throw result.error } } diff --git a/packages/core/src/project/project.ts b/packages/core/src/project/project.ts index a553794c..f2f3e704 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 '../_exports' import {getClientState} from '../client/clientStore' import {type ProjectHandle} from '../config/sanityConfig' import {createFetcherStore} from '../utils/createFetcherStore' @@ -31,6 +32,9 @@ const project = createFetcherStore({ (projectId ?? instance.config.projectId)!, ), ), + catchError((error) => { + throw new ConfigurationError(error) + }), ) }, }) diff --git a/packages/react/src/components/auth/AuthBoundary.tsx b/packages/react/src/components/auth/AuthBoundary.tsx index d439e28b..457a5c16 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' @@ -136,12 +134,12 @@ interface AuthSwitchProps { function AuthSwitch({ CallbackComponent = LoginCallback, children, - verifyOrganization = true, - projectIds, + // verifyOrganization = true, + // projectIds, ...props }: AuthSwitchProps) { const authState = useAuthState() - const orgError = useVerifyOrgProjects(!verifyOrganization, projectIds) + // const orgError = useVerifyOrgProjects(!verifyOrganization, projectIds) const isLoggedOut = authState.type === AuthStateType.LOGGED_OUT && !authState.isDestroyingSession const loginUrl = useLoginUrl() @@ -153,11 +151,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..24a85e7a 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 {ConfigurationError} from '@sanity/sdk' +import {useCallback} 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' /** * @alpha */ @@ -19,36 +16,37 @@ 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 AuthError || error instanceof ConfigurationError)) { + throw error + } + const logout = useLogOut() - const authState = useAuthState() + // useAuthState() - const [authErrorMessage, setAuthErrorMessage] = useState( - 'Please try again or contact support if the problem persists.', - ) + const authErrorMessage = error.message const handleRetry = useCallback(async () => { await logout() resetErrorBoundary() }, [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.') - } - } - } - if (authState.type !== AuthStateType.ERROR && error instanceof ConfigurationError) { - setAuthErrorMessage(error.message) - } - }, [authState, handleRetry, error]) + // 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.') + // } + // } + // } + // if (authState.type !== AuthStateType.ERROR && error instanceof ConfigurationError) { + // setAuthErrorMessage(error.message) + // } + // }, [authState, handleRetry, error]) return (
diff --git a/packages/react/src/hooks/auth/useVerifyOrgProjects.tsx b/packages/react/src/hooks/auth/useVerifyOrgProjects.tsx index 04c8d25c..643e67a1 100644 --- a/packages/react/src/hooks/auth/useVerifyOrgProjects.tsx +++ b/packages/react/src/hooks/auth/useVerifyOrgProjects.tsx @@ -26,6 +26,10 @@ import {useSanityInstance} from '../context/useSanityInstance' export function useVerifyOrgProjects(disabled = false, projectIds?: string[]): string | null { const instance = useSanityInstance() const [error, setError] = useState(null) + if (error) { + console.error('AHHH', error) + throw error + } useEffect(() => { if (disabled || !projectIds || projectIds.length === 0) { @@ -35,8 +39,11 @@ export function useVerifyOrgProjects(disabled = false, projectIds?: string[]): s const verificationObservable$ = observeOrganizationVerificationState(instance, projectIds) - const subscription = verificationObservable$.subscribe((result: OrgVerificationResult) => { - setError(result.error) + const subscription = verificationObservable$.subscribe({ + next: (result: OrgVerificationResult) => { + setError(result.error) + }, + error: setError, }) return () => { From fbc994ceea3322917f67da212ea4d898f96177be Mon Sep 17 00:00:00 2001 From: Ryan Bonial Date: Wed, 14 May 2025 14:18:52 -0600 Subject: [PATCH 2/6] chore: remove unused functions --- packages/core/src/_exports/index.ts | 1 - .../getOrganizationVerificationState.test.ts | 197 ------------------ .../auth/getOrganizationVerificationState.ts | 73 ------- packages/core/src/project/project.ts | 3 +- packages/react/src/_exports/sdk-react.ts | 1 - .../src/components/auth/AuthBoundary.tsx | 1 - .../hooks/auth/useVerifyOrgProjects.test.tsx | 136 ------------ .../src/hooks/auth/useVerifyOrgProjects.tsx | 55 ----- 8 files changed, 2 insertions(+), 465 deletions(-) delete mode 100644 packages/core/src/auth/getOrganizationVerificationState.test.ts delete mode 100644 packages/core/src/auth/getOrganizationVerificationState.ts delete mode 100644 packages/react/src/hooks/auth/useVerifyOrgProjects.test.tsx delete mode 100644 packages/react/src/hooks/auth/useVerifyOrgProjects.tsx diff --git a/packages/core/src/_exports/index.ts b/packages/core/src/_exports/index.ts index 517047db..51ee8c0d 100644 --- a/packages/core/src/_exports/index.ts +++ b/packages/core/src/_exports/index.ts @@ -20,7 +20,6 @@ export { type LoggingInAuthState, } from '../auth/authStore' export {ConfigurationError} from '../auth/ConfigurationError' -export {observeOrganizationVerificationState} from '../auth/getOrganizationVerificationState' export {handleAuthCallback} from '../auth/handleAuthCallback' export {logout} from '../auth/logout' export type {ClientStoreState as ClientState} from '../client/clientStore' 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 bdeb7596..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) { - throw result.error - } - } - - // 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 f2f3e704..ea511577 100644 --- a/packages/core/src/project/project.ts +++ b/packages/core/src/project/project.ts @@ -1,6 +1,7 @@ import {catchError, switchMap} from 'rxjs' -import {ConfigurationError} from '../_exports' +import {ConfigurationError} from '../auth/ConfigurationError' +// eslint-disable-next-line import/no-cycle import {getClientState} from '../client/clientStore' import {type ProjectHandle} from '../config/sanityConfig' import {createFetcherStore} from '../utils/createFetcherStore' diff --git a/packages/react/src/_exports/sdk-react.ts b/packages/react/src/_exports/sdk-react.ts index d108edd3..e0bf0a2d 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/auth/AuthBoundary.tsx b/packages/react/src/components/auth/AuthBoundary.tsx index 457a5c16..bef3b003 100644 --- a/packages/react/src/components/auth/AuthBoundary.tsx +++ b/packages/react/src/components/auth/AuthBoundary.tsx @@ -139,7 +139,6 @@ function AuthSwitch({ ...props }: AuthSwitchProps) { const authState = useAuthState() - // const orgError = useVerifyOrgProjects(!verifyOrganization, projectIds) const isLoggedOut = authState.type === AuthStateType.LOGGED_OUT && !authState.isDestroyingSession const loginUrl = useLoginUrl() 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 643e67a1..00000000 --- a/packages/react/src/hooks/auth/useVerifyOrgProjects.tsx +++ /dev/null @@ -1,55 +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) - if (error) { - console.error('AHHH', error) - throw error - } - - useEffect(() => { - if (disabled || !projectIds || projectIds.length === 0) { - if (error !== null) setError(null) - return - } - - const verificationObservable$ = observeOrganizationVerificationState(instance, projectIds) - - const subscription = verificationObservable$.subscribe({ - next: (result: OrgVerificationResult) => { - setError(result.error) - }, - error: setError, - }) - - return () => { - subscription.unsubscribe() - } - }, [instance, disabled, error, projectIds]) - - return error -} From 19e19f18d7271a9cc0ed71eefeb74d2680b36fa4 Mon Sep 17 00:00:00 2001 From: Ryan Bonial Date: Thu, 15 May 2025 10:52:25 -0600 Subject: [PATCH 3/6] chore: show errors to users in LoginError --- packages/core/src/project/project.ts | 3 +- .../react/src/components/auth/LoginError.tsx | 51 +++++++++++-------- .../react/src/context/ResourceProvider.tsx | 6 ++- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/core/src/project/project.ts b/packages/core/src/project/project.ts index ea511577..b2740f68 100644 --- a/packages/core/src/project/project.ts +++ b/packages/core/src/project/project.ts @@ -1,7 +1,6 @@ import {catchError, switchMap} from 'rxjs' import {ConfigurationError} from '../auth/ConfigurationError' -// eslint-disable-next-line import/no-cycle import {getClientState} from '../client/clientStore' import {type ProjectHandle} from '../config/sanityConfig' import {createFetcherStore} from '../utils/createFetcherStore' @@ -13,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 }, diff --git a/packages/react/src/components/auth/LoginError.tsx b/packages/react/src/components/auth/LoginError.tsx index 24a85e7a..1e4d924e 100644 --- a/packages/react/src/components/auth/LoginError.tsx +++ b/packages/react/src/components/auth/LoginError.tsx @@ -1,5 +1,6 @@ +import {ClientError} from '@sanity/client' import {ConfigurationError} from '@sanity/sdk' -import {useCallback} from 'react' +import {useCallback, useEffect, useMemo} from 'react' import {type FallbackProps} from 'react-error-boundary' import {useLogOut} from '../../hooks/auth/useLogOut' @@ -16,37 +17,45 @@ export type LoginErrorProps = FallbackProps * @alpha */ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.ReactNode { - if (!(error instanceof AuthError || error instanceof ConfigurationError)) { + if (!(error instanceof ClientError || error instanceof ConfigurationError)) { throw error } const logout = useLogOut() - // useAuthState() + // const authState = useAuthState() - const authErrorMessage = error.message + // const authErrorMessage = error.message + // const [authErrorMessage, setAuthErrorMessage] = useState(error.message) const handleRetry = useCallback(async () => { await logout() resetErrorBoundary() }, [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.') - // } - // } - // } - // if (authState.type !== AuthStateType.ERROR && error instanceof ConfigurationError) { - // setAuthErrorMessage(error.message) - // } - // }, [authState, handleRetry, error]) + const shouldHandleRetry = useMemo(() => { + if (error instanceof ClientError) { + return error.statusCode === 401 + } + return false + }, [error]) + + useEffect(() => { + if (shouldHandleRetry) { + handleRetry() + } + }, [shouldHandleRetry, handleRetry]) + + const authErrorMessage = useMemo(() => { + if (!(error instanceof ClientError)) { + return error.message + } + 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 'The login link is invalid or expired. Please try again.' + } + }, [error]) return (
diff --git a/packages/react/src/context/ResourceProvider.tsx b/packages/react/src/context/ResourceProvider.tsx index 3fe0d663..cab99ba7 100644 --- a/packages/react/src/context/ResourceProvider.tsx +++ b/packages/react/src/context/ResourceProvider.tsx @@ -1,5 +1,6 @@ import {createSanityInstance, type SanityConfig, type SanityInstance} from '@sanity/sdk' import {Suspense, useContext, useEffect, useMemo, useRef} from 'react' +import {ErrorBoundary} from 'react-error-boundary' import {SanityInstanceContext} from './SanityInstanceContext' @@ -70,6 +71,7 @@ export interface ResourceProviderProps extends SanityConfig { export function ResourceProvider({ children, fallback, + ErrorComponent = SDKError, ...config }: ResourceProviderProps): React.ReactNode { const parent = useContext(SanityInstanceContext) @@ -105,7 +107,9 @@ export function ResourceProvider({ return ( - {children} + + {children} + ) } From 5e4138bfc6257f5e8fe3d8fb1fa8e8f2e7854994 Mon Sep 17 00:00:00 2001 From: Ryan Bonial Date: Thu, 15 May 2025 15:43:29 -0600 Subject: [PATCH 4/6] chore: fix circular dep --- packages/core/src/auth/authStore.ts | 30 +++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/core/src/auth/authStore.ts b/packages/core/src/auth/authStore.ts index 35a8ae39..f95a003c 100644 --- a/packages/core/src/auth/authStore.ts +++ b/packages/core/src/auth/authStore.ts @@ -1,13 +1,13 @@ import {type ClientConfig, createClient, type SanityClient} from '@sanity/client' import {type CurrentUser} from '@sanity/types' -import {combineLatest, filter, map, type Subscription} from 'rxjs' +import {combineLatest, filter, map, type Subscription, switchMap} from 'rxjs' import {type AuthConfig, type AuthProvider} from '../config/authConfig' -import {getProjectState} from '../project/project' import {bindActionGlobally} from '../store/createActionBinder' import {type SanityInstance} from '../store/createSanityInstance' import {createStateSourceAction} from '../store/createStateSourceAction' 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' @@ -247,8 +247,30 @@ function getProjectIdsFromInstanceAndParents(instance: SanityInstance | undefine } 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), @@ -256,8 +278,8 @@ const listenToProjectIdsAndDashboard = ({instance, state}: StoreContext - getProjectState(instance, {projectId}).observable.pipe( - filter(Boolean), + client$.pipe( + switchMap((client) => client.observable.projects.getById(projectId)), map((i) => ({projectId: i.id, organizationId: i.organizationId})), ), ), From a2d625feb6c8601a565053b4a7c87f756246051d Mon Sep 17 00:00:00 2001 From: Ryan Bonial Date: Fri, 16 May 2025 13:10:31 -0600 Subject: [PATCH 5/6] chore: add SDKError component --- apps/kitchensink-react/src/App.tsx | 8 ++-- packages/core/src/auth/dashboardUtils.ts | 19 +------- packages/react/src/components/SDKError.tsx | 47 +++++++++++++++++++ .../src/components/auth/AuthBoundary.test.tsx | 30 ++++-------- .../src/components/auth/AuthBoundary.tsx | 24 +--------- .../react/src/components/auth/LoginError.tsx | 13 ++--- packages/react/src/components/utils.ts | 26 ++++++++++ .../react/src/context/ResourceProvider.tsx | 4 +- 8 files changed, 94 insertions(+), 77 deletions(-) create mode 100644 packages/react/src/components/SDKError.tsx diff --git a/apps/kitchensink-react/src/App.tsx b/apps/kitchensink-react/src/App.tsx index 3e3ae34e..33821d1b 100644 --- a/apps/kitchensink-react/src/App.tsx +++ b/apps/kitchensink-react/src/App.tsx @@ -9,10 +9,10 @@ import {AppRoutes} from './AppRoutes' const theme = buildTheme({}) const sanityConfigs: SanityConfig[] = [ - { - projectId: 'project-id', - dataset: 'data-set', - }, + // { + // projectId: 'project-id', + // dataset: 'data-set', + // }, { projectId: 'ppsg7ml5', dataset: 'test', diff --git a/packages/core/src/auth/dashboardUtils.ts b/packages/core/src/auth/dashboardUtils.ts index df1ec511..0148a59f 100644 --- a/packages/core/src/auth/dashboardUtils.ts +++ b/packages/core/src/auth/dashboardUtils.ts @@ -1,29 +1,12 @@ import {bindActionGlobally} from '../store/createActionBinder' -import {type SanityInstance} from '../store/createSanityInstance' import {createStateSourceAction} from '../store/createStateSourceAction' import {authStore} from './authStore' -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) -} - /** * Gets the dashboard organization ID from the auth store * @internal */ export const getDashboardOrganizationId = bindActionGlobally( authStore, - createStateSourceAction(({instance, state: {dashboardContext}}) => { - const projectIds = getProjectIdsFromInstanceAndParents(instance) - console.log({projectIds}) - return dashboardContext?.orgId - }), + createStateSourceAction(({state: {dashboardContext}}) => dashboardContext?.orgId), ) diff --git a/packages/react/src/components/SDKError.tsx b/packages/react/src/components/SDKError.tsx new file mode 100644 index 00000000..b91b8b40 --- /dev/null +++ b/packages/react/src/components/SDKError.tsx @@ -0,0 +1,47 @@ +import {ClientError} from '@sanity/client' +import {ConfigurationError} from '@sanity/sdk' +import {useMemo} from 'react' +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 ClientError || error instanceof ConfigurationError)) { + throw error + } + + // const authState = useAuthState() + + // const authErrorMessage = error.message + // const [authErrorMessage, setAuthErrorMessage] = useState(error.message) + + const sdkErrorMessage = useMemo(() => { + if (!(error instanceof ClientError)) { + return error.message + } + 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 'The login link is invalid or expired. Please try again.' + } + }, [error]) + + return ( +
+
+

Configuration Error

+

{sdkErrorMessage}

+
+
+ ) +} 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 bef3b003..55b563bd 100644 --- a/packages/react/src/components/auth/AuthBoundary.tsx +++ b/packages/react/src/components/auth/AuthBoundary.tsx @@ -56,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 } /** @@ -127,17 +113,9 @@ 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 isLoggedOut = authState.type === AuthStateType.LOGGED_OUT && !authState.isDestroyingSession diff --git a/packages/react/src/components/auth/LoginError.tsx b/packages/react/src/components/auth/LoginError.tsx index 1e4d924e..7f029206 100644 --- a/packages/react/src/components/auth/LoginError.tsx +++ b/packages/react/src/components/auth/LoginError.tsx @@ -4,6 +4,7 @@ import {useCallback, useEffect, useMemo} from 'react' import {type FallbackProps} from 'react-error-boundary' import {useLogOut} from '../../hooks/auth/useLogOut' +import {isAuthError} from '../utils' import {AuthError} from './AuthError' /** * @alpha @@ -32,18 +33,12 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React. resetErrorBoundary() }, [logout, resetErrorBoundary]) - const shouldHandleRetry = useMemo(() => { - if (error instanceof ClientError) { - return error.statusCode === 401 - } - return false - }, [error]) - useEffect(() => { - if (shouldHandleRetry) { + const needsRetry = error instanceof ClientError && isAuthError(error) + if (needsRetry) { handleRetry() } - }, [shouldHandleRetry, handleRetry]) + }, [error, handleRetry]) const authErrorMessage = useMemo(() => { if (!(error instanceof ClientError)) { 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 cab99ba7..23c5b26b 100644 --- a/packages/react/src/context/ResourceProvider.tsx +++ b/packages/react/src/context/ResourceProvider.tsx @@ -1,7 +1,8 @@ import {createSanityInstance, type SanityConfig, type SanityInstance} from '@sanity/sdk' import {Suspense, useContext, useEffect, useMemo, useRef} from 'react' -import {ErrorBoundary} from 'react-error-boundary' +import {ErrorBoundary, type FallbackProps} from 'react-error-boundary' +import {SDKError} from '../components/SDKError' import {SanityInstanceContext} from './SanityInstanceContext' const DEFAULT_FALLBACK = ( @@ -21,6 +22,7 @@ export interface ResourceProviderProps extends SanityConfig { */ fallback: React.ReactNode children: React.ReactNode + ErrorComponent?: React.ComponentType } /** From 2452c2fdd0821543727c8fc9ac62cb4a323bb818 Mon Sep 17 00:00:00 2001 From: Ryan Bonial Date: Tue, 20 May 2025 14:41:14 -0600 Subject: [PATCH 6/6] chore: handle correct errors in SDKError --- apps/kitchensink-react/src/App.tsx | 8 +++---- packages/react/src/components/SDKError.tsx | 23 ++----------------- .../react/src/components/auth/LoginError.tsx | 13 +++-------- 3 files changed, 9 insertions(+), 35 deletions(-) diff --git a/apps/kitchensink-react/src/App.tsx b/apps/kitchensink-react/src/App.tsx index 33821d1b..3e3ae34e 100644 --- a/apps/kitchensink-react/src/App.tsx +++ b/apps/kitchensink-react/src/App.tsx @@ -9,10 +9,10 @@ import {AppRoutes} from './AppRoutes' const theme = buildTheme({}) const sanityConfigs: SanityConfig[] = [ - // { - // projectId: 'project-id', - // dataset: 'data-set', - // }, + { + projectId: 'project-id', + dataset: 'data-set', + }, { projectId: 'ppsg7ml5', dataset: 'test', diff --git a/packages/react/src/components/SDKError.tsx b/packages/react/src/components/SDKError.tsx index b91b8b40..53767329 100644 --- a/packages/react/src/components/SDKError.tsx +++ b/packages/react/src/components/SDKError.tsx @@ -1,6 +1,4 @@ -import {ClientError} from '@sanity/client' import {ConfigurationError} from '@sanity/sdk' -import {useMemo} from 'react' import {type FallbackProps} from 'react-error-boundary' /** @@ -15,32 +13,15 @@ export type SDKErrorProps = FallbackProps * @alpha */ export function SDKError({error}: SDKErrorProps): React.ReactNode { - if (!(error instanceof ClientError || error instanceof ConfigurationError)) { + if (!(error instanceof ConfigurationError)) { throw error } - // const authState = useAuthState() - - // const authErrorMessage = error.message - // const [authErrorMessage, setAuthErrorMessage] = useState(error.message) - - const sdkErrorMessage = useMemo(() => { - if (!(error instanceof ClientError)) { - return error.message - } - 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 'The login link is invalid or expired. Please try again.' - } - }, [error]) - return (

Configuration Error

-

{sdkErrorMessage}

+

{error.message}

) diff --git a/packages/react/src/components/auth/LoginError.tsx b/packages/react/src/components/auth/LoginError.tsx index 7f029206..e87e80ff 100644 --- a/packages/react/src/components/auth/LoginError.tsx +++ b/packages/react/src/components/auth/LoginError.tsx @@ -1,11 +1,9 @@ import {ClientError} from '@sanity/client' -import {ConfigurationError} from '@sanity/sdk' import {useCallback, useEffect, useMemo} from 'react' import {type FallbackProps} from 'react-error-boundary' import {useLogOut} from '../../hooks/auth/useLogOut' import {isAuthError} from '../utils' -import {AuthError} from './AuthError' /** * @alpha */ @@ -18,7 +16,7 @@ export type LoginErrorProps = FallbackProps * @alpha */ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.ReactNode { - if (!(error instanceof ClientError || error instanceof ConfigurationError)) { + if (!(error instanceof ClientError)) { throw error } @@ -41,23 +39,18 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React. }, [error, handleRetry]) const authErrorMessage = useMemo(() => { - if (!(error instanceof ClientError)) { - return error.message - } 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 'The login link is invalid or expired. Please try again.' + return errMess } }, [error]) return (
-

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

+

Authentication Error

{authErrorMessage}