From 3af510f442580b44b7ac4040d770cbe1cf54fd9f Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Tue, 17 Feb 2026 10:10:50 +0530 Subject: [PATCH 01/27] feat(core, react): update components to handle step up flows --- examples/next-rwa/package.json | 4 +- examples/next-rwa/src/lib/auth0.ts | 4 - examples/react-spa-npm/vite.config.ts | 3 + packages/core/src/auth/token-manager.ts | 247 +++--------------- packages/core/src/index.ts | 2 + packages/core/src/services/index.ts | 1 + packages/core/src/services/step-up/index.ts | 2 + .../src/services/step-up/step-up-types.ts | 13 + .../src/services/step-up/step-up-utils.ts | 11 + packages/core/src/styles/globals.css | 2 - .../components/auth0/shared/gatekeeper.tsx | 121 +++++++++ packages/react/src/components/index.ts | 2 + packages/react/src/hooks/index.ts | 2 +- .../providers/mfa-error-handler-provider.tsx | 85 ++++++ .../react/src/providers/proxy-provider.tsx | 5 +- .../src/providers/scope-manager-provider.tsx | 125 ++++++--- packages/react/src/providers/spa-provider.tsx | 5 +- packages/react/src/styles/globals.css | 1 + pnpm-lock.yaml | 12 +- 19 files changed, 387 insertions(+), 260 deletions(-) create mode 100644 packages/core/src/services/step-up/index.ts create mode 100644 packages/core/src/services/step-up/step-up-types.ts create mode 100644 packages/core/src/services/step-up/step-up-utils.ts create mode 100644 packages/react/src/components/auth0/shared/gatekeeper.tsx create mode 100644 packages/react/src/providers/mfa-error-handler-provider.tsx diff --git a/examples/next-rwa/package.json b/examples/next-rwa/package.json index f25334384..e34de9da0 100644 --- a/examples/next-rwa/package.json +++ b/examples/next-rwa/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev -p 5173", + "dev": "next dev -p 3000", "build": "next build", "start": "next start", "lint": "next lint" }, "dependencies": { - "@auth0/nextjs-auth0": "^4.13.2", + "@auth0/nextjs-auth0": "^v4.15.0", "@auth0/universal-components-react": "workspace:*", "@tailwindcss/postcss": "^4.1.17", "execa": "^9.1.0", diff --git a/examples/next-rwa/src/lib/auth0.ts b/examples/next-rwa/src/lib/auth0.ts index 81939ed08..21e40de46 100644 --- a/examples/next-rwa/src/lib/auth0.ts +++ b/examples/next-rwa/src/lib/auth0.ts @@ -25,10 +25,6 @@ export const auth0 = new Auth0Client({ httpTimeout: 20000, // 20 seconds authorizationParameters: { scope: process.env.AUTH0_SCOPE || 'openid profile email offline_access', - - ...(process.env.AUTH0_DOMAIN && { - audience: `${process.env.AUTH0_DOMAIN.replace(/\/$/, '')}/my-org/`, - }), }, // Using SDK defaults: rolling: true, absoluteDuration: 3 days, inactivityDuration: 1 day }); diff --git a/examples/react-spa-npm/vite.config.ts b/examples/react-spa-npm/vite.config.ts index de71ae564..2f89056c3 100644 --- a/examples/react-spa-npm/vite.config.ts +++ b/examples/react-spa-npm/vite.config.ts @@ -9,4 +9,7 @@ export default defineConfig({ optimizeDeps: { exclude: ['@auth0/universal-components-react'], }, + server: { + port: 3000, + }, }); diff --git a/packages/core/src/auth/token-manager.ts b/packages/core/src/auth/token-manager.ts index 462f75f3c..da4f8d462 100644 --- a/packages/core/src/auth/token-manager.ts +++ b/packages/core/src/auth/token-manager.ts @@ -1,228 +1,65 @@ -import type { AuthDetails, BasicAuth0ContextInterface } from './auth-types'; +import type { AuthDetails } from './auth-types'; import { AuthUtils } from './auth-utils'; -/** - * Store for pending token requests to prevent duplicate requests for the same token. - * Maps request keys (scope + audience combination) to pending promises. - */ -const pendingTokenRequests = new Map>(); - -const FALLBACK_ERRORS = new Set(['consent_required', 'login_required', 'mfa_required']); - -/** - * Pure utility functions for token management operations. - * These functions handle token requests, validation, and caching logic. - */ -const TokenUtils = { - /** - * Builds a complete audience URL by combining the Auth0 domain with the audience path. - * - * @param domain - The Auth0 domain - * @param audiencePath - The API audience path (e.g., 'mfa', 'users') - * @returns The complete audience URL with trailing slash or empty string if domain is not defined - */ - buildAudience(domain: string, audiencePath: string): string { - const domainURL = AuthUtils.toURL(domain); - return domainURL ? `${domainURL}${audiencePath}/` : ''; - }, - - /** - * Creates a unique key for token requests to enable deduplication and caching. - * - * @param scope - The OAuth scope for the token request - * @param audience - The target audience URL - * @returns A unique string key combining scope and audience - */ - createRequestKey(scope: string, audience: string): string { - return `${scope}:${audience}`; - }, - - /** - * Validates that the core client is properly initialized with auth data. - * - * @param auth - The authentication details to validate - * @throws {Error} When the core client is not initialized - */ - isCoreClientAuthInitialized(auth: AuthDetails): void { - if (!auth) { - throw new Error('TokenUtils: auth in CoreClient is not initialized.'); - } - }, +const FALLBACK_ERRORS = new Set(['consent_required', 'login_required']); - /** - * Validates that the core client is properly initialized with auth data and required authentication context. - * - * @param auth - The authentication details to validate - * @throws {Error} When the core client is not initialized or missing context interface - */ - isCoreClientContextInterfaceInitialized(auth: AuthDetails): void { - if (!auth || !auth.contextInterface) { - throw new Error('TokenUtils: contextInterface in CoreClient is not initialized.'); - } - }, - - /** - * Validates that a domain is configured. - * - * @param domain - The Auth0 domain to validate - * @throws {Error} When domain is not configured - */ - validateDomain(domain: string | undefined): void { - if (!domain) { - throw new Error('TokenUtils: Auth0 domain is not configured'); - } - }, - - /** - * Determines if the client is running in proxy mode. - * In proxy mode, access tokens are not sent to avoid security issues. - * - * @param auth - The authentication details to check - * @returns True if running in proxy mode, false otherwise - */ - isProxyMode(auth: AuthDetails): boolean { - return !!auth.authProxyUrl; - }, - - /** - * Fetches an access token silently. - * - * @param contextInterface - The Auth0 context interface for token operations - * @param scope - The OAuth scope for the token request - * @param audience - The target audience URL - * @param ignoreCache - Whether to bypass token cache and request fresh token - * @returns Promise resolving to the access token - * @throws {Error} When silent retrieval fail - */ - async fetchToken( - contextInterface: BasicAuth0ContextInterface, - scope: string, - audience: string, - ignoreCache: boolean, - ): Promise { - try { - const tokenResponse = await contextInterface.getAccessTokenSilently({ - authorizationParams: { - audience, - scope, - }, - detailedResponse: true, - ...(ignoreCache ? { cacheMode: 'off' } : {}), - }); - - const token = tokenResponse.access_token; - return token; - } catch (error) { - if ( - typeof error === 'object' && - error !== null && - 'error' in error && - FALLBACK_ERRORS.has((error as { error: string }).error) - ) { - const errorType = (error as { error: string }).error; - const prompt = errorType === 'login_required' ? 'login' : 'consent'; - - const token = await contextInterface.getAccessTokenWithPopup({ - authorizationParams: { - audience, - scope, - prompt, - }, - }); +function hasErrorProperty(error: unknown): error is { error: string } { + return typeof error === 'object' && error !== null && 'error' in error; +} - if (!token) { - throw new Error('getAccessTokenWithPopup: Access token is not defined'); - } - return token; - } - throw new Error('getAccessToken: failed', { cause: error }); - } - }, -}; +function buildAudience(domain: string, audiencePath: string): string { + try { + const url = new URL(AuthUtils.toURL(domain) || ''); + url.pathname = `${url.pathname.replace(/\/$/, '')}/${audiencePath.replace(/^\//, '')}/`; + return url.toString(); + } catch { + return ''; + } +} /** - * Creates a token manager service that handles access token retrieval with caching and deduplication. - * - * The token manager provides intelligent caching to prevent duplicate requests for the same token - * and supportssilent authentication flows. - * - * @param auth - The authentication details containing domain, client configuration, and context interface - * @returns A token manager service interface - * - * @example - * ```typescript - * const tokenManager = createTokenManager(authDetails); - * - * // Get token for MFA operations - * const token = await tokenManager.getToken('read:me:authentication_methods', 'mfa'); - * - * // Force fresh token (ignore cache) - * const freshToken = await tokenManager.getToken('read:users', 'management', true); - * ``` + * Creates a token manager for retrieving access tokens. */ export function createTokenManager(auth: AuthDetails) { + if (!auth) throw new Error('TokenManager: auth is not initialized.'); + return { /** - * Retrieves an access token for the specified scope and audience with intelligent caching and deduplication. - * - * In proxy mode, this method returns undefined as tokens should not be sent to proxy endpoints. - * For non-proxy mode, it attempts silent token retrieval. - * - * @param scope - The OAuth scope required for the token (e.g., 'read:me:authentication_methods') - * @param audiencePath - The API audience path (e.g., 'mfa', 'users') - * @param ignoreCache - Whether to bypass cache and request a fresh token - * @returns Promise resolving to access token string, or undefined in proxy mode - * @throws {Error} When core client is not initialized, parameters are invalid, or token retrieval fails + * Retrieves an access token for the specified scope and audience. */ async getToken( scope: string, audiencePath: string, - ignoreCache: boolean = false, + ignoreCache = false, ): Promise { - // Ensure core client auth is initialized - TokenUtils.isCoreClientAuthInitialized(auth); - - if (TokenUtils.isProxyMode(auth)) { - return Promise.resolve(undefined); + if (auth.authProxyUrl) return undefined; + if (!auth.contextInterface) { + throw new Error('TokenManager: contextInterface is not initialized.'); } - // Ensure core client "contextInterface" is initialized before getting a token - TokenUtils.isCoreClientContextInterfaceInitialized(auth); - - const domain = auth.domain ?? auth.contextInterface!.getConfiguration()?.domain; - TokenUtils.validateDomain(domain); + const domain = auth.domain ?? auth.contextInterface.getConfiguration()?.domain; + if (!domain) throw new Error('TokenManager: Auth0 domain is not configured'); - // Build audience and request key - const audience = TokenUtils.buildAudience(domain!, audiencePath); - const requestKey = TokenUtils.createRequestKey(scope, audience); - - // If ignoreCache is true, clear any pending request for this key - if (ignoreCache) { - pendingTokenRequests.delete(requestKey); - } - - // Check if there's already a pending request for this token - const existingRequest = pendingTokenRequests.get(requestKey); - if (existingRequest) { - return existingRequest; - } - - // Create new token request - const tokenPromise = TokenUtils.fetchToken( - auth.contextInterface!, - scope, - audience, - ignoreCache, - ); - - pendingTokenRequests.set(requestKey, tokenPromise); + const audience = buildAudience(domain, audiencePath); try { - const token = await tokenPromise; - return token; - } finally { - // Clean up the pending request after completion - pendingTokenRequests.delete(requestKey); + const tokenResponse = await auth.contextInterface.getAccessTokenSilently({ + authorizationParams: { audience, scope }, + detailedResponse: true, + ...(ignoreCache && { cacheMode: 'off' }), + }); + return tokenResponse.access_token; + } catch (error) { + if (hasErrorProperty(error) && FALLBACK_ERRORS.has(error.error)) { + const prompt = error.error === 'login_required' ? 'login' : 'consent'; + const token = await auth.contextInterface.getAccessTokenWithPopup({ + authorizationParams: { audience, scope, prompt }, + }); + if (!token) throw new Error('getAccessTokenWithPopup: Access token is not defined'); + return token; + } + + throw new Error('getAccessToken: failed', { cause: error }); } }, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 17f71d259..aa642066c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,4 +35,6 @@ export * from './services/my-organization'; export * from './services/my-account'; +export * from './services/step-up'; + export * from './assets/icons'; diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 2909a3a8d..40256d48d 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -1,2 +1,3 @@ export * from './my-account'; export * from './my-organization'; +export * from './step-up'; diff --git a/packages/core/src/services/step-up/index.ts b/packages/core/src/services/step-up/index.ts new file mode 100644 index 000000000..a281c63bd --- /dev/null +++ b/packages/core/src/services/step-up/index.ts @@ -0,0 +1,2 @@ +export * from './step-up-types'; +export * from './step-up-utils'; diff --git a/packages/core/src/services/step-up/step-up-types.ts b/packages/core/src/services/step-up/step-up-types.ts new file mode 100644 index 000000000..73618fc30 --- /dev/null +++ b/packages/core/src/services/step-up/step-up-types.ts @@ -0,0 +1,13 @@ +export interface MfaRequirements { + /** Required enrollment types (user needs to enroll new authenticator) */ + enroll?: Array<{ type: string }>; + /** Available challenge types (existing authenticators) */ + challenge?: Array<{ type: string }>; +} + +export interface MfaRequiredError extends Error { + error: 'mfa_required'; + error_description: string; + mfa_token: string; + mfa_requirements?: MfaRequirements; +} diff --git a/packages/core/src/services/step-up/step-up-utils.ts b/packages/core/src/services/step-up/step-up-utils.ts new file mode 100644 index 000000000..a3cc2c1cc --- /dev/null +++ b/packages/core/src/services/step-up/step-up-utils.ts @@ -0,0 +1,11 @@ +import type { MfaRequiredError } from './step-up-types'; + +/** + * Type guard to check if an error is an MFA required error. + */ +export function isMfaRequiredError(error: unknown): error is MfaRequiredError { + if (typeof error !== 'object' || error === null) return false; + + const err = error as Record; + return err.error === 'mfa_required' || err.code === 'mfa_required'; +} diff --git a/packages/core/src/styles/globals.css b/packages/core/src/styles/globals.css index b94054af1..de9af8381 100644 --- a/packages/core/src/styles/globals.css +++ b/packages/core/src/styles/globals.css @@ -1,5 +1,3 @@ -@import 'tailwindcss'; - @import './font-sizes.css'; @import './themes/default.css'; diff --git a/packages/react/src/components/auth0/shared/gatekeeper.tsx b/packages/react/src/components/auth0/shared/gatekeeper.tsx new file mode 100644 index 000000000..8962215a8 --- /dev/null +++ b/packages/react/src/components/auth0/shared/gatekeeper.tsx @@ -0,0 +1,121 @@ +import { isMfaRequiredError, getStatusCode } from '@auth0/universal-components-core'; +import React, { useEffect } from 'react'; + +import { showToast } from '@/components/auth0/shared/toast'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; +import { useMfaErrorHandler } from '@/providers/mfa-error-handler-provider'; + +interface GateKeeperProps { + isLoading: boolean; + error: unknown; + onRetry: () => void; + children: React.ReactNode; + loadingFallback?: React.ReactNode; + errorFallback?: (error: unknown, retry: () => void) => React.ReactNode; +} + +/** + * GateKeeper guards children from rendering during loading/error states. + * Handles all error types: + * - MFA errors → Opens global MFA modal + * - 500 errors → Shows blocking alert with retry + * - Other errors (400, 401, 403) → Shows toast notification, renders children + * + * @example + * ```tsx + * function OrgDetails() { + * const { data, isLoading, error, refetch } = useOrganization(); + * + * return ( + * + * {data && } + * + * ); + * } + * ``` + */ +export function GateKeeper({ + isLoading, + error, + onRetry, + children, + loadingFallback, + errorFallback, +}: GateKeeperProps) { + const { handleMfaError } = useMfaErrorHandler(); + + // Handle errors in useEffect for side effects (modal, toast) + useEffect(() => { + if (!error) return; + + // MFA error → delegate to global modal + if (isMfaRequiredError(error)) { + handleMfaError(error, onRetry); + return; + } + + // Critical errors (500+) → blocking UI (handled in render below) + const statusCode = getStatusCode(error); + if (statusCode && statusCode >= 500) { + return; // Let render show blocking Alert + } + + // Non-critical errors (400, 401, 403, network, etc.) → show toast + const errorMessage = error instanceof Error ? error.message : 'An error occurred'; + showToast({ + type: 'error', + message: errorMessage, + }); + }, [error, handleMfaError, onRetry]); + + // Loading state + if (isLoading) { + return ( + <> + {loadingFallback || ( +
+ +
+ )} + + ); + } + + // MFA required - show loading while global dialog handles it + if (error && isMfaRequiredError(error)) { + return ( +
+ +
+ ); + } + + // Critical errors (500+) - blocking alert with retry + const statusCode = getStatusCode(error); + if (error && statusCode && statusCode >= 500) { + return ( + <> + {errorFallback ? ( + errorFallback(error, onRetry) + ) : ( + + + + {error instanceof Error ? error.message : 'A server error occurred'} + + + + + )} + + ); + } + + // Non-critical errors - toast shown in useEffect, render children + // Success - render children + return <>{children}; +} diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 291556d63..1f9b56ed5 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,3 +1,5 @@ +export { GateKeeper } from './shared/gatekeeper'; + export { UserMFAMgmt } from './auth0/my-account/user-mfa-management'; export { SsoProviderEdit } from './auth0/my-organization/sso-provider-edit'; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 354a58c27..8b5ad9863 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -3,7 +3,7 @@ export { useCoreClient, CoreClientContext } from './shared/use-core-client'; export { useTranslator } from './shared/use-translator'; export { useTheme } from './shared/use-theme'; export { useCoreClientInitialization } from './shared/use-core-client-initialization'; -export { useScopeManager } from './shared/use-scope-manager'; +export { useScopeManager, type Audience } from '../providers/scope-manager-provider'; export { useErrorHandler } from './shared/use-error-handler'; // My Account hooks diff --git a/packages/react/src/providers/mfa-error-handler-provider.tsx b/packages/react/src/providers/mfa-error-handler-provider.tsx new file mode 100644 index 000000000..dfb62c454 --- /dev/null +++ b/packages/react/src/providers/mfa-error-handler-provider.tsx @@ -0,0 +1,85 @@ +import type { MfaRequiredError } from '@auth0/universal-components-core'; +import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; + +interface MfaErrorHandlerContextValue { + handleMfaError: (error: MfaRequiredError, onRetry: () => void) => void; +} + +const MfaErrorHandlerContext = createContext(null); + +export function useMfaErrorHandler() { + const context = useContext(MfaErrorHandlerContext); + if (!context) { + throw new Error('useMfaErrorHandler must be used within MfaErrorHandlerProvider'); + } + return context; +} + +/** + * Global provider that handles MFA step-up authentication. + * Shows MFA modal when MFA is required and retries operation after success. + */ +export const MfaErrorHandlerProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [mfaError, setMfaError] = useState(null); + const [onRetry, setOnRetry] = useState<(() => void) | null>(null); + const [isOpen, setIsOpen] = useState(false); + + const handleMfaError = useCallback((error: MfaRequiredError, retry: () => void) => { + setMfaError(error); + setOnRetry(() => retry); + setIsOpen(true); + }, []); + + const handleSuccess = useCallback(() => { + setIsOpen(false); + setMfaError(null); + if (onRetry) { + onRetry(); + } + setOnRetry(null); + }, [onRetry]); + + const handleClose = useCallback(() => { + setIsOpen(false); + setMfaError(null); + setOnRetry(null); + }, []); + + const contextValue = React.useMemo(() => ({ handleMfaError }), [handleMfaError]); + + return ( + + {children} + {/* TODO: Replace with actual MfaStepUpDialog when implemented */} + {mfaError && isOpen && ( +
+

MFA Required

+

Multi-factor authentication is required.

+

+ Token: {mfaError.mfa_token} +

+
+ + +
+
+ )} +
+ ); +}; diff --git a/packages/react/src/providers/proxy-provider.tsx b/packages/react/src/providers/proxy-provider.tsx index 289709fab..e48008fdd 100644 --- a/packages/react/src/providers/proxy-provider.tsx +++ b/packages/react/src/providers/proxy-provider.tsx @@ -7,6 +7,7 @@ import { Spinner } from '@/components/ui/spinner'; import { CoreClientContext } from '@/hooks/shared/use-core-client'; import { useCoreClientInitialization } from '@/hooks/shared/use-core-client-initialization'; import { useToastProvider } from '@/hooks/shared/use-toast-provider'; +import { MfaErrorHandlerProvider } from '@/providers/mfa-error-handler-provider'; import { QueryProvider } from '@/providers/query-provider'; import { ScopeManagerProvider } from '@/providers/scope-manager-provider'; import { ThemeProvider } from '@/providers/theme-provider'; @@ -93,7 +94,9 @@ export const Auth0ComponentProvider = ({ > - {children} + + {children} + diff --git a/packages/react/src/providers/scope-manager-provider.tsx b/packages/react/src/providers/scope-manager-provider.tsx index aea22dd82..a8b745cb3 100644 --- a/packages/react/src/providers/scope-manager-provider.tsx +++ b/packages/react/src/providers/scope-manager-provider.tsx @@ -1,10 +1,40 @@ -import React, { useState, useCallback, useEffect, type ReactNode } from 'react'; +import { isMfaRequiredError } from '@auth0/universal-components-core'; +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, + useRef, + type ReactNode, +} from 'react'; import { useCoreClient } from '@/hooks/shared/use-core-client'; -import { ScopeManagerContext, type Audience } from '@/hooks/shared/use-scope-manager'; +import { useMfaErrorHandler } from '@/providers/mfa-error-handler-provider'; + +export type Audience = 'me' | 'my-org'; + +interface ScopeManagerContextValue { + registerScopes: (audience: Audience, scopes: string) => void; + isReady: boolean; + ensured: Record; +} + +const ScopeManagerContext = createContext(null); + +export const useScopeManager = () => { + const context = useContext(ScopeManagerContext); + if (!context) { + throw new Error('useScopeManager must be used within ScopeManagerProvider'); + } + return context; +}; + +const AUDIENCES: readonly Audience[] = ['me', 'my-org'] as const; export const ScopeManagerProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const { coreClient } = useCoreClient(); + const { handleMfaError } = useMfaErrorHandler(); const [scopeRegistry, setScopeRegistry] = useState>>(() => ({ me: new Set(), @@ -17,6 +47,7 @@ export const ScopeManagerProvider: React.FC<{ children: ReactNode }> = ({ childr }); const [isReady, setIsReady] = useState(false); + const lastEnsuredRef = useRef>({ me: '', 'my-org': '' }); const registerScopes = useCallback((audience: Audience, scopes: string) => { if (!scopes?.trim()) return; @@ -28,66 +59,84 @@ export const ScopeManagerProvider: React.FC<{ children: ReactNode }> = ({ childr if (newScopes.length === 0) return; - setScopeRegistry((currentRegistry) => { - const audienceSet = currentRegistry[audience]; + setScopeRegistry((prev) => { + const audienceSet = prev[audience]; + const nextSet = new Set(audienceSet); let changed = false; - const nextAudienceSet = new Set(audienceSet); - newScopes.forEach((scope) => { - if (!nextAudienceSet.has(scope)) { - nextAudienceSet.add(scope); + for (const scope of newScopes) { + if (!nextSet.has(scope)) { + nextSet.add(scope); changed = true; } - }); - - if (changed) { - return { - ...currentRegistry, - [audience]: nextAudienceSet, - }; } - return currentRegistry; + + return changed ? { ...prev, [audience]: nextSet } : prev; }); }, []); useEffect(() => { if (!coreClient) return; - const ensureAllScopesSequential = async () => { - let hasScopes = false; - let anyUpdated = false; - - for (const audience of ['me', 'my-org'] as const) { + const ensureScopes = async () => { + const scopeData = AUDIENCES.map((audience) => { const scopes = Array.from(scopeRegistry[audience]).sort(); const scopeString = scopes.join(' '); + return { audience, scopeString, hasScopes: scopeString.trim().length > 0 }; + }); - if (scopes.length > 0 && scopeString.trim()) { - hasScopes = true; + const hasAnyScopes = scopeData.some((data) => data.hasScopes); + const updates = scopeData.filter( + (data) => data.hasScopes && data.scopeString !== lastEnsuredRef.current[data.audience], + ); + + if (updates.length === 0) { + setIsReady(hasAnyScopes); + return; + } - if (scopeString !== ensured[audience]) { - try { - await coreClient.ensureScopes(scopeString, audience); - anyUpdated = true; - } catch (error) { - console.error(`Failed to ensure scopes for ${audience}: ${scopeString}`, error); + const results = await Promise.allSettled( + updates.map(({ audience, scopeString }) => + coreClient.ensureScopes(scopeString, audience).then(() => ({ audience, scopeString })), + ), + ); + + const nextEnsured = { ...lastEnsuredRef.current }; + let anyUpdated = false; + + for (const result of results) { + if (result.status === 'fulfilled') { + const { audience, scopeString } = result.value; + nextEnsured[audience] = scopeString; + lastEnsuredRef.current[audience] = scopeString; + anyUpdated = true; + } else { + const error = result.reason; + + if (isMfaRequiredError(error)) { + // Retry by clearing the last ensured ref for this audience + const failedAudience = updates.find( + (u) => u.scopeString === result.reason?.scopeString, + )?.audience; + if (failedAudience) { + handleMfaError(error, () => { + lastEnsuredRef.current[failedAudience] = ''; + }); } + } else { + console.error('Failed to ensure scopes:', error); } } } - // Update ensured state to match current registry if (anyUpdated) { - setEnsured({ - me: Array.from(scopeRegistry.me).sort().join(' '), - 'my-org': Array.from(scopeRegistry['my-org']).sort().join(' '), - }); + setEnsured(nextEnsured); } - - setIsReady(hasScopes); + setIsReady(hasAnyScopes); }; - ensureAllScopesSequential(); - }, [coreClient, scopeRegistry, ensured]); + ensureScopes(); + }, [coreClient, scopeRegistry, handleMfaError]); const contextValue = React.useMemo( () => ({ registerScopes, isReady, ensured }), diff --git a/packages/react/src/providers/spa-provider.tsx b/packages/react/src/providers/spa-provider.tsx index 81dfad094..aab842b2d 100644 --- a/packages/react/src/providers/spa-provider.tsx +++ b/packages/react/src/providers/spa-provider.tsx @@ -9,6 +9,7 @@ import { Spinner } from '@/components/ui/spinner'; import { CoreClientContext } from '@/hooks/shared/use-core-client'; import { useCoreClientInitialization } from '@/hooks/shared/use-core-client-initialization'; import { useToastProvider } from '@/hooks/shared/use-toast-provider'; +import { MfaErrorHandlerProvider } from '@/providers/mfa-error-handler-provider'; import { QueryProvider } from '@/providers/query-provider'; import { ScopeManagerProvider } from '@/providers/scope-manager-provider'; import { ThemeProvider } from '@/providers/theme-provider'; @@ -97,7 +98,9 @@ export const Auth0ComponentProvider = ({ > - {children} + + {children} + diff --git a/packages/react/src/styles/globals.css b/packages/react/src/styles/globals.css index fc306cd4f..1d9357fc6 100644 --- a/packages/react/src/styles/globals.css +++ b/packages/react/src/styles/globals.css @@ -1 +1,2 @@ +@import 'tailwindcss'; @import '@auth0/universal-components-core/styles/globals.css'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3991ee0cd..586af4a7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,8 +164,8 @@ importers: examples/next-rwa: dependencies: '@auth0/nextjs-auth0': - specifier: ^4.13.2 - version: 4.13.2(next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ^v4.15.0 + version: 4.15.0(next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@auth0/universal-components-react': specifier: workspace:* version: link:../../packages/react @@ -689,10 +689,10 @@ packages: resolution: {integrity: sha512-ul7BmJjJinfgu1xAK3LPR4tgcSxEW9BgjCHy5RHHbF2UBbioYTlbx7dVLn+b8m09UnPmtxTvPisoza/KhacG3Q==} engines: {node: '>=18.0.0'} - '@auth0/nextjs-auth0@4.13.2': - resolution: {integrity: sha512-yOEyB+xTRpEp63dNcA6F5rV0wDzjEFf054f2q6uq/joM/1nu6ziG1ALV3lKJdL/1o4Mn1aRADylRJ4PFRWBCVw==} + '@auth0/nextjs-auth0@4.15.0': + resolution: {integrity: sha512-W5rfOkZ3EvRi5rUnxNiZr/HqsrssdVX0WhRRfUov6kbDKNeAJcefhnka/5hgPd513L4FV7pJZ/Cc/Mij3q9Gqg==} peerDependencies: - next: ^14.2.25 || ~15.0.5 || ~15.1.9 || ~15.2.6 || ~15.3.6 || ~15.4.8 || ~15.5.7 || ^16.0.7 + next: ^14.2.35 || ~15.0.7 || ~15.1.11 || ~15.2.8 || ~15.3.8 || ~15.4.10 || ~15.5.9 || ^16.0.10 react: 19.2.1 react-dom: 19.2.1 @@ -6892,7 +6892,7 @@ snapshots: dependencies: '@auth0/auth0-auth-js': 1.2.0 - '@auth0/nextjs-auth0@4.13.2(next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@auth0/nextjs-auth0@4.15.0(next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@edge-runtime/cookies': 5.0.2 '@panva/hkdf': 1.2.1 From f146f657901677028c7c171ba11d7a7e92fbab31 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Tue, 17 Feb 2026 10:30:25 +0530 Subject: [PATCH 02/27] feat(react): update gatekeeper --- .../core/src/i18n/translations/en-US.json | 7 +- .../components/auth0/shared/gatekeeper.tsx | 82 ++++++++++--------- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/packages/core/src/i18n/translations/en-US.json b/packages/core/src/i18n/translations/en-US.json index 1762a8c9c..7ac6762e0 100644 --- a/packages/core/src/i18n/translations/en-US.json +++ b/packages/core/src/i18n/translations/en-US.json @@ -1,7 +1,12 @@ { "common": { "copy": "Copy", - "copied": "Copied!" + "copied": "Copied!", + "fallback": { + "title": "We couldn't load this information", + "description": "Please try again or contact support if the problem persists.", + "retry": "Retry" + } }, "domain_management": { "domain_table": { diff --git a/packages/react/src/components/auth0/shared/gatekeeper.tsx b/packages/react/src/components/auth0/shared/gatekeeper.tsx index 8962215a8..2bc70cfdb 100644 --- a/packages/react/src/components/auth0/shared/gatekeeper.tsx +++ b/packages/react/src/components/auth0/shared/gatekeeper.tsx @@ -1,10 +1,12 @@ import { isMfaRequiredError, getStatusCode } from '@auth0/universal-components-core'; -import React, { useEffect } from 'react'; +import { RefreshCcw } from 'lucide-react'; +import React, { useEffect, useState } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; -import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { cn } from '@/lib/utils'; import { useMfaErrorHandler } from '@/providers/mfa-error-handler-provider'; interface GateKeeperProps { @@ -22,19 +24,6 @@ interface GateKeeperProps { * - MFA errors → Opens global MFA modal * - 500 errors → Shows blocking alert with retry * - Other errors (400, 401, 403) → Shows toast notification, renders children - * - * @example - * ```tsx - * function OrgDetails() { - * const { data, isLoading, error, refetch } = useOrganization(); - * - * return ( - * - * {data && } - * - * ); - * } - * ``` */ export function GateKeeper({ isLoading, @@ -45,24 +34,31 @@ export function GateKeeper({ errorFallback, }: GateKeeperProps) { const { handleMfaError } = useMfaErrorHandler(); + const { t } = useTranslator('common'); + const [isRetrying, setIsRetrying] = useState(false); + + const handleRetry = async () => { + setIsRetrying(true); + try { + await onRetry(); + } finally { + setIsRetrying(false); + } + }; - // Handle errors in useEffect for side effects (modal, toast) useEffect(() => { if (!error) return; - // MFA error → delegate to global modal if (isMfaRequiredError(error)) { handleMfaError(error, onRetry); return; } - // Critical errors (500+) → blocking UI (handled in render below) const statusCode = getStatusCode(error); if (statusCode && statusCode >= 500) { - return; // Let render show blocking Alert + return; } - // Non-critical errors (400, 401, 403, network, etc.) → show toast const errorMessage = error instanceof Error ? error.message : 'An error occurred'; showToast({ type: 'error', @@ -70,7 +66,6 @@ export function GateKeeper({ }); }, [error, handleMfaError, onRetry]); - // Loading state if (isLoading) { return ( <> @@ -83,7 +78,6 @@ export function GateKeeper({ ); } - // MFA required - show loading while global dialog handles it if (error && isMfaRequiredError(error)) { return (
@@ -92,30 +86,44 @@ export function GateKeeper({ ); } - // Critical errors (500+) - blocking alert with retry const statusCode = getStatusCode(error); if (error && statusCode && statusCode >= 500) { return ( <> {errorFallback ? ( - errorFallback(error, onRetry) + errorFallback(error, handleRetry) ) : ( - - - - {error instanceof Error ? error.message : 'A server error occurred'} - - - - +
+
+

+ {error instanceof Error ? error.message : t('fallback.title')} +

+

+ {t('fallback.description')} +

+
+ +
)} ); } - - // Non-critical errors - toast shown in useEffect, render children - // Success - render children return <>{children}; } From 65030837fb1a8e9a578b1eddcb6248ad6b4085f5 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Tue, 17 Feb 2026 10:52:09 +0530 Subject: [PATCH 03/27] feat(react): remove use error handler --- .../components/auth0/shared/gatekeeper.tsx | 29 ++- packages/react/src/hooks/index.ts | 1 - .../my-organization/use-domain-table-logic.ts | 52 ++---- .../my-organization/use-sso-domain-tab.ts | 173 ++++++++---------- .../src/hooks/shared/use-error-handler.ts | 44 ----- .../react/src/tests/utils/test-utilities.ts | 2 + .../domain-management/domain-table-types.ts | 3 + .../sso-domain/sso-domain-tab-types.ts | 2 + 8 files changed, 130 insertions(+), 176 deletions(-) delete mode 100644 packages/react/src/hooks/shared/use-error-handler.ts diff --git a/packages/react/src/components/auth0/shared/gatekeeper.tsx b/packages/react/src/components/auth0/shared/gatekeeper.tsx index 2bc70cfdb..3000f5deb 100644 --- a/packages/react/src/components/auth0/shared/gatekeeper.tsx +++ b/packages/react/src/components/auth0/shared/gatekeeper.tsx @@ -1,4 +1,8 @@ -import { isMfaRequiredError, getStatusCode } from '@auth0/universal-components-core'; +import { + isMfaRequiredError, + getStatusCode, + hasApiErrorBody, +} from '@auth0/universal-components-core'; import { RefreshCcw } from 'lucide-react'; import React, { useEffect, useState } from 'react'; @@ -18,6 +22,25 @@ interface GateKeeperProps { errorFallback?: (error: unknown, retry: () => void) => React.ReactNode; } +/** + * Extracts error message from various error types + */ +function getErrorMessage(error: unknown, fallback: string = 'An error occurred'): string { + if (hasApiErrorBody(error) && error.body?.detail) { + return error.body.detail; + } + + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + return fallback; +} + /** * GateKeeper guards children from rendering during loading/error states. * Handles all error types: @@ -59,7 +82,7 @@ export function GateKeeper({ return; } - const errorMessage = error instanceof Error ? error.message : 'An error occurred'; + const errorMessage = getErrorMessage(error); showToast({ type: 'error', message: errorMessage, @@ -100,7 +123,7 @@ export function GateKeeper({ >

- {error instanceof Error ? error.message : t('fallback.title')} + {getErrorMessage(error, t('fallback.title'))}

{t('fallback.description')} diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 8b5ad9863..d3fc931bb 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -4,7 +4,6 @@ export { useTranslator } from './shared/use-translator'; export { useTheme } from './shared/use-theme'; export { useCoreClientInitialization } from './shared/use-core-client-initialization'; export { useScopeManager, type Audience } from '../providers/scope-manager-provider'; -export { useErrorHandler } from './shared/use-error-handler'; // My Account hooks export { useContactEnrollment } from './my-account/use-contact-enrollment'; diff --git a/packages/react/src/hooks/my-organization/use-domain-table-logic.ts b/packages/react/src/hooks/my-organization/use-domain-table-logic.ts index 8d53ad822..3740e599c 100644 --- a/packages/react/src/hooks/my-organization/use-domain-table-logic.ts +++ b/packages/react/src/hooks/my-organization/use-domain-table-logic.ts @@ -2,7 +2,6 @@ import { type Domain, type IdentityProvider } from '@auth0/universal-components- import { useCallback, useEffect, useState } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; -import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import type { UseDomainTableLogicOptions, UseDomainTableLogicResult, @@ -18,7 +17,7 @@ export function useDomainTableLogic({ fetchProviders, fetchDomains, }: UseDomainTableLogicOptions): UseDomainTableLogicResult { - const { handleError } = useErrorHandler(); + const [error, setError] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false); const [showConfigureModal, setShowConfigureModal] = useState(false); const [showVerifyModal, setShowVerifyModal] = useState(false); @@ -40,12 +39,10 @@ export function useDomainTableLogic({ setShowCreateModal(false); setShowVerifyModal(true); } catch (error) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_create.error'), - }); + setError(error); } }, - [onCreateDomain, t, handleError], + [onCreateDomain, t], ); const handleVerify = useCallback( @@ -66,12 +63,10 @@ export function useDomainTableLogic({ ); } } catch (error) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_verify.error'), - }); + setError(error); } }, - [onVerifyDomain, t, handleError], + [onVerifyDomain, t], ); const handleDelete = useCallback( @@ -87,12 +82,10 @@ export function useDomainTableLogic({ setShowDeleteModal(false); setShowVerifyModal(false); } catch (error) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_delete.error'), - }); + setError(error); } }, - [onDeleteDomain, t, handleError], + [onDeleteDomain, t], ); const handleToggleSwitch = useCallback( @@ -108,9 +101,7 @@ export function useDomainTableLogic({ }), }); } catch (error) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_associate_provider.error'), - }); + setError(error); } } else { try { @@ -123,13 +114,11 @@ export function useDomainTableLogic({ }), }); } catch (error) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_delete_provider.error'), - }); + setError(error); } } }, - [onAssociateToProvider, onDeleteFromProvider, t, handleError], + [onAssociateToProvider, onDeleteFromProvider, t], ); const handleCloseVerifyModal = useCallback(() => { @@ -151,13 +140,11 @@ export function useDomainTableLogic({ await fetchProviders(domain); setShowConfigureModal(true); } catch (error) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.fetch_providers_error'), - }); + setError(error); } } }, - [fetchProviders, t, handleError], + [fetchProviders, t], ); const handleVerifyClick = useCallback( @@ -182,12 +169,10 @@ export function useDomainTableLogic({ }); } } catch (error) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_verify.error'), - }); + setError(error); } }, - [onVerifyDomain, t, handleError], + [onVerifyDomain, t], ); const handleDeleteClick = useCallback((domain: Domain) => { @@ -201,13 +186,14 @@ export function useDomainTableLogic({ try { fetchDomains(); } catch (error) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.fetch_domains_error'), - }); + setError(error); } - }, []); + }, [fetchDomains]); return { + // Error state + error, + // Modal state showCreateModal, showConfigureModal, diff --git a/packages/react/src/hooks/my-organization/use-sso-domain-tab.ts b/packages/react/src/hooks/my-organization/use-sso-domain-tab.ts index 1f72f09bf..0699e0132 100644 --- a/packages/react/src/hooks/my-organization/use-sso-domain-tab.ts +++ b/packages/react/src/hooks/my-organization/use-sso-domain-tab.ts @@ -1,11 +1,10 @@ import type { CreateOrganizationDomainRequestContent } from '@auth0/universal-components-core'; -import { BusinessError, type Domain, type IdpId } from '@auth0/universal-components-core'; +import { type Domain, type IdpId } from '@auth0/universal-components-core'; import { useQuery, useQueryClient, useMutation, useQueries } from '@tanstack/react-query'; -import { useCallback, useState, useMemo, useEffect } from 'react'; +import { useCallback, useState, useMemo } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; import { useCoreClient } from '@/hooks/shared/use-core-client'; -import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { UseSsoDomainTabOptions, @@ -27,7 +26,6 @@ export function useSsoDomainTab( ): UseSsoDomainTabReturn { const { coreClient } = useCoreClient(); const { t } = useTranslator('idp_management.notifications', customMessages); - const { handleError } = useErrorHandler(); const queryClient = useQueryClient(); const [selectedDomain, setSelectedDomain] = useState(null); @@ -51,15 +49,6 @@ export function useSsoDomainTab( const domainsList = domainsQuery.data ?? []; const isLoading = domainsQuery.isLoading; - // Handle errors from domains query - useEffect(() => { - if (domainsQuery.error) { - handleError(domainsQuery.error, { - fallbackMessage: t('general_error'), - }); - } - }, [domainsQuery.error, handleError, t]); - // Fetch IDP associations for each domain using useQueries const idpAssociationQueries = useQueries({ queries: domainsList.map((domain) => ({ @@ -92,7 +81,7 @@ export function useSsoDomainTab( if (domains?.createAction?.onBefore) { const canProceed = domains.createAction.onBefore(data as Domain); if (!canProceed) { - throw new BusinessError({ message: t('domain_create.on_before') }); + throw new Error(t('domain_create.on_before')); } } @@ -118,7 +107,7 @@ export function useSsoDomainTab( if (domains?.verifyAction?.onBefore) { const canProceed = domains.verifyAction.onBefore(domain); if (!canProceed) { - throw new BusinessError({ message: t('domain_verify.on_before') }); + throw new Error(t('domain_verify.on_before')); } } @@ -151,7 +140,7 @@ export function useSsoDomainTab( if (domains?.deleteAction?.onBefore) { const canProceed = domains.deleteAction.onBefore(domain); if (!canProceed) { - throw new BusinessError({ message: t('domain_delete.on_before') }); + throw new Error(t('domain_delete.on_before')); } } @@ -177,7 +166,7 @@ export function useSsoDomainTab( if (domains?.associateToProviderAction?.onBefore) { const canProceed = domains.associateToProviderAction.onBefore(domain, provider); if (!canProceed) { - throw new BusinessError({ message: t('domain_associate_provider.on_before') }); + throw new Error(t('domain_associate_provider.on_before')); } } @@ -210,7 +199,7 @@ export function useSsoDomainTab( if (domains?.deleteFromProviderAction?.onBefore) { const canProceed = domains.deleteFromProviderAction.onBefore(domain, provider); if (!canProceed) { - throw new BusinessError({ message: t('domain_delete_provider.on_before') }); + throw new Error(t('domain_delete_provider.on_before')); } } @@ -236,26 +225,20 @@ export function useSsoDomainTab( const handleCreate = useCallback( async (domainUrl: string) => { - try { - const newDomain = await createDomainMutation.mutateAsync({ domain: domainUrl }); + const newDomain = await createDomainMutation.mutateAsync({ domain: domainUrl }); - showToast({ - type: 'success', - message: t('domain_create.success', { - domainName: newDomain?.domain, - }), - }); + showToast({ + type: 'success', + message: t('domain_create.success', { + domainName: newDomain?.domain, + }), + }); - setSelectedDomain(newDomain); - setShowCreateModal(false); - setShowVerifyModal(true); - } catch (error) { - handleError(error, { - fallbackMessage: t('domain_create.error'), - }); - } + setSelectedDomain(newDomain); + setShowCreateModal(false); + setShowVerifyModal(true); }, - [handleError, createDomainMutation, t], + [createDomainMutation, t], ); const handleCloseVerifyModal = useCallback(() => { @@ -265,29 +248,23 @@ export function useSsoDomainTab( const handleVerify = useCallback( async (domain: Domain) => { - try { - const { isVerified } = await verifyDomainMutation.mutateAsync(domain); - if (isVerified) { - setShowVerifyModal(false); - - showToast({ - type: 'success', - message: t('domain_verify.success', { - domainName: domain.domain, - }), - }); + const { isVerified } = await verifyDomainMutation.mutateAsync(domain); + if (isVerified) { + setShowVerifyModal(false); - await associateToProviderMutation.mutateAsync(domain); - } else { - setVerifyError(t('domain_verify.verification_failed', { domainName: domain.domain })); - } - } catch (error) { - handleError(error, { - fallbackMessage: t('domain_verify.verification_failed'), + showToast({ + type: 'success', + message: t('domain_verify.success', { + domainName: domain.domain, + }), }); + + await associateToProviderMutation.mutateAsync(domain); + } else { + setVerifyError(t('domain_verify.verification_failed', { domainName: domain.domain })); } }, - [verifyDomainMutation, t, handleError, associateToProviderMutation], + [verifyDomainMutation, t, associateToProviderMutation], ); const handleDeleteClick = useCallback((domain: Domain) => { @@ -298,25 +275,19 @@ export function useSsoDomainTab( const handleDelete = useCallback( async (domain: Domain) => { - try { - await deleteDomainMutation.mutateAsync(domain); + await deleteDomainMutation.mutateAsync(domain); - showToast({ - type: 'success', - message: t('domain_delete.success', { - domainName: domain.domain, - }), - }); + showToast({ + type: 'success', + message: t('domain_delete.success', { + domainName: domain.domain, + }), + }); - setShowDeleteModal(false); - setShowVerifyModal(false); - } catch (error) { - handleError(error, { - fallbackMessage: t('domain_delete.error'), - }); - } + setShowDeleteModal(false); + setShowVerifyModal(false); }, - [handleError, deleteDomainMutation, t], + [deleteDomainMutation, t], ); const handleVerifyActionColumn = useCallback( @@ -343,16 +314,12 @@ export function useSsoDomainTab( }), }); } - } catch (error) { - handleError(error, { - fallbackMessage: t('domain_verify.verification_failed', { domainName: domain.domain }), - }); } finally { setIsUpdating(false); setIsUpdatingId(null); } }, - [verifyDomainMutation, t, handleError, associateToProviderMutation], + [verifyDomainMutation, t, associateToProviderMutation], ); const handleToggleSwitch = useCallback( @@ -360,8 +327,8 @@ export function useSsoDomainTab( setIsUpdating(true); setIsUpdatingId(domain.id); - if (newCheckedValue) { - try { + try { + if (newCheckedValue) { await associateToProviderMutation.mutateAsync(domain); showToast({ @@ -371,16 +338,7 @@ export function useSsoDomainTab( idp: provider?.name, }), }); - } catch (error) { - handleError(error, { - fallbackMessage: t('general_error'), - }); - } finally { - setIsUpdating(false); - setIsUpdatingId(null); - } - } else { - try { + } else { await deleteFromProviderMutation.mutateAsync(domain); showToast({ @@ -390,21 +348,46 @@ export function useSsoDomainTab( idp: provider?.name, }), }); - } catch (error) { - handleError(error, { - fallbackMessage: t('general_error'), - }); - } finally { - setIsUpdating(false); - setIsUpdatingId(null); } + } finally { + setIsUpdating(false); + setIsUpdatingId(null); } }, - [associateToProviderMutation, t, provider, handleError, deleteFromProviderMutation], + [associateToProviderMutation, t, provider, deleteFromProviderMutation], ); + // Combine errors from all queries and mutations + const error = + domainsQuery.error || + createDomainMutation.error || + verifyDomainMutation.error || + deleteDomainMutation.error || + associateToProviderMutation.error || + deleteFromProviderMutation.error; + + // Refetch function to retry on error + const refetch = useCallback(() => { + createDomainMutation.reset(); + verifyDomainMutation.reset(); + deleteDomainMutation.reset(); + associateToProviderMutation.reset(); + deleteFromProviderMutation.reset(); + queryClient.invalidateQueries({ queryKey: domainQueryKeys.list(idpId) }); + }, [ + createDomainMutation, + verifyDomainMutation, + deleteDomainMutation, + associateToProviderMutation, + deleteFromProviderMutation, + queryClient, + idpId, + ]); + return { isLoading, + error, + refetch, domainsList, isCreating: createDomainMutation.isPending, selectedDomain, diff --git a/packages/react/src/hooks/shared/use-error-handler.ts b/packages/react/src/hooks/shared/use-error-handler.ts deleted file mode 100644 index fe84ea51f..000000000 --- a/packages/react/src/hooks/shared/use-error-handler.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { hasApiErrorBody, isBusinessError } from '@auth0/universal-components-core'; -import { useCallback } from 'react'; - -import { showToast } from '@/components/auth0/shared/toast'; - -interface ErrorHandlerOptions { - fallbackMessage?: string; - showToastNotification?: boolean; -} - -/** - * Hook for handling errors with optional toast notifications - */ -export const useErrorHandler = () => { - const handleError = useCallback((error: unknown, options: ErrorHandlerOptions = {}) => { - const { fallbackMessage = 'An error occurred', showToastNotification = true } = options; - - // Extract error message from various error types - let errorMessage: string; - - if (isBusinessError(error)) { - errorMessage = error.message; - } else if (hasApiErrorBody(error) && error.body?.detail) { - errorMessage = error.body.detail; - } else if (error instanceof Error) { - errorMessage = error.message; - } else if (typeof error === 'string') { - errorMessage = error; - } else { - errorMessage = fallbackMessage; - } - - if (showToastNotification) { - showToast({ - type: 'error', - message: errorMessage, - }); - } - - return errorMessage; - }, []); - - return { handleError }; -}; diff --git a/packages/react/src/tests/utils/test-utilities.ts b/packages/react/src/tests/utils/test-utilities.ts index 64b828843..cad33d27d 100644 --- a/packages/react/src/tests/utils/test-utilities.ts +++ b/packages/react/src/tests/utils/test-utilities.ts @@ -30,6 +30,8 @@ export const createMockUseTranslator = (_customMessages?: object) => ({ fallbackLanguage: 'en', }); +// Deprecated: useErrorHandler has been removed. Errors are now handled by GateKeeper component. +// Tests should be updated to check for error state exposure instead of handleError calls. export const createMockUseErrorHandler = (handleError: ReturnType) => ({ handleError, }); diff --git a/packages/react/src/types/my-organization/domain-management/domain-table-types.ts b/packages/react/src/types/my-organization/domain-management/domain-table-types.ts index cf5fa4c09..7ab1e2843 100644 --- a/packages/react/src/types/my-organization/domain-management/domain-table-types.ts +++ b/packages/react/src/types/my-organization/domain-management/domain-table-types.ts @@ -106,6 +106,9 @@ export interface UseDomainTableLogicOptions { } export interface UseDomainTableLogicResult { + // Error state + error: unknown; + // Modal state showCreateModal: boolean; showConfigureModal: boolean; diff --git a/packages/react/src/types/my-organization/idp-management/sso-domain/sso-domain-tab-types.ts b/packages/react/src/types/my-organization/idp-management/sso-domain/sso-domain-tab-types.ts index d13800d47..6c32b54bf 100644 --- a/packages/react/src/types/my-organization/idp-management/sso-domain/sso-domain-tab-types.ts +++ b/packages/react/src/types/my-organization/idp-management/sso-domain/sso-domain-tab-types.ts @@ -67,6 +67,8 @@ export interface UseSsoDomainTabOptions extends SharedComponentProps { export interface UseSsoDomainTabReturn { domainsList: Domain[]; isLoading: boolean; + error: unknown; + refetch: () => void; showCreateModal: boolean; isCreating: boolean; selectedDomain: Domain | null; From 0120bb41d0868af7a3713b9f86e6977cdaf6a123 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Wed, 18 Feb 2026 05:27:26 +0530 Subject: [PATCH 04/27] feat(core, react): refactore to remove scope manager --- examples/next-rwa/package.json | 2 +- examples/react-spa-npm/package.json | 2 +- .../react-spa-npm/src/components/side-bar.tsx | 10 + .../src/auth/__mocks__/core-client.mocks.ts | 88 +- .../src/auth/__tests__/core-client.test.ts | 189 +-- .../src/auth/__tests__/token-manager.test.ts | 95 +- packages/core/src/auth/auth-types.ts | 186 ++- packages/core/src/auth/core-client.ts | 43 +- packages/core/src/auth/token-manager.ts | 4 +- .../core/src/i18n/translations/en-US.json | 6 + packages/core/src/index.ts | 11 + .../__mocks__/shared/api-service.mocks.ts | 19 + .../__mocks__/my-account-api-service.mocks.ts | 10 +- .../__tests__/my-account-api-service.test.ts | 170 ++- .../my-account/my-account-api-service.ts | 50 +- .../my-organization-api-service.mocks.ts | 10 +- .../my-organization-api-service.test.ts | 208 ++- .../my-organization-api-service.ts | 50 +- .../__tests__/step-up-api-service.test.ts | 578 +++++++ .../step-up/__tests__/step-up-utils.test.ts | 205 +++ packages/core/src/services/step-up/index.ts | 1 + .../services/step-up/step-up-api-service.ts | 103 ++ .../src/services/step-up/step-up-utils.ts | 14 +- packages/react/package.json | 2 +- .../auth0/my-account/user-mfa-management.tsx | 6 +- .../auth0/my-organization/domain-table.tsx | 194 ++- .../organization-details-edit.tsx | 80 +- .../__tests__/sso-provisioning-tab.test.tsx | 19 +- .../my-organization/sso-provider-create.tsx | 89 +- .../my-organization/sso-provider-edit.tsx | 259 ++-- .../my-organization/sso-provider-table.tsx | 142 +- .../shared/__tests__/gatekeeper.test.tsx | 366 +++++ .../components/auth0/shared/gatekeeper.tsx | 312 ++-- packages/react/src/components/index.ts | 2 - packages/react/src/hoc/with-services.tsx | 83 -- packages/react/src/hooks/index.ts | 3 +- .../__tests__/use-config.test.ts | 64 +- .../__tests__/use-domain-table-logic.test.ts | 591 -------- .../__tests__/use-domain-table.test.ts | 746 ++-------- .../__tests__/use-idp-config.test.ts | 138 +- .../use-organization-details-edit.test.ts | 85 +- .../__tests__/use-sso-domain-tab.test.ts | 58 +- .../__tests__/use-sso-provider-create.test.ts | 12 + .../__tests__/use-sso-provider-edit.test.ts | 1322 ++++------------- .../__tests__/use-sso-provider-table.test.ts | 814 +++------- .../src/hooks/my-organization/use-config.ts | 22 +- .../my-organization/use-domain-table-logic.ts | 222 --- .../hooks/my-organization/use-domain-table.ts | 310 +++- .../hooks/my-organization/use-idp-config.ts | 22 +- .../use-organization-details-edit.ts | 65 +- .../my-organization/use-sso-domain-tab.ts | 101 +- .../use-sso-provider-create.ts | 41 +- .../my-organization/use-sso-provider-edit.ts | 838 ++++------- .../my-organization/use-sso-provider-table.ts | 185 +-- .../__tests__/use-error-handler.test.ts | 191 +++ .../src/hooks/shared/use-error-handler.ts | 81 + .../src/hooks/shared/use-scope-manager.ts | 17 - .../my-organization/config/config.mocks.ts | 2 + .../__tests__/proxy-provider.test.tsx | 16 - .../__tests__/scope-manager-provider.test.tsx | 133 -- .../providers/mfa-error-handler-provider.tsx | 85 -- .../react/src/providers/proxy-provider.tsx | 27 +- .../src/providers/scope-manager-provider.tsx | 149 -- packages/react/src/providers/spa-provider.tsx | 43 +- .../tests/utils/__mocks__/core/auth.mocks.ts | 19 + .../utils/__mocks__/core/core-client.mocks.ts | 39 +- .../__mocks__/my-account/mfa/mfa.mocks.ts | 8 - .../my-organization/config/config.mocks.ts | 2 + .../idp-management/idp-config.mocks.ts | 2 + .../react/src/tests/utils/test-provider.tsx | 5 +- .../react/src/tests/utils/test-utilities.ts | 6 +- .../config/config-idp-types.ts | 2 + .../my-organization/config/config-types.ts | 2 + .../domain-management/domain-table-types.ts | 46 +- .../sso-domain/sso-domain-tab-types.ts | 2 +- .../sso-provider/sso-provider-edit-types.ts | 5 +- .../sso-provider/sso-provider-table-types.ts | 8 +- .../organization-details-edit-types.ts | 5 +- pnpm-lock.yaml | 22 +- 79 files changed, 4670 insertions(+), 5494 deletions(-) create mode 100644 packages/core/src/services/step-up/__tests__/step-up-api-service.test.ts create mode 100644 packages/core/src/services/step-up/__tests__/step-up-utils.test.ts create mode 100644 packages/core/src/services/step-up/step-up-api-service.ts create mode 100644 packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx delete mode 100644 packages/react/src/hoc/with-services.tsx delete mode 100644 packages/react/src/hooks/my-organization/__tests__/use-domain-table-logic.test.ts delete mode 100644 packages/react/src/hooks/my-organization/use-domain-table-logic.ts create mode 100644 packages/react/src/hooks/shared/__tests__/use-error-handler.test.ts create mode 100644 packages/react/src/hooks/shared/use-error-handler.ts delete mode 100644 packages/react/src/hooks/shared/use-scope-manager.ts delete mode 100644 packages/react/src/providers/__tests__/scope-manager-provider.test.tsx delete mode 100644 packages/react/src/providers/mfa-error-handler-provider.tsx delete mode 100644 packages/react/src/providers/scope-manager-provider.tsx diff --git a/examples/next-rwa/package.json b/examples/next-rwa/package.json index e34de9da0..75f210b35 100644 --- a/examples/next-rwa/package.json +++ b/examples/next-rwa/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev -p 3000", + "dev": "next dev -p 5173", "build": "next build", "start": "next start", "lint": "next lint" diff --git a/examples/react-spa-npm/package.json b/examples/react-spa-npm/package.json index 9f761082d..73a65d514 100644 --- a/examples/react-spa-npm/package.json +++ b/examples/react-spa-npm/package.json @@ -10,7 +10,7 @@ "preview": "vite preview" }, "dependencies": { - "@auth0/auth0-react": "^2.12.0", + "@auth0/auth0-react": "^2.15.0", "@auth0/universal-components-react": "workspace:*", "i18next": "^25.2.1", "lucide-react": "^0.511.0", diff --git a/examples/react-spa-npm/src/components/side-bar.tsx b/examples/react-spa-npm/src/components/side-bar.tsx index 8f6449b23..51025778e 100644 --- a/examples/react-spa-npm/src/components/side-bar.tsx +++ b/examples/react-spa-npm/src/components/side-bar.tsx @@ -61,6 +61,16 @@ export const Sidebar: React.FC = () => { {t('sidebar.sso-provider')} + +

  • + + + {t('sidebar.sso-provider-create')} + +
  • ({ + getAuthenticators: vi.fn().mockResolvedValue([]), + enroll: vi.fn().mockResolvedValue({ + authenticatorType: 'otp', + secret: 'mock-secret', + barcodeUri: 'otpauth://totp/mock', + id: 'authenticator_123', + }), + challenge: vi.fn().mockResolvedValue({ + challengeType: 'oob', + oobCode: 'mock-oob-code', + }), + getEnrollmentFactors: vi.fn().mockResolvedValue([]), + verify: vi.fn().mockResolvedValue({ + id_token: 'mock-id-token', + access_token: 'mock-access-token', + expires_in: 3600, + }), +}); + /** * Creates a mock BasicAuth0ContextInterface */ @@ -59,36 +81,7 @@ export const createMockBasicAuth0Context = ( domain: TEST_DOMAIN, clientId: TEST_CLIENT_ID, }), - ...overrides, -}); - -/** - * Creates a mock Auth0ContextInterface with full properties - */ -export const createMockAuth0Context = ( - overrides?: Partial, -): Auth0ContextInterface => ({ - isAuthenticated: true, - isLoading: false, - user: createMockUser(), - getAccessTokenSilently: vi.fn().mockImplementation(async (options?: GetTokenSilentlyOptions) => { - if (options?.detailedResponse) { - return createMockVerboseTokenResponse(); - } - return 'mock-access-token'; - }), - getAccessTokenWithPopup: vi.fn().mockResolvedValue('mock-access-token'), - loginWithRedirect: vi.fn().mockResolvedValue(undefined), - loginWithPopup: vi.fn().mockResolvedValue(undefined), - logout: vi.fn().mockResolvedValue(undefined), - getIdTokenClaims: vi.fn().mockResolvedValue({ - sub: 'auth0|test-user-123', - aud: 'test-client-id', - iss: 'https://test-domain.auth0.com/', - }), - handleRedirectCallback: vi.fn().mockResolvedValue({ - appState: {}, - }), + mfa: createMockMfaClient(), ...overrides, }); @@ -115,9 +108,35 @@ export const createMockMyAccountApiClient = (): CoreClientInterface['myAccountAp delete: vi.fn().mockResolvedValue(undefined), verify: vi.fn().mockResolvedValue({ confirmed: true }), }, + withScopes: vi.fn().mockReturnThis(), } as unknown as CoreClientInterface['myAccountApiClient']; }; +/** + * Creates a mock StepUpApiService + */ +export const createMockStepUpApiService = (): CoreClientInterface['stepUpApiService'] => { + return { + getAuthenticators: vi.fn().mockResolvedValue([]), + enroll: vi.fn().mockResolvedValue({ + authenticatorType: 'otp', + secret: 'mock-secret', + barcodeUri: 'otpauth://totp/mock', + id: 'authenticator_123', + }), + challenge: vi.fn().mockResolvedValue({ + challengeType: 'oob', + oobCode: 'mock-oob-code', + }), + getEnrollmentFactors: vi.fn().mockResolvedValue([]), + verify: vi.fn().mockResolvedValue({ + id_token: 'mock-id-token', + access_token: 'mock-access-token', + expires_in: 3600, + }), + } as unknown as CoreClientInterface['stepUpApiService']; +}; + /** * Creates a mock MyOrganizationClient service */ @@ -203,6 +222,7 @@ export const createMockMyOrganizationApiClient = }, }, }, + withScopes: vi.fn().mockReturnThis(), } as unknown as CoreClientInterface['myOrganizationApiClient']; }; @@ -214,21 +234,25 @@ export const createMockCoreClient = (authDetails?: Partial): CoreCl const mockI18nService = createMockI18nService(); const mockMyAccountApiClient = createMockMyAccountApiClient(); const mockMyOrganizationApiClient = createMockMyOrganizationApiClient(); + const mockStepUpApiService = createMockStepUpApiService(); return { auth: mockAuth, i18nService: mockI18nService, myAccountApiClient: mockMyAccountApiClient, myOrganizationApiClient: mockMyOrganizationApiClient, + stepUpApiService: mockStepUpApiService, getMyAccountApiClient: vi.fn( () => mockMyAccountApiClient, ) as CoreClientInterface['getMyAccountApiClient'], getMyOrganizationApiClient: vi.fn( () => mockMyOrganizationApiClient, ) as CoreClientInterface['getMyOrganizationApiClient'], + getStepUpApiService: vi.fn( + () => mockStepUpApiService, + ) as CoreClientInterface['getStepUpApiService'], getToken: vi.fn().mockResolvedValue('mock-access-token'), isProxyMode: vi.fn().mockReturnValue(false), - ensureScopes: vi.fn().mockResolvedValue(undefined), getDomain: vi.fn( () => mockAuth.domain ?? mockAuth.contextInterface?.getConfiguration()?.domain, ), diff --git a/packages/core/src/auth/__tests__/core-client.test.ts b/packages/core/src/auth/__tests__/core-client.test.ts index dc3702ab4..e31d6351f 100644 --- a/packages/core/src/auth/__tests__/core-client.test.ts +++ b/packages/core/src/auth/__tests__/core-client.test.ts @@ -1,7 +1,7 @@ -import type { MyAccountClient } from '@auth0/myaccount-js'; -import type { MyOrganizationClient } from '@auth0/myorganization-js'; import { initializeMyAccountClient } from '@core/services/my-account/my-account-api-service'; +import type { MyAccountClientWithScopes } from '@core/services/my-account/my-account-api-service'; import { initializeMyOrganizationClient } from '@core/services/my-organization/my-organization-api-service'; +import type { MyOrganizationClientWithScopes } from '@core/services/my-organization/my-organization-api-service'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createI18nService } from '../../i18n'; @@ -138,168 +138,7 @@ describe('createCoreClient', () => { }); }); - describe('ensureScopes - proxy mode', () => { - it('sets org scopes without token fetch in proxy mode', async () => { - const authDetails = createAuthDetails({ authProxyUrl: 'https://proxy.auth0.com' }); - const client = await createCoreClient(authDetails); - - await client.ensureScopes('read:org', 'my-org'); - - expect(mockMyOrganizationClient.setLatestScopes).toHaveBeenCalledWith('read:org'); - expect(mockTokenManager.getToken).not.toHaveBeenCalled(); - }); - - it('sets account scopes without token fetch in proxy mode', async () => { - const authDetails = createAuthDetails({ authProxyUrl: 'https://proxy.auth0.com' }); - const client = await createCoreClient(authDetails); - - await client.ensureScopes('read:me', 'me'); - - expect(mockMyAccountClient.setLatestScopes).toHaveBeenCalledWith('read:me'); - expect(mockTokenManager.getToken).not.toHaveBeenCalled(); - }); - - it('does not set scopes for unknown audience in proxy mode', async () => { - const authDetails = createAuthDetails({ authProxyUrl: 'https://proxy.auth0.com' }); - const client = await createCoreClient(authDetails); - - await client.ensureScopes('read:something', 'unknown-audience'); - - expect(mockMyOrganizationClient.setLatestScopes).not.toHaveBeenCalled(); - expect(mockMyAccountClient.setLatestScopes).not.toHaveBeenCalled(); - expect(mockTokenManager.getToken).not.toHaveBeenCalled(); - }); - }); - - describe('ensureScopes - non-proxy mode', () => { - it('throws when domain is missing in non-proxy mode', async () => { - const authDetails = createAuthDetails({ domain: '', contextInterface: undefined }); - const client = await createCoreClient(authDetails); - - await expect(client.ensureScopes('read:org', 'my-org')).rejects.toThrow( - 'Authentication domain is missing, cannot initialize SPA service.', - ); - expect(mockMyOrganizationClient.setLatestScopes).not.toHaveBeenCalled(); - expect(mockTokenManager.getToken).not.toHaveBeenCalled(); - }); - - it('uses domain from contextInterface.getConfiguration() when auth.domain is undefined', async () => { - const mockContext = { - ...createMockContextInterface(), - getConfiguration: vi - .fn() - .mockReturnValue({ domain: 'context.auth0.com', clientId: 'test-client-id' }), - }; - const authDetails = createAuthDetails({ domain: undefined, contextInterface: mockContext }); - const client = await createCoreClient(authDetails); - - await client.ensureScopes('read:org', 'my-org'); - - expect(mockMyOrganizationClient.setLatestScopes).toHaveBeenCalledWith('read:org'); - expect(mockTokenManager.getToken).toHaveBeenCalledWith('read:org', 'my-org', true); - }); - - it('prefers auth.domain over contextInterface.getConfiguration().domain', async () => { - const mockContext = { - ...createMockContextInterface(), - getConfiguration: vi - .fn() - .mockReturnValue({ domain: 'context.auth0.com', clientId: 'test-client-id' }), - }; - const authDetails = createAuthDetails({ - domain: 'explicit.auth0.com', - contextInterface: mockContext, - }); - const client = await createCoreClient(authDetails); - - await client.ensureScopes('read:org', 'my-org'); - - // Should not throw, meaning domain was found - expect(mockMyOrganizationClient.setLatestScopes).toHaveBeenCalledWith('read:org'); - expect(mockTokenManager.getToken).toHaveBeenCalledWith('read:org', 'my-org', true); - }); - - it('throws when contextInterface.getConfiguration() returns undefined domain', async () => { - const mockContext = { - ...createMockContextInterface(), - getConfiguration: vi.fn().mockReturnValue({ clientId: 'test-client-id' }), - }; - const authDetails = createAuthDetails({ domain: undefined, contextInterface: mockContext }); - const client = await createCoreClient(authDetails); - - await expect(client.ensureScopes('read:org', 'my-org')).rejects.toThrow( - 'Authentication domain is missing, cannot initialize SPA service.', - ); - }); - - it('throws when contextInterface.getConfiguration() returns undefined', async () => { - const mockContext = { - ...createMockContextInterface(), - getConfiguration: vi.fn().mockReturnValue(undefined), - }; - const authDetails = createAuthDetails({ domain: undefined, contextInterface: mockContext }); - const client = await createCoreClient(authDetails); - - await expect(client.ensureScopes('read:org', 'my-org')).rejects.toThrow( - 'Authentication domain is missing, cannot initialize SPA service.', - ); - }); - - it('throws when contextInterface is undefined and domain is not provided', async () => { - const authDetails = createAuthDetails({ domain: undefined, contextInterface: undefined }); - const client = await createCoreClient(authDetails); - - await expect(client.ensureScopes('read:org', 'my-org')).rejects.toThrow( - 'Authentication domain is missing, cannot initialize SPA service.', - ); - }); - - it('sets org scopes and fetches token in non-proxy mode', async () => { - const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); - - await client.ensureScopes('read:org', 'my-org'); - - expect(mockMyOrganizationClient.setLatestScopes).toHaveBeenCalledWith('read:org'); - expect(mockTokenManager.getToken).toHaveBeenCalledWith('read:org', 'my-org', true); - }); - - it('sets account scopes and fetches token in non-proxy mode', async () => { - const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); - - await client.ensureScopes('read:me', 'me'); - - expect(mockMyAccountClient.setLatestScopes).toHaveBeenCalledWith('read:me'); - expect(mockTokenManager.getToken).toHaveBeenCalledWith('read:me', 'me', true); - }); - - it('throws when token retrieval returns undefined in non-proxy mode', async () => { - vi.mocked(mockTokenManager.getToken).mockResolvedValueOnce(undefined); - const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); - - await expect(client.ensureScopes('read:me', 'me')).rejects.toThrow( - 'Failed to retrieve token for audience: me', - ); - }); - - it('does not set scopes for unknown audience in non-proxy mode', async () => { - const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); - - await client.ensureScopes('read:something', 'unknown-audience'); - - expect(mockMyOrganizationClient.setLatestScopes).not.toHaveBeenCalled(); - expect(mockMyAccountClient.setLatestScopes).not.toHaveBeenCalled(); - // Token fetch still happens for unknown audiences in non-proxy mode - expect(mockTokenManager.getToken).toHaveBeenCalledWith( - 'read:something', - 'unknown-audience', - true, - ); - }); - }); + // ensureScopes tests removed - functionality replaced with withScopes() per-call pattern describe('API client initialization', () => { it('initializes token manager with auth details', async () => { @@ -332,35 +171,34 @@ describe('createCoreClient', () => { const authDetails = createAuthDetails(); const client = await createCoreClient(authDetails); - expect(client.myAccountApiClient).toBe(mockMyAccountClient.client); + expect(client.myAccountApiClient).toBe(mockMyAccountClient); }); it('exposes myOrganizationApiClient directly on the client', async () => { const authDetails = createAuthDetails(); const client = await createCoreClient(authDetails); - expect(client.myOrganizationApiClient).toBe(mockMyOrganizationClient.client); + expect(client.myOrganizationApiClient).toBe(mockMyOrganizationClient); }); it('returns myAccountApiClient when available via getter', async () => { const authDetails = createAuthDetails(); const client = await createCoreClient(authDetails); - expect(client.getMyAccountApiClient()).toBe(mockMyAccountClient.client); + expect(client.getMyAccountApiClient()).toBe(mockMyAccountClient); }); it('returns myOrganizationApiClient when available via getter', async () => { const authDetails = createAuthDetails(); const client = await createCoreClient(authDetails); - expect(client.getMyOrganizationApiClient()).toBe(mockMyOrganizationClient.client); + expect(client.getMyOrganizationApiClient()).toBe(mockMyOrganizationClient); }); it('throws when myAccountApiClient is not available', async () => { - initializeMyAccountClientMock.mockReturnValueOnce({ - client: undefined as unknown as MyAccountClient, - setLatestScopes: vi.fn(), - }); + initializeMyAccountClientMock.mockReturnValueOnce( + undefined as unknown as MyAccountClientWithScopes, + ); const authDetails = createAuthDetails(); const client = await createCoreClient(authDetails); @@ -371,10 +209,9 @@ describe('createCoreClient', () => { }); it('throws when myOrganizationApiClient is not available', async () => { - initializeMyOrganizationClientMock.mockReturnValueOnce({ - client: undefined as unknown as MyOrganizationClient, - setLatestScopes: vi.fn(), - }); + initializeMyOrganizationClientMock.mockReturnValueOnce( + undefined as unknown as MyOrganizationClientWithScopes, + ); const authDetails = createAuthDetails(); const client = await createCoreClient(authDetails); diff --git a/packages/core/src/auth/__tests__/token-manager.test.ts b/packages/core/src/auth/__tests__/token-manager.test.ts index 36a024fac..7419aff01 100644 --- a/packages/core/src/auth/__tests__/token-manager.test.ts +++ b/packages/core/src/auth/__tests__/token-manager.test.ts @@ -9,6 +9,26 @@ import type { import { createTokenManager } from '../token-manager'; describe('token-manager', () => { + const mockMfaClient = { + getAuthenticators: vi.fn().mockResolvedValue([]), + enroll: vi.fn().mockResolvedValue({ + authenticatorType: 'otp', + secret: 'mock-secret', + barcodeUri: 'otpauth://totp/mock', + id: 'authenticator_123', + }), + challenge: vi.fn().mockResolvedValue({ + challengeType: 'oob', + oobCode: 'mock-oob-code', + }), + getEnrollmentFactors: vi.fn().mockResolvedValue([]), + verify: vi.fn().mockResolvedValue({ + id_token: 'mock-id-token', + access_token: 'mock-access-token', + expires_in: 3600, + }), + }; + let mockContextInterface: BasicAuth0ContextInterface = { user: undefined, isAuthenticated: true, @@ -19,6 +39,7 @@ describe('token-manager', () => { domain: TEST_DOMAIN, clientId: TEST_CLIENT_ID, }), + mfa: mockMfaClient, }; const createAuthConfig = (overrides: Partial = {}): AuthDetails => ({ @@ -53,10 +74,9 @@ describe('token-manager', () => { describe('getToken', () => { describe('validation errors', () => { - it('should throw error when auth is not initialized', async () => { - const tokenManager = createTokenManager(null as unknown as AuthDetails); - await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'TokenUtils: auth in CoreClient is not initialized.', + it('should throw error when auth is not initialized', () => { + expect(() => createTokenManager(null as unknown as AuthDetails)).toThrow( + 'TokenManager: auth is not initialized.', ); }); @@ -64,7 +84,7 @@ describe('token-manager', () => { const authWithoutContext = createAuthConfig({ contextInterface: undefined }); const tokenManager = createTokenManager(authWithoutContext); await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'TokenUtils: contextInterface in CoreClient is not initialized.', + 'TokenManager: contextInterface is not initialized.', ); }); @@ -79,7 +99,7 @@ describe('token-manager', () => { }); const tokenManager = createTokenManager(authWithoutDomain); await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'TokenUtils: Auth0 domain is not configured', + 'TokenManager: Auth0 domain is not configured', ); }); }); @@ -179,7 +199,7 @@ describe('token-manager', () => { }); }); - it('should deduplicate concurrent requests for same token', async () => { + it('should make concurrent requests for same token without deduplication', async () => { const mockToken = 'mock-token'; let resolvePromise: (value: unknown) => void; const delayedPromise = new Promise((resolve) => { @@ -210,8 +230,8 @@ describe('token-manager', () => { expect(token1).toBe(mockToken); expect(token2).toBe(mockToken); expect(token3).toBe(mockToken); - // Should only call the API once despite 3 requests - expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledTimes(1); + // Current implementation does not deduplicate, so each request calls the API + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledTimes(3); }); it('should not deduplicate requests with different scopes', async () => { @@ -335,7 +355,7 @@ describe('token-manager', () => { // First request fails await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'getAccessToken: failed', + 'Network error', ); // Reset mock for second call @@ -395,38 +415,25 @@ describe('token-manager', () => { }); }); - it('should use popup with consent prompt for mfa_required error', async () => { - const mockToken = 'popup-token'; - vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({ - error: 'mfa_required', - }); - vi.mocked(mockContextInterface.getAccessTokenWithPopup).mockResolvedValue(mockToken); + it('should throw error for mfa_required error (not in fallback list)', async () => { + const mfaError = { error: 'mfa_required' }; + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(mfaError); const auth = createAuthConfig(); const tokenManager = createTokenManager(auth); - const token = await tokenManager.getToken('read:users', 'management'); - expect(token).toBe(mockToken); - expect(mockContextInterface.getAccessTokenWithPopup).toHaveBeenCalledWith({ - authorizationParams: { - audience: `https://${TEST_DOMAIN}/management/`, - scope: 'read:users', - prompt: 'consent', - }, - }); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toEqual(mfaError); + expect(mockContextInterface.getAccessTokenWithPopup).not.toHaveBeenCalled(); }); it('should throw error when popup returns undefined token', async () => { - vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({ - error: 'consent_required', - }); + const popupError = { error: 'consent_required' }; + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(popupError); vi.mocked(mockContextInterface.getAccessTokenWithPopup).mockResolvedValue(undefined); const auth = createAuthConfig(); const tokenManager = createTokenManager(auth); - await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'getAccessTokenWithPopup: Access token is not defined', - ); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toEqual(popupError); }); it('should throw error for non-fallback errors', async () => { @@ -436,11 +443,11 @@ describe('token-manager', () => { const auth = createAuthConfig(); const tokenManager = createTokenManager(auth); await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'getAccessToken: failed', + 'Network timeout', ); }); - it('should include original error as cause for non-fallback errors', async () => { + it('should throw error directly without wrapping for non-fallback errors', async () => { const originalError = new Error('Network timeout'); vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(originalError); @@ -450,23 +457,21 @@ describe('token-manager', () => { await tokenManager.getToken('read:users', 'management'); expect.fail('Should have thrown an error'); } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe('getAccessToken: failed'); - expect((error as Error).cause).toBe(originalError); + expect(error).toBe(originalError); + expect((error as Error).message).toBe('Network timeout'); } }); it('should handle error objects with error property correctly', async () => { - vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({ + const errorObj = { error: 'invalid_grant', error_description: 'Some error description', - }); + }; + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(errorObj); const auth = createAuthConfig(); const tokenManager = createTokenManager(auth); - await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'getAccessToken: failed', - ); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toEqual(errorObj); expect(mockContextInterface.getAccessTokenWithPopup).not.toHaveBeenCalled(); }); @@ -475,9 +480,7 @@ describe('token-manager', () => { const auth = createAuthConfig(); const tokenManager = createTokenManager(auth); - await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'getAccessToken: failed', - ); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toBe(null); }); it('should handle string errors', async () => { @@ -487,8 +490,8 @@ describe('token-manager', () => { const auth = createAuthConfig(); const tokenManager = createTokenManager(auth); - await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'getAccessToken: failed', + await expect(tokenManager.getToken('read:users', 'management')).rejects.toBe( + 'String error message', ); }); }); diff --git a/packages/core/src/auth/auth-types.ts b/packages/core/src/auth/auth-types.ts index 3d47d62c1..0e943cf06 100644 --- a/packages/core/src/auth/auth-types.ts +++ b/packages/core/src/auth/auth-types.ts @@ -1,8 +1,9 @@ -import type { MyAccountClient } from '@auth0/myaccount-js'; -import type { MyOrganizationClient } from '@auth0/myorganization-js'; import type { ArbitraryObject } from '@core/types'; import type { I18nServiceInterface } from '../i18n'; +import type { MyAccountClientWithScopes } from '../services/my-account/my-account-api-service'; +import type { MyOrganizationClientWithScopes } from '../services/my-organization/my-organization-api-service'; +import type { StepUpApiService } from '../services/step-up/step-up-api-service'; export type TokenEndpointResponse = { id_token: string; @@ -88,6 +89,175 @@ export interface ClientConfiguration { clientId: string; } +/** + * Supported authenticator types. + * Note: Email authenticators use 'oob' type with oobChannel: 'email' + */ +export type AuthenticatorType = 'otp' | 'oob' | 'recovery-code'; + +/** + * Represents an MFA authenticator enrolled by a user + */ +export interface Authenticator { + id: string; + authenticatorType: AuthenticatorType; + active: boolean; + name?: string; + createdAt?: string; + lastAuth?: string; + type?: string; +} + +/** + * Types of MFA challenges + */ +export type ChallengeType = + | 'otp' + | 'phone' + | 'recovery-code' + | 'email' + | 'push-notification' + | 'totp'; + +/** + * Out-of-band delivery channels. + * Includes 'email' which is also delivered out-of-band. + */ +export type OobChannel = 'sms' | 'voice' | 'auth0' | 'email'; + +/** + * Supported MFA factors for enrollment + */ +export type MfaFactorType = 'otp' | 'sms' | 'email' | 'push' | 'voice'; + +/** + * Base parameters for all enrollment types + */ +export interface EnrollBaseParams { + mfaToken: string; +} + +/** + * OTP (Time-based One-Time Password) enrollment parameters + */ +export interface EnrollOtpParams extends EnrollBaseParams { + factorType: 'otp'; +} + +/** + * SMS enrollment parameters + */ +export interface EnrollSmsParams extends EnrollBaseParams { + factorType: 'sms'; + phoneNumber: string; +} + +/** + * Voice enrollment parameters + */ +export interface EnrollVoiceParams extends EnrollBaseParams { + factorType: 'voice'; + phoneNumber: string; +} + +/** + * Email enrollment parameters + */ +export interface EnrollEmailParams extends EnrollBaseParams { + factorType: 'email'; + email?: string; +} + +/** + * Push notification enrollment parameters + */ +export interface EnrollPushParams extends EnrollBaseParams { + factorType: 'push'; +} + +/** + * Union type for all enrollment parameter types + */ +export type EnrollParams = + | EnrollOtpParams + | EnrollSmsParams + | EnrollVoiceParams + | EnrollEmailParams + | EnrollPushParams; + +/** + * Response when enrolling an OTP authenticator + */ +export interface OtpEnrollmentResponse { + authenticatorType: 'otp'; + secret: string; + barcodeUri: string; + recoveryCodes?: string[]; + id?: string; +} + +/** + * Response when enrolling an OOB authenticator + */ +export interface OobEnrollmentResponse { + authenticatorType: 'oob'; + oobChannel: OobChannel; + oobCode?: string; + bindingMethod?: string; + recoveryCodes?: string[]; + id?: string; + barcodeUri?: string; +} + +/** + * Union type for all enrollment response types + */ +export type EnrollmentResponse = OtpEnrollmentResponse | OobEnrollmentResponse; + +/** + * Parameters for initiating an MFA challenge + */ +export interface ChallengeAuthenticatorParams { + mfaToken: string; + challengeType: 'otp' | 'oob'; + authenticatorId?: string; +} + +/** + * Response from initiating an MFA challenge + */ +export interface ChallengeResponse { + challengeType: 'otp' | 'oob'; + oobCode?: string; + bindingMethod?: string; +} + +export interface VerifyParams { + mfaToken: string; + otp?: string; + oobCode?: string; + bindingCode?: string; + recoveryCode?: string; +} + +/** + * Enrollment factor returned by getEnrollmentFactors + */ +export interface EnrollmentFactor { + type: string; +} + +/** + * MFA API Client interface + */ +export interface MfaApiClient { + getAuthenticators(mfaToken: string): Promise; + enroll(params: EnrollParams): Promise; + challenge(params: ChallengeAuthenticatorParams): Promise; + getEnrollmentFactors(mfaToken: string): Promise; + verify(params: VerifyParams): Promise; +} + export interface BasicAuth0ContextInterface { user?: TUser; isAuthenticated: boolean; @@ -101,6 +271,7 @@ export interface BasicAuth0ContextInterface { getAccessTokenWithPopup: (options?: unknown) => Promise; loginWithRedirect: (options?: unknown) => Promise; getConfiguration: () => Readonly; + mfa: MfaApiClient; } export interface AuthDetails { @@ -118,13 +289,14 @@ export interface BaseCoreClientInterface { ignoreCache?: boolean, ) => Promise; isProxyMode: () => boolean; - ensureScopes: (requiredScopes: string, audiencePath: string) => Promise; getDomain: () => string | undefined; } export interface CoreClientInterface extends BaseCoreClientInterface { - myAccountApiClient: MyAccountClient | undefined; - myOrganizationApiClient: MyOrganizationClient | undefined; - getMyAccountApiClient: () => MyAccountClient; - getMyOrganizationApiClient: () => MyOrganizationClient; + myAccountApiClient: MyAccountClientWithScopes | undefined; + myOrganizationApiClient: MyOrganizationClientWithScopes | undefined; + stepUpApiService: StepUpApiService | undefined; + getMyAccountApiClient: () => MyAccountClientWithScopes; + getMyOrganizationApiClient: () => MyOrganizationClientWithScopes; + getStepUpApiService: () => StepUpApiService; } diff --git a/packages/core/src/auth/core-client.ts b/packages/core/src/auth/core-client.ts index 058084d30..a1cbb3994 100644 --- a/packages/core/src/auth/core-client.ts +++ b/packages/core/src/auth/core-client.ts @@ -1,5 +1,6 @@ import { initializeMyAccountClient } from '@core/services/my-account/my-account-api-service'; import { initializeMyOrganizationClient } from '@core/services/my-organization/my-organization-api-service'; +import { initializeStepUpApiService } from '@core/services/step-up'; import type { I18nInitOptions } from '../i18n'; import { createI18nService } from '../i18n'; @@ -17,48 +18,24 @@ export async function createCoreClient( const tokenManagerService = createTokenManager(authDetails); - const { client: myOrganizationApiClient, setLatestScopes: setOrgScopes } = - initializeMyOrganizationClient(authDetails, tokenManagerService); + const myOrganizationApiClient = initializeMyOrganizationClient(authDetails, tokenManagerService); - const { client: myAccountApiClient, setLatestScopes: setAccountScopes } = - initializeMyAccountClient(authDetails, tokenManagerService); + const myAccountApiClient = initializeMyAccountClient(authDetails, tokenManagerService); + + const stepUpApiService = initializeStepUpApiService(authDetails); return { auth: authDetails, i18nService, myAccountApiClient, myOrganizationApiClient, + stepUpApiService, getToken: (scope, aud, ignoreCache) => tokenManagerService.getToken(scope, aud, ignoreCache), isProxyMode: () => !!authDetails.authProxyUrl, getDomain: () => authDetails.domain ?? authDetails.contextInterface?.getConfiguration()?.domain, - ensureScopes: async (requiredScopes: string, audiencePath: string) => { - const isProxyMode = !!authDetails.authProxyUrl; - - if (!isProxyMode) { - const domain = - authDetails.domain ?? authDetails.contextInterface?.getConfiguration()?.domain; - - if (!domain) { - throw new Error('Authentication domain is missing, cannot initialize SPA service.'); - } - } - - if (audiencePath === 'my-org') setOrgScopes(requiredScopes); - if (audiencePath === 'me') setAccountScopes(requiredScopes); - - if (isProxyMode) { - return; - } - - const token = await tokenManagerService.getToken(requiredScopes, audiencePath, true); - if (!token) { - throw new Error(`Failed to retrieve token for audience: ${audiencePath}`); - } - }, - getMyAccountApiClient: () => { if (!myAccountApiClient) throw new Error( @@ -74,5 +51,13 @@ export async function createCoreClient( ); return myOrganizationApiClient; }, + + getStepUpApiService: () => { + if (!stepUpApiService) + throw new Error( + 'stepUpApiService is not enabled. Please use it within Auth0ComponentProvider.', + ); + return stepUpApiService; + }, }; } diff --git a/packages/core/src/auth/token-manager.ts b/packages/core/src/auth/token-manager.ts index da4f8d462..6c61d63d6 100644 --- a/packages/core/src/auth/token-manager.ts +++ b/packages/core/src/auth/token-manager.ts @@ -55,11 +55,11 @@ export function createTokenManager(auth: AuthDetails) { const token = await auth.contextInterface.getAccessTokenWithPopup({ authorizationParams: { audience, scope, prompt }, }); - if (!token) throw new Error('getAccessTokenWithPopup: Access token is not defined'); + if (!token) throw error; return token; } - throw new Error('getAccessToken: failed', { cause: error }); + throw error; } }, }; diff --git a/packages/core/src/i18n/translations/en-US.json b/packages/core/src/i18n/translations/en-US.json index 7ac6762e0..8f63d28cd 100644 --- a/packages/core/src/i18n/translations/en-US.json +++ b/packages/core/src/i18n/translations/en-US.json @@ -6,6 +6,12 @@ "title": "We couldn't load this information", "description": "Please try again or contact support if the problem persists.", "retry": "Retry" + }, + "error": { + "generic": "There was an issue processing your request. Please try again or contact support if the issue persists.", + "mfa": { + "title": "Verify its you" + } } }, "domain_management": { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aa642066c..d43195d1c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,17 @@ export { createCoreClient } from './auth/core-client'; export { AuthDetails, CoreClientInterface, BasicAuth0ContextInterface } from './auth/auth-types'; +export type { + Authenticator as StepUpAuthenticator, + AuthenticatorType, + EnrollmentFactor, + EnrollmentResponse, + EnrollParams, + ChallengeAuthenticatorParams, + ChallengeResponse, + VerifyParams, +} from './auth/auth-types'; + export * from './schemas'; export * from './theme'; diff --git a/packages/core/src/internals/__mocks__/shared/api-service.mocks.ts b/packages/core/src/internals/__mocks__/shared/api-service.mocks.ts index d242be431..2f2d1a294 100644 --- a/packages/core/src/internals/__mocks__/shared/api-service.mocks.ts +++ b/packages/core/src/internals/__mocks__/shared/api-service.mocks.ts @@ -20,6 +20,25 @@ export const createMockContextInterface = (): BasicAuth0ContextInterface => ({ getAccessTokenWithPopup: vi.fn().mockResolvedValue('mock-access-token'), loginWithRedirect: vi.fn().mockResolvedValue(undefined), getConfiguration: vi.fn().mockReturnValue({ domain: TEST_DOMAIN, clientId: TEST_CLIENT_ID }), + mfa: { + getAuthenticators: vi.fn().mockResolvedValue([]), + enroll: vi.fn().mockResolvedValue({ + authenticatorType: 'otp', + secret: 'mock-secret', + barcodeUri: 'otpauth://totp/mock', + id: 'authenticator_123', + }), + challenge: vi.fn().mockResolvedValue({ + challengeType: 'oob', + oobCode: 'mock-oob-code', + }), + getEnrollmentFactors: vi.fn().mockResolvedValue([]), + verify: vi.fn().mockResolvedValue({ + id_token: 'mock-id-token', + access_token: 'mock-access-token', + expires_in: 3600, + }), + }, }); // ============================================================================= diff --git a/packages/core/src/services/my-account/__tests__/__mocks__/my-account-api-service.mocks.ts b/packages/core/src/services/my-account/__tests__/__mocks__/my-account-api-service.mocks.ts index eb6a41e43..f416ae69c 100644 --- a/packages/core/src/services/my-account/__tests__/__mocks__/my-account-api-service.mocks.ts +++ b/packages/core/src/services/my-account/__tests__/__mocks__/my-account-api-service.mocks.ts @@ -1,4 +1,3 @@ -import type { MyAccountClient } from '@auth0/myaccount-js'; import { vi } from 'vitest'; import type { initializeMyAccountClient } from '../../my-account-api-service'; @@ -7,10 +6,11 @@ import type { initializeMyAccountClient } from '../../my-account-api-service'; * Creates a mock MyAccount API client */ export const createMockMyAccountClient = (): ReturnType => { - return { - client: {} as MyAccountClient, - setLatestScopes: vi.fn(), - }; + const client = { + withScopes: vi.fn().mockReturnThis(), + } as unknown as ReturnType; + + return client; }; // Re-export shared API service mocks diff --git a/packages/core/src/services/my-account/__tests__/my-account-api-service.test.ts b/packages/core/src/services/my-account/__tests__/my-account-api-service.test.ts index 02eec2f30..a3ca42efb 100644 --- a/packages/core/src/services/my-account/__tests__/my-account-api-service.test.ts +++ b/packages/core/src/services/my-account/__tests__/my-account-api-service.test.ts @@ -55,10 +55,9 @@ describe('initializeMyAccountClient', () => { describe('basic functionality', () => { it('should create MyAccountClient with proxy URL', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - expect(result).toHaveProperty('client'); - expect(result).toHaveProperty('setLatestScopes'); + expect(client).toHaveProperty('withScopes'); expect(mockMyAccountClient).toHaveBeenCalled(); }); @@ -111,35 +110,35 @@ describe('initializeMyAccountClient', () => { }); }); - describe('setLatestScopes function', () => { - it('should provide setLatestScopes function', () => { + describe('withScopes function', () => { + it('should provide withScopes function', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - expect(result.setLatestScopes).toBeDefined(); - expect(typeof result.setLatestScopes).toBe('function'); + expect(client.withScopes).toBeDefined(); + expect(typeof client.withScopes).toBe('function'); }); it('should accept scope strings without throwing', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - expect(() => result.setLatestScopes(mockScopes.mfa)).not.toThrow(); + expect(() => client.withScopes(mockScopes.mfa)).not.toThrow(); }); it('should handle empty scope string', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - expect(() => result.setLatestScopes('')).not.toThrow(); + expect(() => client.withScopes('')).not.toThrow(); }); it('should handle complex scope strings', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); const complexScopes = `${mockScopes.mfa} ${mockScopes.profile} ${mockScopes.email}`; - expect(() => result.setLatestScopes(complexScopes)).not.toThrow(); + expect(() => client.withScopes(complexScopes)).not.toThrow(); }); }); @@ -163,8 +162,8 @@ describe('initializeMyAccountClient', () => { vi.stubGlobal('fetch', mockFetch); const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - result.setLatestScopes(mockScopes.mfa); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + client.withScopes(mockScopes.mfa); const fetcher = getFetcherFromMockCalls(mockMyAccountClient); @@ -206,8 +205,8 @@ describe('initializeMyAccountClient', () => { vi.stubGlobal('fetch', mockFetch); const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - result.setLatestScopes(''); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + client.withScopes(''); const fetcher = getFetcherFromMockCalls(mockMyAccountClient); @@ -247,14 +246,14 @@ describe('initializeMyAccountClient', () => { vi.stubGlobal('fetch', mockFetch); const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); const fetcher = getFetcherFromMockCalls(mockMyAccountClient); - result.setLatestScopes(mockScopes.mfa); + client.withScopes(mockScopes.mfa); await fetcher!(TEST_URL, {}); - result.setLatestScopes(mockScopes.profile); + client.withScopes(mockScopes.profile); await fetcher!(TEST_URL, {}); expect(mockFetch).toHaveBeenNthCalledWith( @@ -327,10 +326,9 @@ describe('initializeMyAccountClient', () => { describe('basic functionality', () => { it('should create MyAccountClient with domain', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithDomain, tokenManager); + const client = initializeMyAccountClient(mockAuthWithDomain, tokenManager); - expect(result).toHaveProperty('client'); - expect(result).toHaveProperty('setLatestScopes'); + expect(client).toHaveProperty('withScopes'); expect(mockMyAccountClient).toHaveBeenCalled(); }); @@ -364,21 +362,21 @@ describe('initializeMyAccountClient', () => { }); }); - describe('setLatestScopes function', () => { - it('should provide setLatestScopes function in domain mode', () => { + describe('withScopes function', () => { + it('should provide withScopes function in domain mode', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithDomain, tokenManager); + const client = initializeMyAccountClient(mockAuthWithDomain, tokenManager); - expect(result.setLatestScopes).toBeDefined(); - expect(typeof result.setLatestScopes).toBe('function'); + expect(client.withScopes).toBeDefined(); + expect(typeof client.withScopes).toBe('function'); }); it('should track scope changes in domain mode', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithDomain, tokenManager); + const client = initializeMyAccountClient(mockAuthWithDomain, tokenManager); - expect(() => result.setLatestScopes(mockScopes.mfa)).not.toThrow(); - expect(() => result.setLatestScopes(mockScopes.profile)).not.toThrow(); + expect(() => client.withScopes(mockScopes.mfa)).not.toThrow(); + expect(() => client.withScopes(mockScopes.profile)).not.toThrow(); }); }); @@ -388,8 +386,8 @@ describe('initializeMyAccountClient', () => { vi.stubGlobal('fetch', mockFetch); const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithDomain, tokenManager); - result.setLatestScopes(mockScopes.mfa); + const client = initializeMyAccountClient(mockAuthWithDomain, tokenManager); + client.withScopes(mockScopes.mfa); const fetcher = getFetcherFromMockCalls(mockMyAccountClient); @@ -536,8 +534,8 @@ describe('initializeMyAccountClient', () => { vi.stubGlobal('fetch', mockFetch); const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithDomain, tokenManager); - result.setLatestScopes(mockScopes.mfa); + const client = initializeMyAccountClient(mockAuthWithDomain, tokenManager); + client.withScopes(mockScopes.mfa); const fetcher = getFetcherFromMockCalls(mockMyAccountClient); @@ -689,11 +687,11 @@ describe('initializeMyAccountClient', () => { // This will create a MyAccountClient with empty string domain after trim() // which is allowed by MyAccountClient, so it should not throw - const result = initializeMyAccountClient(authWithWhitespace, tokenManager); + const client = initializeMyAccountClient(authWithWhitespace, tokenManager); const config = getConfigFromMockCalls(mockMyAccountClient); - expect(result.client).toBeDefined(); + expect(client).toBeDefined(); expect(config.domain).toBe(''); }); }); @@ -703,61 +701,61 @@ describe('initializeMyAccountClient', () => { const tokenManager = createMockTokenManager(); const authWithSpecialChars = { domain: 'my-domain.eu.auth0.com' }; - const result = initializeMyAccountClient(authWithSpecialChars, tokenManager); + const client = initializeMyAccountClient(authWithSpecialChars, tokenManager); - expect(result.client).toBeDefined(); + expect(client).toBeDefined(); }); it('should handle proxy URL with encoded characters', () => { const tokenManager = createMockTokenManager(); const authWithEncoded = { authProxyUrl: 'https://example.com/path%20with%20spaces' }; - const result = initializeMyAccountClient(authWithEncoded, tokenManager); + const client = initializeMyAccountClient(authWithEncoded, tokenManager); - expect(result.client).toBeDefined(); + expect(client).toBeDefined(); }); it('should handle international domains', () => { const tokenManager = createMockTokenManager(); const authWithIntl = { domain: 'münchen.auth0.com' }; - const result = initializeMyAccountClient(authWithIntl, tokenManager); + const client = initializeMyAccountClient(authWithIntl, tokenManager); - expect(result.client).toBeDefined(); + expect(client).toBeDefined(); }); }); describe('multiple consecutive calls', () => { it('should handle multiple scope updates', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); expect(() => { - result.setLatestScopes(mockScopes.mfa); - result.setLatestScopes(mockScopes.profile); - result.setLatestScopes(mockScopes.email); - result.setLatestScopes(''); + client.withScopes(mockScopes.mfa); + client.withScopes(mockScopes.profile); + client.withScopes(mockScopes.email); + client.withScopes(''); }).not.toThrow(); }); it('should create independent instances on each call', () => { const tokenManager = createMockTokenManager(); - const result1 = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - const result2 = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client1 = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client2 = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - expect(result1.client).not.toBe(result2.client); - expect(result1.setLatestScopes).not.toBe(result2.setLatestScopes); + expect(client1).not.toBe(client2); + expect(client1.withScopes).not.toBe(client2.withScopes); }); }); describe('concurrent operations', () => { it('should handle concurrent scope updates', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); expect(() => { - result.setLatestScopes(mockScopes.mfa); - result.setLatestScopes(mockScopes.profile); + client.withScopes(mockScopes.mfa); + client.withScopes(mockScopes.profile); }).not.toThrow(); }); @@ -766,8 +764,8 @@ describe('initializeMyAccountClient', () => { vi.stubGlobal('fetch', mockFetch); const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - result.setLatestScopes(mockScopes.mfa); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + client.withScopes(mockScopes.mfa); const fetcher = getFetcherFromMockCalls(mockMyAccountClient); @@ -785,8 +783,8 @@ describe('initializeMyAccountClient', () => { vi.stubGlobal('fetch', mockFetch); const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithDomain, tokenManager); - result.setLatestScopes(mockScopes.mfa); + const client = initializeMyAccountClient(mockAuthWithDomain, tokenManager); + client.withScopes(mockScopes.mfa); const fetcher = getFetcherFromMockCalls(mockMyAccountClient); @@ -803,37 +801,35 @@ describe('initializeMyAccountClient', () => { }); describe('return value structure', () => { - it('should return object with client and setLatestScopes', () => { + it('should return client with withScopes method', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - expect(result).toHaveProperty('client'); - expect(result).toHaveProperty('setLatestScopes'); - expect(Object.keys(result)).toHaveLength(2); + expect(client).toHaveProperty('withScopes'); + expect(typeof client.withScopes).toBe('function'); }); it('should have client as MyAccountClient instance', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - expect(result.client).toBeDefined(); + expect(client).toBeDefined(); expect(mockMyAccountClient).toHaveBeenCalled(); }); - it('should have setLatestScopes as a function', () => { + it('should have withScopes as a function', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - expect(typeof result.setLatestScopes).toBe('function'); + expect(typeof client.withScopes).toBe('function'); }); it('should return new instances on each call', () => { const tokenManager = createMockTokenManager(); - const result1 = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - const result2 = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client1 = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client2 = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); - expect(result1).not.toBe(result2); - expect(result1.client).not.toBe(result2.client); + expect(client1).not.toBe(client2); }); }); @@ -843,10 +839,10 @@ describe('initializeMyAccountClient', () => { vi.stubGlobal('fetch', mockFetch); const tokenManager = createMockTokenManager(); - const result = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyAccountClient(mockAuthWithProxyUrl, tokenManager); // Set scopes - result.setLatestScopes(mockScopes.mfa); + client.withScopes(mockScopes.mfa); // Get the fetcher const config = getConfigFromMockCalls(mockMyAccountClient); @@ -877,10 +873,10 @@ describe('initializeMyAccountClient', () => { vi.stubGlobal('fetch', mockFetch); const tokenManager = createMockTokenManager(mockTokens.standard); - const result = initializeMyAccountClient(mockAuthWithDomain, tokenManager); + const client = initializeMyAccountClient(mockAuthWithDomain, tokenManager); // Set scopes - result.setLatestScopes(mockScopes.mfa); + client.withScopes(mockScopes.mfa); // Get the fetcher const config = getConfigFromMockCalls(mockMyAccountClient); @@ -907,17 +903,17 @@ describe('initializeMyAccountClient', () => { vi.stubGlobal('fetch', mockFetch); const tokenManager = createMockTokenManager(mockTokens.standard); - const result = initializeMyAccountClient(mockAuthWithDomain, tokenManager); + const client = initializeMyAccountClient(mockAuthWithDomain, tokenManager); const config = getConfigFromMockCalls(mockMyAccountClient); const fetcher = config.fetcher; // Start with empty scope - result.setLatestScopes(''); + client.withScopes(''); await fetcher!(TEST_URL, {}); // Change to populated scope - result.setLatestScopes(mockScopes.mfa); + client.withScopes(mockScopes.mfa); await fetcher!(TEST_URL, {}); // Verify both calls @@ -934,9 +930,9 @@ describe('initializeMyAccountClient', () => { const tokenManager = createMockTokenManager(mockTokens.standard); const auth = { contextInterface }; - const result = initializeMyAccountClient(auth, tokenManager); + const client = initializeMyAccountClient(auth, tokenManager); - expect(result.client).toBeDefined(); + expect(client).toBeDefined(); expect(mockMyAccountClient).toHaveBeenCalledWith( expect.objectContaining({ domain: 'context.auth0.com', @@ -953,9 +949,9 @@ describe('initializeMyAccountClient', () => { contextInterface, }; - const result = initializeMyAccountClient(auth, tokenManager); + const client = initializeMyAccountClient(auth, tokenManager); - expect(result.client).toBeDefined(); + expect(client).toBeDefined(); expect(mockMyAccountClient).toHaveBeenCalledWith( expect.objectContaining({ domain: 'direct.auth0.com', @@ -1008,7 +1004,7 @@ describe('initializeMyAccountClient', () => { const tokenManager = createMockTokenManager(mockTokens.standard); const auth = { contextInterface }; - const result = initializeMyAccountClient(auth, tokenManager); + const client = initializeMyAccountClient(auth, tokenManager); const config = getConfigFromMockCalls(mockMyAccountClient); const fetcher = config.fetcher; @@ -1028,7 +1024,7 @@ describe('initializeMyAccountClient', () => { expect(tokenManager.getToken).toHaveBeenCalled(); // Verify the client was created - expect(result.client).toBeDefined(); + expect(client).toBeDefined(); }); }); }); diff --git a/packages/core/src/services/my-account/my-account-api-service.ts b/packages/core/src/services/my-account/my-account-api-service.ts index c97097d2e..bec7038e8 100644 --- a/packages/core/src/services/my-account/my-account-api-service.ts +++ b/packages/core/src/services/my-account/my-account-api-service.ts @@ -3,19 +3,16 @@ import { MyAccountClient } from '@auth0/myaccount-js'; import type { AuthDetails } from '../../auth/auth-types'; import type { createTokenManager } from '../../auth/token-manager'; +export interface MyAccountClientWithScopes extends MyAccountClient { + withScopes: (scopes: string) => MyAccountClientWithScopes; +} + export function initializeMyAccountClient( auth: AuthDetails, tokenManagerService: ReturnType, -): { - client: MyAccountClient; - setLatestScopes: (scopes: string) => void; -} { +): MyAccountClientWithScopes { let latestScopes = ''; - const setLatestScopes = (scopes: string) => { - latestScopes = scopes; - }; - if (auth.authProxyUrl) { const myAccountProxyPath = 'me'; const myAccountBaseUrl = `${auth.authProxyUrl.replace(/\/$/, '')}/${myAccountProxyPath}`; @@ -29,15 +26,19 @@ export function initializeMyAccountClient( }, }); }; - return { - client: new MyAccountClient({ - domain: '', - baseUrl: myAccountBaseUrl.trim(), - telemetry: false, - fetcher, - }), - setLatestScopes, + const client = new MyAccountClient({ + domain: '', + baseUrl: myAccountBaseUrl.trim(), + telemetry: false, + fetcher, + }) as MyAccountClientWithScopes; + + client.withScopes = (scopes: string) => { + latestScopes = scopes; + return client; }; + + return client; } const domain = auth.domain ?? auth.contextInterface?.getConfiguration()?.domain; @@ -58,13 +59,18 @@ export function initializeMyAccountClient( headers, }); }; - return { - client: new MyAccountClient({ - domain: domain.trim(), - fetcher, - }), - setLatestScopes, + + const client = new MyAccountClient({ + domain: domain.trim(), + fetcher, + }) as MyAccountClientWithScopes; + + client.withScopes = (scopes: string) => { + latestScopes = scopes; + return client; }; + + return client; } throw new Error('Missing domain or proxy URL for MyAccountClient'); } diff --git a/packages/core/src/services/my-organization/__tests__/__mocks__/my-organization-api-service.mocks.ts b/packages/core/src/services/my-organization/__tests__/__mocks__/my-organization-api-service.mocks.ts index c7c64bf5c..293d3e193 100644 --- a/packages/core/src/services/my-organization/__tests__/__mocks__/my-organization-api-service.mocks.ts +++ b/packages/core/src/services/my-organization/__tests__/__mocks__/my-organization-api-service.mocks.ts @@ -1,4 +1,3 @@ -import type { MyOrganizationClient } from '@auth0/myorganization-js'; import { vi } from 'vitest'; import type { initializeMyOrganizationClient } from '../../my-organization-api-service'; @@ -102,8 +101,9 @@ export const mockMyOrganizationClientMethods = { export const createMockMyOrganizationClient = (): ReturnType< typeof initializeMyOrganizationClient > => { - return { - client: {} as MyOrganizationClient, - setLatestScopes: vi.fn(), - }; + const client = { + withScopes: vi.fn().mockReturnThis(), + } as unknown as ReturnType; + + return client; }; diff --git a/packages/core/src/services/my-organization/__tests__/my-organization-api-service.test.ts b/packages/core/src/services/my-organization/__tests__/my-organization-api-service.test.ts index 4057953cd..67b90ed90 100644 --- a/packages/core/src/services/my-organization/__tests__/my-organization-api-service.test.ts +++ b/packages/core/src/services/my-organization/__tests__/my-organization-api-service.test.ts @@ -108,46 +108,34 @@ describe('initializeMyOrganizationClient', () => { }); }); - describe('setLatestScopes function', () => { - it('should provide setLatestScopes function', () => { + describe('withScopes method', () => { + it('should provide withScopes method', () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); - expect(setLatestScopes).toBeDefined(); - expect(typeof setLatestScopes).toBe('function'); + expect(client.withScopes).toBeDefined(); + expect(typeof client.withScopes).toBe('function'); }); it('should accept scope strings without throwing', () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); - expect(() => setLatestScopes(mockScopes.organizationRead)).not.toThrow(); + expect(() => client.withScopes(mockScopes.organizationRead)).not.toThrow(); }); it('should handle empty scope string', () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); - expect(() => setLatestScopes(mockScopes.empty)).not.toThrow(); + expect(() => client.withScopes(mockScopes.empty)).not.toThrow(); }); it('should handle complex scope strings', () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); - expect(() => setLatestScopes(mockScopes.complex)).not.toThrow(); + expect(() => client.withScopes(mockScopes.complex)).not.toThrow(); }); }); @@ -165,14 +153,11 @@ describe('initializeMyOrganizationClient', () => { it('should add scope header when scopes are set', async () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); const fetcher = getFetcherFromMockCalls(mockMyOrganizationClient); - setLatestScopes(mockScopes.organizationRead); + client.withScopes(mockScopes.organizationRead); await fetcher!(TEST_URL, mockRequestInits.post); const headers = getHeadersFromFetchCall(mockFetch) as Record; @@ -181,14 +166,11 @@ describe('initializeMyOrganizationClient', () => { it('should add Content-Type header when body is present', async () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); const fetcher = getFetcherFromMockCalls(mockMyOrganizationClient); - setLatestScopes(mockScopes.organizationRead); + client.withScopes(mockScopes.organizationRead); await fetcher!(TEST_URL, mockRequestInits.post); const headers = getHeadersFromFetchCall(mockFetch) as Record; @@ -209,14 +191,11 @@ describe('initializeMyOrganizationClient', () => { it('should preserve existing headers', async () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); const fetcher = getFetcherFromMockCalls(mockMyOrganizationClient); - setLatestScopes(mockScopes.organizationRead); + client.withScopes(mockScopes.organizationRead); await fetcher!(TEST_URL, mockRequestInits.postWithHeaders); const headers = getHeadersFromFetchCall(mockFetch) as Record; @@ -226,15 +205,12 @@ describe('initializeMyOrganizationClient', () => { it('should update scope header when scopes change', async () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); const fetcher = getFetcherFromMockCalls(mockMyOrganizationClient); // First call with orgRead scope - setLatestScopes(mockScopes.organizationRead); + client.withScopes(mockScopes.organizationRead); await fetcher!(TEST_URL, mockRequestInits.post); const firstCall = mockFetch.mock.calls[0]!; @@ -242,7 +218,7 @@ describe('initializeMyOrganizationClient', () => { expect(firstHeaders['auth0-scope']).toBe(mockScopes.organizationRead); // Second call with complex scope - setLatestScopes(mockScopes.complex); + client.withScopes(mockScopes.complex); await fetcher!(TEST_URL, mockRequestInits.post); const secondCall = mockFetch.mock.calls[1]!; @@ -367,29 +343,23 @@ describe('initializeMyOrganizationClient', () => { expect(typeof config.fetcher).toBe('function'); }); - it('should provide setLatestScopes function', () => { + it('should provide withScopes method', () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithDomain, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithDomain, tokenManager); - expect(setLatestScopes).toBeDefined(); - expect(typeof setLatestScopes).toBe('function'); + expect(client.withScopes).toBeDefined(); + expect(typeof client.withScopes).toBe('function'); }); }); describe('custom fetcher behavior in domain mode', () => { it('should call tokenManager.getToken with correct parameters', async () => { const tokenManager = createMockTokenManagerWithScopes(mockTokens.standard); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithDomain, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithDomain, tokenManager); const fetcher = getFetcherFromMockCalls(mockMyOrganizationClient); - setLatestScopes(mockScopes.organizationRead); + client.withScopes(mockScopes.organizationRead); await fetcher!(TEST_URL, mockRequestInits.post); expect(tokenManager.getToken).toHaveBeenCalledTimes(1); @@ -451,22 +421,19 @@ describe('initializeMyOrganizationClient', () => { it('should handle scope updates correctly', async () => { const tokenManager = createMockTokenManagerWithScopes(mockTokens.standard); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithDomain, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithDomain, tokenManager); const fetcher = getFetcherFromMockCalls(mockMyOrganizationClient); // First call with orgRead scope - setLatestScopes(mockScopes.organizationRead); + client.withScopes(mockScopes.organizationRead); await fetcher!(TEST_URL, mockRequestInits.post); expect(tokenManager.lastScope).toBe(mockScopes.organizationRead); expect(tokenManager.lastAudiencePath).toBe('my-org'); // Second call with complex scope - setLatestScopes(mockScopes.complex); + client.withScopes(mockScopes.complex); await fetcher!(TEST_URL, mockRequestInits.post); expect(tokenManager.lastScope).toBe(mockScopes.complex); @@ -597,17 +564,14 @@ describe('initializeMyOrganizationClient', () => { describe('edge cases', () => { it('should handle very long scope strings', async () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); const longScope = 'read:organization '.repeat(100).trim(); const calls = mockMyOrganizationClient.mock.calls; const config = calls[0]![0]; const fetcher = config.fetcher; - setLatestScopes(longScope); + client.withScopes(longScope); await fetcher!(TEST_URL, mockRequestInits.post); const fetchCall = mockFetch.mock.calls[0]!; @@ -647,17 +611,14 @@ describe('initializeMyOrganizationClient', () => { it('should handle multiple rapid scope changes', async () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); const calls = mockMyOrganizationClient.mock.calls; const config = calls[0]![0]; const fetcher = config.fetcher; for (let i = 0; i < 5; i++) { - setLatestScopes(`scope${i}`); + client.withScopes(`scope${i}`); await fetcher!(TEST_URL, mockRequestInits.post); const fetchCall = mockFetch.mock.calls[i]!; @@ -712,16 +673,13 @@ describe('initializeMyOrganizationClient', () => { it('should handle scope strings with leading/trailing whitespace', async () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); const calls = mockMyOrganizationClient.mock.calls; const config = calls[0]![0]; const fetcher = config.fetcher; - setLatestScopes(mockScopes.withSpaces); + client.withScopes(mockScopes.withSpaces); await fetcher!(TEST_URL, mockRequestInits.post); const fetchCall = mockFetch.mock.calls[0]!; @@ -783,53 +741,42 @@ describe('initializeMyOrganizationClient', () => { }); describe('return value structure', () => { - it('should return object with client and setLatestScopes', () => { + it('should return client with withScopes method', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); - expect(result).toHaveProperty('client'); - expect(result).toHaveProperty('setLatestScopes'); + expect(client).toHaveProperty('withScopes'); + expect(typeof client.withScopes).toBe('function'); }); - it('should return MyOrganizationClient instance as client', () => { + it('should return MyOrganizationClient instance', () => { const tokenManager = createMockTokenManager(); - const { client } = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); expect(client).toBeInstanceOf(mockMyOrganizationClient); }); - it('should return function as setLatestScopes', () => { + it('should have withScopes method for proxy mode', () => { const tokenManager = createMockTokenManager(); - const { setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); - expect(typeof setLatestScopes).toBe('function'); + expect(client.withScopes).toBeDefined(); + expect(typeof client.withScopes).toBe('function'); }); - it('should have consistent return structure for proxy mode', () => { + it('should have withScopes method for domain mode', () => { const tokenManager = createMockTokenManager(); - const result = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); + const client = initializeMyOrganizationClient(mockAuthWithDomain, tokenManager); - expect(Object.keys(result).sort()).toEqual(['client', 'setLatestScopes'].sort()); - }); - - it('should have consistent return structure for domain mode', () => { - const tokenManager = createMockTokenManager(); - const result = initializeMyOrganizationClient(mockAuthWithDomain, tokenManager); - - expect(Object.keys(result).sort()).toEqual(['client', 'setLatestScopes'].sort()); + expect(client.withScopes).toBeDefined(); + expect(typeof client.withScopes).toBe('function'); }); }); describe('integration scenarios', () => { it('should handle complete proxy mode workflow', async () => { const tokenManager = createMockTokenManager(); - const { client, setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithProxyUrl, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager); expect(client).toBeInstanceOf(mockMyOrganizationClient); @@ -837,7 +784,7 @@ describe('initializeMyOrganizationClient', () => { const config = calls[0]![0]; const fetcher = config.fetcher; - setLatestScopes(mockScopes.organizationRead); + client.withScopes(mockScopes.organizationRead); await fetcher!(TEST_URL, mockRequestInits.post); expect(mockFetch).toHaveBeenCalled(); @@ -848,10 +795,7 @@ describe('initializeMyOrganizationClient', () => { it('should handle complete domain mode workflow', async () => { const tokenManager = createMockTokenManagerWithScopes(mockTokens.standard); - const { client, setLatestScopes } = initializeMyOrganizationClient( - mockAuthWithDomain, - tokenManager, - ); + const client = initializeMyOrganizationClient(mockAuthWithDomain, tokenManager); expect(client).toBeInstanceOf(mockMyOrganizationClient); @@ -859,7 +803,7 @@ describe('initializeMyOrganizationClient', () => { const config = calls[0]![0]; const fetcher = config.fetcher; - setLatestScopes(mockScopes.complex); + client.withScopes(mockScopes.complex); await fetcher!(TEST_URL, mockRequestInits.post); expect(tokenManager.getToken).toHaveBeenCalledWith(mockScopes.complex, 'my-org'); @@ -873,8 +817,8 @@ describe('initializeMyOrganizationClient', () => { const proxyClient = initializeMyOrganizationClient(mockAuthWithProxyUrl, tokenManager1); const domainClient = initializeMyOrganizationClient(mockAuthWithDomain, tokenManager2); - expect(proxyClient.client).toBeInstanceOf(mockMyOrganizationClient); - expect(domainClient.client).toBeInstanceOf(mockMyOrganizationClient); + expect(proxyClient).toBeInstanceOf(mockMyOrganizationClient); + expect(domainClient).toBeInstanceOf(mockMyOrganizationClient); expect(mockMyOrganizationClient).toHaveBeenCalledTimes(2); }); }); @@ -891,6 +835,13 @@ describe('initializeMyOrganizationClient', () => { getConfiguration: vi .fn() .mockReturnValue({ domain: 'context.auth0.com', clientId: 'client-id' }), + mfa: { + getAuthenticators: vi.fn().mockResolvedValue([]), + enroll: vi.fn(), + challenge: vi.fn(), + getEnrollmentFactors: vi.fn().mockResolvedValue([]), + verify: vi.fn(), + }, }, }; @@ -912,6 +863,13 @@ describe('initializeMyOrganizationClient', () => { getConfiguration: vi .fn() .mockReturnValue({ domain: 'context.auth0.com', clientId: 'client-id' }), + mfa: { + getAuthenticators: vi.fn().mockResolvedValue([]), + enroll: vi.fn(), + challenge: vi.fn(), + getEnrollmentFactors: vi.fn().mockResolvedValue([]), + verify: vi.fn(), + }, }, }; @@ -930,6 +888,13 @@ describe('initializeMyOrganizationClient', () => { getAccessTokenWithPopup: vi.fn().mockResolvedValue('mock-token'), loginWithRedirect: vi.fn(), getConfiguration: vi.fn().mockReturnValue({ clientId: 'client-id' }), + mfa: { + getAuthenticators: vi.fn().mockResolvedValue([]), + enroll: vi.fn(), + challenge: vi.fn(), + getEnrollmentFactors: vi.fn().mockResolvedValue([]), + verify: vi.fn(), + }, }, }; @@ -947,6 +912,13 @@ describe('initializeMyOrganizationClient', () => { getAccessTokenWithPopup: vi.fn().mockResolvedValue('mock-token'), loginWithRedirect: vi.fn(), getConfiguration: vi.fn().mockReturnValue(undefined), + mfa: { + getAuthenticators: vi.fn().mockResolvedValue([]), + enroll: vi.fn(), + challenge: vi.fn(), + getEnrollmentFactors: vi.fn().mockResolvedValue([]), + verify: vi.fn(), + }, }, }; @@ -977,16 +949,20 @@ describe('initializeMyOrganizationClient', () => { getConfiguration: vi .fn() .mockReturnValue({ domain: 'context.auth0.com', clientId: 'client-id' }), + mfa: { + getAuthenticators: vi.fn().mockResolvedValue([]), + enroll: vi.fn(), + challenge: vi.fn(), + getEnrollmentFactors: vi.fn().mockResolvedValue([]), + verify: vi.fn(), + }, }, }; - const { setLatestScopes } = initializeMyOrganizationClient( - authWithContextInterfaceOnly, - tokenManager, - ); + const client = initializeMyOrganizationClient(authWithContextInterfaceOnly, tokenManager); const fetcher = getFetcherFromMockCalls(mockMyOrganizationClient); - setLatestScopes(mockScopes.organizationRead); + client.withScopes(mockScopes.organizationRead); await fetcher!(TEST_URL, mockRequestInits.post); expect(tokenManager.getToken).toHaveBeenCalledWith(mockScopes.organizationRead, 'my-org'); diff --git a/packages/core/src/services/my-organization/my-organization-api-service.ts b/packages/core/src/services/my-organization/my-organization-api-service.ts index e65523b98..291b9e132 100644 --- a/packages/core/src/services/my-organization/my-organization-api-service.ts +++ b/packages/core/src/services/my-organization/my-organization-api-service.ts @@ -2,19 +2,16 @@ import { MyOrganizationClient } from '@auth0/myorganization-js'; import type { AuthDetails } from '@core/auth/auth-types'; import type { createTokenManager } from '@core/auth/token-manager'; +export interface MyOrganizationClientWithScopes extends MyOrganizationClient { + withScopes: (scopes: string) => MyOrganizationClientWithScopes; +} + export function initializeMyOrganizationClient( auth: AuthDetails, tokenManagerService: ReturnType, -): { - client: MyOrganizationClient; - setLatestScopes: (scopes: string) => void; -} { +): MyOrganizationClientWithScopes { let latestScopes = ''; - const setLatestScopes = (scopes: string) => { - latestScopes = scopes; - }; - if (auth.authProxyUrl) { const myOrganizationProxyPath = 'my-org'; const myOrganizationProxyBaseUrl = `${auth.authProxyUrl.replace(/\/$/, '')}/${myOrganizationProxyPath}`; @@ -28,15 +25,19 @@ export function initializeMyOrganizationClient( }, }); }; - return { - client: new MyOrganizationClient({ - domain: '', - baseUrl: myOrganizationProxyBaseUrl.trim(), - telemetry: false, - fetcher, - }), - setLatestScopes, + const client = new MyOrganizationClient({ + domain: '', + baseUrl: myOrganizationProxyBaseUrl.trim(), + telemetry: false, + fetcher, + }) as MyOrganizationClientWithScopes; + + client.withScopes = (scopes: string) => { + latestScopes = scopes; + return client; }; + + return client; } const domain = auth.domain ?? auth.contextInterface?.getConfiguration()?.domain; @@ -57,13 +58,18 @@ export function initializeMyOrganizationClient( headers, }); }; - return { - client: new MyOrganizationClient({ - domain: domain.trim(), - fetcher, - }), - setLatestScopes, + + const client = new MyOrganizationClient({ + domain: domain.trim(), + fetcher, + }) as MyOrganizationClientWithScopes; + + client.withScopes = (scopes: string) => { + latestScopes = scopes; + return client; }; + + return client; } throw new Error('Missing domain or proxy URL for MyOrganizationClient'); } diff --git a/packages/core/src/services/step-up/__tests__/step-up-api-service.test.ts b/packages/core/src/services/step-up/__tests__/step-up-api-service.test.ts new file mode 100644 index 000000000..29c142db3 --- /dev/null +++ b/packages/core/src/services/step-up/__tests__/step-up-api-service.test.ts @@ -0,0 +1,578 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import type { AuthDetails, MfaApiClient } from '../../../auth/auth-types'; +import { initializeStepUpApiService } from '../step-up-api-service'; + +describe('step-up-api-service', () => { + describe('initializeStepUpApiService', () => { + describe('SPA mode', () => { + it('should return contextInterface.mfa when authProxyUrl is not provided', () => { + const mockMfaClient: MfaApiClient = { + getAuthenticators: vi.fn(), + enroll: vi.fn(), + challenge: vi.fn(), + verify: vi.fn(), + getEnrollmentFactors: vi.fn(), + }; + + const auth: AuthDetails = { + contextInterface: { + mfa: mockMfaClient, + } as AuthDetails['contextInterface'], + }; + + const result = initializeStepUpApiService(auth); + + expect(result).toBe(mockMfaClient); + }); + + it('should throw error when contextInterface is not initialized', () => { + const auth: AuthDetails = {}; + + expect(() => initializeStepUpApiService(auth)).toThrow( + 'StepUpApiService: contextInterface is not initialized.', + ); + }); + + it('should throw error when contextInterface is undefined', () => { + const auth: AuthDetails = { + contextInterface: undefined, + }; + + expect(() => initializeStepUpApiService(auth)).toThrow( + 'StepUpApiService: contextInterface is not initialized.', + ); + }); + }); + + describe('Proxy mode', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + global.fetch = fetchSpy; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return proxy MFA client when authProxyUrl is provided', () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const result = initializeStepUpApiService(auth); + + expect(result).toBeDefined(); + expect(result.getAuthenticators).toBeDefined(); + expect(result.enroll).toBeDefined(); + expect(result.challenge).toBeDefined(); + expect(result.verify).toBeDefined(); + }); + + it('should remove trailing slash from authProxyUrl', () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com/', + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([]), + }); + + const result = initializeStepUpApiService(auth); + result.getAuthenticators('mfa_token_123'); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://proxy.example.com/auth/mfa/authenticators?mfa_token=mfa_token_123', + ); + }); + + describe('getAuthenticators', () => { + it('should fetch authenticators with mfa_token', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const mockAuthenticators = [ + { id: 'auth_1', type: 'otp' }, + { id: 'auth_2', type: 'oob' }, + ]; + + fetchSpy.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockAuthenticators), + }); + + const client = initializeStepUpApiService(auth); + const result = await client.getAuthenticators('mfa_token_123'); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://proxy.example.com/auth/mfa/authenticators?mfa_token=mfa_token_123', + ); + expect(result).toEqual(mockAuthenticators); + }); + + it('should throw error when response is not ok', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const errorBody = { + error: 'invalid_token', + error_description: 'Invalid MFA token', + }; + + fetchSpy.mockResolvedValue({ + ok: false, + status: 403, + json: vi.fn().mockResolvedValue(errorBody), + }); + + const client = initializeStepUpApiService(auth); + + await expect(client.getAuthenticators('invalid_token')).rejects.toThrow( + 'Invalid MFA token', + ); + }); + + it('should handle error when json parsing fails', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + fetchSpy.mockResolvedValue({ + ok: false, + status: 500, + json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), + }); + + const client = initializeStepUpApiService(auth); + + await expect(client.getAuthenticators('token')).rejects.toThrow('HTTP 500'); + }); + }); + + describe('enroll', () => { + it('should enroll OTP authenticator', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const mockResponse = { + authenticatorType: 'otp', + secret: 'secret_123', + barcodeUri: 'otpauth://totp/...', + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const client = initializeStepUpApiService(auth); + const result = await client.enroll({ + mfaToken: 'mfa_token_123', + factorType: 'otp', + }); + + expect(fetchSpy).toHaveBeenCalledWith('https://proxy.example.com/auth/mfa/enroll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mfaToken: 'mfa_token_123', + authenticatorTypes: ['otp'], + }), + }); + expect(result).toEqual(mockResponse); + }); + + it('should enroll SMS authenticator with phone number', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const mockResponse = { + authenticatorType: 'sms', + id: 'auth_123', + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const client = initializeStepUpApiService(auth); + const result = await client.enroll({ + mfaToken: 'mfa_token_123', + factorType: 'sms', + phoneNumber: '+1234567890', + }); + + expect(fetchSpy).toHaveBeenCalledWith('https://proxy.example.com/auth/mfa/enroll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mfaToken: 'mfa_token_123', + authenticatorTypes: ['sms'], + phoneNumber: '+1234567890', + }), + }); + expect(result).toEqual(mockResponse); + }); + + it('should enroll voice authenticator with phone number', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const mockResponse = { + authenticatorType: 'voice', + id: 'auth_123', + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const client = initializeStepUpApiService(auth); + const result = await client.enroll({ + mfaToken: 'mfa_token_123', + factorType: 'voice', + phoneNumber: '+1234567890', + }); + + expect(fetchSpy).toHaveBeenCalledWith('https://proxy.example.com/auth/mfa/enroll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mfaToken: 'mfa_token_123', + authenticatorTypes: ['voice'], + phoneNumber: '+1234567890', + }), + }); + expect(result).toEqual(mockResponse); + }); + + it('should enroll email authenticator with email', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const mockResponse = { + authenticatorType: 'email', + id: 'auth_123', + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const client = initializeStepUpApiService(auth); + const result = await client.enroll({ + mfaToken: 'mfa_token_123', + factorType: 'email', + email: 'user@example.com', + }); + + expect(fetchSpy).toHaveBeenCalledWith('https://proxy.example.com/auth/mfa/enroll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mfaToken: 'mfa_token_123', + authenticatorTypes: ['email'], + email: 'user@example.com', + }), + }); + expect(result).toEqual(mockResponse); + }); + + it('should enroll email authenticator without email field', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const mockResponse = { + authenticatorType: 'email', + id: 'auth_123', + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const client = initializeStepUpApiService(auth); + const result = await client.enroll({ + mfaToken: 'mfa_token_123', + factorType: 'email', + }); + + expect(fetchSpy).toHaveBeenCalledWith('https://proxy.example.com/auth/mfa/enroll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mfaToken: 'mfa_token_123', + authenticatorTypes: ['email'], + }), + }); + expect(result).toEqual(mockResponse); + }); + + it('should throw error when enrollment fails', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const errorBody = { + error: 'enrollment_failed', + error_description: 'Failed to enroll authenticator', + }; + + fetchSpy.mockResolvedValue({ + ok: false, + status: 400, + json: vi.fn().mockResolvedValue(errorBody), + }); + + const client = initializeStepUpApiService(auth); + + await expect( + client.enroll({ + mfaToken: 'mfa_token_123', + factorType: 'otp', + }), + ).rejects.toThrow('Failed to enroll authenticator'); + }); + }); + + describe('challenge', () => { + it('should challenge authenticator', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const mockResponse = { + challengeType: 'oob', + oobCode: 'oob_code_123', + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const client = initializeStepUpApiService(auth); + const result = await client.challenge({ + mfaToken: 'mfa_token_123', + challengeType: 'oob', + authenticatorId: 'auth_123', + }); + + expect(fetchSpy).toHaveBeenCalledWith('https://proxy.example.com/auth/mfa/challenge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mfaToken: 'mfa_token_123', + challengeType: 'oob', + authenticatorId: 'auth_123', + }), + }); + expect(result).toEqual(mockResponse); + }); + + it('should throw error when challenge fails', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const errorBody = { + error: 'challenge_failed', + error_description: 'Failed to challenge authenticator', + }; + + fetchSpy.mockResolvedValue({ + ok: false, + status: 400, + json: vi.fn().mockResolvedValue(errorBody), + }); + + const client = initializeStepUpApiService(auth); + + await expect( + client.challenge({ + mfaToken: 'mfa_token_123', + challengeType: 'oob', + authenticatorId: 'auth_123', + }), + ).rejects.toThrow('Failed to challenge authenticator'); + }); + }); + + describe('verify', () => { + it('should verify OTP code', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const mockResponse = { + access_token: 'access_token_123', + id_token: 'id_token_123', + expires_in: 3600, + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const client = initializeStepUpApiService(auth); + const result = await client.verify({ + mfaToken: 'mfa_token_123', + otp: '123456', + }); + + expect(fetchSpy).toHaveBeenCalledWith('https://proxy.example.com/auth/mfa/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mfaToken: 'mfa_token_123', + otp: '123456', + }), + }); + expect(result).toEqual(mockResponse); + }); + + it('should verify OOB code', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const mockResponse = { + access_token: 'access_token_123', + id_token: 'id_token_123', + expires_in: 3600, + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const client = initializeStepUpApiService(auth); + const result = await client.verify({ + mfaToken: 'mfa_token_123', + oobCode: 'oob_code_123', + bindingCode: 'binding_123', + }); + + expect(fetchSpy).toHaveBeenCalledWith('https://proxy.example.com/auth/mfa/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mfaToken: 'mfa_token_123', + oobCode: 'oob_code_123', + bindingCode: 'binding_123', + }), + }); + expect(result).toEqual(mockResponse); + }); + + it('should verify recovery code', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const mockResponse = { + access_token: 'access_token_123', + id_token: 'id_token_123', + expires_in: 3600, + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }); + + const client = initializeStepUpApiService(auth); + const result = await client.verify({ + mfaToken: 'mfa_token_123', + recoveryCode: 'recovery_123', + }); + + expect(fetchSpy).toHaveBeenCalledWith('https://proxy.example.com/auth/mfa/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mfaToken: 'mfa_token_123', + recoveryCode: 'recovery_123', + }), + }); + expect(result).toEqual(mockResponse); + }); + + it('should throw error when verification fails', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const errorBody = { + error: 'invalid_code', + error_description: 'Invalid OTP code', + }; + + fetchSpy.mockResolvedValue({ + ok: false, + status: 401, + json: vi.fn().mockResolvedValue(errorBody), + }); + + const client = initializeStepUpApiService(auth); + + await expect( + client.verify({ + mfaToken: 'mfa_token_123', + otp: '999999', + }), + ).rejects.toThrow('Invalid OTP code'); + }); + + it('should handle error with error properties spread', async () => { + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + + const errorBody = { + error: 'invalid_code', + error_description: 'Invalid code', + code: 'E001', + }; + + fetchSpy.mockResolvedValue({ + ok: false, + status: 401, + json: vi.fn().mockResolvedValue(errorBody), + }); + + const client = initializeStepUpApiService(auth); + + try { + await client.verify({ + mfaToken: 'mfa_token_123', + otp: '999999', + }); + } catch (error) { + expect(error).toHaveProperty('error', 'invalid_code'); + expect(error).toHaveProperty('error_description', 'Invalid code'); + expect(error).toHaveProperty('code', 'E001'); + expect(error).toHaveProperty('status', 401); + expect(error).toHaveProperty('body', errorBody); + } + }); + }); + }); + }); +}); diff --git a/packages/core/src/services/step-up/__tests__/step-up-utils.test.ts b/packages/core/src/services/step-up/__tests__/step-up-utils.test.ts new file mode 100644 index 000000000..413cb4621 --- /dev/null +++ b/packages/core/src/services/step-up/__tests__/step-up-utils.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; + +import { isMfaRequiredError } from '../step-up-utils'; + +describe('step-up-utils', () => { + describe('isMfaRequiredError', () => { + it('should return true for error with error="mfa_required" at root level', () => { + const error = { + error: 'mfa_required', + error_description: 'MFA is required', + mfa_token: 'token_123', + }; + + expect(isMfaRequiredError(error)).toBe(true); + }); + + it('should return true for error with code="mfa_required" at root level', () => { + const error = { + code: 'mfa_required', + error_description: 'MFA is required', + mfa_token: 'token_123', + }; + + expect(isMfaRequiredError(error)).toBe(true); + }); + + it('should return true for error with error="mfa_required" in body', () => { + const error = { + status: 403, + body: { + error: 'mfa_required', + error_description: 'MFA is required', + mfa_token: 'token_123', + }, + }; + + expect(isMfaRequiredError(error)).toBe(true); + }); + + it('should return true for error with code="mfa_required" in body', () => { + const error = { + status: 403, + body: { + code: 'mfa_required', + error_description: 'MFA is required', + mfa_token: 'token_123', + }, + }; + + expect(isMfaRequiredError(error)).toBe(true); + }); + + it('should return false for null', () => { + expect(isMfaRequiredError(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isMfaRequiredError(undefined)).toBe(false); + }); + + it('should return false for string', () => { + expect(isMfaRequiredError('mfa_required')).toBe(false); + }); + + it('should return false for number', () => { + expect(isMfaRequiredError(403)).toBe(false); + }); + + it('should return false for boolean', () => { + expect(isMfaRequiredError(true)).toBe(false); + }); + + it('should return false for empty object', () => { + expect(isMfaRequiredError({})).toBe(false); + }); + + it('should return false for object with different error', () => { + const error = { + error: 'access_denied', + error_description: 'Access denied', + }; + + expect(isMfaRequiredError(error)).toBe(false); + }); + + it('should return false for object with different code', () => { + const error = { + code: 'access_denied', + error_description: 'Access denied', + }; + + expect(isMfaRequiredError(error)).toBe(false); + }); + + it('should return false for object with body but different error', () => { + const error = { + status: 403, + body: { + error: 'access_denied', + error_description: 'Access denied', + }, + }; + + expect(isMfaRequiredError(error)).toBe(false); + }); + + it('should return false for object with body but different code', () => { + const error = { + status: 403, + body: { + code: 'access_denied', + error_description: 'Access denied', + }, + }; + + expect(isMfaRequiredError(error)).toBe(false); + }); + + it('should return false for object with body as null', () => { + const error = { + status: 403, + body: null, + }; + + expect(isMfaRequiredError(error)).toBe(false); + }); + + it('should return false for object with body as string', () => { + const error = { + status: 403, + body: 'Error message', + }; + + expect(isMfaRequiredError(error)).toBe(false); + }); + + it('should handle Error instance with mfa_required properties', () => { + const error = new Error('MFA required'); + Object.assign(error, { + error: 'mfa_required', + mfa_token: 'token_123', + }); + + expect(isMfaRequiredError(error)).toBe(true); + }); + + it('should handle Error instance with mfa_required in body', () => { + const error = new Error('MFA required'); + Object.assign(error, { + body: { + error: 'mfa_required', + mfa_token: 'token_123', + }, + }); + + expect(isMfaRequiredError(error)).toBe(true); + }); + + it('should return false for Error instance without mfa_required', () => { + const error = new Error('Some error'); + + expect(isMfaRequiredError(error)).toBe(false); + }); + + it('should return true for error with mfa_requirements', () => { + const error = { + error: 'mfa_required', + error_description: 'MFA is required', + mfa_token: 'token_123', + mfa_requirements: { + enroll: [{ type: 'otp' }], + challenge: [{ type: 'oob' }], + }, + }; + + expect(isMfaRequiredError(error)).toBe(true); + }); + + it('should return true for error with only enroll in mfa_requirements', () => { + const error = { + error: 'mfa_required', + error_description: 'MFA is required', + mfa_token: 'token_123', + mfa_requirements: { + enroll: [{ type: 'otp' }], + }, + }; + + expect(isMfaRequiredError(error)).toBe(true); + }); + + it('should return true for error with only challenge in mfa_requirements', () => { + const error = { + error: 'mfa_required', + error_description: 'MFA is required', + mfa_token: 'token_123', + mfa_requirements: { + challenge: [{ type: 'oob' }], + }, + }; + + expect(isMfaRequiredError(error)).toBe(true); + }); + }); +}); diff --git a/packages/core/src/services/step-up/index.ts b/packages/core/src/services/step-up/index.ts index a281c63bd..91c7bb79e 100644 --- a/packages/core/src/services/step-up/index.ts +++ b/packages/core/src/services/step-up/index.ts @@ -1,2 +1,3 @@ +export * from './step-up-api-service'; export * from './step-up-types'; export * from './step-up-utils'; diff --git a/packages/core/src/services/step-up/step-up-api-service.ts b/packages/core/src/services/step-up/step-up-api-service.ts new file mode 100644 index 000000000..1ae750095 --- /dev/null +++ b/packages/core/src/services/step-up/step-up-api-service.ts @@ -0,0 +1,103 @@ +import type { + AuthDetails, + Authenticator, + ChallengeAuthenticatorParams, + ChallengeResponse, + EnrollmentResponse, + EnrollParams, + MfaApiClient, + TokenEndpointResponse, + VerifyParams, +} from '../../auth/auth-types'; + +/** + * Step-Up Authentication API Service + * + * Provides MFA operations for both SPA and proxy modes: + * - SPA mode: Returns Auth0 SDK's MFA client directly + * - Proxy mode: Creates proxy-based MFA client + */ +export type StepUpApiService = MfaApiClient; + +/** + * Initializes a Step-Up API service instance based on auth configuration + */ +export function initializeStepUpApiService(auth: AuthDetails): StepUpApiService { + if (auth.authProxyUrl) { + return createProxyMfaClient(auth.authProxyUrl) as StepUpApiService; + } + + if (!auth.contextInterface) { + throw new Error('StepUpApiService: contextInterface is not initialized.'); + } + + return auth.contextInterface.mfa; +} + +/** + * Creates an MFA client for proxy mode + */ +function createProxyMfaClient(authProxyUrl: string): Omit { + const baseUrl = authProxyUrl.replace(/\/$/, ''); + + const handleResponse = async (response: Response): Promise => { + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + throw Object.assign(new Error(errorBody.error_description || `HTTP ${response.status}`), { + status: response.status, + body: errorBody, + ...errorBody, + }); + } + return response.json(); + }; + + return { + getAuthenticators: async (mfaToken: string) => { + const response = await fetch(`${baseUrl}/auth/mfa/authenticators?mfa_token=${mfaToken}`); + return handleResponse(response); + }, + + enroll: async (params: EnrollParams) => { + const body: Record = { + mfaToken: params.mfaToken, + authenticatorTypes: [params.factorType], + }; + + if (params.factorType === 'sms' || params.factorType === 'voice') { + body.phoneNumber = params.phoneNumber; + } else if (params.factorType === 'email' && 'email' in params && params.email) { + body.email = params.email; + } + + const response = await fetch(`${baseUrl}/auth/mfa/enroll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return handleResponse(response); + }, + + challenge: async (params: ChallengeAuthenticatorParams) => { + const response = await fetch(`${baseUrl}/auth/mfa/challenge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mfaToken: params.mfaToken, + challengeType: params.challengeType, + authenticatorId: params.authenticatorId, + }), + }); + return handleResponse(response); + }, + + verify: async (params: VerifyParams) => { + const response = await fetch(`${baseUrl}/auth/mfa/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + return handleResponse(response); + }, + }; +} diff --git a/packages/core/src/services/step-up/step-up-utils.ts b/packages/core/src/services/step-up/step-up-utils.ts index a3cc2c1cc..9cc9d1410 100644 --- a/packages/core/src/services/step-up/step-up-utils.ts +++ b/packages/core/src/services/step-up/step-up-utils.ts @@ -7,5 +7,17 @@ export function isMfaRequiredError(error: unknown): error is MfaRequiredError { if (typeof error !== 'object' || error === null) return false; const err = error as Record; - return err.error === 'mfa_required' || err.code === 'mfa_required'; + + // Check if error properties are at the root level + if (err.error === 'mfa_required' || err.code === 'mfa_required') { + return true; + } + + // Check if error properties are nested in a body property (API error structure) + if (err.body && typeof err.body === 'object' && err.body !== null) { + const body = err.body as Record; + return body.error === 'mfa_required' || body.code === 'mfa_required'; + } + + return false; } diff --git a/packages/react/package.json b/packages/react/package.json index df4436f6c..52e73caab 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -80,7 +80,7 @@ "author": "Auth0", "license": "MIT", "devDependencies": { - "@auth0/auth0-react": "^2.12.0", + "@auth0/auth0-react": "^2.15.0", "@tailwindcss/cli": "^4.1.17", "@tailwindcss/postcss": "^4.1.10", "@testing-library/jest-dom": "^6.6.3", diff --git a/packages/react/src/components/auth0/my-account/user-mfa-management.tsx b/packages/react/src/components/auth0/my-account/user-mfa-management.tsx index de59dc6ab..39cf197c3 100644 --- a/packages/react/src/components/auth0/my-account/user-mfa-management.tsx +++ b/packages/react/src/components/auth0/my-account/user-mfa-management.tsx @@ -3,7 +3,6 @@ import { FACTOR_TYPE_PUSH_NOTIFICATION, type MFAType, getComponentStyles, - USER_MFA_SCOPES, } from '@auth0/universal-components-core'; import * as React from 'react'; import { toast } from 'sonner'; @@ -18,7 +17,6 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'; import { List, ListItem } from '@/components/ui/list'; import { Spinner } from '@/components/ui/spinner'; -import { withMyAccountService } from '@/hoc/with-services'; import { useMFA } from '@/hooks/my-account/use-mfa'; import { useTheme } from '@/hooks/shared/use-theme'; import { useTranslator } from '@/hooks/shared/use-translator'; @@ -36,7 +34,7 @@ import type { UserMFAMgmtProps } from '@/types/my-account/mfa/mfa-types'; * - **ProxyMode (RWA)**: In this mode, the component interacts with a proxy service to manage MFA * - **SPA (Single Page Application)**: In this mode, the component communicates directly with the API to manage MFA factors. */ -function UserMFAMgmtComponent({ +export function UserMFAMgmt({ customMessages = {}, styling = { variables: { @@ -417,5 +415,3 @@ function UserMFAMgmtComponent({
  • ); } - -export const UserMFAMgmt = withMyAccountService(UserMFAMgmtComponent, USER_MFA_SCOPES); diff --git a/packages/react/src/components/auth0/my-organization/domain-table.tsx b/packages/react/src/components/auth0/my-organization/domain-table.tsx index d2466ef55..350303a08 100644 --- a/packages/react/src/components/auth0/my-organization/domain-table.tsx +++ b/packages/react/src/components/auth0/my-organization/domain-table.tsx @@ -1,8 +1,4 @@ -import { - type Domain, - getComponentStyles, - MY_ORGANIZATION_DOMAIN_SCOPES, -} from '@auth0/universal-components-core'; +import { type Domain, getComponentStyles } from '@auth0/universal-components-core'; import { Plus } from 'lucide-react'; import * as React from 'react'; @@ -12,11 +8,10 @@ import { DomainDeleteModal } from '@/components/auth0/my-organization/shared/dom import { DomainTableActionsColumn } from '@/components/auth0/my-organization/shared/domain-management/domain-table/domain-table-actions-column'; import { DomainVerifyModal } from '@/components/auth0/my-organization/shared/domain-management/domain-verify/domain-verify-modal'; import { DataTable, type Column } from '@/components/auth0/shared/data-table'; +import { GateKeeper } from '@/components/auth0/shared/gatekeeper'; import { Header } from '@/components/auth0/shared/header'; import { Badge } from '@/components/ui/badge'; -import { withMyOrganizationService } from '@/hoc/with-services'; import { useDomainTable } from '@/hooks/my-organization/use-domain-table'; -import { useDomainTableLogic } from '@/hooks/my-organization/use-domain-table-logic'; import { useTheme } from '@/hooks/shared/use-theme'; import { useTranslator } from '@/hooks/shared/use-translator'; import { getStatusBadgeVariant } from '@/lib/utils/my-organization/domain-management/domain-management-utils'; @@ -25,7 +20,7 @@ import type { DomainTableProps } from '@/types/my-organization/domain-management /** * DomainTable Component */ -function DomainTableComponent({ +export function DomainTable({ customMessages = {}, schema, styling = { @@ -48,55 +43,35 @@ function DomainTableComponent({ const { domains, providers, + error, + retry, isFetching, isCreating, isVerifying, isDeleting, isLoadingProviders, - fetchProviders, - fetchDomains, - onCreateDomain, - onVerifyDomain, - onDeleteDomain, - onAssociateToProvider, - onDeleteFromProvider, - } = useDomainTable({ - createAction, - verifyAction, - deleteAction, - associateToProviderAction, - deleteFromProviderAction, - customMessages, - }); - - const { showCreateModal, showConfigureModal, showVerifyModal, showDeleteModal, verifyError, selectedDomain, - setShowCreateModal, - setShowConfigureModal, - setShowDeleteModal, + closeModal, handleCreate, handleVerify, handleDelete, handleToggleSwitch, - handleCloseVerifyModal, handleCreateClick, handleConfigureClick, handleVerifyClick, handleDeleteClick, - } = useDomainTableLogic({ - t, - onCreateDomain, - onVerifyDomain, - onDeleteDomain, - onAssociateToProvider, - onDeleteFromProvider, - fetchProviders, - fetchDomains, + } = useDomainTable({ + createAction, + verifyAction, + deleteAction, + associateToProviderAction, + deleteFromProviderAction, + customMessages, }); const currentStyles = React.useMemo( @@ -145,83 +120,80 @@ function DomainTableComponent({ ); return ( -
    - {!hideHeader && ( -
    -
    handleCreateClick(), - icon: Plus, - disabled: createAction?.disabled || readOnly || isFetching, - }, - ]} - /> -
    - )} + +
    + {!hideHeader && ( +
    +
    handleCreateClick(), + icon: Plus, + disabled: createAction?.disabled || readOnly || isFetching, + }, + ]} + /> +
    + )} - + - setShowCreateModal(false)} - onCreate={handleCreate} - customMessages={customMessages.create} - /> + - setShowConfigureModal(false)} - onToggleSwitch={handleToggleSwitch} - onOpenProvider={onOpenProvider} - onCreateProvider={onCreateProvider} - customMessages={customMessages.configure} - /> + - + - setShowDeleteModal(false)} - onDelete={handleDelete} - customMessages={customMessages.delete} - /> -
    + +
    + ); } - -export const DomainTable = withMyOrganizationService( - DomainTableComponent, - MY_ORGANIZATION_DOMAIN_SCOPES, -); diff --git a/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx b/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx index 574d2ddad..158109485 100644 --- a/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx +++ b/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx @@ -1,13 +1,9 @@ -import { - getComponentStyles, - MY_ORGANIZATION_DETAILS_EDIT_SCOPES, -} from '@auth0/universal-components-core'; +import { getComponentStyles } from '@auth0/universal-components-core'; import * as React from 'react'; import { OrganizationDetails } from '@/components/auth0/my-organization/shared/organization-management/organization-details/organization-details'; +import { GateKeeper } from '@/components/auth0/shared/gatekeeper'; import { Header } from '@/components/auth0/shared/header'; -import { Spinner } from '@/components/ui/spinner'; -import { withMyOrganizationService } from '@/hoc/with-services'; import { useOrganizationDetailsEdit } from '@/hooks/my-organization/use-organization-details-edit'; import { useTheme } from '@/hooks/shared/use-theme'; import { useTranslator } from '@/hooks/shared/use-translator'; @@ -20,7 +16,7 @@ import type { OrganizationDetailsEditProps } from '@/types/my-organization/organ * editing and deletion capabilities in a single interface. This component provides * a complete editing experience with form validation, lifecycle hooks, and user feedback. */ -function OrganizationDetailsEditComponent({ +export function OrganizationDetailsEdit({ schema, customMessages = {}, styling = { @@ -38,7 +34,9 @@ function OrganizationDetailsEditComponent({ const { organization, - isFetchLoading, + error, + retry, + isLoading, formActions: enhancedFormActions, } = useOrganizationDetailsEdit({ saveAction, @@ -52,50 +50,36 @@ function OrganizationDetailsEditComponent({ [styling, isDarkMode], ); - if (isFetchLoading) { - return ( -
    - -
    - ); - } - return ( -
    - {!hideHeader && ( -
    -
    +
    + {!hideHeader && ( +
    +
    +
    + )} + +
    +
    - )} - -
    -
    -
    + ); } - -export const OrganizationDetailsEdit = withMyOrganizationService( - OrganizationDetailsEditComponent, - MY_ORGANIZATION_DETAILS_EDIT_SCOPES, -); diff --git a/packages/react/src/components/auth0/my-organization/shared/idp-management/sso-provider-edit/sso-provisioning/__tests__/sso-provisioning-tab.test.tsx b/packages/react/src/components/auth0/my-organization/shared/idp-management/sso-provider-edit/sso-provisioning/__tests__/sso-provisioning-tab.test.tsx index b6c381d87..34552c713 100644 --- a/packages/react/src/components/auth0/my-organization/shared/idp-management/sso-provider-edit/sso-provisioning/__tests__/sso-provisioning-tab.test.tsx +++ b/packages/react/src/components/auth0/my-organization/shared/idp-management/sso-provider-edit/sso-provisioning/__tests__/sso-provisioning-tab.test.tsx @@ -12,18 +12,31 @@ import { renderWithProviders, mockOnCreateScimToken, mockOnDeleteScimToken, - mockFetchProvisioning, } from '@/tests/utils'; const createMockSsoProviderEditReturn = (overrides = {}) => ({ + provider: mockProvider, + organization: null, provisioningConfig: null, - isProvisioningLoading: false, + isLoading: false, + isUpdating: false, + isDeleting: false, + isRemoving: false, isProvisioningUpdating: false, isProvisioningDeleting: false, + isProvisioningLoading: false, isScimTokensLoading: false, isScimTokenCreating: false, isScimTokenDeleting: false, - fetchProvisioning: mockFetchProvisioning, + isSsoAttributesSyncing: false, + isProvisioningAttributesSyncing: false, + error: null, + retry: vi.fn(), + fetchProvider: vi.fn(), + fetchProvisioning: vi.fn(), + updateProvider: vi.fn(), + deleteProvider: vi.fn(), + detachProvider: vi.fn(), createProvisioning: mockOnCreateProvisioning, deleteProvisioning: mockOnDeleteProvisioning, listScimTokens: mockOnListScimTokens, diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-create.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-create.tsx index 0690fcf68..d63284f14 100644 --- a/packages/react/src/components/auth0/my-organization/sso-provider-create.tsx +++ b/packages/react/src/components/auth0/my-organization/sso-provider-create.tsx @@ -3,7 +3,6 @@ import { type IdpStrategy, type ProviderDetailsFormValues, type ProviderConfigureFormValues, - MY_ORGANIZATION_SSO_PROVIDER_CREATE_SCOPES, } from '@auth0/universal-components-core'; import React, { useState, useRef, useCallback, useMemo } from 'react'; @@ -15,10 +14,10 @@ import { type ProviderDetailsFormHandle, } from '@/components/auth0/my-organization/shared/idp-management/sso-provider-create/provider-details'; import { ProviderSelect } from '@/components/auth0/my-organization/shared/idp-management/sso-provider-create/provider-select'; +import { GateKeeper } from '@/components/auth0/shared/gatekeeper'; import { Header } from '@/components/auth0/shared/header'; import { Wizard } from '@/components/auth0/shared/wizard'; import type { StepProps } from '@/components/auth0/shared/wizard'; -import { withMyOrganizationService } from '@/hoc/with-services'; import { useConfig } from '@/hooks/my-organization/use-config'; import { useIdpConfig } from '@/hooks/my-organization/use-idp-config'; import { useSsoProviderCreate } from '@/hooks/my-organization/use-sso-provider-create'; @@ -49,12 +48,27 @@ export function SsoProviderCreateComponent({ const [formData, setFormData] = useState({}); const { strategy, details, configure } = formData; - const { createProvider, isCreating } = useSsoProviderCreate({ + const { + createProvider, + isCreating, + error: createError, + retry: retryCreate, + } = useSsoProviderCreate({ createAction, customMessages, }); - const { isLoadingConfig, filteredStrategies } = useConfig(); - const { isLoadingIdpConfig, idpConfig } = useIdpConfig(); + const { + isLoadingConfig, + filteredStrategies, + error: configError, + retry: retryConfig, + } = useConfig(); + const { + isLoadingIdpConfig, + idpConfig, + error: idpConfigError, + retry: retryIdpConfig, + } = useIdpConfig(); const detailsRef = useRef(null); const configureRef = useRef(null); @@ -64,6 +78,18 @@ export function SsoProviderCreateComponent({ [styling, isDarkMode], ); + const error = configError || idpConfigError || createError; + + const retry = useCallback(async () => { + if (configError) { + await retryConfig(); + } else if (idpConfigError) { + await retryIdpConfig(); + } else if (createError) { + await retryCreate(); + } + }, [configError, idpConfigError, createError, retryConfig, retryIdpConfig, retryCreate]); + const createStepActions = useCallback( ( stepId: 'provider_details' | 'provider_configure', @@ -181,36 +207,35 @@ export function SsoProviderCreateComponent({ ); return ( -
    -
    +
    +
    -
    - +
    + +
    -
    + ); } -export const SsoProviderCreate = withMyOrganizationService( - SsoProviderCreateComponent, - MY_ORGANIZATION_SSO_PROVIDER_CREATE_SCOPES, -); +export const SsoProviderCreate = SsoProviderCreateComponent; diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx index 4c4a28345..347aeadc9 100644 --- a/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx +++ b/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx @@ -1,18 +1,14 @@ 'use client'; -import { - getComponentStyles, - MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES, -} from '@auth0/universal-components-core'; +import { getComponentStyles } from '@auth0/universal-components-core'; import React, { useState } from 'react'; import { SsoDomainTab } from '@/components/auth0/my-organization/shared/idp-management/sso-provider-edit/sso-domain-tab'; import { SsoProviderTab } from '@/components/auth0/my-organization/shared/idp-management/sso-provider-edit/sso-provider-tab'; import { SsoProvisioningTab } from '@/components/auth0/my-organization/shared/idp-management/sso-provider-edit/sso-provisioning/sso-provisioning-tab'; +import { GateKeeper } from '@/components/auth0/shared/gatekeeper'; import { Header } from '@/components/auth0/shared/header'; -import { Spinner } from '@/components/ui/spinner'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { withMyOrganizationService } from '@/hoc/with-services'; import { useConfig } from '@/hooks/my-organization/use-config'; import { useIdpConfig } from '@/hooks/my-organization/use-idp-config'; import { useSsoProviderEdit } from '@/hooks/my-organization/use-sso-provider-edit'; @@ -55,6 +51,8 @@ export function SsoProviderEditComponent({ isProvisioningAttributesSyncing, hasSsoAttributeSyncWarning, hasProvisioningAttributeSyncWarning, + error: editError, + retry: editRetry, updateProvider, createProvisioning: createProvisioningAction, deleteProvisioning: deleteProvisioningAction, @@ -71,9 +69,27 @@ export function SsoProviderEditComponent({ domains, customMessages, }); - const { shouldAllowDeletion, isLoadingConfig } = useConfig(); - const { idpConfig, isLoadingIdpConfig, isProvisioningEnabled, isProvisioningMethodEnabled } = - useIdpConfig(); + const { + shouldAllowDeletion, + isLoadingConfig, + error: configError, + retry: configRetry, + } = useConfig(); + const { + idpConfig, + isLoadingIdpConfig, + isProvisioningEnabled, + isProvisioningMethodEnabled, + error: idpConfigError, + retry: idpConfigRetry, + } = useIdpConfig(); + + const error = editError || configError || idpConfigError; + const retry = async () => { + if (editError) await editRetry(); + else if (configError) await configRetry(); + else if (idpConfigError) await idpConfigRetry(); + }; const showProvisioningTab = isProvisioningEnabled(provider?.strategy) && isProvisioningMethodEnabled(provider?.strategy); @@ -94,130 +110,125 @@ export function SsoProviderEditComponent({ }); }; - if (isLoading || isLoadingConfig || isLoadingIdpConfig) { - return ( -
    - -
    - ); - } - return ( -
    - {!hideHeader && ( -
    +
    + {!hideHeader && ( +
    - )} - - - + )} + + - - {t('tabs.sso.name')} - - {showProvisioningTab && ( - - {t('tabs.provisioning.name')} + + + {t('tabs.sso.name')} - )} - - {t('tabs.domains.name')} - - - - - - - - {showProvisioningTab && ( - - + {t('tabs.provisioning.name')} + + )} + + {t('tabs.domains.name')} + + + + + - )} - - - - -
    + {showProvisioningTab && ( + + + + )} + + + + + +
    + ); } -export const SsoProviderEdit = withMyOrganizationService( - SsoProviderEditComponent, - MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES, -); +export const SsoProviderEdit = SsoProviderEditComponent; diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx index 23d1ea5b3..bc85496fe 100644 --- a/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx +++ b/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx @@ -2,7 +2,6 @@ import { getComponentStyles, type IdentityProvider, STRATEGY_DISPLAY_NAMES, - MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES, } from '@auth0/universal-components-core'; import { Plus } from 'lucide-react'; import * as React from 'react'; @@ -11,8 +10,8 @@ import { SsoProviderDeleteModal } from '@/components/auth0/my-organization/share import { SsoProviderRemoveFromOrganizationModal } from '@/components/auth0/my-organization/shared/idp-management/sso-provider-remove/provider-remove-modal'; import { SsoProviderTableActionsColumn } from '@/components/auth0/my-organization/shared/idp-management/sso-provider-table/sso-provider-table-action'; import { DataTable, type Column } from '@/components/auth0/shared/data-table'; +import { GateKeeper } from '@/components/auth0/shared/gatekeeper'; import { Header } from '@/components/auth0/shared/header'; -import { withMyOrganizationService } from '@/hoc/with-services'; import { useConfig } from '@/hooks/my-organization/use-config'; import { useIdpConfig } from '@/hooks/my-organization/use-idp-config'; import { useSsoProviderTable } from '@/hooks/my-organization/use-sso-provider-table'; @@ -49,22 +48,42 @@ function SsoProviderTableComponent({ onDeleteConfirm, onRemoveConfirm, onEnableProvider, - organization, + getOrganizationName, + error: tableError, + retry: retryTable, } = useSsoProviderTable( deleteAction, deleteFromOrganizationAction, enableProviderAction, customMessages, ); - const { isLoadingConfig, shouldAllowDeletion, isConfigValid } = useConfig(); - const { isLoadingIdpConfig, isIdpConfigValid } = useIdpConfig(); + const { + isLoadingConfig, + shouldAllowDeletion, + isConfigValid, + error: configError, + retry: retryConfig, + } = useConfig(); + const { + isLoadingIdpConfig, + isIdpConfigValid, + error: idpConfigError, + retry: retryIdpConfig, + } = useIdpConfig(); const shouldHideCreate = !isConfigValid || !isIdpConfigValid; const isViewLoading = isLoading || isLoadingConfig || isLoadingIdpConfig; + const error = tableError || configError || idpConfigError; + + const retry = React.useCallback(async () => { + await Promise.all([retryTable(), retryConfig(), retryIdpConfig()]); + }, [retryTable, retryConfig, retryIdpConfig]); + const [showDeleteModal, setShowDeleteModal] = React.useState(false); const [showRemoveModal, setShowRemoveModal] = React.useState(false); const [selectedIdp, setSelectedIdp] = React.useState(null); + const [organizationName, setOrganizationName] = React.useState(); const currentStyles = React.useMemo( () => getComponentStyles(styling, isDarkMode), @@ -101,7 +120,7 @@ function SsoProviderTableComponent({ ); const handleDeleteFromOrganization = React.useCallback( - (idp: IdentityProvider) => { + async (idp: IdentityProvider) => { setSelectedIdp(idp); if (deleteFromOrganizationAction?.onBefore) { @@ -109,9 +128,11 @@ function SsoProviderTableComponent({ if (!shouldProceed) return; } + const orgName = await getOrganizationName(); + setOrganizationName(orgName); setShowRemoveModal(true); }, - [deleteFromOrganizationAction], + [deleteFromOrganizationAction, getOrganizationName], ); const handleToggleEnabled = React.useCallback( @@ -200,63 +221,62 @@ function SsoProviderTableComponent({ ); return ( -
    -
    -
    handleCreate(), - icon: Plus, - hidden: shouldHideCreate || isViewLoading, - disabled: createAction?.disabled || readOnly, - }, - ]} - /> -
    - - + +
    +
    +
    handleCreate(), + icon: Plus, + hidden: shouldHideCreate || isViewLoading, + disabled: createAction?.disabled || readOnly, + }, + ]} + /> +
    - {selectedIdp && ( - setShowDeleteModal(false)} - provider={selectedIdp} - onDelete={handleDeleteConfirm} - isLoading={isDeleting} - customMessages={customMessages.delete_modal} + - )} - {selectedIdp && ( - setShowRemoveModal(false)} - provider={selectedIdp} - organizationName={organization?.name} - onRemove={handleRemoveConfirm} - isLoading={isRemoving} - customMessages={customMessages.remove_modal} - /> - )} -
    + {selectedIdp && ( + setShowDeleteModal(false)} + provider={selectedIdp} + onDelete={handleDeleteConfirm} + isLoading={isDeleting} + customMessages={customMessages.delete_modal} + /> + )} + + {selectedIdp && ( + setShowRemoveModal(false)} + provider={selectedIdp} + organizationName={organizationName} + onRemove={handleRemoveConfirm} + isLoading={isRemoving} + customMessages={customMessages.remove_modal} + /> + )} +
    + ); } -export const SsoProviderTable = withMyOrganizationService( - SsoProviderTableComponent, - MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES, -); +export const SsoProviderTable = SsoProviderTableComponent; diff --git a/packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx b/packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx new file mode 100644 index 000000000..29e7669e3 --- /dev/null +++ b/packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx @@ -0,0 +1,366 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { GateKeeper } from '@/components/auth0/shared/gatekeeper'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import * as useTranslatorModule from '@/hooks/shared/use-translator'; +import { + createMockCoreClient, + setupMockUseCoreClient, + setupMockUseTranslator, +} from '@/tests/utils'; +import { createTestQueryClientWrapper } from '@/tests/utils/test-provider'; + +describe('GateKeeper', () => { + const mockOnRetry = vi.fn(async () => {}); + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockCoreClient = createMockCoreClient(); + + setupMockUseCoreClient(mockCoreClient, useCoreClientModule); + setupMockUseTranslator(useTranslatorModule); + }); + + const renderGateKeeper = (props: { + isLoading?: boolean; + error?: unknown; + onRetry?: () => Promise; + }) => { + const { wrapper } = createTestQueryClientWrapper(); + return render( + +
    Children Content
    +
    , + { wrapper }, + ); + }; + + describe('Loading State', () => { + it('should show spinner when loading', () => { + renderGateKeeper({ isLoading: true }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(screen.queryByTestId('children')).not.toBeInTheDocument(); + }); + }); + + describe('Success State', () => { + it('should render children when no error', () => { + renderGateKeeper({ error: null }); + + expect(screen.getByTestId('children')).toBeInTheDocument(); + expect(screen.getByText('Children Content')).toBeInTheDocument(); + }); + }); + + describe('500+ Error Handling', () => { + it('should show error fallback for 500 error', () => { + const error = { body: { status: 500 } }; + renderGateKeeper({ error }); + + expect(screen.getByText('fallback.title')).toBeInTheDocument(); + expect(screen.getByText('fallback.description')).toBeInTheDocument(); + expect(screen.getByText('fallback.retry')).toBeInTheDocument(); + expect(screen.queryByTestId('children')).not.toBeInTheDocument(); + }); + + it('should show error fallback for 503 error', () => { + const error = { body: { status: 503 } }; + renderGateKeeper({ error }); + + expect(screen.getByText('fallback.title')).toBeInTheDocument(); + expect(screen.queryByTestId('children')).not.toBeInTheDocument(); + }); + + it('should call onRetry when retry button is clicked', async () => { + const user = userEvent.setup(); + const error = { body: { status: 500 } }; + const onRetry = vi.fn(async (): Promise => {}); + + renderGateKeeper({ error, onRetry }); + + const retryButton = screen.getByRole('button', { name: /fallback.retry/i }); + await user.click(retryButton); + + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('should show spinner in button during retry', async () => { + const user = userEvent.setup(); + const error = { body: { status: 500 } }; + const onRetry = vi.fn( + async (): Promise => new Promise((resolve) => setTimeout(resolve, 100)), + ); + + renderGateKeeper({ error, onRetry }); + + const retryButton = screen.getByRole('button', { name: /fallback.retry/i }); + + await user.click(retryButton); + + await waitFor(() => { + expect(retryButton).toBeDisabled(); + }); + + await waitFor(() => { + expect(retryButton).not.toBeDisabled(); + }); + + expect(onRetry).toHaveBeenCalledTimes(1); + }); + }); + + describe('MFA Error Handling', () => { + const mfaError = { + body: { + error: 'mfa_required', + mfa_token: 'test-mfa-token', + }, + mfa_token: 'test-mfa-token', + }; + + it('should show MFA dialog for MFA error in proxy mode', async () => { + mockCoreClient.isProxyMode = vi.fn().mockReturnValue(true); + mockCoreClient.getStepUpApiService().getAuthenticators = vi.fn().mockResolvedValue([ + { + id: 'auth-1', + authenticatorType: 'otp', + name: 'Google Authenticator', + active: true, + }, + ]); + + renderGateKeeper({ error: mfaError }); + + await waitFor(() => { + expect(screen.getByText('error.mfa.title')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Google Authenticator')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('children')).not.toBeInTheDocument(); + }); + + it('should show enrollment list in SPA mode when enrollment needed', async () => { + mockCoreClient.isProxyMode = vi.fn().mockReturnValue(false); + mockCoreClient.getStepUpApiService().getEnrollmentFactors = vi.fn().mockResolvedValue([ + { type: 'otp', name: 'OTP' }, + { type: 'sms', name: 'SMS' }, + ]); + + renderGateKeeper({ error: mfaError }); + + await waitFor(() => { + expect(screen.getByText('error.mfa.enrollment_required')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('otp')).toBeInTheDocument(); + expect(screen.getByText('sms')).toBeInTheDocument(); + }); + }); + + it('should show authenticators in SPA mode when no enrollment needed', async () => { + mockCoreClient.isProxyMode = vi.fn().mockReturnValue(false); + mockCoreClient.getStepUpApiService().getEnrollmentFactors = vi.fn().mockResolvedValue([]); + mockCoreClient.getStepUpApiService().getAuthenticators = vi.fn().mockResolvedValue([ + { + id: 'auth-1', + authenticatorType: 'sms', + name: 'SMS Auth', + active: true, + }, + ]); + + renderGateKeeper({ error: mfaError }); + + await waitFor(() => { + expect(screen.getByText('SMS Auth')).toBeInTheDocument(); + }); + }); + + it('should show loading state while fetching MFA data', () => { + mockCoreClient.isProxyMode = vi.fn().mockReturnValue(true); + mockCoreClient.getStepUpApiService().getAuthenticators = vi + .fn() + .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve([]), 1000))); + + renderGateKeeper({ error: mfaError }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('should show error state when fetching MFA data fails', async () => { + mockCoreClient.isProxyMode = vi.fn().mockReturnValue(true); + mockCoreClient.getStepUpApiService().getAuthenticators = vi + .fn() + .mockRejectedValue(new Error('Failed to fetch')); + + renderGateKeeper({ error: mfaError }); + + await waitFor(() => { + expect(screen.getByText('error.mfa.fetch_failed')).toBeInTheDocument(); + }); + }); + + it('should show empty state when no authenticators', async () => { + mockCoreClient.isProxyMode = vi.fn().mockReturnValue(true); + mockCoreClient.getStepUpApiService().getAuthenticators = vi.fn().mockResolvedValue([]); + + renderGateKeeper({ error: mfaError }); + + await waitFor(() => { + expect(screen.getByText('error.mfa.no_authenticators')).toBeInTheDocument(); + }); + }); + + it('should show error fallback when MFA dialog is closed', async () => { + const user = userEvent.setup(); + mockCoreClient.isProxyMode = vi.fn().mockReturnValue(true); + mockCoreClient.getStepUpApiService().getAuthenticators = vi.fn().mockResolvedValue([ + { + id: 'auth-1', + authenticatorType: 'otp', + name: 'OTP Auth', + active: true, + }, + ]); + + renderGateKeeper({ error: mfaError }); + + await waitFor(() => { + expect(screen.getByText('error.mfa.title')).toBeInTheDocument(); + }); + + // Close the dialog + const closeButton = screen.getByRole('button', { name: /close/i }); + await user.click(closeButton); + + await waitFor(() => { + expect(screen.getByText('fallback.title')).toBeInTheDocument(); + }); + }); + + it('should extract mfa_token from error.body if not at root level', async () => { + const errorWithNestedToken = { + body: { + error: 'mfa_required', + mfa_token: 'nested-token', + }, + }; + + mockCoreClient.isProxyMode = vi.fn().mockReturnValue(true); + mockCoreClient.getStepUpApiService().getAuthenticators = vi.fn().mockResolvedValue([]); + + renderGateKeeper({ error: errorWithNestedToken }); + + await waitFor(() => { + expect(mockCoreClient.getStepUpApiService().getAuthenticators).toHaveBeenCalledWith( + 'nested-token', + ); + }); + }); + + it('should show authenticator details with type and active status', async () => { + mockCoreClient.isProxyMode = vi.fn().mockReturnValue(true); + mockCoreClient.getStepUpApiService().getAuthenticators = vi.fn().mockResolvedValue([ + { + id: 'auth-1', + authenticatorType: 'otp', + name: 'Test Authenticator', + active: true, + }, + { + id: 'auth-2', + authenticatorType: 'webauthn-roaming', + name: null, + active: false, + }, + ]); + + renderGateKeeper({ error: mfaError }); + + await waitFor(() => { + expect(screen.getByText('Test Authenticator')).toBeInTheDocument(); + expect(screen.getByText(/Type: otp/)).toBeInTheDocument(); + expect(screen.getByText(/Active: Yes/)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('webauthn-roaming')).toBeInTheDocument(); + expect(screen.getByText(/Active: No/)).toBeInTheDocument(); + }); + }); + }); + + describe('Non-500 Errors', () => { + it('should render children for 400 errors', () => { + const error = { body: { status: 400 } }; + renderGateKeeper({ error }); + + expect(screen.getByTestId('children')).toBeInTheDocument(); + }); + + it('should render children for 404 errors', () => { + const error = { body: { status: 404 } }; + renderGateKeeper({ error }); + + expect(screen.getByTestId('children')).toBeInTheDocument(); + }); + + it('should render children for errors without status code', () => { + const error = new Error('Generic error'); + renderGateKeeper({ error }); + + expect(screen.getByTestId('children')).toBeInTheDocument(); + }); + }); + + describe('Edge Cases', () => { + it('should handle MFA error without mfa_token', async () => { + const mfaErrorNoToken = { + body: { + error: 'mfa_required', + }, + }; + + renderGateKeeper({ error: mfaErrorNoToken }); + + // Should show dialog but with empty state since we don't have a token + await waitFor(() => { + expect(screen.getByText('error.mfa.title')).toBeInTheDocument(); + expect(screen.getByText('error.mfa.no_authenticators')).toBeInTheDocument(); + }); + }); + + it('should handle enrollment factors fetch error in SPA mode', async () => { + mockCoreClient.isProxyMode = vi.fn().mockReturnValue(false); + mockCoreClient.getStepUpApiService().getEnrollmentFactors = vi + .fn() + .mockRejectedValue(new Error('Failed to fetch enrollment factors')); + + const mfaError = { + body: { + error: 'mfa_required', + mfa_token: 'test-token', + }, + mfa_token: 'test-token', + }; + + renderGateKeeper({ error: mfaError }); + + await waitFor(() => { + expect(screen.getByText('error.mfa.fetch_failed')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/packages/react/src/components/auth0/shared/gatekeeper.tsx b/packages/react/src/components/auth0/shared/gatekeeper.tsx index 3000f5deb..1d9cf448c 100644 --- a/packages/react/src/components/auth0/shared/gatekeeper.tsx +++ b/packages/react/src/components/auth0/shared/gatekeeper.tsx @@ -1,152 +1,258 @@ import { - isMfaRequiredError, getStatusCode, - hasApiErrorBody, + isMfaRequiredError, + type MfaRequiredError, + type StepUpAuthenticator, + type EnrollmentFactor, } from '@auth0/universal-components-core'; +import { useQuery } from '@tanstack/react-query'; import { RefreshCcw } from 'lucide-react'; -import React, { useEffect, useState } from 'react'; +import React, { useState, useMemo } from 'react'; -import { showToast } from '@/components/auth0/shared/toast'; import { Button } from '@/components/ui/button'; +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Spinner } from '@/components/ui/spinner'; +import { useCoreClient } from '@/hooks/shared/use-core-client'; import { useTranslator } from '@/hooks/shared/use-translator'; -import { cn } from '@/lib/utils'; -import { useMfaErrorHandler } from '@/providers/mfa-error-handler-provider'; interface GateKeeperProps { - isLoading: boolean; + isLoading?: boolean; error: unknown; - onRetry: () => void; + onRetry: () => Promise; children: React.ReactNode; - loadingFallback?: React.ReactNode; - errorFallback?: (error: unknown, retry: () => void) => React.ReactNode; } /** - * Extracts error message from various error types + * Renders error fallback UI with retry button */ -function getErrorMessage(error: unknown, fallback: string = 'An error occurred'): string { - if (hasApiErrorBody(error) && error.body?.detail) { - return error.body.detail; - } - - if (error instanceof Error) { - return error.message; - } - - if (typeof error === 'string') { - return error; - } - - return fallback; +function ErrorFallback({ + title, + description, + retryText, + onRetry, + isRetrying, +}: { + title: string; + description: string; + retryText: string; + onRetry: () => void; + isRetrying: boolean; +}) { + return ( + + + {title} + {description} + + + + + + ); } /** * GateKeeper guards children from rendering during loading/error states. - * Handles all error types: - * - MFA errors → Opens global MFA modal - * - 500 errors → Shows blocking alert with retry - * - Other errors (400, 401, 403) → Shows toast notification, renders children + * Handles: + * - MFA errors → Shows MFA Step up dialog, then retries on completion + * - 500+ errors → Shows blocking fallback UI with retry */ -export function GateKeeper({ - isLoading, - error, - onRetry, - children, - loadingFallback, - errorFallback, -}: GateKeeperProps) { - const { handleMfaError } = useMfaErrorHandler(); +export function GateKeeper({ isLoading = false, error, onRetry, children }: GateKeeperProps) { const { t } = useTranslator('common'); + const { coreClient } = useCoreClient(); const [isRetrying, setIsRetrying] = useState(false); + const [isMfaDialogOpen, setIsMfaDialogOpen] = useState(true); + + const mfaToken = useMemo(() => { + if (error && isMfaRequiredError(error)) { + const err = error as MfaRequiredError & { body?: { mfa_token?: string } }; + return err.mfa_token ?? err.body?.mfa_token ?? null; + } + return null; + }, [error]); + + const isProxyMode = coreClient?.isProxyMode() ?? false; + + // Step 1: Check if user needs to enroll MFA factors (SPA mode only) + const { + data: enrollmentFactors, + isLoading: isFetchingEnrollmentFactors, + error: fetchEnrollmentFactorsError, + } = useQuery({ + queryKey: ['mfa-enrollment-factors', mfaToken], + queryFn: async () => { + if (!coreClient || !mfaToken) { + throw new Error('CoreClient or MFA token not available'); + } + const stepUpService = coreClient.getStepUpApiService(); + return stepUpService.getEnrollmentFactors(mfaToken); + }, + enabled: Boolean( + !isProxyMode && + error && + isMfaRequiredError(error) && + mfaToken && + coreClient && + isMfaDialogOpen, + ), + retry: false, + }); + + // Determine if user needs enrollment or has authenticators + const needsEnrollment = enrollmentFactors && enrollmentFactors.length > 0; + + // Step 2: Fetch authenticators + const { + data: authenticators, + isLoading: isFetchingAuthenticators, + error: fetchAuthenticatorsError, + } = useQuery({ + queryKey: ['mfa-authenticators', mfaToken], + queryFn: async () => { + if (!coreClient || !mfaToken) { + throw new Error('CoreClient or MFA token not available'); + } + const stepUpService = coreClient.getStepUpApiService(); + return stepUpService.getAuthenticators(mfaToken); + }, + enabled: Boolean( + error && + isMfaRequiredError(error) && + isMfaDialogOpen && + coreClient && + mfaToken && + (isProxyMode || (!needsEnrollment && enrollmentFactors !== undefined)), + ), + retry: false, + }); const handleRetry = async () => { setIsRetrying(true); try { await onRetry(); + setIsMfaDialogOpen(true); } finally { setIsRetrying(false); } }; - useEffect(() => { - if (!error) return; + if (isLoading) { + return ( +
    + +
    + ); + } + + const LoadingState = () => ( +
    + +
    + ); - if (isMfaRequiredError(error)) { - handleMfaError(error, onRetry); - return; - } + const ErrorState = () => ( +
    {t('error.mfa.fetch_failed')}
    + ); + + const EmptyState = () => ( +
    {t('error.mfa.no_authenticators')}
    + ); + + const AuthenticatorList = ({ items }: { items: StepUpAuthenticator[] }) => ( +
    + {items.map((auth) => ( +
    +
    {auth.name || auth.authenticatorType}
    +
    + Type: {auth.authenticatorType} | Active: {auth.active ? 'Yes' : 'No'} +
    +
    + ))} +
    + ); - const statusCode = getStatusCode(error); - if (statusCode && statusCode >= 500) { - return; + const EnrollmentList = ({ factors }: { factors: EnrollmentFactor[] }) => ( +
    +
    + {t('error.mfa.enrollment_required')} +
    + {factors.map((factor) => ( +
    +
    {factor.type}
    +
    {t('error.mfa.factor_available')}
    +
    + ))} +
    + ); + + // Determine current MFA state + const getMfaState = () => { + // SPA mode: Check enrollment factors first + if (!isProxyMode) { + if (isFetchingEnrollmentFactors) return 'LOADING'; + if (fetchEnrollmentFactorsError) return 'ERROR'; + if (needsEnrollment) return 'ENROLLMENT'; } - const errorMessage = getErrorMessage(error); - showToast({ - type: 'error', - message: errorMessage, - }); - }, [error, handleMfaError, onRetry]); + // Both modes: Check authenticators + if (isFetchingAuthenticators) return 'LOADING'; + if (fetchAuthenticatorsError) return 'ERROR'; + if (authenticators?.length) return 'AUTHENTICATORS'; - if (isLoading) { - return ( - <> - {loadingFallback || ( -
    - -
    - )} - - ); - } + return 'EMPTY'; + }; + + const stateComponentMap: Record = { + LOADING: , + ERROR: , + EMPTY: , + AUTHENTICATORS: authenticators ? : , + ENROLLMENT: enrollmentFactors ? : , + }; + + const renderMfaDialogContent = () => { + const state = getMfaState(); + return stateComponentMap[state] || ; + }; - if (error && isMfaRequiredError(error)) { + // Handle MFA errors - show dialog first, then fallback if closed + if (error && isMfaRequiredError(error) && isMfaDialogOpen) { return ( -
    - -
    + + + + {t('error.mfa.title')} + + {renderMfaDialogContent()} + + ); } + // Handle 500+ errors or MFA errors (when dialog is closed) const statusCode = getStatusCode(error); - if (error && statusCode && statusCode >= 500) { + const shouldShowErrorFallback = + error && ((statusCode && statusCode >= 500) || isMfaRequiredError(error)); + + if (shouldShowErrorFallback) { return ( - <> - {errorFallback ? ( - errorFallback(error, handleRetry) - ) : ( -
    -
    -

    - {getErrorMessage(error, t('fallback.title'))} -

    -

    - {t('fallback.description')} -

    -
    - -
    - )} - + ); } + return <>{children}; } diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 1f9b56ed5..291556d63 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,5 +1,3 @@ -export { GateKeeper } from './shared/gatekeeper'; - export { UserMFAMgmt } from './auth0/my-account/user-mfa-management'; export { SsoProviderEdit } from './auth0/my-organization/sso-provider-edit'; diff --git a/packages/react/src/hoc/with-services.tsx b/packages/react/src/hoc/with-services.tsx deleted file mode 100644 index 4e5ab4614..000000000 --- a/packages/react/src/hoc/with-services.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import * as React from 'react'; - -import { Spinner } from '@/components/ui/spinner'; -import { useScopeManager } from '@/hooks/shared/use-scope-manager'; -import { useTheme } from '@/hooks/shared/use-theme'; - -export interface ServiceRequirements { - myAccountApiScopes?: string; - myOrganizationApiScopes?: string; -} - -function scopesSatisfied(required: string, ensured: string) { - if (!required) return true; - const requiredSet = required.split(' ').filter(Boolean); - const ensuredSet = new Set(ensured.split(' ').filter(Boolean)); - return requiredSet.every((scope) => ensuredSet.has(scope)); -} - -function normalizeScopes(scopes?: string) { - return scopes - ? scopes - .split(' ') - .map((s) => s.trim()) - .filter(Boolean) - .sort() - .join(' ') - : ''; -} - -export function withServices

    ( - WrappedComponent: React.ComponentType

    , - requirements: ServiceRequirements = {}, -): React.ComponentType

    { - const WithServicesComponent = (props: P) => { - const { loader } = useTheme(); - const { registerScopes, ensured } = useScopeManager(); - const defaultLoader = ( -

    - -
    - ); - - const requiredMe = normalizeScopes(requirements.myAccountApiScopes); - const requiredOrganization = normalizeScopes(requirements.myOrganizationApiScopes); - - const meEnsured = scopesSatisfied(requiredMe, ensured.me); - const organizationEnsured = scopesSatisfied(requiredOrganization, ensured['my-org']); - - React.useEffect(() => { - if (requirements.myAccountApiScopes) { - registerScopes('me', requirements.myAccountApiScopes); - } - if (requirements.myOrganizationApiScopes) { - registerScopes('my-org', requirements.myOrganizationApiScopes); - } - }, [requirements.myAccountApiScopes, requirements.myOrganizationApiScopes, registerScopes]); - - if ( - (requirements.myAccountApiScopes && !meEnsured) || - (requirements.myOrganizationApiScopes && !organizationEnsured) - ) { - return <>{loader || defaultLoader}; - } - - return ; - }; - - return WithServicesComponent; -} - -export function withMyOrganizationService

    ( - WrappedComponent: React.ComponentType

    , - scopes: string, -): React.ComponentType

    { - return withServices(WrappedComponent, { myOrganizationApiScopes: scopes }); -} - -export function withMyAccountService

    ( - WrappedComponent: React.ComponentType

    , - scopes: string, -): React.ComponentType

    { - return withServices(WrappedComponent, { myAccountApiScopes: scopes }); -} diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index d3fc931bb..6690d4791 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -3,7 +3,7 @@ export { useCoreClient, CoreClientContext } from './shared/use-core-client'; export { useTranslator } from './shared/use-translator'; export { useTheme } from './shared/use-theme'; export { useCoreClientInitialization } from './shared/use-core-client-initialization'; -export { useScopeManager, type Audience } from '../providers/scope-manager-provider'; +export { useErrorHandler } from './shared/use-error-handler'; // My Account hooks export { useContactEnrollment } from './my-account/use-contact-enrollment'; @@ -17,7 +17,6 @@ export { useConfig } from './my-organization/use-config'; export { useIdpConfig } from './my-organization/use-idp-config'; export { useOrganizationDetailsEdit } from './my-organization/use-organization-details-edit'; export { useDomainTable } from './my-organization/use-domain-table'; -export { useDomainTableLogic } from './my-organization/use-domain-table-logic'; export { useProviderFormMode } from './my-organization/use-provider-form-mode'; export { useSsoDomainTab } from './my-organization/use-sso-domain-tab'; export { useSsoProviderCreate } from './my-organization/use-sso-provider-create'; diff --git a/packages/react/src/hooks/my-organization/__tests__/use-config.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-config.test.ts index cc493a17f..9853cb843 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-config.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-config.test.ts @@ -1,9 +1,13 @@ import { AVAILABLE_STRATEGY_LIST } from '@auth0/universal-components-core'; +import { QueryClient } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useConfig } from '@/hooks/my-organization/use-config'; import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import * as useErrorHandlerModule from '@/hooks/shared/use-error-handler'; +import * as useTranslatorModule from '@/hooks/shared/use-translator'; +import { setupAllCommonMocks } from '@/tests/utils'; import { createTestQueryClientWrapper } from '@/tests/utils/test-provider'; import { mockCore } from '@/tests/utils/test-setup'; @@ -22,10 +26,14 @@ describe('useConfig', () => { beforeEach(() => { vi.clearAllMocks(); mockCoreClient = initMockCoreClient(); - vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + mockGet = vi.mocked(mockCoreClient.getMyOrganizationApiClient().organization.configuration.get); + + setupAllCommonMocks({ coreClient: mockCoreClient, + useCoreClientModule, + useTranslatorModule, + useErrorHandlerModule, }); - mockGet = vi.mocked(mockCoreClient.getMyOrganizationApiClient().organization.configuration.get); }); const renderUseConfig = async () => { @@ -135,6 +143,46 @@ describe('useConfig', () => { expect(result.current.config).toBeNull(); expect(result.current.isConfigValid).toBe(false); }); + + it('retries up to 3 times on non-404 errors', async () => { + const error = new Error('Network error'); + mockGet.mockRejectedValue(error); + + // Create a query client that allows retries with minimal delay + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 3, + retryDelay: 1, + gcTime: 0, + staleTime: 0, + }, + }, + }); + + const { wrapper } = createTestQueryClientWrapper(queryClient); + renderHook(() => useConfig(), { wrapper }); + + await waitFor( + () => { + // Should retry 3 times (initial + 3 retries = 4 total calls) + expect(mockGet).toHaveBeenCalledTimes(4); + }, + { timeout: 5000 }, + ); + }); + + it('does not retry on 404 errors', async () => { + mockGet.mockRejectedValue({ body: { status: 404 } }); + + const { wrapper } = createTestQueryClientWrapper(); + renderHook(() => useConfig(), { wrapper }); + + await waitFor(() => { + // Should only call once, no retries for 404 + expect(mockGet).toHaveBeenCalledTimes(1); + }); + }); }); describe('fetchConfig', () => { @@ -148,4 +196,16 @@ describe('useConfig', () => { await waitFor(() => expect(mockGet).toHaveBeenCalled()); }); }); + + describe('retry', () => { + it('triggers refetch', async () => { + mockGet.mockResolvedValue(createMockConfig()); + const { result } = await renderUseConfig(); + + mockGet.mockClear(); + await result.current.retry(); + + await waitFor(() => expect(mockGet).toHaveBeenCalled()); + }); + }); }); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-domain-table-logic.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-domain-table-logic.test.ts deleted file mode 100644 index 6ffdade40..000000000 --- a/packages/react/src/hooks/my-organization/__tests__/use-domain-table-logic.test.ts +++ /dev/null @@ -1,591 +0,0 @@ -import { renderHook, waitFor, act } from '@testing-library/react'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; - -import { useDomainTableLogic } from '@/hooks/my-organization/use-domain-table-logic'; -import * as useCoreClientModule from '@/hooks/shared/use-core-client'; -import * as useErrorHandlerModule from '@/hooks/shared/use-error-handler'; -import { - mockCore, - mockToast, - createMockDomain, - createMockIdentityProvider, - createMockI18nService, -} from '@/tests/utils'; -import type { UseDomainTableLogicOptions } from '@/types/my-organization/domain-management/domain-table-types'; - -// ===== Mock packages ===== - -const { mockedShowToast } = mockToast(); -const { initMockCoreClient } = mockCore(); - -// ===== Mock Data ===== - -const createMockOptions = ( - overrides?: Partial, -): UseDomainTableLogicOptions => ({ - t: createMockI18nService().translator('my-organization'), - onCreateDomain: vi.fn(), - onVerifyDomain: vi.fn(), - onDeleteDomain: vi.fn(), - onAssociateToProvider: vi.fn(), - onDeleteFromProvider: vi.fn(), - fetchProviders: vi.fn(), - fetchDomains: vi.fn(), - ...overrides, -}); - -// ===== Tests ===== - -describe('useDomainTableLogic', () => { - let mockCoreClient: ReturnType; - let mockHandleError: ReturnType; - let mockOptions: UseDomainTableLogicOptions; - - beforeEach(() => { - vi.clearAllMocks(); - - mockCoreClient = initMockCoreClient(); - mockHandleError = vi.fn(); - mockOptions = createMockOptions(); - - vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ - coreClient: mockCoreClient, - }); - - vi.spyOn(useErrorHandlerModule, 'useErrorHandler').mockReturnValue({ - handleError: mockHandleError, - }); - }); - - describe('Initial State', () => { - it('should initialize with correct default state', () => { - const { result } = renderHook(() => useDomainTableLogic(mockOptions)); - - expect(result.current.showCreateModal).toBe(false); - expect(result.current.showConfigureModal).toBe(false); - expect(result.current.showVerifyModal).toBe(false); - expect(result.current.showDeleteModal).toBe(false); - expect(result.current.verifyError).toBeUndefined(); - expect(result.current.selectedDomain).toBeNull(); - }); - - it('should call fetchDomains on mount when coreClient is available', async () => { - renderHook(() => useDomainTableLogic(mockOptions)); - - await waitFor(() => { - expect(mockOptions.fetchDomains).toHaveBeenCalledTimes(1); - }); - }); - - it('should handle fetchDomains error on initialization', async () => { - const error = new Error('Fetch domains failed'); - const mockFetchDomains = vi.fn().mockImplementation(() => { - throw error; - }); - const options = createMockOptions({ fetchDomains: mockFetchDomains }); - - renderHook(() => useDomainTableLogic(options)); - - await waitFor(() => { - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_table.notifications.fetch_domains_error', - }); - }); - }); - }); - - describe('Modal State Management', () => { - it('should update create modal state', () => { - const { result } = renderHook(() => useDomainTableLogic(mockOptions)); - - act(() => { - result.current.setShowCreateModal(true); - }); - - expect(result.current.showCreateModal).toBe(true); - }); - - it('should update configure modal state', () => { - const { result } = renderHook(() => useDomainTableLogic(mockOptions)); - - act(() => { - result.current.setShowConfigureModal(true); - }); - - expect(result.current.showConfigureModal).toBe(true); - }); - - it('should update verify modal state', () => { - const { result } = renderHook(() => useDomainTableLogic(mockOptions)); - - act(() => { - result.current.setShowVerifyModal(true); - }); - - expect(result.current.showVerifyModal).toBe(true); - }); - - it('should update delete modal state', () => { - const { result } = renderHook(() => useDomainTableLogic(mockOptions)); - - act(() => { - result.current.setShowDeleteModal(true); - }); - - expect(result.current.showDeleteModal).toBe(true); - }); - }); - - describe('handleCreate', () => { - it('should create domain successfully and show verify modal', async () => { - const mockDomain = createMockDomain({ domain: 'test.com' }); - const mockOnCreateDomain = vi.fn().mockResolvedValue(mockDomain); - const options = createMockOptions({ onCreateDomain: mockOnCreateDomain }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleCreate('test.com'); - }); - - expect(mockOnCreateDomain).toHaveBeenCalledWith({ domain: 'test.com' }); - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'success', - message: 'domain_table.notifications.domain_create.success', - }); - expect(result.current.selectedDomain).toEqual(mockDomain); - expect(result.current.showCreateModal).toBe(false); - expect(result.current.showVerifyModal).toBe(true); - }); - - it('should handle create domain error', async () => { - const error = new Error('Create failed'); - const mockOnCreateDomain = vi.fn().mockRejectedValue(error); - const options = createMockOptions({ onCreateDomain: mockOnCreateDomain }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleCreate('test.com'); - }); - - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_table.notifications.domain_create.error', - }); - }); - }); - - describe('handleVerify', () => { - it('should verify domain successfully and close verify modal', async () => { - const mockDomain = createMockDomain(); - const mockOnVerifyDomain = vi.fn().mockResolvedValue(true); - const options = createMockOptions({ onVerifyDomain: mockOnVerifyDomain }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleVerify(mockDomain); - }); - - expect(mockOnVerifyDomain).toHaveBeenCalledWith(mockDomain); - expect(result.current.showVerifyModal).toBe(false); - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'success', - message: 'domain_table.notifications.domain_verify.success', - }); - }); - - it('should handle verification failure and set verify error', async () => { - const mockDomain = createMockDomain({ domain: 'test.com' }); - const mockOnVerifyDomain = vi.fn().mockResolvedValue(false); - const options = createMockOptions({ onVerifyDomain: mockOnVerifyDomain }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleVerify(mockDomain); - }); - - expect(result.current.verifyError).toBe('domain_verify.modal.errors.verification_failed'); - }); - - it('should handle verify domain error', async () => { - const mockDomain = createMockDomain(); - const error = new Error('Verify failed'); - const mockOnVerifyDomain = vi.fn().mockRejectedValue(error); - const options = createMockOptions({ onVerifyDomain: mockOnVerifyDomain }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleVerify(mockDomain); - }); - - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_table.notifications.domain_verify.error', - }); - }); - }); - - describe('handleDelete', () => { - it('should delete domain successfully', async () => { - const mockDomain = createMockDomain({ domain: 'test.com' }); - const mockOnDeleteDomain = vi.fn().mockResolvedValue(undefined); - const options = createMockOptions({ onDeleteDomain: mockOnDeleteDomain }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleDelete(mockDomain); - }); - - expect(mockOnDeleteDomain).toHaveBeenCalledWith(mockDomain); - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'success', - message: 'domain_table.notifications.domain_delete.success', - }); - expect(result.current.showDeleteModal).toBe(false); - expect(result.current.showVerifyModal).toBe(false); - }); - - it('should handle delete domain error', async () => { - const mockDomain = createMockDomain(); - const error = new Error('Delete failed'); - const mockOnDeleteDomain = vi.fn().mockRejectedValue(error); - const options = createMockOptions({ onDeleteDomain: mockOnDeleteDomain }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleDelete(mockDomain); - }); - - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_table.notifications.domain_delete.error', - }); - }); - }); - - describe('handleToggleSwitch', () => { - it('should associate domain to provider when checked is true', async () => { - const mockDomain = createMockDomain({ domain: 'test.com' }); - const mockProvider = createMockIdentityProvider({ name: 'TestIDP' }); - const mockOnAssociateToProvider = vi.fn().mockResolvedValue(undefined); - const options = createMockOptions({ onAssociateToProvider: mockOnAssociateToProvider }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleToggleSwitch(mockDomain, mockProvider, true); - }); - - expect(mockOnAssociateToProvider).toHaveBeenCalledWith(mockDomain, mockProvider); - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'success', - message: 'domain_table.notifications.domain_associate_provider.success', - }); - }); - - it('should delete domain from provider when checked is false', async () => { - const mockDomain = createMockDomain({ domain: 'test.com' }); - const mockProvider = createMockIdentityProvider({ name: 'TestIDP' }); - const mockOnDeleteFromProvider = vi.fn().mockResolvedValue(undefined); - const options = createMockOptions({ onDeleteFromProvider: mockOnDeleteFromProvider }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleToggleSwitch(mockDomain, mockProvider, false); - }); - - expect(mockOnDeleteFromProvider).toHaveBeenCalledWith(mockDomain, mockProvider); - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'success', - message: 'domain_table.notifications.domain_delete_provider.success', - }); - }); - - it('should handle associate to provider error', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider(); - const error = new Error('Associate failed'); - const mockOnAssociateToProvider = vi.fn().mockRejectedValue(error); - const options = createMockOptions({ onAssociateToProvider: mockOnAssociateToProvider }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleToggleSwitch(mockDomain, mockProvider, true); - }); - - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_table.notifications.domain_associate_provider.error', - }); - }); - - it('should handle delete from provider error', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider(); - const error = new Error('Delete from provider failed'); - const mockOnDeleteFromProvider = vi.fn().mockRejectedValue(error); - const options = createMockOptions({ onDeleteFromProvider: mockOnDeleteFromProvider }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleToggleSwitch(mockDomain, mockProvider, false); - }); - - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_table.notifications.domain_delete_provider.error', - }); - }); - }); - - describe('handleCloseVerifyModal', () => { - it('should close verify modal and clear verify error', async () => { - const { result } = renderHook(() => useDomainTableLogic(mockOptions)); - - // Set initial state - act(() => { - result.current.setShowVerifyModal(true); - }); - - // Simulate verify error - await act(async () => { - await result.current.handleVerify(createMockDomain()); - }); - - // Close modal - act(() => { - result.current.handleCloseVerifyModal(); - }); - - expect(result.current.showVerifyModal).toBe(false); - expect(result.current.verifyError).toBeUndefined(); - }); - }); - - describe('handleCreateClick', () => { - it('should show create modal', () => { - const { result } = renderHook(() => useDomainTableLogic(mockOptions)); - - act(() => { - result.current.handleCreateClick(); - }); - - expect(result.current.showCreateModal).toBe(true); - }); - }); - - describe('handleConfigureClick', () => { - it('should show verify modal for unverified domain', async () => { - const mockDomain = createMockDomain({ status: 'pending' }); - const { result } = renderHook(() => useDomainTableLogic(mockOptions)); - - await act(async () => { - await result.current.handleConfigureClick(mockDomain); - }); - - expect(result.current.selectedDomain).toEqual(mockDomain); - expect(result.current.showVerifyModal).toBe(true); - }); - - it('should fetch providers and show configure modal for verified domain', async () => { - const mockDomain = createMockDomain({ status: 'verified' }); - const mockFetchProviders = vi.fn().mockResolvedValue(undefined); - const options = createMockOptions({ fetchProviders: mockFetchProviders }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleConfigureClick(mockDomain); - }); - - expect(result.current.selectedDomain).toEqual(mockDomain); - expect(mockFetchProviders).toHaveBeenCalledWith(mockDomain); - expect(result.current.showConfigureModal).toBe(true); - }); - - it('should handle fetchProviders error for verified domain', async () => { - const mockDomain = createMockDomain({ status: 'verified' }); - const error = new Error('Fetch providers failed'); - const mockFetchProviders = vi.fn().mockRejectedValue(error); - const options = createMockOptions({ fetchProviders: mockFetchProviders }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleConfigureClick(mockDomain); - }); - - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_table.notifications.fetch_providers_error', - }); - }); - }); - - describe('handleVerifyClick', () => { - it('should verify domain and show configure modal on success', async () => { - const mockDomain = createMockDomain({ domain: 'test.com' }); - const mockOnVerifyDomain = vi.fn().mockResolvedValue(true); - const options = createMockOptions({ onVerifyDomain: mockOnVerifyDomain }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleVerifyClick(mockDomain); - }); - - expect(result.current.selectedDomain).toEqual(mockDomain); - expect(mockOnVerifyDomain).toHaveBeenCalledWith(mockDomain); - expect(result.current.showConfigureModal).toBe(true); - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'success', - message: 'domain_table.notifications.domain_verify.success', - }); - }); - - it('should show error toast on verification failure', async () => { - const mockDomain = createMockDomain({ domain: 'test.com' }); - const mockOnVerifyDomain = vi.fn().mockResolvedValue(false); - const options = createMockOptions({ onVerifyDomain: mockOnVerifyDomain }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleVerifyClick(mockDomain); - }); - - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'error', - message: 'domain_table.notifications.domain_verify.verification_failed', - }); - }); - - it('should handle verify click error', async () => { - const mockDomain = createMockDomain(); - const error = new Error('Verify click failed'); - const mockOnVerifyDomain = vi.fn().mockRejectedValue(error); - const options = createMockOptions({ onVerifyDomain: mockOnVerifyDomain }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleVerifyClick(mockDomain); - }); - - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_table.notifications.domain_verify.error', - }); - }); - }); - - describe('handleDeleteClick', () => { - it('should set selected domain and show delete modal', () => { - const mockDomain = createMockDomain(); - const { result } = renderHook(() => useDomainTableLogic(mockOptions)); - - // Set verify modal to true initially - act(() => { - result.current.setShowVerifyModal(true); - }); - - act(() => { - result.current.handleDeleteClick(mockDomain); - }); - - expect(result.current.selectedDomain).toEqual(mockDomain); - expect(result.current.showVerifyModal).toBe(false); - expect(result.current.showDeleteModal).toBe(true); - }); - }); - - describe('Edge Cases and Integration', () => { - it('should handle multiple modal state changes correctly', () => { - const { result } = renderHook(() => useDomainTableLogic(mockOptions)); - - act(() => { - result.current.setShowCreateModal(true); - result.current.setShowConfigureModal(true); - result.current.setShowVerifyModal(true); - result.current.setShowDeleteModal(true); - }); - - expect(result.current.showCreateModal).toBe(true); - expect(result.current.showConfigureModal).toBe(true); - expect(result.current.showVerifyModal).toBe(true); - expect(result.current.showDeleteModal).toBe(true); - - act(() => { - result.current.setShowCreateModal(false); - result.current.setShowConfigureModal(false); - result.current.setShowVerifyModal(false); - result.current.setShowDeleteModal(false); - }); - - expect(result.current.showCreateModal).toBe(false); - expect(result.current.showConfigureModal).toBe(false); - expect(result.current.showVerifyModal).toBe(false); - expect(result.current.showDeleteModal).toBe(false); - }); - - it('should handle domain creation with null return value', async () => { - const mockOnCreateDomain = vi.fn().mockResolvedValue(null); - const options = createMockOptions({ onCreateDomain: mockOnCreateDomain }); - - const { result } = renderHook(() => useDomainTableLogic(options)); - - await act(async () => { - await result.current.handleCreate('test.com'); - }); - - expect(result.current.selectedDomain).toBeNull(); - expect(result.current.showCreateModal).toBe(false); - expect(result.current.showVerifyModal).toBe(true); - }); - - it('should handle various domain statuses in handleConfigureClick', async () => { - const { result } = renderHook(() => useDomainTableLogic(mockOptions)); - - // Test with 'failed' status - const failedDomain = createMockDomain({ status: 'failed' }); - await act(async () => { - await result.current.handleConfigureClick(failedDomain); - }); - expect(result.current.showVerifyModal).toBe(true); - - // Reset state - act(() => { - result.current.setShowVerifyModal(false); - }); - - // Test with 'verified' status - const verifiedDomain = createMockDomain({ status: 'verified' }); - await act(async () => { - await result.current.handleConfigureClick(verifiedDomain); - }); - expect(result.current.showConfigureModal).toBe(true); - }); - }); - - describe('Callback Dependencies', () => { - it('should update callbacks when dependencies change', () => { - const { result, rerender } = renderHook((options) => useDomainTableLogic(options), { - initialProps: mockOptions, - }); - - const initialHandleCreate = result.current.handleCreate; - - // Update the options with a new onCreateDomain function - const newOptions = createMockOptions({ - onCreateDomain: vi.fn(), - }); - - rerender(newOptions); - - // The callback should be different due to dependency change - expect(result.current.handleCreate).not.toBe(initialHandleCreate); - }); - }); -}); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-domain-table.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-domain-table.test.ts index 4d0bbd6ac..e36776144 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-domain-table.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-domain-table.test.ts @@ -2,18 +2,19 @@ import type { CreateOrganizationDomainRequestContent, EnhancedTranslationFunction, } from '@auth0/universal-components-core'; -import { BusinessError } from '@auth0/universal-components-core'; -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook, waitFor, act } from '@testing-library/react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { useDomainTable } from '@/hooks/my-organization/use-domain-table'; import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import * as useErrorHandlerModule from '@/hooks/shared/use-error-handler'; import * as useTranslatorModule from '@/hooks/shared/use-translator'; import { mockCore, createMockDomain, createMockIdentityProvider, createMockI18nService, + setupAllCommonMocks, } from '@/tests/utils'; import { createTestQueryClientWrapper } from '@/tests/utils/test-provider'; import type { UseDomainTableOptions } from '@/types/my-organization/domain-management/domain-table-types'; @@ -69,12 +70,16 @@ describe('useDomainTable', () => { mockCoreClient = initMockCoreClient(); mockOptions = createMockOptions(); - mockT = createMockI18nService().translator('my-organization'); + mockT = createMockI18nService().translator('domain_management'); - vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + setupAllCommonMocks({ coreClient: mockCoreClient, + useCoreClientModule, + useTranslatorModule, + useErrorHandlerModule, }); + // Override translator with custom mock that has domain_management context vi.spyOn(useTranslatorModule, 'useTranslator').mockReturnValue({ t: mockT, changeLanguage: vi.fn(), @@ -87,39 +92,24 @@ describe('useDomainTable', () => { it('should initialize with correct default state', async () => { const { result } = renderUseDomainTable(mockOptions); - // Initial state before query completes expect(result.current.domains).toEqual([]); expect(result.current.providers).toEqual([]); expect(result.current.isCreating).toBe(false); expect(result.current.isDeleting).toBe(false); expect(result.current.isVerifying).toBe(false); - expect(result.current.isLoadingProviders).toBe(false); + expect(result.current.showCreateModal).toBe(false); + expect(result.current.showConfigureModal).toBe(false); + expect(result.current.showVerifyModal).toBe(false); + expect(result.current.showDeleteModal).toBe(false); - // Wait for initial query to complete await waitFor(() => { expect(result.current.isFetching).toBe(false); }); }); - it('should provide all expected functions', () => { + it('should fetch domains automatically on mount', async () => { const { result } = renderUseDomainTable(mockOptions); - expect(typeof result.current.fetchDomains).toBe('function'); - expect(typeof result.current.fetchProviders).toBe('function'); - expect(typeof result.current.onCreateDomain).toBe('function'); - expect(typeof result.current.onVerifyDomain).toBe('function'); - expect(typeof result.current.onDeleteDomain).toBe('function'); - expect(typeof result.current.onAssociateToProvider).toBe('function'); - expect(typeof result.current.onDeleteFromProvider).toBe('function'); - }); - }); - - describe('fetchDomains', () => { - it('should fetch domains successfully', async () => { - const { result } = renderUseDomainTable(mockOptions); - - await result.current.fetchDomains(); - await waitFor(() => { expect(result.current.isFetching).toBe(false); }); @@ -128,582 +118,229 @@ describe('useDomainTable', () => { mockCoreClient.getMyOrganizationApiClient().organization.domains.list, ).toHaveBeenCalled(); }); + }); - it('should handle fetchDomains error and reset loading state', async () => { - const error = new Error('Network error'); - mockCoreClient.getMyOrganizationApiClient().organization.domains.list = vi - .fn() - .mockRejectedValue(error); - + describe('Modal Management', () => { + it('should open create modal', () => { const { result } = renderUseDomainTable(mockOptions); - await result.current.fetchDomains(); - - await waitFor(() => { - expect(result.current.isFetching).toBe(false); + act(() => { + result.current.handleCreateClick(); }); - expect(result.current.isFetching).toBe(false); + expect(result.current.showCreateModal).toBe(true); }); - it('should handle empty domains response', async () => { - const { result } = renderUseDomainTable(mockOptions); - - await result.current.fetchDomains(); - - await waitFor(() => { - expect(result.current.isFetching).toBe(false); - }); - - expect(result.current.domains).toEqual([]); - }); + it('should open delete modal', async () => { + const mockDomain = createMockDomain(); + mockCoreClient.getMyOrganizationApiClient().organization.domains.list = vi + .fn() + .mockResolvedValue({ organization_domains: [mockDomain] }); - it('should invalidate and refetch when fetchDomains is called', async () => { const { result } = renderUseDomainTable(mockOptions); - // Wait for initial fetch to complete await waitFor(() => { expect(result.current.isFetching).toBe(false); }); - const initialCallCount = vi.mocked( - mockCoreClient.getMyOrganizationApiClient().organization.domains.list, - ).mock.calls.length; - - // Call fetchDomains - should always invalidate and trigger refetch - await result.current.fetchDomains(); - - // Should trigger a refetch - await waitFor(() => { - expect( - vi.mocked(mockCoreClient.getMyOrganizationApiClient().organization.domains.list).mock - .calls.length, - ).toBeGreaterThan(initialCallCount); - }); - }); - - it('should refetch when data is invalidated', async () => { - const { result, queryClient } = renderUseDomainTable(mockOptions); - - // Wait for initial fetch to complete - await waitFor(() => { - expect(result.current.isFetching).toBe(false); + act(() => { + result.current.handleDeleteClick(mockDomain); }); - const initialCallCount = vi.mocked( - mockCoreClient.getMyOrganizationApiClient().organization.domains.list, - ).mock.calls.length; - - // Invalidate the query - await queryClient.invalidateQueries({ queryKey: ['domains', 'list'] }); - - // Call fetchDomains - await result.current.fetchDomains(); - - // Should call the API again due to invalidation - await waitFor(() => { - expect( - vi.mocked(mockCoreClient.getMyOrganizationApiClient().organization.domains.list).mock - .calls.length, - ).toBeGreaterThan(initialCallCount); - }); + expect(result.current.showDeleteModal).toBe(true); + expect(result.current.selectedDomain).toEqual(mockDomain); }); - }); - describe('fetchProviders', () => { - it('should fetch providers with correct association status', async () => { - const mockDomain = createMockDomain(); - const provider1 = createMockIdentityProvider({ - id: 'provider-1', - display_name: 'Provider 1', - }); - const provider2 = createMockIdentityProvider({ - id: 'provider-2', - display_name: 'Provider 2', - }); - const provider3 = createMockIdentityProvider({ - id: 'provider-3', - display_name: 'Provider 3', - }); + it('should open configure modal for verified domain and fetch providers', async () => { + const mockDomain = createMockDomain({ status: 'verified', id: 'domain-1' }); + const mockProvider = createMockIdentityProvider({ id: 'provider-1' }); - // Mock all providers response - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi - .fn() - .mockResolvedValue({ - identity_providers: [provider1, provider2, provider3], - }); - - // Mock associated providers response - only provider1 and provider3 are associated - mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get = vi + mockCoreClient.getMyOrganizationApiClient().organization.domains.list = vi .fn() - .mockResolvedValue({ - identity_providers: [{ id: 'provider-1' }, { id: 'provider-3' }], - }); + .mockResolvedValue({ organization_domains: [mockDomain] }); - const { result } = renderUseDomainTable(mockOptions); - - await result.current.fetchProviders(mockDomain); - - await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); - }); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list, - ).toHaveBeenCalled(); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get, - ).toHaveBeenCalledWith(mockDomain.id); - - // Verify the providers are correctly matched with association status - expect(result.current.providers).toHaveLength(3); - - // Provider 1 should be associated - const resultProvider1 = result.current.providers.find((p) => p.id === 'provider-1'); - expect(resultProvider1).toBeDefined(); - expect(resultProvider1!.is_associated).toBe(true); - expect(resultProvider1!.display_name).toBe('Provider 1'); - - // Provider 2 should NOT be associated - const resultProvider2 = result.current.providers.find((p) => p.id === 'provider-2'); - expect(resultProvider2).toBeDefined(); - expect(resultProvider2!.is_associated).toBe(false); - expect(resultProvider2!.display_name).toBe('Provider 2'); - - // Provider 3 should be associated - const resultProvider3 = result.current.providers.find((p) => p.id === 'provider-3'); - expect(resultProvider3).toBeDefined(); - expect(resultProvider3!.is_associated).toBe(true); - expect(resultProvider3!.display_name).toBe('Provider 3'); - }); - - it('should handle providers with no associations', async () => { - const mockDomain = createMockDomain(); - const provider1 = createMockIdentityProvider({ - id: 'provider-1', - display_name: 'Provider 1', - }); - const provider2 = createMockIdentityProvider({ - id: 'provider-2', - display_name: 'Provider 2', - }); - - // Mock all providers response mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi .fn() - .mockResolvedValue({ - identity_providers: [provider1, provider2], - }); + .mockResolvedValue({ identity_providers: [mockProvider] }); - // Mock empty associated providers response mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get = vi .fn() - .mockResolvedValue({ - identity_providers: [], - }); + .mockResolvedValue({ identity_providers: [] }); const { result } = renderUseDomainTable(mockOptions); - await result.current.fetchProviders(mockDomain); - await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); - }); - - // All providers should have is_associated = false - expect(result.current.providers).toHaveLength(2); - result.current.providers.forEach((provider) => { - expect(provider.is_associated).toBe(false); + expect(result.current.isFetching).toBe(false); }); - }); - it('should handle all providers being associated', async () => { - const mockDomain = createMockDomain(); - const provider1 = createMockIdentityProvider({ - id: 'provider-1', - display_name: 'Provider 1', - }); - const provider2 = createMockIdentityProvider({ - id: 'provider-2', - display_name: 'Provider 2', + act(() => { + result.current.handleConfigureClick(mockDomain); }); - // Mock all providers response - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi - .fn() - .mockResolvedValue({ - identity_providers: [provider1, provider2], - }); - - // Mock all providers as associated - mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get = vi - .fn() - .mockResolvedValue({ - identity_providers: [{ id: 'provider-1' }, { id: 'provider-2' }], - }); - - const { result } = renderUseDomainTable(mockOptions); - - await result.current.fetchProviders(mockDomain); + expect(result.current.showConfigureModal).toBe(true); + expect(result.current.selectedDomain).toEqual(mockDomain); + // Should fetch providers await waitFor(() => { expect(result.current.isLoadingProviders).toBe(false); }); - // All providers should have is_associated = true - expect(result.current.providers).toHaveLength(2); - result.current.providers.forEach((provider) => { - expect(provider.is_associated).toBe(true); - }); - }); - - it('should handle fetchProviders error and reset loading state', async () => { - const mockDomain = createMockDomain(); - const error = new Error('Network error'); - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi - .fn() - .mockRejectedValue(error); - - const { result } = renderUseDomainTable(mockOptions); - - await expect(result.current.fetchProviders(mockDomain)).rejects.toThrow('Network error'); - - await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); - }); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list, + ).toHaveBeenCalled(); + expect(result.current.providers).toHaveLength(1); }); - it('should handle null/undefined responses gracefully', async () => { - const mockDomain = createMockDomain(); - - // Mock null responses - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi - .fn() - .mockResolvedValue({ - identity_providers: null, - }); - mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get = vi + it('should open verify modal for unverified domain', async () => { + const mockDomain = createMockDomain({ status: 'pending' }); + mockCoreClient.getMyOrganizationApiClient().organization.domains.list = vi .fn() - .mockResolvedValue({ - identity_providers: null, - }); + .mockResolvedValue({ organization_domains: [mockDomain] }); const { result } = renderUseDomainTable(mockOptions); - await result.current.fetchProviders(mockDomain); - await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); - }); - - // Should handle null gracefully and return empty array - expect(result.current.providers).toEqual([]); - }); - - it('should use ensureQueryData to fetch providers', async () => { - const mockDomain = createMockDomain(); - const provider1 = createMockIdentityProvider({ - id: 'provider-1', - display_name: 'Provider 1', + expect(result.current.isFetching).toBe(false); }); - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi - .fn() - .mockResolvedValue({ - identity_providers: [provider1], - }); - mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get = vi - .fn() - .mockResolvedValue({ - identity_providers: [{ id: 'provider-1' }], - }); - - const { result } = renderUseDomainTable(mockOptions); - - await result.current.fetchProviders(mockDomain); - - await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); + act(() => { + result.current.handleConfigureClick(mockDomain); }); - expect(result.current.providers).toHaveLength(1); - const firstProvider = result.current.providers[0]; - expect(firstProvider).toBeDefined(); - expect(firstProvider!.is_associated).toBe(true); + expect(result.current.showVerifyModal).toBe(true); + expect(result.current.showConfigureModal).toBe(false); }); - it('should fetch providers from cache via ensureQueryData', async () => { - const mockDomain = createMockDomain(); - const provider1 = createMockIdentityProvider({ - id: 'provider-1', - display_name: 'Provider 1', - }); - - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi - .fn() - .mockResolvedValue({ - identity_providers: [provider1], - }); - mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get = vi - .fn() - .mockResolvedValue({ - identity_providers: [{ id: 'provider-1' }], - }); - + it('should close all modals and clear verifyError', () => { const { result } = renderUseDomainTable(mockOptions); - // First fetch - await result.current.fetchProviders(mockDomain); - - await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); + act(() => { + result.current.handleCreateClick(); }); - const initialApiCallCount = vi.mocked( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list, - ).mock.calls.length; - - // Second fetch - should use cached data since it's fresh - await result.current.fetchProviders(mockDomain); + expect(result.current.showCreateModal).toBe(true); - await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); + act(() => { + result.current.closeModal(); }); - // Verify providers are loaded correctly - expect(result.current.providers).toHaveLength(1); - const cachedProvider = result.current.providers[0]; - expect(cachedProvider).toBeDefined(); - expect(cachedProvider!.is_associated).toBe(true); - - // Should use cache if available and fresh (not make additional API calls) - const finalApiCallCount = vi.mocked( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list, - ).mock.calls.length; - - expect(finalApiCallCount).toBe(initialApiCallCount); + expect(result.current.showCreateModal).toBe(false); + expect(result.current.verifyError).toBeUndefined(); }); }); - describe('onCreateDomain', () => { - it('should create domain successfully with callbacks', async () => { + describe('handleCreate', () => { + it('should create domain and open verify modal', async () => { const mockDomain = createMockDomain(); - const createData: CreateOrganizationDomainRequestContent = { domain: mockDomain.domain }; - - const { result } = renderUseDomainTable(mockOptions); - - await result.current.onCreateDomain(createData); - - await waitFor(() => { - expect(result.current.isCreating).toBe(false); - }); - - expect(mockOptions.createAction!.onBefore).toHaveBeenCalledWith(createData); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.create, - ).toHaveBeenCalledWith(createData); - }); - - it('should handle onBefore callback returning false', async () => { - const createData: CreateOrganizationDomainRequestContent = { domain: 'test.com' }; - const mockOptionsWithFalseBefore = createMockOptions({ - createAction: { - onBefore: vi.fn().mockReturnValue(false), - onAfter: vi.fn(), - }, - }); - - const { result } = renderUseDomainTable(mockOptionsWithFalseBefore); + const domainUrl = mockDomain.domain; + const expectedPayload: CreateOrganizationDomainRequestContent = { domain: domainUrl }; - await expect(result.current.onCreateDomain(createData)).rejects.toThrow(BusinessError); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.create, - ).not.toHaveBeenCalled(); - }); - - it('should handle create domain API error', async () => { - const createData: CreateOrganizationDomainRequestContent = { domain: 'test.com' }; - const error = new Error('API error'); mockCoreClient.getMyOrganizationApiClient().organization.domains.create = vi .fn() - .mockRejectedValue(error); + .mockResolvedValue(mockDomain); - const { result } = renderUseDomainTable(mockOptions); - - await expect(result.current.onCreateDomain(createData)).rejects.toThrow('API error'); - - await waitFor(() => { - expect(result.current.isCreating).toBe(false); - }); + mockCoreClient.getMyOrganizationApiClient().organization.domains.list = vi + .fn() + .mockResolvedValue({ organization_domains: [mockDomain] }); - expect(result.current.isCreating).toBe(false); - }); + const { result } = renderUseDomainTable(mockOptions); - it('should work without onBefore and onAfter callbacks', async () => { - const mockDomain = createMockDomain(); - const createData: CreateOrganizationDomainRequestContent = { domain: mockDomain.domain }; - const mockOptionsWithoutCallbacks = createMockOptions({ - createAction: undefined, + await act(async () => { + await result.current.handleCreate(domainUrl); }); - const { result } = renderUseDomainTable(mockOptionsWithoutCallbacks); - - await result.current.onCreateDomain(createData); - await waitFor(() => { expect(result.current.isCreating).toBe(false); }); + expect(mockOptions.createAction!.onBefore).toHaveBeenCalled(); expect( mockCoreClient.getMyOrganizationApiClient().organization.domains.create, - ).toHaveBeenCalledWith(createData); + ).toHaveBeenCalledWith(expectedPayload); + expect(mockOptions.createAction!.onAfter).toHaveBeenCalled(); + + // Should transition to verify modal + expect(result.current.showVerifyModal).toBe(true); + expect(result.current.selectedDomain?.id).toBe(mockDomain.id); }); }); - describe('onVerifyDomain', () => { - it('should verify domain successfully and return true', async () => { + describe('handleDelete', () => { + it('should delete domain and close modal', async () => { const mockDomain = createMockDomain(); + const { result } = renderUseDomainTable(mockOptions); - const isVerified = await result.current.onVerifyDomain(mockDomain); + await act(async () => { + await result.current.handleDelete(mockDomain); + }); await waitFor(() => { - expect(result.current.isVerifying).toBe(false); + expect(result.current.isDeleting).toBe(false); }); - expect(mockOptions.verifyAction!.onBefore).toHaveBeenCalledWith(mockDomain); + expect(mockOptions.deleteAction!.onBefore).toHaveBeenCalledWith(mockDomain); expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create, + mockCoreClient.getMyOrganizationApiClient().organization.domains.delete, ).toHaveBeenCalledWith(mockDomain.id); - expect(isVerified).toBe(true); - }); - - it('should verify domain and return false when status is not verified', async () => { - const mockDomain = createMockDomain(); - mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create = vi - .fn() - .mockResolvedValue({ - status: 'pending', - }); - - const { result } = renderUseDomainTable(mockOptions); - - const isVerified = await result.current.onVerifyDomain(mockDomain); + expect(mockOptions.deleteAction!.onAfter).toHaveBeenCalled(); - await waitFor(() => { - expect(result.current.isVerifying).toBe(false); - }); - - expect(isVerified).toBe(false); + // Should close modal and clear selection + expect(result.current.showDeleteModal).toBe(false); + expect(result.current.selectedDomain).toBeNull(); }); + }); - it('should handle onBefore callback returning false', async () => { + describe('handleVerify', () => { + it('should verify domain successfully and close modal', async () => { const mockDomain = createMockDomain(); - const mockOptionsWithFalseBefore = createMockOptions({ - verifyAction: { - onBefore: vi.fn().mockReturnValue(false), - onAfter: vi.fn(), - }, - }); - - const { result } = renderUseDomainTable(mockOptionsWithFalseBefore); - - await expect(result.current.onVerifyDomain(mockDomain)).rejects.toThrow(BusinessError); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create, - ).not.toHaveBeenCalled(); - }); + const { result } = renderUseDomainTable(mockOptions); - it('should work without onBefore and onAfter callbacks', async () => { - const mockDomain = createMockDomain(); - const mockOptionsWithoutCallbacks = createMockOptions({ - verifyAction: undefined, + await act(async () => { + await result.current.handleVerify(mockDomain); }); - const { result } = renderUseDomainTable(mockOptionsWithoutCallbacks); - - const isVerified = await result.current.onVerifyDomain(mockDomain); - await waitFor(() => { expect(result.current.isVerifying).toBe(false); }); + expect(mockOptions.verifyAction!.onBefore).toHaveBeenCalledWith(mockDomain); expect( mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create, ).toHaveBeenCalledWith(mockDomain.id); - expect(isVerified).toBe(true); + expect(mockOptions.verifyAction!.onAfter).toHaveBeenCalled(); }); - }); - describe('onDeleteDomain', () => { - it('should delete domain successfully with callbacks', async () => { + it('should set verifyError when verification fails', async () => { const mockDomain = createMockDomain(); - const { result } = renderUseDomainTable(mockOptions); - - await result.current.onDeleteDomain(mockDomain); - - await waitFor(() => { - expect(result.current.isDeleting).toBe(false); - }); - - expect(mockOptions.deleteAction!.onBefore).toHaveBeenCalledWith(mockDomain); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.delete, - ).toHaveBeenCalledWith(mockDomain.id); - expect(mockOptions.deleteAction!.onAfter).toHaveBeenCalledWith(mockDomain); - }); - - it('should handle onBefore callback returning false', async () => { - const mockDomain = createMockDomain(); - const mockOptionsWithFalseBefore = createMockOptions({ - deleteAction: { - onBefore: vi.fn().mockReturnValue(false), - onAfter: vi.fn(), - }, - }); - - const { result } = renderUseDomainTable(mockOptionsWithFalseBefore); - - await expect(result.current.onDeleteDomain(mockDomain)).rejects.toThrow(BusinessError); + mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create = vi + .fn() + .mockResolvedValue({ status: 'pending' }); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.delete, - ).not.toHaveBeenCalled(); - }); + const { result } = renderUseDomainTable(mockOptions); - it('should work without onBefore and onAfter callbacks', async () => { - const mockDomain = createMockDomain(); - const mockOptionsWithoutCallbacks = createMockOptions({ - deleteAction: undefined, + await act(async () => { + await result.current.handleVerify(mockDomain); }); - const { result } = renderUseDomainTable(mockOptionsWithoutCallbacks); - - await result.current.onDeleteDomain(mockDomain); - await waitFor(() => { - expect(result.current.isDeleting).toBe(false); + expect(result.current.verifyError).toBeDefined(); }); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.delete, - ).toHaveBeenCalledWith(mockDomain.id); + expect(result.current.verifyError).toBeTruthy(); }); }); - describe('onAssociateToProvider', () => { - it('should associate domain to provider successfully', async () => { + describe('handleToggleSwitch', () => { + it('should associate domain to provider when checked', async () => { const mockDomain = createMockDomain(); const mockProvider = createMockIdentityProvider(); const { result } = renderUseDomainTable(mockOptions); - await result.current.onAssociateToProvider(mockDomain, mockProvider); - - await waitFor(() => { - expect(result.current.isCreating).toBe(false); + await act(async () => { + await result.current.handleToggleSwitch(mockDomain, mockProvider, true); }); expect(mockOptions.associateToProviderAction!.onBefore).toHaveBeenCalledWith( @@ -715,59 +352,14 @@ describe('useDomainTable', () => { ).toHaveBeenCalledWith(mockProvider.id, { domain: mockDomain.domain }); }); - it('should handle onBefore callback returning false', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider(); - const mockOptionsWithFalseBefore = createMockOptions({ - associateToProviderAction: { - onBefore: vi.fn().mockReturnValue(false), - onAfter: vi.fn(), - }, - }); - - const { result } = renderUseDomainTable(mockOptionsWithFalseBefore); - - await expect(result.current.onAssociateToProvider(mockDomain, mockProvider)).rejects.toThrow( - BusinessError, - ); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.create, - ).not.toHaveBeenCalled(); - }); - - it('should work without onBefore and onAfter callbacks', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider(); - const mockOptionsWithoutCallbacks = createMockOptions({ - associateToProviderAction: undefined, - }); - - const { result } = renderUseDomainTable(mockOptionsWithoutCallbacks); - - await result.current.onAssociateToProvider(mockDomain, mockProvider); - - await waitFor(() => { - expect(result.current.isCreating).toBe(false); - }); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.create, - ).toHaveBeenCalledWith(mockProvider.id, { domain: mockDomain.domain }); - }); - }); - - describe('onDeleteFromProvider', () => { - it('should delete domain from provider successfully', async () => { + it('should delete domain from provider when unchecked', async () => { const mockDomain = createMockDomain(); const mockProvider = createMockIdentityProvider(); const { result } = renderUseDomainTable(mockOptions); - await result.current.onDeleteFromProvider(mockDomain, mockProvider); - - await waitFor(() => { - expect(result.current.isDeleting).toBe(false); + await act(async () => { + await result.current.handleToggleSwitch(mockDomain, mockProvider, false); }); expect(mockOptions.deleteFromProviderAction!.onBefore).toHaveBeenCalledWith( @@ -778,92 +370,62 @@ describe('useDomainTable', () => { mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete, ).toHaveBeenCalledWith(mockProvider.id, mockDomain.domain); }); + }); - it('should handle onBefore callback returning false', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider(); - const mockOptionsWithFalseBefore = createMockOptions({ - deleteFromProviderAction: { - onBefore: vi.fn().mockReturnValue(false), - onAfter: vi.fn(), - }, - }); - - const { result } = renderUseDomainTable(mockOptionsWithFalseBefore); - - await expect(result.current.onDeleteFromProvider(mockDomain, mockProvider)).rejects.toThrow( - BusinessError, - ); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete, - ).not.toHaveBeenCalled(); - }); - - it('should work without onBefore and onAfter callbacks', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider(); - const mockOptionsWithoutCallbacks = createMockOptions({ - deleteFromProviderAction: undefined, - }); + describe('Error Handling', () => { + it('should expose error from domains query', async () => { + const error = new Error('Network error'); + const mockList = vi.fn().mockRejectedValue(error); - const { result } = renderUseDomainTable(mockOptionsWithoutCallbacks); + // Set up the mock before creating the core client reference + const apiService = mockCoreClient.getMyOrganizationApiClient(); + apiService.organization.domains.list = mockList; - await result.current.onDeleteFromProvider(mockDomain, mockProvider); + const { result } = renderUseDomainTable(mockOptions); await waitFor(() => { - expect(result.current.isDeleting).toBe(false); + expect(result.current.isFetching).toBe(false); }); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete, - ).toHaveBeenCalledWith(mockProvider.id, mockDomain.domain); + // Error should be exposed even though handleError processes it + expect(result.current.error).toBeTruthy(); + expect(mockList).toHaveBeenCalled(); }); - }); - describe('Edge Cases and Integration', () => { - it('should handle provider with undefined id in onAssociateToProvider', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider({ id: undefined }); + it('should retry on error', async () => { + const error = new Error('Network error'); + const mockList = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue({ organization_domains: [] }); - const { result } = renderUseDomainTable(mockOptions); + // Set up error scenario + const apiService = mockCoreClient.getMyOrganizationApiClient(); + apiService.organization.domains.list = mockList; - await result.current.onAssociateToProvider(mockDomain, mockProvider); + const { result } = renderUseDomainTable(mockOptions); await waitFor(() => { - expect(result.current.isCreating).toBe(false); + expect(result.current.isFetching).toBe(false); }); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.create, - ).toHaveBeenCalledWith(undefined, { domain: mockDomain.domain }); - }); - - it('should handle provider with undefined id in onDeleteFromProvider', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider({ id: undefined }); - - const { result } = renderUseDomainTable(mockOptions); + // Should have error after initial failed fetch + expect(result.current.error).toBeTruthy(); + expect(mockList).toHaveBeenCalledTimes(1); - await result.current.onDeleteFromProvider(mockDomain, mockProvider); + // Retry should trigger another fetch + await act(async () => { + await result.current.retry(); + }); await waitFor(() => { - expect(result.current.isDeleting).toBe(false); + expect(mockList).toHaveBeenCalledTimes(2); }); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete, - ).toHaveBeenCalledWith(undefined, mockDomain.domain); - }); - - it('should call useTranslator with correct parameters', () => { - const useTranslatorSpy = vi.spyOn(useTranslatorModule, 'useTranslator'); - renderUseDomainTable(mockOptions); - - expect(useTranslatorSpy).toHaveBeenCalledWith( - 'domain_management.domain_table.notifications', - {}, - ); + // Error should be cleared after successful retry + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); }); }); }); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-idp-config.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-idp-config.test.ts index cfe956c86..cba930661 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-idp-config.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-idp-config.test.ts @@ -1,14 +1,16 @@ import type { IdpStrategy } from '@auth0/universal-components-core'; +import { QueryClient } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useIdpConfig } from '@/hooks/my-organization/use-idp-config'; -import { useCoreClient } from '@/hooks/shared/use-core-client'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import * as useErrorHandlerModule from '@/hooks/shared/use-error-handler'; +import * as useTranslatorModule from '@/hooks/shared/use-translator'; +import { setupAllCommonMocks, setupMockUseCoreClientNull } from '@/tests/utils'; import { createMockCoreClient } from '@/tests/utils/__mocks__/core/core-client.mocks'; import { createTestQueryClientWrapper } from '@/tests/utils/test-provider'; -vi.mock('@/hooks/shared/use-core-client'); - const createMockIdpConfig = (overrides = {}) => ({ strategies: { okta: { @@ -20,20 +22,30 @@ const createMockIdpConfig = (overrides = {}) => ({ }); describe('useIdpConfig', () => { - const mockCoreClient = createMockCoreClient(); - const mockGet = vi.fn(); + let mockCoreClient: ReturnType; + let mockGet: ReturnType; beforeEach(() => { vi.clearAllMocks(); - mockCoreClient.getMyOrganizationApiClient().organization.configuration.identityProviders.get = - mockGet; - vi.mocked(useCoreClient).mockReturnValue({ coreClient: mockCoreClient }); + + mockCoreClient = createMockCoreClient(); + mockGet = vi.fn().mockResolvedValue(createMockIdpConfig()); + + // Set up the mock chain properly + const apiClient = mockCoreClient.getMyOrganizationApiClient(); + apiClient.organization.configuration.identityProviders.get = mockGet; + + setupAllCommonMocks({ + coreClient: mockCoreClient, + useCoreClientModule, + useTranslatorModule, + useErrorHandlerModule, + }); }); - const renderUseIdpConfig = async () => { + const renderUseIdpConfig = () => { const { wrapper, queryClient } = createTestQueryClientWrapper(); const hook = renderHook(() => useIdpConfig(), { wrapper }); - await waitFor(() => expect(hook.result.current.isLoadingIdpConfig).toBe(false)); return { queryClient, ...hook }; }; @@ -42,14 +54,16 @@ describe('useIdpConfig', () => { const mockConfig = createMockIdpConfig(); mockGet.mockResolvedValue(mockConfig); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); expect(result.current.idpConfig).toEqual(mockConfig); expect(result.current.isIdpConfigValid).toBe(true); }); it('does not fetch when coreClient is unavailable', async () => { - vi.mocked(useCoreClient).mockReturnValue({ coreClient: null }); + setupMockUseCoreClientNull(useCoreClientModule); const { wrapper } = createTestQueryClientWrapper(); const { result } = renderHook(() => useIdpConfig(), { wrapper }); @@ -63,7 +77,9 @@ describe('useIdpConfig', () => { it('is true when strategies has items', async () => { mockGet.mockResolvedValue(createMockIdpConfig()); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); expect(result.current.isIdpConfigValid).toBe(true); }); @@ -71,7 +87,9 @@ describe('useIdpConfig', () => { it('is false when strategies is empty', async () => { mockGet.mockResolvedValue(createMockIdpConfig({ strategies: {} })); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); expect(result.current.isIdpConfigValid).toBe(false); }); @@ -79,7 +97,9 @@ describe('useIdpConfig', () => { it('is false when strategies is undefined', async () => { mockGet.mockResolvedValue({ strategies: undefined }); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); expect(result.current.isIdpConfigValid).toBe(false); }); @@ -99,7 +119,9 @@ describe('useIdpConfig', () => { }), ); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); expect(result.current.isProvisioningEnabled(strategy as IdpStrategy)).toBe(expected); }); @@ -107,7 +129,9 @@ describe('useIdpConfig', () => { it('returns false for strategy not in config', async () => { mockGet.mockResolvedValue(createMockIdpConfig()); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); expect(result.current.isProvisioningEnabled('google-apps')).toBe(false); }); @@ -115,7 +139,9 @@ describe('useIdpConfig', () => { it('returns false for undefined strategy', async () => { mockGet.mockResolvedValue(createMockIdpConfig()); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); expect(result.current.isProvisioningEnabled(undefined)).toBe(false); }); @@ -135,7 +161,9 @@ describe('useIdpConfig', () => { }), ); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); expect(result.current.isProvisioningMethodEnabled(strategy as IdpStrategy)).toBe(expected); }); @@ -143,7 +171,9 @@ describe('useIdpConfig', () => { it('returns false for strategy not in config', async () => { mockGet.mockResolvedValue(createMockIdpConfig()); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); expect(result.current.isProvisioningMethodEnabled('google-apps')).toBe(false); }); @@ -151,7 +181,9 @@ describe('useIdpConfig', () => { it('returns false for undefined strategy', async () => { mockGet.mockResolvedValue(createMockIdpConfig()); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); expect(result.current.isProvisioningMethodEnabled(undefined)).toBe(false); }); @@ -161,7 +193,9 @@ describe('useIdpConfig', () => { it('returns null on 404', async () => { mockGet.mockRejectedValue({ body: { status: 404 } }); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); expect(result.current.idpConfig).toBeNull(); expect(result.current.isIdpConfigValid).toBe(false); @@ -171,7 +205,9 @@ describe('useIdpConfig', () => { describe('fetchIdpConfig', () => { it('triggers refetch', async () => { mockGet.mockResolvedValue(createMockIdpConfig()); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); mockGet.mockClear(); result.current.fetchIdpConfig(); @@ -179,4 +215,60 @@ describe('useIdpConfig', () => { await waitFor(() => expect(mockGet).toHaveBeenCalled()); }); }); + + describe('retry', () => { + it('triggers refetch', async () => { + mockGet.mockResolvedValue(createMockIdpConfig()); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); + + mockGet.mockClear(); + await result.current.retry(); + + await waitFor(() => expect(mockGet).toHaveBeenCalled()); + }); + }); + + describe('error retry logic', () => { + it('retries up to 3 times on non-404 errors', async () => { + const error = new Error('Network error'); + mockGet.mockRejectedValue(error); + + // Create a query client that allows retries with minimal delay + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 3, + retryDelay: 1, + gcTime: 0, + staleTime: 0, + }, + }, + }); + + const { wrapper } = createTestQueryClientWrapper(queryClient); + renderHook(() => useIdpConfig(), { wrapper }); + + await waitFor( + () => { + // Should retry 3 times (initial + 3 retries = 4 total calls) + expect(mockGet).toHaveBeenCalledTimes(4); + }, + { timeout: 5000 }, + ); + }); + + it('does not retry on 404 errors', async () => { + mockGet.mockRejectedValue({ body: { status: 404 } }); + + const { wrapper } = createTestQueryClientWrapper(); + renderHook(() => useIdpConfig(), { wrapper }); + + await waitFor(() => { + // Should only call once, no retries for 404 + expect(mockGet).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-organization-details-edit.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-organization-details-edit.test.ts index 17a7b5ce3..1b0a10b4e 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-organization-details-edit.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-organization-details-edit.test.ts @@ -64,18 +64,75 @@ describe('useOrganizationDetailsEdit', () => { expect(result.current.organization).toEqual(mockOrganization); }); - it('should allow manual refetch of organization data', async () => { + it('should allow manual retry of organization data', async () => { const { result, apiService } = await renderUseOrganizationDetailsEdit(); vi.clearAllMocks(); await act(async () => { - await result.current.fetchOrgDetails(); + await result.current.retry(); }); expect(apiService.organizationDetails.get).toHaveBeenCalledTimes(1); }); + it('should retry failed update mutation when retry is called', async () => { + const mockCoreClient = initMockCoreClient(); + const mockOrganization = createMockOrganization(); + const apiService = mockCoreClient.getMyOrganizationApiClient(); + + (apiService.organizationDetails.get as ReturnType).mockResolvedValue( + mockOrganization, + ); + + // First call fails, second call succeeds + (apiService.organizationDetails.update as ReturnType) + .mockRejectedValueOnce(new Error('Update failed')) + .mockResolvedValueOnce(mockOrganization); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + + const { wrapper } = createQueryClientWrapper(); + const { result } = renderHook(() => useOrganizationDetailsEdit({}), { wrapper }); + + await waitFor(() => { + expect(result.current.isFetchLoading).toBe(false); + }); + + // Attempt to update (this will fail) + const success = await act(async () => { + return result.current.updateOrgDetails(mockOrganization); + }); + + expect(success).toBe(false); + + // Wait for error to be set and mutation to complete + await waitFor(() => { + expect(result.current.isSaveLoading).toBe(false); + expect(result.current.error).toBeTruthy(); + }); + + // Clear mock call history but keep the implementations + (apiService.organizationDetails.update as ReturnType).mockClear(); + (apiService.organizationDetails.get as ReturnType).mockClear(); + + // Retry should attempt the mutation again with preserved variables + await act(async () => { + await result.current.retry(); + }); + + // Check if update was called (line 137) or get was called (line 140) + const updateCalls = (apiService.organizationDetails.update as ReturnType).mock + .calls.length; + const getCalls = (apiService.organizationDetails.get as ReturnType).mock.calls + .length; + + // One of these should be called + expect(updateCalls + getCalls).toBeGreaterThan(0); + }); + it('should show error toast when loading fails', async () => { const mockCoreClient = initMockCoreClient(); const apiService = mockCoreClient.getMyOrganizationApiClient(); @@ -99,6 +156,30 @@ describe('useOrganizationDetailsEdit', () => { message: expect.any(String), }); }); + + it('should show generic error message when error is not an Error instance', async () => { + const mockCoreClient = initMockCoreClient(); + const apiService = mockCoreClient.getMyOrganizationApiClient(); + (apiService.organizationDetails.get as ReturnType).mockRejectedValue( + 'String error', + ); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + + const { wrapper } = createQueryClientWrapper(); + const { result } = renderHook(() => useOrganizationDetailsEdit({}), { wrapper }); + + await waitFor(() => { + expect(result.current.isFetchLoading).toBe(false); + }); + + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'error', + message: expect.any(String), + }); + }); }); describe('saving changes', () => { diff --git a/packages/react/src/hooks/my-organization/__tests__/use-sso-domain-tab.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-domain-tab.test.ts index 9d7d7b5b2..04f8337f6 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-sso-domain-tab.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-sso-domain-tab.test.ts @@ -1,4 +1,3 @@ -import { BusinessError } from '@auth0/universal-components-core'; import { renderHook, act, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -83,6 +82,9 @@ describe('useSsoDomainTab', () => { }); mockHandleError = setupMockHandleError; + + // Ensure useErrorHandler always returns the mock function + vi.spyOn(useErrorHandlerModule, 'useErrorHandler').mockReturnValue(mockHandleError); }); const renderUseSsoDomainTab = async ( @@ -152,9 +154,7 @@ describe('useSsoDomainTab', () => { const { result } = renderHook(() => useSsoDomainTab('idp-1'), { wrapper }); await waitFor(() => { - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'general_error', - }); + expect(mockHandleError).toHaveBeenCalledWith(error); expect(result.current.isLoading).toBe(false); }); }); @@ -216,13 +216,15 @@ describe('useSsoDomainTab', () => { const { result } = await renderUseSsoDomainTab('idp-1'); await act(async () => { - await result.current.handleCreate('newdomain.com'); + try { + await result.current.handleCreate('newdomain.com'); + } catch (e) { + // Expected to throw + } }); await waitFor(() => { - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_create.error', - }); + expect(mockHandleError).toHaveBeenCalledWith(error); expect(result.current.isCreating).toBe(false); }); }); @@ -272,12 +274,16 @@ describe('useSsoDomainTab', () => { }); await act(async () => { - await result.current.handleCreate('newdomain.com'); + try { + await result.current.handleCreate('newdomain.com'); + } catch (e) { + // Expected to throw + } }); await waitFor(() => { expect(onBefore).toHaveBeenCalled(); - expect(mockHandleError).toHaveBeenCalledWith(expect.any(BusinessError), expect.any(Object)); + expect(mockHandleError).toHaveBeenCalledWith(expect.any(Error)); expect(result.current.isCreating).toBe(false); }); }); @@ -320,13 +326,15 @@ describe('useSsoDomainTab', () => { const { result } = await renderUseSsoDomainTab('idp-1'); await act(async () => { - await result.current.handleVerify(mockDomain); + try { + await result.current.handleVerify(mockDomain); + } catch (e) { + // Expected to throw + } }); await waitFor(() => { - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_verify.verification_failed', - }); + expect(mockHandleError).toHaveBeenCalledWith(error); expect(result.current.isVerifying).toBe(false); }); }); @@ -379,7 +387,7 @@ describe('useSsoDomainTab', () => { type: 'error', message: 'domain_verify.verification_failed', }); - expect(result.current.isUpdating).toBe(false); + expect(result.current.isVerifying).toBe(false); expect(result.current.isUpdatingId).toBeNull(); }); @@ -428,13 +436,15 @@ describe('useSsoDomainTab', () => { const { result } = await renderUseSsoDomainTab('idp-1'); await act(async () => { - await result.current.handleDelete(mockDomain); + try { + await result.current.handleDelete(mockDomain); + } catch (e) { + // Expected to throw + } }); await waitFor(() => { - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_delete.error', - }); + expect(mockHandleError).toHaveBeenCalledWith(error); expect(result.current.isDeleting).toBe(false); }); }); @@ -527,13 +537,15 @@ describe('useSsoDomainTab', () => { }); await act(async () => { - await result.current.handleToggleSwitch(mockDomain, true); + try { + await result.current.handleToggleSwitch(mockDomain, true); + } catch (e) { + // Expected to throw + } }); await waitFor(() => { - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'general_error', - }); + expect(mockHandleError).toHaveBeenCalledWith(error); expect(result.current.isUpdating).toBe(false); }); }); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-create.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-create.test.ts index fc3464282..5be181d1d 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-create.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-create.test.ts @@ -8,11 +8,13 @@ import { describe, expect, it, vi, beforeEach, type Mock } from 'vitest'; import { showToast } from '@/components/auth0/shared/toast'; import { useSsoProviderCreate } from '@/hooks/my-organization/use-sso-provider-create'; import { useCoreClient } from '@/hooks/shared/use-core-client'; +import * as useErrorHandlerModule from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; import { createTestQueryClientWrapper } from '@/tests/utils/test-provider'; vi.mock('@/hooks/shared/use-core-client'); vi.mock('@/hooks/shared/use-translator'); +vi.mock('@/hooks/shared/use-error-handler'); vi.mock('@/components/auth0/shared/toast'); describe('useSsoProviderCreate', () => { @@ -56,6 +58,16 @@ describe('useSsoProviderCreate', () => { vi.clearAllMocks(); (useCoreClient as Mock).mockReturnValue({ coreClient: mockCoreClient }); (useTranslator as Mock).mockReturnValue({ t: mockT }); + + // Mock handleError to show generic error toast + const mockHandleError = vi.fn(() => { + showToast({ + type: 'error', + message: mockT('notifications.general_error'), + }); + return null; + }); + vi.spyOn(useErrorHandlerModule, 'useErrorHandler').mockReturnValue(mockHandleError); }); const renderUseSsoProviderCreate = (...args: Parameters) => { diff --git a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-edit.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-edit.test.ts index 8513383d0..ee5e376bc 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-edit.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-edit.test.ts @@ -3,80 +3,22 @@ import type { CreateIdpProvisioningScimTokenRequestContent, OrganizationPrivate, } from '@auth0/universal-components-core'; -import { renderHook, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi, beforeEach, type Mock } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { showToast } from '@/components/auth0/shared/toast'; import { useSsoProviderEdit } from '@/hooks/my-organization/use-sso-provider-edit'; -import { useCoreClient } from '@/hooks/shared/use-core-client'; -import { useTranslator } from '@/hooks/shared/use-translator'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import * as useErrorHandlerModule from '@/hooks/shared/use-error-handler'; +import * as useTranslatorModule from '@/hooks/shared/use-translator'; +import { mockCore, setupAllCommonMocks } from '@/tests/utils'; import { createTestQueryClientWrapper } from '@/tests/utils/test-provider'; -vi.mock('@/hooks/shared/use-core-client'); -vi.mock('@/hooks/shared/use-translator'); -vi.mock('@/components/auth0/shared/toast'); +const { initMockCoreClient } = mockCore(); describe('useSsoProviderEdit', () => { const mockIdpId = 'idp_123'; - const mockGet = vi.fn(); - const mockUpdate = vi.fn(); - const mockDelete = vi.fn(); - const mockDetach = vi.fn(); - const mockGetOrgDetails = vi.fn(); - const mockProvisioningGet = vi.fn(); - const mockProvisioningCreate = vi.fn(); - const mockProvisioningDelete = vi.fn(); - const mockScimTokensList = vi.fn(); - const mockScimTokensCreate = vi.fn(); - const mockScimTokensDelete = vi.fn(); - - const mockT = vi.fn((key: string, params?: Record) => { - if (key === 'update_success') { - return `Provider ${params?.providerName} updated successfully`; - } - if (key === 'delete_success') { - return `Provider ${params?.providerName} deleted successfully`; - } - if (key === 'remove_success') { - return `Provider ${params?.providerName} removed from ${params?.organizationName}`; - } - if (key === 'scim_token_create_success') { - return 'SCIM token created successfully'; - } - if (key === 'scim_token_delete_sucess') { - return 'SCIM token deleted successfully'; - } - if (key === 'general_error') { - return 'An error occurred'; - } - return key; - }); - - const mockCoreClient = { - getMyOrganizationApiClient: () => ({ - organization: { - identityProviders: { - get: mockGet, - update: mockUpdate, - delete: mockDelete, - detach: mockDetach, - provisioning: { - get: mockProvisioningGet, - create: mockProvisioningCreate, - delete: mockProvisioningDelete, - scimTokens: { - list: mockScimTokensList, - create: mockScimTokensCreate, - delete: mockScimTokensDelete, - }, - }, - }, - }, - organizationDetails: { - get: mockGetOrgDetails, - }, - }), - }; + let mockCoreClient: ReturnType; + let mockHandleError: ReturnType; const mockProvider: IdentityProvider = { id: mockIdpId, @@ -106,11 +48,66 @@ describe('useSsoProviderEdit', () => { beforeEach(() => { vi.clearAllMocks(); - (useCoreClient as Mock).mockReturnValue({ coreClient: mockCoreClient }); - (useTranslator as Mock).mockReturnValue({ t: mockT }); - mockGet.mockResolvedValue(mockProvider); - mockGetOrgDetails.mockResolvedValue(mockOrganization); - mockProvisioningGet.mockResolvedValue({ enabled: false }); + + mockCoreClient = initMockCoreClient(); + + const apiService = mockCoreClient.getMyOrganizationApiClient(); + + // Mock API calls + (apiService.organization.identityProviders.get as ReturnType).mockResolvedValue( + mockProvider, + ); + (apiService.organizationDetails.get as ReturnType).mockResolvedValue( + mockOrganization, + ); + ( + apiService.organization.identityProviders.provisioning.get as ReturnType + ).mockRejectedValue({ status: 404 }); + ( + apiService.organization.identityProviders.update as ReturnType + ).mockResolvedValue(mockProvider); + ( + apiService.organization.identityProviders.delete as ReturnType + ).mockResolvedValue(undefined); + ( + apiService.organization.identityProviders.detach as ReturnType + ).mockResolvedValue(undefined); + ( + apiService.organization.identityProviders.provisioning.create as ReturnType + ).mockResolvedValue({ enabled: true }); + ( + apiService.organization.identityProviders.provisioning.delete as ReturnType + ).mockResolvedValue(undefined); + ( + apiService.organization.identityProviders.provisioning.updateAttributes as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(undefined); + ( + apiService.organization.identityProviders.provisioning.scimTokens.create as ReturnType< + typeof vi.fn + > + ).mockResolvedValue({ id: 'token_123', token: 'secret_token' }); + ( + apiService.organization.identityProviders.provisioning.scimTokens.delete as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(undefined); + ( + apiService.organization.identityProviders.provisioning.scimTokens.list as ReturnType< + typeof vi.fn + > + ).mockResolvedValue([]); + + const { mockHandleError: setupMockHandleError } = setupAllCommonMocks({ + coreClient: mockCoreClient, + useCoreClientModule, + useTranslatorModule, + useErrorHandlerModule, + }); + + mockHandleError = setupMockHandleError; + vi.spyOn(useErrorHandlerModule, 'useErrorHandler').mockReturnValue(mockHandleError); }); it('should initialize with correct default states', () => { @@ -123,1173 +120,374 @@ describe('useSsoProviderEdit', () => { expect(result.current.isRemoving).toBe(false); expect(result.current.isProvisioningUpdating).toBe(false); expect(result.current.isProvisioningDeleting).toBe(false); - expect(result.current.isProvisioningLoading).toBe(true); - expect(result.current.isScimTokensLoading).toBe(false); - expect(result.current.isScimTokenCreating).toBe(false); - expect(result.current.isScimTokenDeleting).toBe(false); - expect(typeof result.current.fetchProvider).toBe('function'); expect(typeof result.current.updateProvider).toBe('function'); expect(typeof result.current.onDeleteConfirm).toBe('function'); expect(typeof result.current.onRemoveConfirm).toBe('function'); + expect(typeof result.current.retry).toBe('function'); }); it('should fetch provider on mount', async () => { const { result } = renderUseSsoProviderEdit(mockIdpId); await waitFor(() => { - expect(mockGet).toHaveBeenCalledWith(mockIdpId); expect(result.current.provider).toEqual(mockProvider); expect(result.current.isLoading).toBe(false); }); }); - it('should fetch organization details when requested', async () => { + it('should update provider successfully', async () => { const { result } = renderUseSsoProviderEdit(mockIdpId); - await result.current.fetchOrganizationDetails(); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + const updateData = { strategy: 'samlp' as const, is_enabled: true }; + await result.current.updateProvider(updateData); await waitFor(() => { - expect(mockGetOrgDetails).toHaveBeenCalled(); - expect(result.current.organization).toEqual(mockOrganization); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.update, + ).toHaveBeenCalledWith(mockIdpId, expect.any(Object)); + expect(result.current.isUpdating).toBe(false); }); }); it('should delete provider successfully', async () => { - mockDelete.mockResolvedValue(undefined); - const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); await result.current.onDeleteConfirm(); await waitFor(() => { - expect(mockDelete).toHaveBeenCalledWith(mockIdpId); - expect(showToast).toHaveBeenCalledWith({ - type: 'success', - message: 'Provider Test Provider deleted successfully', - }); - expect(result.current.isDeleting).toBe(false); - }); - }); - - it('should set isDeleting to true during deletion', async () => { - mockDelete.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - const deletePromise = result.current.onDeleteConfirm(); - - await deletePromise; - - await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.delete, + ).toHaveBeenCalledWith(mockIdpId); expect(result.current.isDeleting).toBe(false); }); }); it('should remove provider from organization successfully', async () => { - mockDetach.mockResolvedValue(undefined); - const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); await result.current.onRemoveConfirm(); await waitFor(() => { - expect(mockDetach).toHaveBeenCalledWith(mockIdpId); - expect(showToast).toHaveBeenCalledWith({ - type: 'success', - message: expect.stringContaining('removed'), - }); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.detach, + ).toHaveBeenCalledWith(mockIdpId); expect(result.current.isRemoving).toBe(false); }); }); - it('should fetch provisioning config', async () => { - mockProvisioningGet.mockResolvedValue({ - enabled: true, - }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + it('should handle fetch provider error', async () => { + const error = new Error('Fetch failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.get as ReturnType< + typeof vi.fn + > + ).mockRejectedValue(error); - const provisioningResult = await result.current.fetchProvisioning(); + renderUseSsoProviderEdit(mockIdpId); await waitFor(() => { - expect(mockProvisioningGet).toHaveBeenCalledWith(mockIdpId); - expect(provisioningResult).toEqual({ - enabled: true, - }); - expect(result.current.provisioningConfig).toEqual({ - enabled: true, - }); - expect(result.current.isProvisioningLoading).toBe(false); + expect(mockHandleError).toHaveBeenCalledWith(error); }); }); - it('should handle 404 when fetching provisioning config', async () => { - mockProvisioningGet.mockRejectedValue({ - body: { status: 404 }, - }); + it('should handle update provider error', async () => { + const error = new Error('Update failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .update as ReturnType + ).mockRejectedValue(error); const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - const provisioningResult = await result.current.fetchProvisioning(); + try { + await result.current.updateProvider({ strategy: 'samlp', is_enabled: true }); + } catch (e) { + // Expected to throw + } await waitFor(() => { - expect(provisioningResult).toBe(null); - expect(result.current.provisioningConfig).toBe(null); - expect(result.current.isProvisioningLoading).toBe(false); + expect(mockHandleError).toHaveBeenCalledWith(error); }); }); it('should create provisioning successfully', async () => { - mockProvisioningCreate.mockResolvedValue({ - enabled: true, - }); - mockGet.mockResolvedValue(mockProvider); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .create as ReturnType + ).mockResolvedValue({ enabled: true }); const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); await result.current.createProvisioning(); await waitFor(() => { - expect(mockProvisioningCreate).toHaveBeenCalledWith(mockIdpId); - expect(showToast).toHaveBeenCalledWith({ - type: 'success', - message: 'Provider Test Provider updated successfully', - }); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .create, + ).toHaveBeenCalledWith(mockIdpId); expect(result.current.isProvisioningUpdating).toBe(false); }); }); - it('should call onBefore callback for provisioning create and abort when it returns false', async () => { - const onBefore = vi.fn().mockReturnValue(false); - - const { result } = renderUseSsoProviderEdit(mockIdpId, { - provisioning: { - createAction: { onBefore }, - }, - }); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.createProvisioning(); - - expect(onBefore).toHaveBeenCalledWith(mockProvider); - expect(mockProvisioningCreate).not.toHaveBeenCalled(); - expect(showToast).not.toHaveBeenCalled(); - }); - it('should delete provisioning successfully', async () => { - mockProvisioningDelete.mockResolvedValue(undefined); - mockGet.mockResolvedValue(mockProvider); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .delete as ReturnType + ).mockResolvedValue(undefined); const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); await result.current.deleteProvisioning(); await waitFor(() => { - expect(mockProvisioningDelete).toHaveBeenCalledWith(mockIdpId); - expect(result.current.provisioningConfig).toBe(null); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .delete, + ).toHaveBeenCalledWith(mockIdpId); expect(result.current.isProvisioningDeleting).toBe(false); }); }); - it('should list SCIM tokens', async () => { - const mockTokens = [{ id: 'token_1', name: 'Token 1' }]; - mockScimTokensList.mockResolvedValue(mockTokens); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - const tokens = await result.current.listScimTokens(); - - await waitFor(() => { - expect(mockScimTokensList).toHaveBeenCalledWith(mockIdpId); - expect(tokens).toEqual(mockTokens); - expect(result.current.isScimTokensLoading).toBe(false); - }); - }); - it('should create SCIM token successfully', async () => { - const tokenData: CreateIdpProvisioningScimTokenRequestContent = {}; - - const mockNewToken = { id: 'token_123', name: 'New Token', token: 'secret_token' }; - mockScimTokensCreate.mockResolvedValue(mockNewToken); + const mockToken = { id: 'token_123', token: 'secret_token' }; + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.create as ReturnType + ).mockResolvedValue(mockToken); const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + const tokenData: CreateIdpProvisioningScimTokenRequestContent = {}; const token = await result.current.createScimToken(tokenData); + expect(token).toEqual(mockToken); await waitFor(() => { - expect(mockScimTokensCreate).toHaveBeenCalledWith(mockIdpId, tokenData); - expect(showToast).toHaveBeenCalledWith({ - type: 'success', - message: 'SCIM token created successfully', - }); - expect(token).toEqual(mockNewToken); expect(result.current.isScimTokenCreating).toBe(false); }); }); - it('should call onBefore callback for SCIM token create and abort when it returns false', async () => { - const tokenData = {} as CreateIdpProvisioningScimTokenRequestContent; - - const onBefore = vi.fn().mockReturnValue(false); - - const { result } = renderUseSsoProviderEdit(mockIdpId, { - provisioning: { - createScimTokenAction: { onBefore }, - }, - }); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.createScimToken(tokenData); - - expect(onBefore).toHaveBeenCalledWith(mockProvider); - expect(mockScimTokensCreate).not.toHaveBeenCalled(); - }); - it('should delete SCIM token successfully', async () => { - const tokenId = 'token_123'; - mockScimTokensDelete.mockResolvedValue(undefined); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.delete as ReturnType + ).mockResolvedValue(undefined); const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - await result.current.deleteScimToken(tokenId); + await result.current.deleteScimToken('token_123'); await waitFor(() => { - expect(mockScimTokensDelete).toHaveBeenCalledWith(mockIdpId, tokenId); - expect(showToast).toHaveBeenCalledWith({ - type: 'success', - message: 'SCIM token deleted successfully', - }); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.delete, + ).toHaveBeenCalledWith(mockIdpId, 'token_123'); expect(result.current.isScimTokenDeleting).toBe(false); }); }); - it('should return early if coreClient is not available', async () => { - (useCoreClient as Mock).mockReturnValue({ coreClient: null }); + it('should list SCIM tokens successfully', async () => { + const mockTokens = [ + { id: 'token_1', scopes: ['read'] }, + { id: 'token_2', scopes: ['write'] }, + ]; + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.list as ReturnType + ).mockResolvedValue(mockTokens); const { result } = renderUseSsoProviderEdit(mockIdpId); - const provider = await result.current.fetchProvider(); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - expect(provider).toBe(null); - expect(mockGet).not.toHaveBeenCalled(); - }); - - it('should return early if idpId is not provided', async () => { - const { result } = renderUseSsoProviderEdit(''); - - const provider = await result.current.fetchProvider(); - - expect(provider).toBe(null); - expect(mockGet).not.toHaveBeenCalled(); - }); - - it('should handle fetch provider error', async () => { - mockGet.mockRejectedValue(new Error('Fetch failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - expect(result.current.isLoading).toBe(false); - }); - }); - - it('should use custom messages when provided', async () => { - const customMessages = { - update_success: 'Custom update message', - }; - - renderUseSsoProviderEdit(mockIdpId, { customMessages }); + const tokens = await result.current.listScimTokens(); + expect(tokens).toEqual(mockTokens); await waitFor(() => { - expect(useTranslator).toHaveBeenCalledWith('idp_management.notifications', customMessages); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.list, + ).toHaveBeenCalledWith(mockIdpId); + expect(result.current.isScimTokensLoading).toBe(false); }); }); - it('should update provider successfully', async () => { - const updateData = { - display_name: 'Updated Provider', - strategy: mockProvider.strategy, - }; - - const updatedProvider = { - ...mockProvider, - display_name: 'Updated Provider', - strategy: mockProvider.strategy, - }; - - mockUpdate.mockResolvedValue(updatedProvider); + it('should handle list SCIM tokens error', async () => { + const error = new Error('List failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.list as ReturnType + ).mockRejectedValue(error); const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - await result.current.updateProvider(updateData); + const tokens = await result.current.listScimTokens(); + expect(tokens).toBeNull(); await waitFor(() => { - expect(mockUpdate).toHaveBeenCalledWith(mockIdpId, expect.any(Object)); - expect(showToast).toHaveBeenCalledWith({ - type: 'success', - message: 'Provider Test Provider updated successfully', - }); - expect(result.current.provider).toEqual(updatedProvider); - expect(result.current.isUpdating).toBe(false); + expect(mockHandleError).toHaveBeenCalledWith(error); }); }); - describe('syncSsoAttributes', () => { - const mockUpdateAttributes = vi.fn(); - - beforeEach(() => { - mockCoreClient.getMyOrganizationApiClient = () => ({ - organization: { - identityProviders: { - get: mockGet, - update: mockUpdate, - delete: mockDelete, - detach: mockDetach, - updateAttributes: mockUpdateAttributes, - provisioning: { - get: mockProvisioningGet, - create: mockProvisioningCreate, - delete: mockProvisioningDelete, - updateAttributes: vi.fn(), - scimTokens: { - list: mockScimTokensList, - create: mockScimTokensCreate, - delete: mockScimTokensDelete, - }, - }, - }, - }, - organizationDetails: { - get: mockGetOrgDetails, - }, - }); - }); - - const renderUseSsoProviderEdit = (...args: Parameters) => { - const { wrapper } = createTestQueryClientWrapper(); - return renderHook(() => useSsoProviderEdit(...args), { wrapper }); - }; - - it('should sync SSO attributes successfully', async () => { - mockUpdateAttributes.mockResolvedValue(undefined); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.syncSsoAttributes(); - - await waitFor(() => { - expect(mockUpdateAttributes).toHaveBeenCalledWith(mockIdpId, {}); - expect(showToast).toHaveBeenCalledWith({ - type: 'success', - message: 'sso_attributes_sync_success', - }); - expect(result.current.isSsoAttributesSyncing).toBe(false); - }); - }); - - it('should handle error when syncing SSO attributes', async () => { - mockUpdateAttributes.mockRejectedValue(new Error('Sync failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await expect(result.current.syncSsoAttributes()).rejects.toThrow(); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); - }); - - it('should return early if coreClient is not available', async () => { - (useCoreClient as Mock).mockReturnValue({ coreClient: null }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); + it('should expose granular SCIM token loading states', async () => { + const { result } = renderUseSsoProviderEdit(mockIdpId); - await result.current.syncSsoAttributes(); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - expect(mockUpdateAttributes).not.toHaveBeenCalled(); - }); + expect(result.current.isScimTokensLoading).toBe(false); + expect(result.current.isScimTokenCreating).toBe(false); + expect(result.current.isScimTokenDeleting).toBe(false); }); - describe('syncProvisioningAttributes', () => { - const mockProvisioningUpdateAttributes = vi.fn(); - - beforeEach(() => { - mockCoreClient.getMyOrganizationApiClient = () => ({ - organization: { - identityProviders: { - get: mockGet, - update: mockUpdate, - delete: mockDelete, - detach: mockDetach, - updateAttributes: vi.fn(), - provisioning: { - get: mockProvisioningGet, - create: mockProvisioningCreate, - delete: mockProvisioningDelete, - updateAttributes: mockProvisioningUpdateAttributes, - scimTokens: { - list: mockScimTokensList, - create: mockScimTokensCreate, - delete: mockScimTokensDelete, - }, - }, - }, - }, - organizationDetails: { - get: mockGetOrgDetails, - }, - }); - }); - - it('should sync provisioning attributes successfully', async () => { - mockProvisioningUpdateAttributes.mockResolvedValue(undefined); - mockProvisioningGet.mockResolvedValue({ enabled: true }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.syncProvisioningAttributes(); - - await waitFor(() => { - expect(mockProvisioningUpdateAttributes).toHaveBeenCalledWith(mockIdpId, {}); - expect(showToast).toHaveBeenCalledWith({ - type: 'success', - message: 'provisioning_attributes_sync_success', - }); - expect(result.current.isProvisioningAttributesSyncing).toBe(false); - }); - }); - - it('should handle error when syncing provisioning attributes', async () => { - mockProvisioningUpdateAttributes.mockRejectedValue(new Error('Sync failed')); + it('should sync SSO attributes successfully', async () => { + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.create as ReturnType + ).mockResolvedValue(undefined); - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await expect(result.current.syncProvisioningAttributes()).rejects.toThrow(); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); - }); - - it('should return early if coreClient is not available', async () => { - (useCoreClient as Mock).mockReturnValue({ coreClient: null }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await result.current.syncProvisioningAttributes(); - - expect(mockProvisioningUpdateAttributes).not.toHaveBeenCalled(); - }); + const { result } = renderUseSsoProviderEdit(mockIdpId); - it('should return early if idpId is not provided', async () => { - const { result } = renderUseSsoProviderEdit(''); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - await result.current.syncProvisioningAttributes(); + await result.current.syncSsoAttributes(); - expect(mockProvisioningUpdateAttributes).not.toHaveBeenCalled(); + await waitFor(() => { + expect(result.current.isSsoAttributesSyncing).toBe(false); }); }); - describe('hasSsoAttributeSyncWarning', () => { - it('should return true when provider has extra attributes', async () => { - const providerWithExtraAttr = { - ...mockProvider, - attributes: [{ is_extra: true, is_missing: false }], - }; - mockGet.mockResolvedValue(providerWithExtraAttr); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.hasSsoAttributeSyncWarning).toBe(true); - }); - }); - - it('should return true when provider has missing attributes', async () => { - const providerWithMissingAttr = { - ...mockProvider, - attributes: [{ is_extra: false, is_missing: true }], - }; - mockGet.mockResolvedValue(providerWithMissingAttr); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.hasSsoAttributeSyncWarning).toBe(true); - }); - }); - - it('should return false when provider has no attribute issues', async () => { - const providerWithNoIssues = { - ...mockProvider, - attributes: [{ is_extra: false, is_missing: false }], - }; - mockGet.mockResolvedValue(providerWithNoIssues); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.hasSsoAttributeSyncWarning).toBe(false); - }); - }); - - it('should return false when provider has no attributes property', async () => { - mockGet.mockResolvedValue(mockProvider); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.hasSsoAttributeSyncWarning).toBe(false); - }); - }); + it('should sync provisioning attributes successfully', async () => { + const { result } = renderUseSsoProviderEdit(mockIdpId); - it('should return false when provider is null', async () => { - mockGet.mockResolvedValue(null); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - const { result } = renderUseSsoProviderEdit(mockIdpId); + await result.current.syncProvisioningAttributes(); - await waitFor(() => { - expect(result.current.hasSsoAttributeSyncWarning).toBe(false); - }); + await waitFor(() => { + expect(result.current.isProvisioningAttributesSyncing).toBe(false); }); }); - describe('hasProvisioningAttributeSyncWarning', () => { - it('should return true when provisioning config has extra attributes', async () => { - mockProvisioningGet.mockResolvedValue({ - enabled: true, - attributes: [{ is_extra: true, is_missing: false }], - }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.fetchProvisioning(); - - await waitFor(() => { - expect(result.current.hasProvisioningAttributeSyncWarning).toBe(true); - }); - }); - - it('should return true when provisioning config has missing attributes', async () => { - mockProvisioningGet.mockResolvedValue({ - enabled: true, - attributes: [{ is_extra: false, is_missing: true }], - }); + it('should return early if coreClient is not available', () => { + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.fetchProvisioning(); - - await waitFor(() => { - expect(result.current.hasProvisioningAttributeSyncWarning).toBe(true); - }); - }); - - it('should return false when provisioning config has no attribute issues', async () => { - mockProvisioningGet.mockResolvedValue({ - enabled: true, - attributes: [{ is_extra: false, is_missing: false }], - }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.fetchProvisioning(); - - await waitFor(() => { - expect(result.current.hasProvisioningAttributeSyncWarning).toBe(false); - }); - }); - - it('should return false when provisioning config is null', async () => { - mockProvisioningGet.mockRejectedValue({ body: { status: 404 } }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.fetchProvisioning(); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.hasProvisioningAttributeSyncWarning).toBe(false); - }); - }); + expect(result.current.provider).toBe(null); + expect(result.current.isLoading).toBe(false); }); - describe('onBefore callbacks', () => { - it('should call onBefore callback for update and abort when it returns false', async () => { - const onBefore = vi.fn().mockReturnValue(false); - - const { result } = renderUseSsoProviderEdit(mockIdpId, { - sso: { - updateAction: { onBefore }, - deleteAction: {}, - deleteFromOrganizationAction: {}, - }, - }); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.updateProvider({ display_name: 'Test', strategy: 'samlp' }); - - expect(onBefore).toHaveBeenCalledWith(mockProvider); - expect(mockUpdate).not.toHaveBeenCalled(); - expect(showToast).not.toHaveBeenCalled(); - }); - - it('should call onBefore callback for provisioning delete and abort when it returns false', async () => { - const onBefore = vi.fn().mockReturnValue(false); - - const { result } = renderUseSsoProviderEdit(mockIdpId, { - provisioning: { - deleteAction: { onBefore }, - }, - }); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.deleteProvisioning(); - - expect(onBefore).toHaveBeenCalledWith(mockProvider); - expect(mockProvisioningDelete).not.toHaveBeenCalled(); - expect(showToast).not.toHaveBeenCalled(); - }); - - it('should call onBefore callback for SCIM token delete and abort when it returns false', async () => { - const onBefore = vi.fn().mockReturnValue(false); - - const { result } = renderUseSsoProviderEdit(mockIdpId, { - provisioning: { - deleteScimTokenAction: { onBefore }, - }, - }); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.deleteScimToken('token_123'); - - expect(onBefore).toHaveBeenCalledWith(mockProvider); - expect(mockScimTokensDelete).not.toHaveBeenCalled(); - expect(showToast).not.toHaveBeenCalled(); - }); - - it('should call onBefore callback for remove from org and abort when it returns false', async () => { - const onBefore = vi.fn().mockReturnValue(false); - - const { result } = renderUseSsoProviderEdit(mockIdpId, { - sso: { - deleteAction: {}, - deleteFromOrganizationAction: { onBefore }, - }, - }); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.onRemoveConfirm(); + it('should return early if idpId is not provided', () => { + const { result } = renderUseSsoProviderEdit(''); - expect(onBefore).toHaveBeenCalledWith(mockProvider); - expect(mockDetach).not.toHaveBeenCalled(); - }); + expect(result.current.provider).toBe(null); + expect(result.current.isLoading).toBe(false); }); - describe('organization query errors', () => { - it('should show toast when organization query fails on mount', async () => { - mockGetOrgDetails.mockRejectedValue(new Error('Organization fetch failed')); - - renderUseSsoProviderEdit(mockIdpId); + it('should handle 404 when fetching provisioning config', async () => { + const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); + await waitFor(() => { + expect(result.current.provisioningConfig).toBe(null); }); }); - describe('onAfter callbacks', () => { - it('should call onAfter callback after successful update', async () => { - const updatedProvider = { ...mockProvider, display_name: 'Updated' }; - mockUpdate.mockResolvedValue(updatedProvider); - const onAfter = vi.fn(); - - const { result } = renderUseSsoProviderEdit(mockIdpId, { - sso: { - updateAction: { onAfter }, - deleteAction: {}, - deleteFromOrganizationAction: {}, - }, - }); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.updateProvider({ display_name: 'Updated', strategy: 'samlp' }); - - await waitFor(() => { - expect(onAfter).toHaveBeenCalledWith(mockProvider, updatedProvider); - }); - }); - - it('should call onAfter callback after successful provisioning create', async () => { - const provisioningResult = { enabled: true }; - mockProvisioningCreate.mockResolvedValue(provisioningResult); - const onAfter = vi.fn(); - - const { result } = renderUseSsoProviderEdit(mockIdpId, { - provisioning: { - createAction: { onAfter }, - }, - }); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.createProvisioning(); - - await waitFor(() => { - expect(onAfter).toHaveBeenCalledWith(mockProvider, provisioningResult); - }); - }); - - it('should call onAfter callback after successful provisioning delete', async () => { - mockProvisioningDelete.mockResolvedValue(undefined); - const onAfter = vi.fn(); - - const { result } = renderUseSsoProviderEdit(mockIdpId, { - provisioning: { - deleteAction: { onAfter }, - }, - }); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.deleteProvisioning(); + it('should call onBefore callback and abort when it returns false', async () => { + const onBefore = vi.fn().mockReturnValue(false); - await waitFor(() => { - expect(onAfter).toHaveBeenCalledWith(mockProvider); - }); + const { result } = renderUseSsoProviderEdit(mockIdpId, { + sso: { + updateAction: { onBefore }, + deleteAction: {}, + deleteFromOrganizationAction: {}, + }, }); - it('should call onAfter callback after successful SCIM token create', async () => { - const newToken = { id: 'token_123', token: 'secret' }; - mockScimTokensCreate.mockResolvedValue(newToken); - const onAfter = vi.fn(); - - const { result } = renderUseSsoProviderEdit(mockIdpId, { - provisioning: { - createScimTokenAction: { onAfter }, - }, - }); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.createScimToken({}); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - await waitFor(() => { - expect(onAfter).toHaveBeenCalledWith(mockProvider, newToken); - }); + await act(async () => { + try { + await result.current.updateProvider({ strategy: 'samlp', is_enabled: true }); + } catch (e) { + // Expected to throw + } }); - it('should call onAfter callback after successful SCIM token delete', async () => { - mockScimTokensDelete.mockResolvedValue(undefined); - const onAfter = vi.fn(); - - const { result } = renderUseSsoProviderEdit(mockIdpId, { - provisioning: { - deleteScimTokenAction: { onAfter }, - }, - }); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.deleteScimToken('token_123'); - - await waitFor(() => { - expect(onAfter).toHaveBeenCalledWith(mockProvider); - }); + await waitFor(() => { + expect(onBefore).toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); // Should NOT call handleError for cancelled actions }); }); - describe('error handling', () => { - it('should handle update provider error', async () => { - mockUpdate.mockRejectedValue(new Error('Update failed')); + it('should call onAfter callback after successful update', async () => { + const onAfter = vi.fn(); - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await expect( - result.current.updateProvider({ display_name: 'Test', strategy: 'samlp' }), - ).rejects.toThrow(); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - expect(result.current.isUpdating).toBe(false); - }); - }); - - it('should handle create provisioning error', async () => { - mockProvisioningCreate.mockRejectedValue(new Error('Create failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await expect(result.current.createProvisioning()).rejects.toThrow(); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); - }); - - it('should handle delete provisioning error', async () => { - mockProvisioningDelete.mockRejectedValue(new Error('Delete failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await expect(result.current.deleteProvisioning()).rejects.toThrow(); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); - }); - - it('should handle list SCIM tokens error', async () => { - mockScimTokensList.mockRejectedValue(new Error('List failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - const tokens = await result.current.listScimTokens(); - - expect(tokens).toBe(null); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); - }); - - it('should handle create SCIM token error', async () => { - mockScimTokensCreate.mockRejectedValue(new Error('Create failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await expect(result.current.createScimToken({})).rejects.toThrow(); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); - }); - - it('should handle delete SCIM token error', async () => { - mockScimTokensDelete.mockRejectedValue(new Error('Delete failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await expect(result.current.deleteScimToken('token_123')).rejects.toThrow(); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); - }); - - it('should handle delete provider error', async () => { - mockDelete.mockRejectedValue(new Error('Delete failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await expect(result.current.onDeleteConfirm()).rejects.toThrow(); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); - }); - - it('should handle remove from organization error', async () => { - mockDetach.mockRejectedValue(new Error('Remove failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await expect(result.current.onRemoveConfirm()).rejects.toThrow(); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); - }); - - it('should handle fetch organization details error', async () => { - mockGetOrgDetails.mockRejectedValue(new Error('Fetch failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await result.current.fetchOrganizationDetails(); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); + const { result } = renderUseSsoProviderEdit(mockIdpId, { + sso: { + updateAction: { onAfter }, + deleteAction: {}, + deleteFromOrganizationAction: {}, + }, }); - it('should handle non-404 error when fetching provisioning config', async () => { - mockProvisioningGet.mockRejectedValue({ - body: { status: 500 }, - }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await result.current.updateProvider({ strategy: 'samlp', is_enabled: true }); - await result.current.fetchProvisioning(); - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider, mockProvider); }); }); - describe('early returns', () => { - it('should return early from updateProvider if provider is null', async () => { - mockGet.mockResolvedValue(null); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await result.current.updateProvider({ display_name: 'Test', strategy: 'samlp' }); - - expect(mockUpdate).not.toHaveBeenCalled(); - }); - - it('should return early from createProvisioning if provider is null', async () => { - mockGet.mockResolvedValue(null); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await result.current.createProvisioning(); - - expect(mockProvisioningCreate).not.toHaveBeenCalled(); - }); - - it('should return early from deleteProvisioning if provider is null', async () => { - mockGet.mockResolvedValue(null); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await result.current.deleteProvisioning(); - - expect(mockProvisioningDelete).not.toHaveBeenCalled(); - }); + it('should expose error from queries', async () => { + const error = new Error('Query error'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.get as ReturnType< + typeof vi.fn + > + ).mockRejectedValue(error); - it('should return early from listScimTokens if coreClient is null', async () => { - (useCoreClient as Mock).mockReturnValue({ coreClient: null }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - const tokens = await result.current.listScimTokens(); - - expect(tokens).toBe(null); - expect(mockScimTokensList).not.toHaveBeenCalled(); - }); - - it('should return early from createScimToken if coreClient is null', async () => { - (useCoreClient as Mock).mockReturnValue({ coreClient: null }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await result.current.createScimToken({}); - - expect(mockScimTokensCreate).not.toHaveBeenCalled(); - }); - - it('should return early from deleteScimToken if coreClient is null', async () => { - (useCoreClient as Mock).mockReturnValue({ coreClient: null }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await result.current.deleteScimToken('token_123'); + const { result } = renderUseSsoProviderEdit(mockIdpId); - expect(mockScimTokensDelete).not.toHaveBeenCalled(); + await waitFor(() => { + expect(result.current.error).toBe(error); }); + }); - it('should return early from onDeleteConfirm if provider is null', async () => { - mockGet.mockResolvedValue(null); - - const { result } = renderUseSsoProviderEdit(mockIdpId); + it('should retry on error', async () => { + const error = new Error('Query error'); + const mockGet = mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .get as ReturnType; + mockGet.mockRejectedValueOnce(error).mockResolvedValue(mockProvider); - await result.current.onDeleteConfirm(); + const { result } = renderUseSsoProviderEdit(mockIdpId); - expect(mockDelete).not.toHaveBeenCalled(); + await waitFor(() => { + expect(result.current.error).toBe(error); }); - it('should return early from onRemoveConfirm if provider is null', async () => { - mockGet.mockResolvedValue(null); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await result.current.onRemoveConfirm(); + await result.current.retry(); - expect(mockDetach).not.toHaveBeenCalled(); + await waitFor(() => { + expect(result.current.error).toBeNull(); + expect(result.current.provider).toEqual(mockProvider); }); }); }); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-table.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-table.test.ts index 2de03dcef..ce5cedd03 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-table.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-table.test.ts @@ -1,695 +1,371 @@ -import type { IdentityProvider, OrganizationPrivate } from '@auth0/universal-components-core'; -import { renderHook, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - ssoProviderQueryKeys, - useSsoProviderTable, -} from '@/hooks/my-organization/use-sso-provider-table'; +import { useSsoProviderTable } from '@/hooks/my-organization/use-sso-provider-table'; import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import * as useErrorHandlerModule from '@/hooks/shared/use-error-handler'; import * as useTranslatorModule from '@/hooks/shared/use-translator'; -import { mockToast, createMockI18nService } from '@/tests/utils'; -import { createMockCoreClient } from '@/tests/utils/__mocks__/core/core-client.mocks'; +import { + mockCore, + createMockIdentityProvider, + setupAllCommonMocks, + setupMockUseCoreClientNull, +} from '@/tests/utils'; import { createTestQueryClientWrapper } from '@/tests/utils/test-provider'; -import { setupMockUseCoreClient, setupMockUseCoreClientNull } from '@/tests/utils/test-utilities'; - -// ===== Mock packages ===== - -const { mockedShowToast } = mockToast(); - -// ===== Mock Data ===== - -const mockIdentityProviders: IdentityProvider[] = [ - { - id: 'idp-1', - display_name: 'OKTA SSO', - strategy: 'okta', - is_enabled: true, - options: {}, - }, - { - id: 'idp-2', - display_name: 'Azure AD', - strategy: 'waad', - is_enabled: false, - options: {}, - }, -]; - -const mockOrganization: OrganizationPrivate = { - id: 'organization-123', - display_name: 'Test Organization', - name: 'test-organization', - branding: { - colors: { - primary: '#0059d6', - page_background: '#000000', - }, - logo_url: '', - }, -}; - -const renderUseSsoProviderTable = (...args: Parameters) => { - const { wrapper } = createTestQueryClientWrapper(); - return renderHook(() => useSsoProviderTable(...args), { wrapper }); -}; - -const renderUseSsoProviderTableWithClient = (...args: Parameters) => { - const { wrapper, queryClient } = createTestQueryClientWrapper(); - return { queryClient, ...renderHook(() => useSsoProviderTable(...args), { wrapper }) }; -}; + +const { initMockCoreClient } = mockCore(); describe('useSsoProviderTable', () => { - const mockCoreClient = createMockCoreClient(); - - // Helper function to setup the mock organization client with common mocks - const setupMockMyOrgClient = ( - overrides: { - list?: ReturnType; - update?: ReturnType; - delete?: ReturnType; - detach?: ReturnType; - organizationGet?: ReturnType; - } = {}, - ) => { - const mockMyOrgClient = mockCoreClient.getMyOrganizationApiClient(); - - if (overrides.list) { - mockMyOrgClient.organization.identityProviders.list = overrides.list; - } - if (overrides.update) { - mockMyOrgClient.organization.identityProviders.update = overrides.update; - } - if (overrides.delete) { - mockMyOrgClient.organization.identityProviders.delete = overrides.delete; - } - if (overrides.detach) { - mockMyOrgClient.organization.identityProviders.detach = overrides.detach; - } - if (overrides.organizationGet) { - mockMyOrgClient.organizationDetails.get = overrides.organizationGet; - } else { - // Default organization get - mockMyOrgClient.organizationDetails.get = vi.fn().mockResolvedValue(mockOrganization); - } - - return mockMyOrgClient; + let mockCoreClient: ReturnType; + let mockHandleError: ReturnType; + + const mockProvider1 = createMockIdentityProvider({ id: 'idp-1', name: 'Provider 1' }); + const mockProvider2 = createMockIdentityProvider({ id: 'idp-2', name: 'Provider 2' }); + const mockIdentityProviders = [mockProvider1, mockProvider2]; + + const renderUseSsoProviderTable = (...args: Parameters) => { + const { wrapper } = createTestQueryClientWrapper(); + return renderHook(() => useSsoProviderTable(...args), { wrapper }); }; beforeEach(() => { vi.clearAllMocks(); - setupMockUseCoreClient(mockCoreClient, useCoreClientModule); - - // Setup translator using createMockI18nService - // The translator will return the key itself (no interpolation needed for tests) - vi.spyOn(useTranslatorModule, 'useTranslator').mockImplementation((namespace, messages) => { - const mockT = createMockI18nService().translator(namespace, messages); - return { - t: mockT, - changeLanguage: vi.fn(), - currentLanguage: 'en-US', - fallbackLanguage: 'en-US', - }; - }); - }); - - describe('fetchProviders', () => { - // Test: Verifies that the hook successfully fetches identity providers from the API - // and updates the providers state with the fetched data - it('should fetch and set providers successfully', async () => { - const mockList = vi.fn().mockResolvedValue({ - identity_providers: mockIdentityProviders, - }); - setupMockMyOrgClient({ list: mockList }); + mockCoreClient = initMockCoreClient(); - const { result } = renderUseSsoProviderTable(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + const apiService = mockCoreClient.getMyOrganizationApiClient(); - expect(result.current.providers).toEqual(mockIdentityProviders); - expect(mockList).toHaveBeenCalled(); + // Mock API calls + (apiService.organization.identityProviders.list as ReturnType).mockResolvedValue({ + identity_providers: mockIdentityProviders, + }); + ( + apiService.organization.identityProviders.update as ReturnType + ).mockResolvedValue(mockProvider1); + ( + apiService.organization.identityProviders.delete as ReturnType + ).mockResolvedValue(undefined); + ( + apiService.organization.identityProviders.detach as ReturnType + ).mockResolvedValue(undefined); + (apiService.organizationDetails.get as ReturnType).mockResolvedValue({ + id: 'org-1', + name: 'Test Org', + display_name: 'Test Organization', }); - // Test: Validates error handling when the API call to fetch providers fails - // Should display an error toast notification to the user - it('should handle fetch providers error', async () => { - const mockList = vi.fn().mockRejectedValue(new Error('Network error')); + const { mockHandleError: setupMockHandleError } = setupAllCommonMocks({ + coreClient: mockCoreClient, + useCoreClientModule, + useTranslatorModule, + useErrorHandlerModule, + }); - setupMockMyOrgClient({ list: mockList }); + mockHandleError = setupMockHandleError; + vi.spyOn(useErrorHandlerModule, 'useErrorHandler').mockReturnValue(mockHandleError); + }); - const { result } = renderUseSsoProviderTable(); + it('should fetch and set providers successfully', async () => { + const { result } = renderUseSsoProviderTable(); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'error', - message: 'general_error', - }); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); }); - // Test: Ensures the hook doesn't attempt to fetch data when coreClient is unavailable - // Loading state should remain false and providers array should stay empty - it('should not fetch if coreClient is not available', async () => { - setupMockUseCoreClientNull(useCoreClientModule); + expect(result.current.providers).toEqual(mockIdentityProviders); + }); - const { result } = renderUseSsoProviderTable(); + it('should handle fetch providers error', async () => { + const error = new Error('Network error'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list as ReturnType< + typeof vi.fn + > + ).mockRejectedValue(error); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + renderUseSsoProviderTable(); - expect(result.current.providers).toEqual([]); + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); }); + }); - it('should invalidate providers query when refreshing', async () => { - const mockList = vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }); + it('should not fetch if coreClient is not available', async () => { + setupMockUseCoreClientNull(useCoreClientModule); - setupMockMyOrgClient({ list: mockList }); + const { result } = renderUseSsoProviderTable(); - const { result, queryClient } = renderUseSsoProviderTableWithClient(); - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + expect(result.current.isLoading).toBe(false); + expect(result.current.providers).toEqual([]); + }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + it('should enable provider successfully', async () => { + const { result } = renderUseSsoProviderTable(); - queryClient.setQueryData(ssoProviderQueryKeys.list(), mockIdentityProviders); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - await result.current.fetchProviders(); + await act(async () => { + await result.current.onEnableProvider(mockProvider1, true); + }); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ssoProviderQueryKeys.list(), - }); + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.update, + ).toHaveBeenCalledWith(mockProvider1.id, expect.any(Object)); + expect(result.current.isUpdating).toBe(false); }); }); - describe('fetchOrganizationDetails', () => { - // Test: Verifies that organization details are successfully fetched and stored in state - it('should fetch and set organization details successfully', async () => { - const mockGet = vi.fn().mockResolvedValue(mockOrganization); + it('should delete provider successfully', async () => { + const { result } = renderUseSsoProviderTable(); - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: [] }), - organizationGet: mockGet, - }); - - const { result } = renderUseSsoProviderTable(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.organization).toEqual(mockOrganization); - expect(mockGet).toHaveBeenCalled(); + act(() => { + result.current.onDeleteConfirm(mockProvider1); }); - // Test: Validates error handling when fetching organization details fails - // Should display an error toast notification - it('should handle fetch organization details error', async () => { - const mockGet = vi.fn().mockRejectedValue(new Error('Not found')); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: [] }), - organizationGet: mockGet, - }); - - const { result } = renderUseSsoProviderTable(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'error', - message: 'general_error', - }); + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.delete, + ).toHaveBeenCalledWith(mockProvider1.id); + expect(result.current.isDeleting).toBe(false); }); + }); - it('should return null and show toast when fetchOrganizationDetails fails', async () => { - const mockGet = vi.fn().mockRejectedValue(new Error('Not found')); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: [] }), - organizationGet: mockGet, - }); - - const { result } = renderUseSsoProviderTable(); + it('should remove provider from organization successfully', async () => { + const { result } = renderUseSsoProviderTable(); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - const organization = await result.current.fetchOrganizationDetails(); + act(() => { + result.current.onRemoveConfirm(mockProvider1); + }); - expect(organization).toBeNull(); - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'error', - message: 'general_error', - }); + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.detach, + ).toHaveBeenCalledWith(mockProvider1.id); + expect(result.current.isRemoving).toBe(false); }); }); - describe('onEnableProvider', () => { - // Test: Verifies that a provider can be successfully enabled/disabled - // Should call the update API, show success toast, and return true - it('should enable provider successfully', async () => { - const updatedProvider = { ...mockIdentityProviders[1], is_enabled: true }; - const mockUpdate = vi.fn().mockResolvedValue(updatedProvider); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - update: mockUpdate, - }); - - const { result } = renderUseSsoProviderTable(); + it('should handle enable provider error', async () => { + const error = new Error('Enable failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .update as ReturnType + ).mockRejectedValue(error); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + const { result } = renderUseSsoProviderTable(); - await waitFor(() => result.current.onEnableProvider(mockIdentityProviders[1]!, true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(mockUpdate).toHaveBeenCalledWith('idp-2', expect.any(Object)); - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'success', - message: 'update_success', - }); + await act(async () => { + try { + await result.current.onEnableProvider(mockProvider1, true); + } catch (e) { + // Expected to throw + } }); - // Test: Validates that enableAction callbacks (onBefore and onAfter) are properly invoked - // during the enable/disable operation - it('should call enableAction callbacks', async () => { - const onBefore = vi.fn().mockReturnValue(true); - const onAfter = vi.fn(); - const updatedProvider = { ...mockIdentityProviders[0], is_enabled: false }; - const mockUpdate = vi.fn().mockResolvedValue(updatedProvider); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - update: mockUpdate, - }); - - const { result } = renderUseSsoProviderTable(undefined, undefined, { onBefore, onAfter }); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - await waitFor(() => result.current.onEnableProvider(mockIdentityProviders[0]!, false)); - - expect(onBefore).toHaveBeenCalledWith(mockIdentityProviders[0]); - expect(onAfter).toHaveBeenCalledWith(mockIdentityProviders[0]); + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); }); + }); - // Test: Ensures that if onBefore callback returns false, the enable operation is cancelled - // and the update API is never called - it('should not proceed if onBefore returns false', async () => { - const onBefore = vi.fn().mockReturnValue(false); - const mockUpdate = vi.fn(); + it('should handle delete provider error', async () => { + const error = new Error('Delete failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .delete as ReturnType + ).mockRejectedValue(error); - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - update: mockUpdate, - }); + const { result } = renderUseSsoProviderTable(); - const { result } = renderUseSsoProviderTable(undefined, undefined, { onBefore }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - await waitFor(() => result.current.onEnableProvider(mockIdentityProviders[0]!, true)); - - expect(mockUpdate).not.toHaveBeenCalled(); + act(() => { + result.current.onDeleteConfirm(mockProvider1); }); - // Test: Validates error handling when the provider update API call fails - // Should display error toast and return false - it('should handle enable provider error', async () => { - const mockUpdate = vi.fn().mockRejectedValue(new Error('Update failed')); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - update: mockUpdate, - }); - - const { result } = renderUseSsoProviderTable(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - await waitFor(() => result.current.onEnableProvider(mockIdentityProviders[0]!, false)); - - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'error', - message: 'general_error', - }); + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); }); + }); - // Test: Ensures the function safely handles providers without an ID - // Should return false without attempting any API calls - it('should return false if provider has no id', async () => { - const providerWithoutId = { ...mockIdentityProviders[0], id: undefined } as IdentityProvider; - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - }); + it('should handle remove provider error', async () => { + const error = new Error('Remove failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .detach as ReturnType + ).mockRejectedValue(error); - const { result } = renderUseSsoProviderTable(); + const { result } = renderUseSsoProviderTable(); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - await waitFor(() => result.current.onEnableProvider(providerWithoutId, true)); + act(() => { + result.current.onRemoveConfirm(mockProvider1); }); - it('should return false if coreClient is not available', async () => { - setupMockUseCoreClientNull(useCoreClientModule); - - const { result } = renderUseSsoProviderTable(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - const resultValue = await result.current.onEnableProvider(mockIdentityProviders[0]!, true); - - expect(resultValue).toBe(false); + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); }); }); - describe('onDeleteConfirm', () => { - // Test: Verifies that a provider can be successfully deleted - // Should call delete API, show success toast, and refresh the providers list - it('should delete provider successfully', async () => { - const mockDelete = vi.fn().mockResolvedValue(undefined); - const mockList = vi - .fn() - .mockResolvedValue({ identity_providers: [mockIdentityProviders[1]] }); - - setupMockMyOrgClient({ - list: mockList, - delete: mockDelete, - }); - - const { result } = renderUseSsoProviderTable(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - await waitFor(() => result.current.onDeleteConfirm(mockIdentityProviders[0]!)); + it('should set isUpdating and isUpdatingId when enabling provider', async () => { + const mockUpdate = vi + .fn() + .mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(mockProvider1), 50)), + ); - expect(mockDelete).toHaveBeenCalledWith('idp-1'); - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'success', - message: 'delete_success', - }); - expect(mockList).toHaveBeenCalledTimes(2); // Once on mount, once after delete - }); + (mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .update as ReturnType) = mockUpdate; - // Test: Validates that the deleteAction onAfter callback is invoked after deletion - it('should call deleteAction onAfter callback', async () => { - const onAfter = vi.fn(); - const mockDelete = vi.fn().mockResolvedValue(undefined); + const { result } = renderUseSsoProviderTable(); - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - delete: mockDelete, - }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - const { result } = renderUseSsoProviderTable({ onAfter }); + await act(async () => { + const promise = result.current.onEnableProvider(mockProvider1, true); await waitFor(() => { - expect(result.current.isLoading).toBe(false); + expect(result.current.isUpdating).toBe(true); + expect(result.current.isUpdatingId).toBe(mockProvider1.id); }); - await waitFor(() => result.current.onDeleteConfirm(mockIdentityProviders[0]!)); - - expect(onAfter).toHaveBeenCalledWith(mockIdentityProviders[0]); + await promise; }); - // Test: Validates error handling when the delete API call fails - // Should display an error toast notification - it('should handle delete provider error', async () => { - const mockDelete = vi.fn().mockRejectedValue(new Error('Delete failed')); + await waitFor(() => { + expect(result.current.isUpdating).toBe(false); + expect(result.current.isUpdatingId).toBe(null); + }); + }); - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - delete: mockDelete, - }); + it('should set isDeleting when deleting provider', async () => { + const mockDelete = vi + .fn() + .mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 50))); - const { result } = renderUseSsoProviderTable(); + (mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .delete as ReturnType) = mockDelete; - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + const { result } = renderUseSsoProviderTable(); - await waitFor(() => result.current.onDeleteConfirm(mockIdentityProviders[0]!)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'error', - message: 'general_error', - }); + act(() => { + result.current.onDeleteConfirm(mockProvider1); }); - // Test: Ensures the function safely handles providers without an ID - // Should not attempt to call the delete API - it('should not delete if provider has no id', async () => { - const providerWithoutId = { ...mockIdentityProviders[0], id: undefined } as IdentityProvider; - const mockDelete = vi.fn(); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - delete: mockDelete, - }); - - const { result } = renderUseSsoProviderTable(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - await waitFor(() => result.current.onDeleteConfirm(providerWithoutId)); + await waitFor(() => { + expect(result.current.isDeleting).toBe(true); + }); - expect(mockDelete).not.toHaveBeenCalled(); + await waitFor(() => { + expect(result.current.isDeleting).toBe(false); }); }); - describe('onRemoveConfirm', () => { - // Test: Verifies that a provider can be successfully removed from an organization - // Should call detach API, show success toast with organization name, and refresh providers list - it('should remove provider from organization successfully', async () => { - const mockDetach = vi.fn().mockResolvedValue(undefined); - const mockList = vi - .fn() - .mockResolvedValue({ identity_providers: [mockIdentityProviders[1]] }); - const mockOrganizationGet = vi.fn().mockResolvedValue(mockOrganization); - - setupMockMyOrgClient({ - list: mockList, - detach: mockDetach, - organizationGet: mockOrganizationGet, - }); + it('should set isRemoving when removing provider', async () => { + const mockDetach = vi + .fn() + .mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 50))); - const { result } = renderUseSsoProviderTable(); + (mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .detach as ReturnType) = mockDetach; - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + const { result } = renderUseSsoProviderTable(); - await waitFor(() => result.current.onRemoveConfirm(mockIdentityProviders[0]!)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(mockDetach).toHaveBeenCalledWith('idp-1'); - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'success', - message: 'remove_success', - }); - expect(mockList).toHaveBeenCalledTimes(2); // Once on mount, once after remove + act(() => { + result.current.onRemoveConfirm(mockProvider1); }); - // Test: Validates that the removeFromOrganization onAfter callback is invoked after removal - it('should call removeFromOrganization onAfter callback', async () => { - const onAfter = vi.fn(); - const mockDetach = vi.fn().mockResolvedValue(undefined); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - detach: mockDetach, - }); - - const { result } = renderUseSsoProviderTable(undefined, { onAfter }); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - await waitFor(() => result.current.onRemoveConfirm(mockIdentityProviders[0]!)); - - expect(onAfter).toHaveBeenCalledWith(mockIdentityProviders[0]); + await waitFor(() => { + expect(result.current.isRemoving).toBe(true); }); - // Test: Validates error handling when the detach API call fails - // Should display an error toast notification - it('should handle remove provider error', async () => { - const mockDetach = vi.fn().mockRejectedValue(new Error('Remove failed')); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - detach: mockDetach, - }); - - const { result } = renderUseSsoProviderTable(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - await waitFor(() => result.current.onRemoveConfirm(mockIdentityProviders[0]!)); - - expect(mockedShowToast).toHaveBeenCalledWith({ - type: 'error', - message: 'general_error', - }); + await waitFor(() => { + expect(result.current.isRemoving).toBe(false); }); + }); - // Test: Ensures the function safely handles providers without an ID - // Should not attempt to call the detach API - it('should not remove if provider has no id', async () => { - const providerWithoutId = { ...mockIdentityProviders[0], id: undefined } as IdentityProvider; - const mockDetach = vi.fn(); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - detach: mockDetach, - }); + it('should call enableAction callbacks', async () => { + const onBefore = vi.fn().mockReturnValue(true); + const onAfter = vi.fn(); - const { result } = renderUseSsoProviderTable(); + const { result } = renderUseSsoProviderTable(undefined, undefined, { onBefore, onAfter }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - await waitFor(() => result.current.onRemoveConfirm(providerWithoutId)); + await act(async () => { + await result.current.onEnableProvider(mockProvider1, true); + }); - expect(mockDetach).not.toHaveBeenCalled(); + await waitFor(() => { + expect(onBefore).toHaveBeenCalledWith(mockProvider1); + expect(onAfter).toHaveBeenCalledWith(mockProvider1); }); }); - describe('loading states', () => { - // Test: Validates that isUpdating and isUpdatingId states are correctly managed - // during the enable/disable operation lifecycle - it('should set isUpdating and isUpdatingId when enabling provider', async () => { - const updatedProvider = { ...mockIdentityProviders[0], is_enabled: false }; - const mockUpdate = vi - .fn() - .mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve(updatedProvider), 100)), - ); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - update: mockUpdate, - }); + it('should call deleteAction onAfter callback', async () => { + const onAfter = vi.fn(); - const { result } = renderUseSsoProviderTable(); + const { result } = renderUseSsoProviderTable({ onAfter }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - await waitFor(() => { - result.current.onEnableProvider(mockIdentityProviders[0]!, false); - expect(result.current.isUpdating).toBe(true); - expect(result.current.isUpdatingId).toBe('idp-1'); - }); - - await waitFor(() => { - expect(result.current.isUpdating).toBe(false); - expect(result.current.isUpdatingId).toBe(null); - }); + act(() => { + result.current.onDeleteConfirm(mockProvider1); }); - // Test: Validates that isDeleting state is correctly managed during deletion - it('should set isDeleting when deleting provider', async () => { - const mockDelete = vi - .fn() - .mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - delete: mockDelete, - }); - - const { result } = renderUseSsoProviderTable(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - await waitFor(() => { - result.current.onDeleteConfirm(mockIdentityProviders[0]!); - expect(result.current.isDeleting).toBe(true); - }); - - await waitFor(() => { - expect(result.current.isDeleting).toBe(false); - }); + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider1); }); + }); - // Test: Validates that isRemoving state is correctly managed during removal - it('should set isRemoving when removing provider', async () => { - const mockDetach = vi - .fn() - .mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: mockIdentityProviders }), - detach: mockDetach, - }); + it('should call removeFromOrganization onAfter callback', async () => { + const onAfter = vi.fn(); - const { result } = renderUseSsoProviderTable(); + const { result } = renderUseSsoProviderTable(undefined, { onAfter }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); - await waitFor(() => { - result.current.onRemoveConfirm(mockIdentityProviders[0]!); - expect(result.current.isRemoving).toBe(true); - }); + act(() => { + result.current.onRemoveConfirm(mockProvider1); + }); - await waitFor(() => { - expect(result.current.isRemoving).toBe(false); - }); + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider1); }); }); - describe('custom messages', () => { - // Test: Verifies that custom toast messages are properly passed to the translator - // for displaying localized notifications - it('should pass custom messages to translator', async () => { - const customMessages = { update_success: 'Custom update message' }; + it('should expose error and retry', async () => { + const error = new Error('Query error'); + const mockList = mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .list as ReturnType; + mockList + .mockRejectedValueOnce(error) + .mockResolvedValue({ identity_providers: mockIdentityProviders }); - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: [] }), - }); + const { result } = renderUseSsoProviderTable(); - renderUseSsoProviderTable(undefined, undefined, undefined, customMessages); + await waitFor(() => { + expect(result.current.error).toBe(error); + }); - await waitFor(() => { - expect(useTranslatorModule.useTranslator).toHaveBeenCalledWith( - 'idp_management.notifications', - customMessages, - ); - }); + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + expect(result.current.providers).toEqual(mockIdentityProviders); }); }); }); diff --git a/packages/react/src/hooks/my-organization/use-config.ts b/packages/react/src/hooks/my-organization/use-config.ts index d1464b249..5e0f999e6 100644 --- a/packages/react/src/hooks/my-organization/use-config.ts +++ b/packages/react/src/hooks/my-organization/use-config.ts @@ -1,11 +1,14 @@ import { AVAILABLE_STRATEGY_LIST, hasApiErrorBody, + MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES, type IdpStrategy, } from '@auth0/universal-components-core'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import type { UseConfigResult } from '@/types/my-organization/config/config-types'; const configQueryKeys = { @@ -16,10 +19,15 @@ const configQueryKeys = { export function useConfig(): UseConfigResult { const { coreClient } = useCoreClient(); const queryClient = useQueryClient(); + const handleError = useErrorHandler(); const configQuery = useQuery({ queryKey: configQueryKeys.details(), - queryFn: () => coreClient!.getMyOrganizationApiClient().organization.configuration.get(), + queryFn: () => + coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES) + .organization.configuration.get(), enabled: !!coreClient, retry: (failureCount, error) => { if (hasApiErrorBody(error) && error.body?.status === 404) { @@ -29,6 +37,12 @@ export function useConfig(): UseConfigResult { }, }); + useEffect(() => { + if (configQuery.error) { + handleError(configQuery.error); + } + }, [configQuery.error, handleError]); + const config = configQuery.data; const allowedStrategies = config?.allowed_strategies; @@ -42,6 +56,10 @@ export function useConfig(): UseConfigResult { const isConfigValid = !!allowedStrategies?.length; + const retry = async () => { + await queryClient.invalidateQueries({ queryKey: configQueryKeys.details() }); + }; + return { config: config ?? null, isLoadingConfig: configQuery.isLoading, @@ -49,5 +67,7 @@ export function useConfig(): UseConfigResult { filteredStrategies, shouldAllowDeletion, isConfigValid, + error: configQuery.error, + retry, }; } diff --git a/packages/react/src/hooks/my-organization/use-domain-table-logic.ts b/packages/react/src/hooks/my-organization/use-domain-table-logic.ts deleted file mode 100644 index 3740e599c..000000000 --- a/packages/react/src/hooks/my-organization/use-domain-table-logic.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { type Domain, type IdentityProvider } from '@auth0/universal-components-core'; -import { useCallback, useEffect, useState } from 'react'; - -import { showToast } from '@/components/auth0/shared/toast'; -import type { - UseDomainTableLogicOptions, - UseDomainTableLogicResult, -} from '@/types/my-organization/domain-management/domain-table-types'; - -export function useDomainTableLogic({ - t, - onCreateDomain, - onVerifyDomain, - onDeleteDomain, - onAssociateToProvider, - onDeleteFromProvider, - fetchProviders, - fetchDomains, -}: UseDomainTableLogicOptions): UseDomainTableLogicResult { - const [error, setError] = useState(null); - const [showCreateModal, setShowCreateModal] = useState(false); - const [showConfigureModal, setShowConfigureModal] = useState(false); - const [showVerifyModal, setShowVerifyModal] = useState(false); - const [verifyError, setVerifyError] = useState(undefined); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [selectedDomain, setSelectedDomain] = useState(null); - - const handleCreate = useCallback( - async (domainUrl: string) => { - try { - const newDomain = await onCreateDomain({ domain: domainUrl }); - showToast({ - type: 'success', - message: t('domain_table.notifications.domain_create.success', { - domainName: newDomain?.domain, - }), - }); - setSelectedDomain(newDomain); - setShowCreateModal(false); - setShowVerifyModal(true); - } catch (error) { - setError(error); - } - }, - [onCreateDomain, t], - ); - - const handleVerify = useCallback( - async (domain: Domain) => { - try { - const isVerified = await onVerifyDomain(domain); - if (isVerified) { - setShowVerifyModal(false); - showToast({ - type: 'success', - message: t('domain_table.notifications.domain_verify.success', { - domainName: domain.domain, - }), - }); - } else { - setVerifyError( - t('domain_verify.modal.errors.verification_failed', { domainName: domain.domain }), - ); - } - } catch (error) { - setError(error); - } - }, - [onVerifyDomain, t], - ); - - const handleDelete = useCallback( - async (domain: Domain) => { - try { - await onDeleteDomain(domain); - showToast({ - type: 'success', - message: t('domain_table.notifications.domain_delete.success', { - domainName: domain.domain, - }), - }); - setShowDeleteModal(false); - setShowVerifyModal(false); - } catch (error) { - setError(error); - } - }, - [onDeleteDomain, t], - ); - - const handleToggleSwitch = useCallback( - async (domain: Domain, provider: IdentityProvider, newCheckedValue: boolean) => { - if (newCheckedValue) { - try { - await onAssociateToProvider(domain, provider); - showToast({ - type: 'success', - message: t('domain_table.notifications.domain_associate_provider.success', { - domain: domain.domain, - idp: provider.name, - }), - }); - } catch (error) { - setError(error); - } - } else { - try { - await onDeleteFromProvider(domain, provider); - showToast({ - type: 'success', - message: t('domain_table.notifications.domain_delete_provider.success', { - domain: domain.domain, - idp: provider.name, - }), - }); - } catch (error) { - setError(error); - } - } - }, - [onAssociateToProvider, onDeleteFromProvider, t], - ); - - const handleCloseVerifyModal = useCallback(() => { - setShowVerifyModal(false); - setVerifyError(undefined); - }, []); - - const handleCreateClick = useCallback(() => { - setShowCreateModal(true); - }, []); - - const handleConfigureClick = useCallback( - async (domain: Domain) => { - setSelectedDomain(domain); - if (domain.status !== 'verified') { - setShowVerifyModal(true); - } else { - try { - await fetchProviders(domain); - setShowConfigureModal(true); - } catch (error) { - setError(error); - } - } - }, - [fetchProviders, t], - ); - - const handleVerifyClick = useCallback( - async (domain: Domain) => { - setSelectedDomain(domain); - try { - const isVerified = await onVerifyDomain(domain); - if (isVerified) { - setShowConfigureModal(true); - showToast({ - type: 'success', - message: t('domain_table.notifications.domain_verify.success', { - domainName: domain.domain, - }), - }); - } else { - showToast({ - type: 'error', - message: t('domain_table.notifications.domain_verify.verification_failed', { - domainName: domain.domain, - }), - }); - } - } catch (error) { - setError(error); - } - }, - [onVerifyDomain, t], - ); - - const handleDeleteClick = useCallback((domain: Domain) => { - setSelectedDomain(domain); - setShowVerifyModal(false); - setShowDeleteModal(true); - }, []); - - // Initialization - useEffect(() => { - try { - fetchDomains(); - } catch (error) { - setError(error); - } - }, [fetchDomains]); - - return { - // Error state - error, - - // Modal state - showCreateModal, - showConfigureModal, - showVerifyModal, - showDeleteModal, - verifyError, - selectedDomain, - - // State setters - setShowCreateModal, - setShowConfigureModal, - setShowVerifyModal, - setShowDeleteModal, - - // Handlers - handleCreate, - handleVerify, - handleDelete, - handleToggleSwitch, - handleCloseVerifyModal, - handleCreateClick, - handleConfigureClick, - handleVerifyClick, - handleDeleteClick, - }; -} diff --git a/packages/react/src/hooks/my-organization/use-domain-table.ts b/packages/react/src/hooks/my-organization/use-domain-table.ts index 306c62ab1..ffc1ae6ea 100644 --- a/packages/react/src/hooks/my-organization/use-domain-table.ts +++ b/packages/react/src/hooks/my-organization/use-domain-table.ts @@ -3,24 +3,40 @@ import { type IdentityProvider, type CreateOrganizationDomainRequestContent, type IdentityProviderAssociatedWithDomain, - BusinessError, + MY_ORGANIZATION_DOMAIN_SCOPES, } from '@auth0/universal-components-core'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { showToast } from '@/components/auth0/shared/toast'; import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { UseDomainTableOptions, UseDomainTableResult, } from '@/types/my-organization/domain-management/domain-table-types'; +type ModalType = 'create' | 'configure' | 'verify' | 'delete'; + const domainQueryKeys = { all: ['domains'] as const, list: () => [...domainQueryKeys.all, 'list'] as const, providers: (domainId: string) => [...domainQueryKeys.all, 'providers', domainId] as const, }; +const mapProviders = ( + all: IdentityProvider[], + associatedIds: Set, +): IdentityProviderAssociatedWithDomain[] => + all.map((provider) => ({ + ...provider, + is_associated: provider.id ? associatedIds.has(provider.id) : false, + })); + +/** + * Hook for managing organization domain verification and provider associations. + */ export function useDomainTable({ createAction, deleteAction, @@ -29,88 +45,130 @@ export function useDomainTable({ deleteFromProviderAction, customMessages, }: UseDomainTableOptions): UseDomainTableResult { - const { t } = useTranslator('domain_management.domain_table.notifications', customMessages); + const { t } = useTranslator('domain_management', customMessages); const { coreClient } = useCoreClient(); const queryClient = useQueryClient(); + const handleError = useErrorHandler(); + const [activeModal, setActiveModal] = useState(null); const [selectedDomainId, setSelectedDomainId] = useState(null); + const [verifyError, setVerifyError] = useState(undefined); - const fetchProvidersForDomain = async (domainId: string) => { - const api = coreClient!.getMyOrganizationApiClient(); - - const [allProvidersResponse, associatedProvidersResponse] = await Promise.all([ - api.organization.identityProviders.list(), - api.organization.domains.identityProviders.get(domainId), - ]); - - const allProviders = allProvidersResponse?.identity_providers ?? []; - const associatedProviders = associatedProvidersResponse?.identity_providers ?? []; - const associatedIds = new Set(associatedProviders.map((p) => p.id).filter(Boolean)); - - return allProviders.map( - (provider): IdentityProviderAssociatedWithDomain => ({ - ...provider, - is_associated: provider.id ? associatedIds.has(provider.id) : false, - }), - ); + const notifySuccess = (key: string, params?: Record) => { + showToast({ + type: 'success', + message: t(`domain_table.notifications.${key}.success`, params), + }); }; const domainsQuery = useQuery({ queryKey: domainQueryKeys.list(), queryFn: async () => { - const response = await coreClient!.getMyOrganizationApiClient().organization.domains.list(); + const response = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) + .organization.domains.list(); return response?.organization_domains ?? []; }, enabled: !!coreClient, + retry: false, }); + useEffect(() => { + if (domainsQuery.error) { + handleError(domainsQuery.error); + } + }, [domainsQuery.error, handleError]); + + const selectedDomain = useMemo( + () => domainsQuery.data?.find((d) => d.id === selectedDomainId) ?? null, + [domainsQuery.data, selectedDomainId], + ); + const providersQuery = useQuery({ queryKey: domainQueryKeys.providers(selectedDomainId ?? ''), - queryFn: () => fetchProvidersForDomain(selectedDomainId!), - enabled: !!coreClient && !!selectedDomainId, + queryFn: async () => { + const api = coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES); + const [allRes, assocRes] = await Promise.all([ + api.organization.identityProviders.list(), + api.organization.domains.identityProviders.get(selectedDomainId!), + ]); + const associatedIds = new Set( + (assocRes?.identity_providers ?? []).map((p) => p.id).filter((id): id is string => !!id), + ); + return mapProviders(allRes?.identity_providers ?? [], associatedIds); + }, + enabled: !!coreClient && !!selectedDomainId && activeModal === 'configure', + retry: false, }); + useEffect(() => { + if (providersQuery.error) { + handleError(providersQuery.error); + } + }, [providersQuery.error, handleError]); + const createDomainMutation = useMutation({ - mutationFn: async (data: CreateOrganizationDomainRequestContent): Promise => { + mutationFn: async (data: CreateOrganizationDomainRequestContent) => { if (createAction?.onBefore && !createAction.onBefore(data as Domain)) { - throw new BusinessError({ message: t('domain_create.on_before') }); + throw new Error(t('domain_table.notifications.domain_create.on_before')); } - return coreClient!.getMyOrganizationApiClient().organization.domains.create(data); + return coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) + .organization.domains.create(data); }, - onSuccess: (result) => { - createAction?.onAfter?.(result); + onSuccess: (newDomain) => { + createAction?.onAfter?.(newDomain); queryClient.invalidateQueries({ queryKey: domainQueryKeys.list() }); + notifySuccess('domain_create', { domainName: newDomain?.domain }); + setSelectedDomainId(newDomain.id); + setActiveModal('verify'); }, + onError: (error) => handleError(error), }); const verifyDomainMutation = useMutation({ - mutationFn: async (domain: Domain): Promise => { + mutationFn: async (domain: Domain) => { if (verifyAction?.onBefore && !verifyAction.onBefore(domain)) { - throw new BusinessError({ message: t('domain_verify.on_before') }); + throw new Error(t('domain_table.notifications.domain_verify.on_before')); } - const response = await coreClient! + const res = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.domains.verify.create(domain.id); - return response.status === 'verified'; + return res.status === 'verified'; }, - onSuccess: (_, domain) => { - verifyAction?.onAfter?.(domain); - queryClient.invalidateQueries({ queryKey: domainQueryKeys.list() }); + onSuccess: (isVerified, domain) => { + if (isVerified) { + verifyAction?.onAfter?.(domain); + queryClient.invalidateQueries({ queryKey: domainQueryKeys.list() }); + } }, + onError: (error) => handleError(error), }); const deleteDomainMutation = useMutation({ - mutationFn: async (domain: Domain): Promise => { + mutationFn: async (domain: Domain) => { if (deleteAction?.onBefore && !deleteAction.onBefore(domain)) { - throw new BusinessError({ message: t('domain_delete.on_before') }); + throw new Error(t('domain_table.notifications.domain_delete.on_before')); } - await coreClient!.getMyOrganizationApiClient().organization.domains.delete(domain.id); + await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) + .organization.domains.delete(domain.id); }, onSuccess: (_, domain) => { deleteAction?.onAfter?.(domain); queryClient.invalidateQueries({ queryKey: domainQueryKeys.list() }); queryClient.removeQueries({ queryKey: domainQueryKeys.providers(domain.id) }); + notifySuccess('domain_delete', { domainName: domain.domain }); + setActiveModal(null); + setSelectedDomainId(null); }, + onError: (error) => handleError(error), }); const associateToProviderMutation = useMutation({ @@ -119,16 +177,19 @@ export function useDomainTable({ associateToProviderAction?.onBefore && !associateToProviderAction.onBefore(domain, provider) ) { - throw new BusinessError({ message: t('domain_associate_provider.on_before') }); + throw new Error(t('domain_table.notifications.domain_associate_provider.on_before')); } await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.identityProviders.domains.create(provider.id!, { domain: domain.domain }); }, onSuccess: (_, { domain, provider }) => { associateToProviderAction?.onAfter?.(domain, provider); queryClient.invalidateQueries({ queryKey: domainQueryKeys.providers(domain.id) }); + notifySuccess('domain_associate_provider', { domain: domain.domain, idp: provider.name }); }, + onError: (error) => handleError(error), }); const deleteFromProviderMutation = useMutation({ @@ -137,42 +198,173 @@ export function useDomainTable({ deleteFromProviderAction?.onBefore && !deleteFromProviderAction.onBefore(domain, provider) ) { - throw new BusinessError({ message: t('domain_delete_provider.on_before') }); + throw new Error(t('domain_table.notifications.domain_delete_provider.on_before')); } await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.identityProviders.domains.delete(provider.id!, domain.domain); }, onSuccess: (_, { domain, provider }) => { deleteFromProviderAction?.onAfter?.(domain, provider); queryClient.invalidateQueries({ queryKey: domainQueryKeys.providers(domain.id) }); + notifySuccess('domain_delete_provider', { domain: domain.domain, idp: provider.name }); }, + onError: (error) => handleError(error), }); + const verifyAndTransition = useCallback( + async (domain: Domain, nextModal: ModalType | null) => { + const isVerified = await verifyDomainMutation.mutateAsync(domain); + if (isVerified) { + setActiveModal(nextModal); + notifySuccess('domain_verify', { domainName: domain.domain }); + } else { + setVerifyError( + t('domain_verify.modal.errors.verification_failed', { domainName: domain.domain }), + ); + } + }, + [verifyDomainMutation, t], + ); + + const handleCreate = useCallback( + async (domainUrl: string) => { + await createDomainMutation.mutateAsync({ domain: domainUrl }); + }, + [createDomainMutation], + ); + + const handleVerify = useCallback( + async (domain: Domain) => verifyAndTransition(domain, null), + [verifyAndTransition], + ); + + const handleDelete = useCallback( + async (domain: Domain) => { + await deleteDomainMutation.mutateAsync(domain); + }, + [deleteDomainMutation], + ); + + const handleToggleSwitch = useCallback( + async (domain: Domain, provider: IdentityProvider, checked: boolean) => { + const mutation = checked ? associateToProviderMutation : deleteFromProviderMutation; + await mutation.mutateAsync({ domain, provider }); + }, + [associateToProviderMutation, deleteFromProviderMutation], + ); + + const closeModal = useCallback(() => { + setActiveModal(null); + setVerifyError(undefined); + }, []); + + const handleCreateClick = useCallback(() => setActiveModal('create'), []); + + const handleConfigureClick = useCallback((domain: Domain) => { + setSelectedDomainId(domain.id); + setActiveModal(domain.status === 'verified' ? 'configure' : 'verify'); + }, []); + + const handleVerifyClick = useCallback( + async (domain: Domain) => { + setSelectedDomainId(domain.id); + await verifyAndTransition(domain, 'configure'); + }, + [verifyAndTransition], + ); + + const handleDeleteClick = useCallback((domain: Domain) => { + setSelectedDomainId(domain.id); + setActiveModal('delete'); + }, []); + + const error = + domainsQuery.error || + providersQuery.error || + createDomainMutation.error || + verifyDomainMutation.error || + deleteDomainMutation.error || + associateToProviderMutation.error || + deleteFromProviderMutation.error; + + const retry = async () => { + if (domainsQuery.error) { + await queryClient.invalidateQueries({ queryKey: domainQueryKeys.list() }); + return; + } + + if (providersQuery.error) { + await queryClient.invalidateQueries({ + queryKey: domainQueryKeys.providers(selectedDomainId ?? ''), + }); + return; + } + + const mutations = [ + { + error: createDomainMutation.error, + retry: () => + createDomainMutation.variables && + createDomainMutation.mutateAsync(createDomainMutation.variables), + }, + { + error: verifyDomainMutation.error, + retry: () => + verifyDomainMutation.variables && + verifyDomainMutation.mutateAsync(verifyDomainMutation.variables), + }, + { + error: deleteDomainMutation.error, + retry: () => + deleteDomainMutation.variables && + deleteDomainMutation.mutateAsync(deleteDomainMutation.variables), + }, + { + error: associateToProviderMutation.error, + retry: () => + associateToProviderMutation.variables && + associateToProviderMutation.mutateAsync(associateToProviderMutation.variables), + }, + { + error: deleteFromProviderMutation.error, + retry: () => + deleteFromProviderMutation.variables && + deleteFromProviderMutation.mutateAsync(deleteFromProviderMutation.variables), + }, + ]; + + const failedMutation = mutations.find((m) => m.error); + if (failedMutation) { + await failedMutation.retry(); + } + }; + return { domains: domainsQuery.data ?? [], providers: providersQuery.data ?? [], + error, + retry, isFetching: domainsQuery.isLoading, isCreating: createDomainMutation.isPending, isDeleting: deleteDomainMutation.isPending, isVerifying: verifyDomainMutation.isPending, - isLoadingProviders: providersQuery.isLoading, - fetchProviders: async (domain: Domain) => { - setSelectedDomainId(domain.id); - await queryClient.ensureQueryData({ - queryKey: domainQueryKeys.providers(domain.id), - queryFn: () => fetchProvidersForDomain(domain.id), - }); - }, - fetchDomains: async () => { - await queryClient.invalidateQueries({ queryKey: domainQueryKeys.list() }); - }, - onCreateDomain: (data) => createDomainMutation.mutateAsync(data), - onVerifyDomain: (domain) => verifyDomainMutation.mutateAsync(domain), - onDeleteDomain: (domain) => deleteDomainMutation.mutateAsync(domain), - onAssociateToProvider: (domain, provider) => - associateToProviderMutation.mutateAsync({ domain, provider }), - onDeleteFromProvider: (domain, provider) => - deleteFromProviderMutation.mutateAsync({ domain, provider }), + isLoadingProviders: providersQuery.isFetching, + showCreateModal: activeModal === 'create', + showConfigureModal: activeModal === 'configure', + showVerifyModal: activeModal === 'verify', + showDeleteModal: activeModal === 'delete', + verifyError, + selectedDomain, + closeModal, + handleCreate, + handleVerify, + handleDelete, + handleToggleSwitch, + handleCreateClick, + handleConfigureClick, + handleVerifyClick, + handleDeleteClick, }; } diff --git a/packages/react/src/hooks/my-organization/use-idp-config.ts b/packages/react/src/hooks/my-organization/use-idp-config.ts index 178796a51..99d49cb02 100644 --- a/packages/react/src/hooks/my-organization/use-idp-config.ts +++ b/packages/react/src/hooks/my-organization/use-idp-config.ts @@ -1,7 +1,13 @@ -import { hasApiErrorBody, type IdpStrategy } from '@auth0/universal-components-core'; +import { + hasApiErrorBody, + MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES, + type IdpStrategy, +} from '@auth0/universal-components-core'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import type { IdpConfig, UseConfigIdpResult, @@ -15,6 +21,7 @@ export const idpConfigQueryKeys = { export function useIdpConfig(): UseConfigIdpResult { const { coreClient } = useCoreClient(); const queryClient = useQueryClient(); + const handleError = useErrorHandler(); const idpConfigQuery = useQuery({ queryKey: idpConfigQueryKeys.config(), @@ -22,6 +29,7 @@ export function useIdpConfig(): UseConfigIdpResult { try { const response = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES) .organization.configuration.identityProviders.get(); return response as unknown as IdpConfig; } catch (error) { @@ -38,6 +46,12 @@ export function useIdpConfig(): UseConfigIdpResult { }, }); + useEffect(() => { + if (idpConfigQuery.error) { + handleError(idpConfigQuery.error); + } + }, [idpConfigQuery.error, handleError]); + const idpConfig = idpConfigQuery.data ?? null; const strategies = idpConfig?.strategies; @@ -51,6 +65,10 @@ export function useIdpConfig(): UseConfigIdpResult { return strategies[strategy].provisioning_methods.includes('scim'); }; + const retry = async () => { + await queryClient.invalidateQueries({ queryKey: idpConfigQueryKeys.config() }); + }; + return { idpConfig, isIdpConfigValid: !!strategies && Object.keys(strategies).length > 0, @@ -58,5 +76,7 @@ export function useIdpConfig(): UseConfigIdpResult { fetchIdpConfig: () => queryClient.invalidateQueries({ queryKey: idpConfigQueryKeys.config() }), isProvisioningEnabled, isProvisioningMethodEnabled, + error: idpConfigQuery.error, + retry, }; } diff --git a/packages/react/src/hooks/my-organization/use-organization-details-edit.ts b/packages/react/src/hooks/my-organization/use-organization-details-edit.ts index cd2867af1..23e8a1b23 100644 --- a/packages/react/src/hooks/my-organization/use-organization-details-edit.ts +++ b/packages/react/src/hooks/my-organization/use-organization-details-edit.ts @@ -1,6 +1,7 @@ import { OrganizationDetailsFactory, OrganizationDetailsMappers, + MY_ORGANIZATION_DETAILS_EDIT_SCOPES, type OrganizationPrivate, } from '@auth0/universal-components-core'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -8,6 +9,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { OrganizationDetailsFormActions, @@ -22,6 +24,9 @@ const organizationDetailsQueryKeys = { const EMPTY_ORGANIZATION = OrganizationDetailsFactory.create(); +/** + * Hook for fetching and updating organization details. + */ export function useOrganizationDetailsEdit({ saveAction, cancelAction, @@ -31,11 +36,10 @@ export function useOrganizationDetailsEdit({ const { t } = useTranslator('organization_management.organization_details_edit', customMessages); const { coreClient } = useCoreClient(); const queryClient = useQueryClient(); - - const isInitializing = !coreClient; + const handleError = useErrorHandler(); const getErrorMessage = useCallback( - (error: unknown): string => + (error: unknown) => error instanceof Error ? t('organization_changes_error_message', { message: error.message }) : t('organization_changes_error_message_generic'), @@ -45,28 +49,28 @@ export function useOrganizationDetailsEdit({ const organizationQuery = useQuery({ queryKey: organizationDetailsQueryKeys.details(), queryFn: async () => { - const response = await coreClient!.getMyOrganizationApiClient().organizationDetails.get(); + const response = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DETAILS_EDIT_SCOPES) + .organizationDetails.get(); return OrganizationDetailsMappers.fromAPI(response); }, enabled: !!coreClient, + retry: false, }); useEffect(() => { if (organizationQuery.error) { - showToast({ - type: 'error', - message: getErrorMessage(organizationQuery.error), - }); + handleError(organizationQuery.error, { getErrorMessage }); } - }, [organizationQuery.error, getErrorMessage]); - - const organization = organizationQuery.data ?? EMPTY_ORGANIZATION; + }, [organizationQuery.error, handleError, getErrorMessage]); const updateMutation = useMutation({ mutationFn: async (data: OrganizationPrivate) => { const updateData = OrganizationDetailsMappers.toAPI(data); const response = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DETAILS_EDIT_SCOPES) .organizationDetails.update(updateData); return OrganizationDetailsMappers.fromAPI(response); @@ -83,26 +87,16 @@ export function useOrganizationDetailsEdit({ saveAction?.onAfter?.(variables); }, - onError: (error) => { - showToast({ - type: 'error', - message: getErrorMessage(error), - }); - }, + onError: (error) => handleError(error, { getErrorMessage }), }); + const organization = organizationQuery.data ?? EMPTY_ORGANIZATION; const hasData = !!organizationQuery.data; - const isActionDisabled = updateMutation.isPending || isInitializing; - - const fetchOrgDetails = useCallback(async (): Promise => { - await queryClient.invalidateQueries({ queryKey: organizationDetailsQueryKeys.details() }); - }, [queryClient]); + const isActionDisabled = updateMutation.isPending; const updateOrgDetails = useCallback( async (data: OrganizationPrivate): Promise => { - if (saveAction?.onBefore && !saveAction.onBefore(data)) { - return false; - } + if (saveAction?.onBefore && !saveAction.onBefore(data)) return false; try { await updateMutation.mutateAsync(data); @@ -127,23 +121,34 @@ export function useOrganizationDetailsEdit({ }, }), [ - updateOrgDetails, - readOnly, + updateMutation.isPending, cancelAction, - saveAction?.disabled, + readOnly, hasData, isActionDisabled, organization, + saveAction?.disabled, + updateOrgDetails, ], ); + const retry = useCallback(async () => { + if (updateMutation.variables) { + await updateMutation.mutateAsync(updateMutation.variables); + } else { + updateMutation.reset(); + await queryClient.invalidateQueries({ queryKey: organizationDetailsQueryKeys.details() }); + } + }, [updateMutation, queryClient]); + return { organization, + error: organizationQuery.error || updateMutation.error, + retry, + isLoading: organizationQuery.isLoading, isFetchLoading: organizationQuery.isFetching, isSaveLoading: updateMutation.isPending, - isInitializing, formActions, - fetchOrgDetails, updateOrgDetails, }; } diff --git a/packages/react/src/hooks/my-organization/use-sso-domain-tab.ts b/packages/react/src/hooks/my-organization/use-sso-domain-tab.ts index 0699e0132..54e8a7fe2 100644 --- a/packages/react/src/hooks/my-organization/use-sso-domain-tab.ts +++ b/packages/react/src/hooks/my-organization/use-sso-domain-tab.ts @@ -1,10 +1,15 @@ import type { CreateOrganizationDomainRequestContent } from '@auth0/universal-components-core'; -import { type Domain, type IdpId } from '@auth0/universal-components-core'; +import { + type Domain, + type IdpId, + MY_ORGANIZATION_DOMAIN_SCOPES, +} from '@auth0/universal-components-core'; import { useQuery, useQueryClient, useMutation, useQueries } from '@tanstack/react-query'; -import { useCallback, useState, useMemo } from 'react'; +import { useCallback, useState, useMemo, useEffect } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { UseSsoDomainTabOptions, @@ -27,6 +32,7 @@ export function useSsoDomainTab( const { coreClient } = useCoreClient(); const { t } = useTranslator('idp_management.notifications', customMessages); const queryClient = useQueryClient(); + const handleError = useErrorHandler(); const [selectedDomain, setSelectedDomain] = useState(null); const [showVerifyModal, setShowVerifyModal] = useState(false); @@ -40,12 +46,21 @@ export function useSsoDomainTab( const domainsQuery = useQuery({ queryKey: domainQueryKeys.list(idpId), queryFn: async () => { - const response = await coreClient!.getMyOrganizationApiClient().organization.domains.list(); + const response = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) + .organization.domains.list(); return response.organization_domains; }, enabled: !!coreClient && !!idpId, }); + useEffect(() => { + if (domainsQuery.error) { + handleError(domainsQuery.error); + } + }, [domainsQuery.error, handleError]); + const domainsList = domainsQuery.data ?? []; const isLoading = domainsQuery.isLoading; @@ -56,13 +71,13 @@ export function useSsoDomainTab( queryFn: async () => { const response = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.domains.identityProviders.get(domain.id); const isIdpEnabled = response.identity_providers?.some((idp) => idp.id === idpId); return { domainId: domain.id, isEnabled: isIdpEnabled ?? false }; }, enabled: !!coreClient && !!idpId, - staleTime: 5 * 60 * 1000, // 5 minutes })), }); @@ -87,6 +102,7 @@ export function useSsoDomainTab( const result: Domain = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.domains.create(data); domains?.createAction?.onAfter?.(result); @@ -100,6 +116,7 @@ export function useSsoDomainTab( queryKey: domainQueryKeys.idpAssociation(newDomain.id, idpId), }); }, + onError: (error) => handleError(error), }); const verifyDomainMutation = useMutation({ @@ -113,6 +130,7 @@ export function useSsoDomainTab( const updatedDomain = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.domains.verify.create(domain.id); if (domains?.verifyAction?.onAfter) { @@ -129,6 +147,7 @@ export function useSsoDomainTab( }); } }, + onError: (error) => handleError(error), }); const deleteDomainMutation = useMutation({ @@ -144,7 +163,10 @@ export function useSsoDomainTab( } } - await coreClient.getMyOrganizationApiClient().organization.domains.delete(domain.id); + await coreClient + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) + .organization.domains.delete(domain.id); if (domains?.deleteAction?.onAfter) { await domains.deleteAction.onAfter(domain); @@ -159,6 +181,7 @@ export function useSsoDomainTab( queryKey: domainQueryKeys.idpAssociation(domain.id, idpId), }); }, + onError: (error) => handleError(error), }); const associateToProviderMutation = useMutation({ @@ -172,6 +195,7 @@ export function useSsoDomainTab( await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.identityProviders.domains.create(idpId, { domain: domain.domain, }); @@ -188,6 +212,7 @@ export function useSsoDomainTab( queryKey: domainQueryKeys.idpAssociation(domain.id, idpId), }); }, + onError: (error) => handleError(error), }); const deleteFromProviderMutation = useMutation({ @@ -205,6 +230,7 @@ export function useSsoDomainTab( await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.identityProviders.domains.delete(provider.id!, domain.domain); if (domains?.deleteFromProviderAction?.onAfter) { @@ -219,6 +245,7 @@ export function useSsoDomainTab( queryKey: domainQueryKeys.idpAssociation(domain.id, idpId), }); }, + onError: (error) => handleError(error), }); // ===== Handlers ===== @@ -366,28 +393,56 @@ export function useSsoDomainTab( associateToProviderMutation.error || deleteFromProviderMutation.error; - // Refetch function to retry on error - const refetch = useCallback(() => { - createDomainMutation.reset(); - verifyDomainMutation.reset(); - deleteDomainMutation.reset(); - associateToProviderMutation.reset(); - deleteFromProviderMutation.reset(); - queryClient.invalidateQueries({ queryKey: domainQueryKeys.list(idpId) }); - }, [ - createDomainMutation, - verifyDomainMutation, - deleteDomainMutation, - associateToProviderMutation, - deleteFromProviderMutation, - queryClient, - idpId, - ]); + // Retry function + const retry = async () => { + if (domainsQuery.error) { + await queryClient.invalidateQueries({ queryKey: domainQueryKeys.list(idpId) }); + return; + } + + const mutations = [ + { + error: createDomainMutation.error, + retry: () => + createDomainMutation.variables && + createDomainMutation.mutateAsync(createDomainMutation.variables), + }, + { + error: verifyDomainMutation.error, + retry: () => + verifyDomainMutation.variables && + verifyDomainMutation.mutateAsync(verifyDomainMutation.variables), + }, + { + error: deleteDomainMutation.error, + retry: () => + deleteDomainMutation.variables && + deleteDomainMutation.mutateAsync(deleteDomainMutation.variables), + }, + { + error: associateToProviderMutation.error, + retry: () => + associateToProviderMutation.variables && + associateToProviderMutation.mutateAsync(associateToProviderMutation.variables), + }, + { + error: deleteFromProviderMutation.error, + retry: () => + deleteFromProviderMutation.variables && + deleteFromProviderMutation.mutateAsync(deleteFromProviderMutation.variables), + }, + ]; + + const failedMutation = mutations.find((m) => m.error); + if (failedMutation) { + await failedMutation.retry(); + } + }; return { isLoading, error, - refetch, + retry, domainsList, isCreating: createDomainMutation.isPending, selectedDomain, diff --git a/packages/react/src/hooks/my-organization/use-sso-provider-create.ts b/packages/react/src/hooks/my-organization/use-sso-provider-create.ts index 032b7e6be..87e531692 100644 --- a/packages/react/src/hooks/my-organization/use-sso-provider-create.ts +++ b/packages/react/src/hooks/my-organization/use-sso-provider-create.ts @@ -11,6 +11,7 @@ import { useCallback } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; import { ssoProviderQueryKeys } from '@/hooks/my-organization/use-sso-provider-table'; import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { UseSsoProviderCreateOptions } from '@/types/my-organization/idp-management/sso-provider/sso-provider-create-types'; @@ -24,11 +25,12 @@ function extractDomainFromDiscoveryError(detail?: string): string | null { export interface UseSsoProviderCreateReturn { createProvider: (data: CreateIdentityProviderRequestContentPrivate) => Promise; isCreating: boolean; + error: unknown; + retry: () => Promise; } /** - * Custom hook for creating SSO providers. - * Uses TanStack Query for mutation management and cache invalidation. + * Creates SSO providers with automatic error handling and cache management. */ export function useSsoProviderCreate({ createAction, @@ -37,19 +39,12 @@ export function useSsoProviderCreate({ const { coreClient } = useCoreClient(); const { t } = useTranslator('idp_management.create_sso_provider', customMessages); const queryClient = useQueryClient(); - - // ============================================ - // MUTATION - // ============================================ + const handleError = useErrorHandler(); const createProviderMutation = useMutation({ mutationFn: async ( data: CreateIdentityProviderRequestContentPrivate, ): Promise => { - if (!coreClient) { - throw new Error('Core client not available'); - } - const { strategy, name, display_name, ...configOptions } = data; const formData = { @@ -62,7 +57,7 @@ export function useSsoProviderCreate({ const apiRequestData: CreateIdentityProviderRequestContent = SsoProviderMappers.createToAPI(formData); - const result: IdentityProvider = await coreClient + const result: IdentityProvider = await coreClient! .getMyOrganizationApiClient() .organization.identityProviders.create(apiRequestData); @@ -80,6 +75,7 @@ export function useSsoProviderCreate({ queryClient.invalidateQueries({ queryKey: ssoProviderQueryKeys.list() }); }, onError: (error, data) => { + // Handle specific business errors with custom messages if ( hasApiErrorBody(error) && error.body?.status === 409 && @@ -93,6 +89,7 @@ export function useSsoProviderCreate({ }); return; } + // Handle discovery failure error for domain if (hasApiErrorBody(error)) { const domainFromError = extractDomainFromDiscoveryError(error.body?.detail); @@ -106,18 +103,10 @@ export function useSsoProviderCreate({ return; } } - - showToast({ - type: 'error', - message: t('notifications.general_error'), - }); + handleError(error); }, }); - // ============================================ - // ACTION - Wrapper around mutation - // ============================================ - const createProvider = useCallback( async (data: CreateIdentityProviderRequestContentPrivate): Promise => { if (!coreClient) { @@ -137,11 +126,21 @@ export function useSsoProviderCreate({ await createProviderMutation.mutateAsync(data); }, - [coreClient, createAction, createProviderMutation], + [coreClient, t, createAction, createProviderMutation], ); + const retry = useCallback(async () => { + if (createProviderMutation.variables) { + await createProviderMutation.mutateAsync(createProviderMutation.variables); + } else { + createProviderMutation.reset(); + } + }, [createProviderMutation]); + return { createProvider, isCreating: createProviderMutation.isPending, + error: createProviderMutation.error, + retry, }; } diff --git a/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts b/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts index 1a211f314..aea0c4493 100644 --- a/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts +++ b/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts @@ -2,6 +2,7 @@ import { OrganizationDetailsFactory, OrganizationDetailsMappers, SsoProviderMappers, + MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES, type IdentityProvider, type IdpId, type OrganizationPrivate, @@ -11,10 +12,11 @@ import { getStatusCode, } from '@auth0/universal-components-core'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { UseSsoProviderEditOptions, @@ -35,6 +37,9 @@ export const ssoProviderEditQueryKeys = { scimTokens: (idpId: IdpId) => [...ssoProviderEditQueryKeys.all, 'scim-tokens', idpId] as const, }; +/** + * Hook for editing SSO identity provider configuration and provisioning. + */ export function useSsoProviderEdit( idpId: IdpId, { sso, provisioning, customMessages = {} }: Partial = {}, @@ -42,60 +47,44 @@ export function useSsoProviderEdit( const { coreClient } = useCoreClient(); const { t } = useTranslator('idp_management.notifications', customMessages); const queryClient = useQueryClient(); - const hasShownProviderError = useRef(false); - const hasShownProvisioningError = useRef(false); - const hasShownOrganizationError = useRef(false); + const handleError = useErrorHandler(); - // ============================================ - // QUERIES - All data managed by TanStack Query - // ============================================ - - /** - * Provider query - fetches the identity provider details. - * TanStack Query handles caching, loading states, and refetching. - */ const providerQuery = useQuery({ queryKey: ssoProviderEditQueryKeys.detail(idpId), queryFn: async (): Promise => { const response = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) .organization.identityProviders.get(idpId); return response; }, enabled: !!coreClient && !!idpId, }); - /** - * Organization query - fetches organization details. - * Shared across the application, so it uses a common query key. - */ const organizationQuery = useQuery({ queryKey: ssoProviderEditQueryKeys.organization(), queryFn: async (): Promise => { - const response = await coreClient!.getMyOrganizationApiClient().organizationDetails.get(); + const response = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organizationDetails.get(); return OrganizationDetailsMappers.fromAPI(response); }, enabled: !!coreClient, initialData: OrganizationDetailsFactory.create(), }); - /** - * Provisioning config query - fetches provisioning configuration. - * Returns null if provisioning is not configured (404). - */ const provisioningQuery = useQuery({ queryKey: ssoProviderEditQueryKeys.provisioning(idpId), queryFn: async (): Promise => { try { const result = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) .organization.identityProviders.provisioning.get(idpId); return result; } catch (error) { - const status = getStatusCode(error); - if (status === 404) { - return null; - } + if (getStatusCode(error) === 404) return null; throw error; } }, @@ -103,700 +92,410 @@ export function useSsoProviderEdit( }); useEffect(() => { - if (providerQuery.isError && !hasShownProviderError.current) { - showToast({ - type: 'error', - message: t('general_error'), - }); - hasShownProviderError.current = true; - } - - if (!providerQuery.isError) { - hasShownProviderError.current = false; - } - }, [providerQuery.isError, t]); + if (providerQuery.error) handleError(providerQuery.error); + }, [providerQuery.error, handleError]); useEffect(() => { - if (organizationQuery.isError && !hasShownOrganizationError.current) { - const errorMessage = - organizationQuery.error instanceof Error - ? t('general_error', { message: organizationQuery.error.message }) - : t('general_error'); - - showToast({ - type: 'error', - message: errorMessage, - }); - hasShownOrganizationError.current = true; - } - - if (!organizationQuery.isError) { - hasShownOrganizationError.current = false; - } - }, [organizationQuery.error, organizationQuery.isError, t]); + if (organizationQuery.error) handleError(organizationQuery.error); + }, [organizationQuery.error, handleError]); useEffect(() => { - if (provisioningQuery.isError && !hasShownProvisioningError.current) { - showToast({ - type: 'error', - message: t('general_error'), - }); - hasShownProvisioningError.current = true; - } + if (provisioningQuery.error) handleError(provisioningQuery.error); + }, [provisioningQuery.error, handleError]); - if (!provisioningQuery.isError) { - hasShownProvisioningError.current = false; - } - }, [provisioningQuery.isError, t]); - - // ============================================ - // MUTATIONS - All actions that modify data - // ============================================ - - /** - * Update provider mutation - updates SSO provider configuration. - */ const updateProviderMutation = useMutation({ mutationFn: async (data: UpdateIdentityProviderRequestContent): Promise => { const provider = providerQuery.data; - if (!provider) { - throw new Error('Provider not loaded'); - } + if (!provider) throw new Error('Provider not loaded'); - if (sso?.updateAction?.onBefore) { - const canProceed = sso.updateAction.onBefore(provider); - if (!canProceed) { - throw new Error(ACTION_CANCELLED_ERROR); - } + if (sso?.updateAction?.onBefore && !sso.updateAction.onBefore(provider)) { + throw new Error(ACTION_CANCELLED_ERROR); } - const apiRequestData: UpdateIdentityProviderRequestContent = SsoProviderMappers.updateToAPI({ + const apiRequestData = SsoProviderMappers.updateToAPI({ strategy: provider.strategy, ...data, }); const result = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) .organization.identityProviders.update(idpId, apiRequestData); return result; }, - onSuccess: async (result, _variables) => { + onSuccess: async (result) => { const provider = providerQuery.data; - showToast({ type: 'success', message: t('update_success', { providerName: provider?.display_name }), }); - - // Update cache with new data queryClient.setQueryData(ssoProviderEditQueryKeys.detail(idpId), result); - if (sso?.updateAction?.onAfter && provider) { await sso.updateAction.onAfter(provider, result); } }, onError: (error) => { - if (isActionCancelledError(error)) { - return; + if (!isActionCancelledError(error)) handleError(error); + }, + }); + + const deleteProviderMutation = useMutation({ + mutationFn: async (): Promise => { + const provider = providerQuery.data; + if (!provider?.id) throw new Error('Provider not loaded or missing ID'); + + await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.delete(provider.id); + }, + onSuccess: async () => { + const provider = providerQuery.data; + showToast({ + type: 'success', + message: t('delete_success', { providerName: provider?.display_name }), + }); + queryClient.removeQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); + queryClient.removeQueries({ queryKey: ssoProviderEditQueryKeys.provisioning(idpId) }); + if (sso?.deleteAction?.onAfter && provider) { + await sso.deleteAction.onAfter(provider); + } + }, + onError: (error) => handleError(error), + }); + + const detachProviderMutation = useMutation({ + mutationFn: async (): Promise => { + const provider = providerQuery.data; + if (!provider?.id) throw new Error('Provider not loaded or missing ID'); + + if ( + sso?.deleteFromOrganizationAction?.onBefore && + !sso.deleteFromOrganizationAction.onBefore(provider) + ) { + throw new Error(ACTION_CANCELLED_ERROR); } + + await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.detach(provider.id); + }, + onSuccess: async () => { + const provider = providerQuery.data; + const organization = organizationQuery.data; showToast({ - type: 'error', - message: t('general_error'), + type: 'success', + message: t('remove_success', { + providerName: provider?.display_name, + organizationName: organization?.display_name, + }), }); + queryClient.removeQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); + if (sso?.deleteFromOrganizationAction?.onAfter && provider) { + await sso.deleteFromOrganizationAction.onAfter(provider); + } + }, + onError: (error) => { + if (!isActionCancelledError(error)) handleError(error); }, }); - /** - * Create provisioning mutation - enables provisioning for the provider. - */ const createProvisioningMutation = useMutation({ mutationFn: async (): Promise => { const provider = providerQuery.data; - if (!provider) { - throw new Error('Provider not loaded'); - } + if (!provider) throw new Error('Provider not loaded'); - if (provisioning?.createAction?.onBefore) { - const canProceed = provisioning.createAction.onBefore(provider); - if (!canProceed) { - throw new Error(ACTION_CANCELLED_ERROR); - } + if (provisioning?.createAction?.onBefore && !provisioning.createAction.onBefore(provider)) { + throw new Error(ACTION_CANCELLED_ERROR); } const result = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) .organization.identityProviders.provisioning.create(idpId); return result; }, onSuccess: async (result) => { const provider = providerQuery.data; - showToast({ type: 'success', message: t('update_success', { providerName: provider?.display_name }), }); - - // Invalidate queries to refetch fresh data - await queryClient.invalidateQueries({ - queryKey: ssoProviderEditQueryKeys.detail(idpId), - }); + await queryClient.invalidateQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); queryClient.setQueryData(ssoProviderEditQueryKeys.provisioning(idpId), result); - if (provisioning?.createAction?.onAfter && provider) { await provisioning.createAction.onAfter(provider, result); } }, onError: (error) => { - if (isActionCancelledError(error)) { - return; - } - showToast({ - type: 'error', - message: t('general_error'), - }); + if (!isActionCancelledError(error)) handleError(error); }, }); - /** - * Delete provisioning mutation - disables provisioning for the provider. - */ const deleteProvisioningMutation = useMutation({ mutationFn: async (): Promise => { const provider = providerQuery.data; - if (!provider) { - throw new Error('Provider not loaded'); - } + if (!provider) throw new Error('Provider not loaded'); - if (provisioning?.deleteAction?.onBefore) { - const canProceed = provisioning.deleteAction.onBefore(provider); - if (!canProceed) { - throw new Error(ACTION_CANCELLED_ERROR); - } + if (provisioning?.deleteAction?.onBefore && !provisioning.deleteAction.onBefore(provider)) { + throw new Error(ACTION_CANCELLED_ERROR); } await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) .organization.identityProviders.provisioning.delete(idpId); }, onSuccess: async () => { const provider = providerQuery.data; - showToast({ type: 'success', message: t('update_success', { providerName: provider?.display_name }), }); - - // Update cache to reflect deleted provisioning queryClient.setQueryData(ssoProviderEditQueryKeys.provisioning(idpId), null); - await queryClient.invalidateQueries({ - queryKey: ssoProviderEditQueryKeys.detail(idpId), - }); - + await queryClient.invalidateQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); if (provisioning?.deleteAction?.onAfter && provider) { await provisioning.deleteAction.onAfter(provider); } }, onError: (error) => { - if (isActionCancelledError(error)) { - return; - } - showToast({ - type: 'error', - message: t('general_error'), - }); + if (!isActionCancelledError(error)) handleError(error); }, }); - /** - * Create SCIM token mutation - generates a new SCIM token for provisioning. - */ const createScimTokenMutation = useMutation({ mutationFn: async (data: CreateIdpProvisioningScimTokenRequestContent) => { const provider = providerQuery.data; - if (!provider) { - throw new Error('Provider not loaded'); - } + if (!provider) throw new Error('Provider not loaded'); - if (provisioning?.createScimTokenAction?.onBefore) { - const canProceed = provisioning.createScimTokenAction.onBefore(provider); - if (!canProceed) { - throw new Error(ACTION_CANCELLED_ERROR); - } + if ( + provisioning?.createScimTokenAction?.onBefore && + !provisioning.createScimTokenAction.onBefore(provider) + ) { + throw new Error(ACTION_CANCELLED_ERROR); } const result = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) .organization.identityProviders.provisioning.scimTokens.create(idpId, data); return result; }, onSuccess: async (result) => { const provider = providerQuery.data; - - showToast({ - type: 'success', - message: t('scim_token_create_success'), - }); - - // Invalidate SCIM tokens list to refetch - await queryClient.invalidateQueries({ - queryKey: ssoProviderEditQueryKeys.scimTokens(idpId), - }); - + showToast({ type: 'success', message: t('scim_token_create_success') }); if (provisioning?.createScimTokenAction?.onAfter && provider) { await provisioning.createScimTokenAction.onAfter(provider, result); } }, onError: (error) => { - if (isActionCancelledError(error)) { - return; - } - showToast({ - type: 'error', - message: t('general_error'), - }); + if (!isActionCancelledError(error)) handleError(error); }, }); - /** - * Delete SCIM token mutation - removes a SCIM token. - */ const deleteScimTokenMutation = useMutation({ mutationFn: async (idpScimTokenId: string): Promise => { const provider = providerQuery.data; - if (!provider) { - throw new Error('Provider not loaded'); - } + if (!provider) throw new Error('Provider not loaded'); - if (provisioning?.deleteScimTokenAction?.onBefore) { - const canProceed = provisioning.deleteScimTokenAction.onBefore(provider); - if (!canProceed) { - throw new Error(ACTION_CANCELLED_ERROR); - } + if ( + provisioning?.deleteScimTokenAction?.onBefore && + !provisioning.deleteScimTokenAction.onBefore(provider) + ) { + throw new Error(ACTION_CANCELLED_ERROR); } await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) .organization.identityProviders.provisioning.scimTokens.delete(idpId, idpScimTokenId); }, onSuccess: async () => { const provider = providerQuery.data; - - showToast({ - type: 'success', - message: t('scim_token_delete_sucess'), - }); - - // Invalidate SCIM tokens list to refetch - await queryClient.invalidateQueries({ - queryKey: ssoProviderEditQueryKeys.scimTokens(idpId), - }); - + showToast({ type: 'success', message: t('scim_token_delete_sucess') }); if (provisioning?.deleteScimTokenAction?.onAfter && provider) { await provisioning.deleteScimTokenAction.onAfter(provider); } }, onError: (error) => { - if (isActionCancelledError(error)) { - return; - } - showToast({ - type: 'error', - message: t('general_error'), - }); + if (!isActionCancelledError(error)) handleError(error); }, }); /** - * Delete provider mutation - completely deletes the provider. + * List SCIM tokens mutation - fetches SCIM tokens for provisioning. + * Note: This uses imperative fetching rather than a query because tokens + * are typically fetched on-demand and the response includes sensitive data + * that shouldn't be automatically cached. */ - const deleteProviderMutation = useMutation({ - mutationFn: async (): Promise => { - const provider = providerQuery.data; - if (!provider?.id) { - throw new Error('Provider not loaded or missing ID'); - } + const listScimTokensMutation = useMutation({ + mutationFn: async () => { + if (!coreClient || !idpId) return null; - await coreClient! + const result = await coreClient .getMyOrganizationApiClient() - .organization.identityProviders.delete(provider.id); + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.scimTokens.list(idpId); + return result; }, - onSuccess: async () => { - const provider = providerQuery.data; - - showToast({ - type: 'success', - message: t('delete_success', { providerName: provider?.display_name }), - }); - - // Remove all related queries from cache - queryClient.removeQueries({ - queryKey: ssoProviderEditQueryKeys.detail(idpId), - }); - queryClient.removeQueries({ - queryKey: ssoProviderEditQueryKeys.provisioning(idpId), - }); - queryClient.removeQueries({ - queryKey: ssoProviderEditQueryKeys.scimTokens(idpId), - }); + onError: (error) => handleError(error), + }); - if (sso?.deleteAction?.onAfter && provider) { - await sso.deleteAction.onAfter(provider); - } + const syncSsoAttributesMutation = useMutation({ + mutationFn: async () => { + await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.updateAttributes(idpId, {}); }, - onError: () => { - showToast({ - type: 'error', - message: t('general_error'), - }); + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); + showToast({ type: 'success', message: t('sso_attributes_sync_success') }); }, + onError: (error) => handleError(error), }); - /** - * Detach provider mutation - removes provider from organization but doesn't delete it. - */ - const detachProviderMutation = useMutation({ - mutationFn: async (): Promise => { - const provider = providerQuery.data; - if (!provider?.id) { - throw new Error('Provider not loaded or missing ID'); - } - - if (sso?.deleteFromOrganizationAction?.onBefore) { - const canProceed = sso.deleteFromOrganizationAction.onBefore(provider); - if (!canProceed) { - throw new Error(ACTION_CANCELLED_ERROR); - } - } - - // Ensure organization data is fresh before detaching - await queryClient.ensureQueryData({ - queryKey: ssoProviderEditQueryKeys.organization(), - }); - + const syncProvisioningAttributesMutation = useMutation({ + mutationFn: async () => { await coreClient! .getMyOrganizationApiClient() - .organization.identityProviders.detach(provider.id); - }, - onSuccess: async () => { - const provider = providerQuery.data; - const organization = organizationQuery.data; - - showToast({ - type: 'success', - message: t('remove_success', { - providerName: provider?.display_name, - organizationName: organization?.display_name, - }), - }); - - // Remove provider from cache - queryClient.removeQueries({ - queryKey: ssoProviderEditQueryKeys.detail(idpId), - }); - - if (sso?.deleteFromOrganizationAction?.onAfter && provider) { - await sso.deleteFromOrganizationAction.onAfter(provider); - } + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.updateAttributes(idpId, {}); }, - onError: (error) => { - if (isActionCancelledError(error)) { - return; - } - showToast({ - type: 'error', - message: t('general_error'), - }); + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ssoProviderEditQueryKeys.provisioning(idpId) }); + showToast({ type: 'success', message: t('provisioning_attributes_sync_success') }); }, + onError: (error) => handleError(error), }); - // ============================================ - // ACTION CALLBACKS - Wrapper functions for mutations - // ============================================ - - const fetchProvider = useCallback(async (): Promise => { - if (!coreClient || !idpId) { - return null; - } - - try { - const data = await queryClient.ensureQueryData({ - queryKey: ssoProviderEditQueryKeys.detail(idpId), - queryFn: async () => { - const response = await coreClient - .getMyOrganizationApiClient() - .organization.identityProviders.get(idpId); - return response; - }, - }); - return data; - } catch (error) { - showToast({ - type: 'error', - message: t('general_error'), - }); - return null; - } - }, [coreClient, idpId, queryClient, t]); - - const fetchOrganizationDetails = useCallback(async (): Promise => { - if (!coreClient) { - return; - } - - await queryClient.invalidateQueries({ - queryKey: ssoProviderEditQueryKeys.organization(), - }); - }, [coreClient, queryClient]); - - const fetchProvisioning = - useCallback(async (): Promise => { - if (!coreClient || !idpId) { - return null; - } - - try { - const data = await queryClient.fetchQuery({ - queryKey: ssoProviderEditQueryKeys.provisioning(idpId), - queryFn: async () => { - try { - const result = await coreClient - .getMyOrganizationApiClient() - .organization.identityProviders.provisioning.get(idpId); - return result; - } catch (error) { - const status = getStatusCode(error); - if (status === 404) { - return null; - } - throw error; - } - }, - }); - return data; - } catch (error) { - const status = getStatusCode(error); - if (status !== 404) { - showToast({ - type: 'error', - message: t('general_error'), - }); - } - return null; - } - }, [coreClient, idpId, queryClient, t]); - const updateProvider = useCallback( - async (data: UpdateIdentityProviderRequestContent): Promise => { - const provider = providerQuery.data; - if (!coreClient || !idpId || !provider) { - return; - } - + async (data: UpdateIdentityProviderRequestContent) => { + if (!coreClient || !providerQuery.data) return; try { await updateProviderMutation.mutateAsync(data); } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } + if (!isActionCancelledError(error)) throw error; } }, - [coreClient, idpId, providerQuery.data, updateProviderMutation], + [coreClient, providerQuery.data, updateProviderMutation], ); - const createProvisioning = useCallback(async (): Promise => { - const provider = providerQuery.data; - if (!coreClient || !idpId || !provider) { - return; - } + const onDeleteConfirm = useCallback(async () => { + if (!coreClient || !providerQuery.data?.id) return; + await deleteProviderMutation.mutateAsync(); + }, [coreClient, deleteProviderMutation, providerQuery.data?.id]); + const onRemoveConfirm = useCallback(async () => { + if (!coreClient || !providerQuery.data?.id) return; try { - await createProvisioningMutation.mutateAsync(); + await detachProviderMutation.mutateAsync(); } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } - } - }, [coreClient, createProvisioningMutation, idpId, providerQuery.data]); - - const deleteProvisioning = useCallback(async (): Promise => { - const provider = providerQuery.data; - if (!coreClient || !idpId || !provider) { - return; + if (!isActionCancelledError(error)) throw error; } + }, [coreClient, detachProviderMutation, providerQuery.data?.id]); + const createProvisioning = useCallback(async () => { + if (!coreClient || !providerQuery.data) return; try { - await deleteProvisioningMutation.mutateAsync(); + await createProvisioningMutation.mutateAsync(); } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } + if (!isActionCancelledError(error)) throw error; } - }, [coreClient, deleteProvisioningMutation, idpId, providerQuery.data]); - - /** - * List SCIM tokens mutation - fetches SCIM tokens for provisioning. - * Note: This uses imperative fetching rather than a query because tokens - * are typically fetched on-demand and the response includes sensitive data - * that shouldn't be automatically cached. - */ - const listScimTokensMutation = useMutation({ - mutationFn: async () => { - if (!coreClient || !idpId) { - return null; - } - - const result = await coreClient - .getMyOrganizationApiClient() - .organization.identityProviders.provisioning.scimTokens.list(idpId); - return result; - }, - onError: () => { - showToast({ - type: 'error', - message: t('general_error'), - }); - }, - }); + }, [coreClient, createProvisioningMutation, providerQuery.data]); - const listScimTokens = useCallback(async () => { + const deleteProvisioning = useCallback(async () => { + if (!coreClient || !providerQuery.data) return; try { - return await listScimTokensMutation.mutateAsync(); + await deleteProvisioningMutation.mutateAsync(); } catch (error) { - return null; + if (!isActionCancelledError(error)) throw error; } - }, [listScimTokensMutation]); + }, [coreClient, deleteProvisioningMutation, providerQuery.data]); const createScimToken = useCallback( async (data: CreateIdpProvisioningScimTokenRequestContent) => { - const provider = providerQuery.data; - if (!coreClient || !idpId || !provider) { - return undefined; - } - + if (!coreClient || !providerQuery.data) return undefined; try { return await createScimTokenMutation.mutateAsync(data); } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } + if (!isActionCancelledError(error)) throw error; return undefined; } }, - [coreClient, createScimTokenMutation, idpId, providerQuery.data], + [coreClient, createScimTokenMutation, providerQuery.data], ); const deleteScimToken = useCallback( - async (idpScimTokenId: string): Promise => { - const provider = providerQuery.data; - if (!coreClient || !idpId || !provider) { - return; - } - + async (idpScimTokenId: string) => { + if (!coreClient || !providerQuery.data) return; try { await deleteScimTokenMutation.mutateAsync(idpScimTokenId); } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } + if (!isActionCancelledError(error)) throw error; } }, - [coreClient, deleteScimTokenMutation, idpId, providerQuery.data], + [coreClient, deleteScimTokenMutation, providerQuery.data], ); - const syncSsoAttributesMutation = useMutation({ - mutationFn: async () => { - await coreClient! - .getMyOrganizationApiClient() - .organization.identityProviders.updateAttributes(idpId, {}); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ssoProviderEditQueryKeys.detail(idpId), - }); - showToast({ - type: 'success', - message: t('sso_attributes_sync_success'), - }); - }, - onError: () => { - showToast({ - type: 'error', - message: t('general_error'), - }); - }, - }); - - const syncSsoAttributes = useCallback(async (): Promise => { - if (!coreClient || !idpId) { - return; + const listScimTokens = useCallback(async () => { + try { + return await listScimTokensMutation.mutateAsync(); + } catch (error) { + return null; } + }, [listScimTokensMutation]); + const syncSsoAttributes = useCallback(async () => { + if (!coreClient) return; await syncSsoAttributesMutation.mutateAsync(); - }, [coreClient, idpId, syncSsoAttributesMutation]); - - const syncProvisioningAttributesMutation = useMutation({ - mutationFn: async () => { - await coreClient! - .getMyOrganizationApiClient() - .organization.identityProviders.provisioning.updateAttributes(idpId, {}); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ssoProviderEditQueryKeys.provisioning(idpId), - }); - showToast({ - type: 'success', - message: t('provisioning_attributes_sync_success'), - }); - }, - onError: () => { - showToast({ - type: 'error', - message: t('general_error'), - }); - }, - }); - - const syncProvisioningAttributes = useCallback(async (): Promise => { - if (!coreClient || !idpId) { - return; - } + }, [coreClient, syncSsoAttributesMutation]); + const syncProvisioningAttributes = useCallback(async () => { + if (!coreClient) return; await syncProvisioningAttributesMutation.mutateAsync(); - }, [coreClient, idpId, syncProvisioningAttributesMutation]); - - const onDeleteConfirm = useCallback(async (): Promise => { - const provider = providerQuery.data; - if (!coreClient || !provider?.id) { - return; - } + }, [coreClient, syncProvisioningAttributesMutation]); - try { - await deleteProviderMutation.mutateAsync(); - } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } - } - }, [coreClient, deleteProviderMutation, providerQuery.data]); - - const onRemoveConfirm = useCallback(async (): Promise => { - const provider = providerQuery.data; - if (!coreClient || !provider?.id) { - return; - } + const fetchProvider = useCallback(async () => { + const result = await queryClient.fetchQuery({ + queryKey: ssoProviderEditQueryKeys.detail(idpId), + queryFn: async (): Promise => { + const response = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.get(idpId); + return response; + }, + }); + return result; + }, [queryClient, idpId, coreClient]); + const fetchProvisioning = useCallback(async () => { try { - await detachProviderMutation.mutateAsync(); + const result = await queryClient.fetchQuery({ + queryKey: ssoProviderEditQueryKeys.provisioning(idpId), + queryFn: async (): Promise => { + try { + const response = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.get(idpId); + return response; + } catch (error) { + if (getStatusCode(error) === 404) return null; + throw error; + } + }, + }); + return result; } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } + return null; } - }, [coreClient, detachProviderMutation, providerQuery.data]); + }, [queryClient, idpId, coreClient]); const hasSsoAttributeSyncWarning = useMemo(() => { const provider = providerQuery.data; @@ -805,18 +504,92 @@ export function useSsoProviderEdit( }, [providerQuery.data]); const hasProvisioningAttributeSyncWarning = useMemo(() => { - const provisioningConfig = provisioningQuery.data; - const attributes = provisioningConfig?.attributes ?? []; + const attributes = provisioningQuery.data?.attributes ?? []; return attributes.some((attr) => attr.is_extra || attr.is_missing); }, [provisioningQuery.data]); + const error = + providerQuery.error || + organizationQuery.error || + provisioningQuery.error || + updateProviderMutation.error || + deleteProviderMutation.error || + detachProviderMutation.error || + createProvisioningMutation.error || + deleteProvisioningMutation.error || + createScimTokenMutation.error || + deleteScimTokenMutation.error || + syncSsoAttributesMutation.error || + syncProvisioningAttributesMutation.error; + + const retry = async () => { + const queries = [ + { error: providerQuery.error, key: ssoProviderEditQueryKeys.detail(idpId) }, + { error: organizationQuery.error, key: ssoProviderEditQueryKeys.organization() }, + { error: provisioningQuery.error, key: ssoProviderEditQueryKeys.provisioning(idpId) }, + ]; + + const failedQuery = queries.find((q) => q.error); + if (failedQuery) { + await queryClient.invalidateQueries({ queryKey: failedQuery.key }); + return; + } + + const mutations = [ + { + error: updateProviderMutation.error, + retry: () => + updateProviderMutation.variables && + updateProviderMutation.mutateAsync(updateProviderMutation.variables), + }, + { + error: deleteProviderMutation.error, + retry: () => deleteProviderMutation.mutateAsync(), + }, + { + error: detachProviderMutation.error, + retry: () => detachProviderMutation.mutateAsync(), + }, + { + error: createProvisioningMutation.error, + retry: () => createProvisioningMutation.mutateAsync(), + }, + { + error: deleteProvisioningMutation.error, + retry: () => deleteProvisioningMutation.mutateAsync(), + }, + { + error: createScimTokenMutation.error, + retry: () => + createScimTokenMutation.variables && + createScimTokenMutation.mutateAsync(createScimTokenMutation.variables), + }, + { + error: deleteScimTokenMutation.error, + retry: () => + deleteScimTokenMutation.variables && + deleteScimTokenMutation.mutateAsync(deleteScimTokenMutation.variables), + }, + { + error: syncSsoAttributesMutation.error, + retry: () => syncSsoAttributesMutation.mutateAsync(), + }, + { + error: syncProvisioningAttributesMutation.error, + retry: () => syncProvisioningAttributesMutation.mutateAsync(), + }, + ]; + + const failedMutation = mutations.find((m) => m.error); + if (failedMutation) { + await failedMutation.retry(); + } + }; + return { - // Data from TanStack Query - single source of truth provider: providerQuery.data ?? null, organization: organizationQuery.data ?? OrganizationDetailsFactory.create(), provisioningConfig: provisioningQuery.data ?? null, - - // Loading states - all derived from TanStack Query isLoading: providerQuery.isLoading || organizationQuery.isLoading, isUpdating: updateProviderMutation.isPending, isDeleting: deleteProviderMutation.isPending, @@ -829,16 +602,15 @@ export function useSsoProviderEdit( isScimTokenDeleting: deleteScimTokenMutation.isPending, isSsoAttributesSyncing: syncSsoAttributesMutation.isPending, isProvisioningAttributesSyncing: syncProvisioningAttributesMutation.isPending, - - // Warning states hasSsoAttributeSyncWarning, hasProvisioningAttributeSyncWarning, - - // Actions + error, + retry, fetchProvider, - fetchOrganizationDetails, fetchProvisioning, updateProvider, + onDeleteConfirm, + onRemoveConfirm, createProvisioning, deleteProvisioning, listScimTokens, @@ -846,7 +618,5 @@ export function useSsoProviderEdit( deleteScimToken, syncSsoAttributes, syncProvisioningAttributes, - onDeleteConfirm, - onRemoveConfirm, }; } diff --git a/packages/react/src/hooks/my-organization/use-sso-provider-table.ts b/packages/react/src/hooks/my-organization/use-sso-provider-table.ts index fadebf620..3851f97a6 100644 --- a/packages/react/src/hooks/my-organization/use-sso-provider-table.ts +++ b/packages/react/src/hooks/my-organization/use-sso-provider-table.ts @@ -1,6 +1,7 @@ import { OrganizationDetailsMappers, SsoProviderMappers, + MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES, type UpdateIdentityProviderRequestContent, type ComponentAction, type IdentityProvider, @@ -8,10 +9,11 @@ import { BusinessError, } from '@auth0/universal-components-core'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { UseSsoProviderTableReturn } from '@/types/my-organization/idp-management/sso-provider/sso-provider-table-types'; @@ -22,8 +24,7 @@ export const ssoProviderQueryKeys = { }; /** - * Custom hook for managing SSO provider table data and actions. - * Uses TanStack Query for caching, loading states, and data synchronization. + * Hook for fetching and managing SSO identity providers. */ export function useSsoProviderTable( deleteAction?: ComponentAction, @@ -34,64 +35,26 @@ export function useSsoProviderTable( const { t } = useTranslator('idp_management.notifications', customMessages); const { coreClient } = useCoreClient(); const queryClient = useQueryClient(); - const hasShownProvidersError = useRef(false); - const hasShownOrganizationError = useRef(false); - - // ============================================ - // QUERIES - All data managed by TanStack Query - // ============================================ + const handleError = useErrorHandler(); const providersQuery = useQuery({ queryKey: ssoProviderQueryKeys.list(), queryFn: async () => { const response = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES) .organization.identityProviders.list(); return (response?.identity_providers ?? []) as IdentityProvider[]; }, enabled: !!coreClient, + retry: false, }); - const organizationQuery = useQuery({ - queryKey: ssoProviderQueryKeys.organization, - queryFn: async () => { - const response = await coreClient!.getMyOrganizationApiClient().organizationDetails.get(); - return OrganizationDetailsMappers.fromAPI(response); - }, - enabled: !!coreClient, - }); - - useEffect(() => { - if (providersQuery.isError && !hasShownProvidersError.current) { - showToast({ - type: 'error', - message: t('general_error'), - }); - hasShownProvidersError.current = true; - } - - if (!providersQuery.isError) { - hasShownProvidersError.current = false; - } - }, [providersQuery.isError, t]); - useEffect(() => { - if (organizationQuery.isError && !hasShownOrganizationError.current) { - showToast({ - type: 'error', - message: t('general_error'), - }); - hasShownOrganizationError.current = true; + if (providersQuery.error) { + handleError(providersQuery.error); } - - if (!organizationQuery.isError) { - hasShownOrganizationError.current = false; - } - }, [organizationQuery.isError, t]); - - // ============================================ - // MUTATIONS - // ============================================ + }, [providersQuery.error, handleError]); const enableProviderMutation = useMutation({ mutationFn: async ({ @@ -101,10 +64,6 @@ export function useSsoProviderTable( selectedIdp: IdentityProvider; enabled: boolean; }): Promise => { - if (!selectedIdp?.id) { - throw new Error('Invalid provider'); - } - if (enableAction?.onBefore) { const shouldProceed = enableAction.onBefore(selectedIdp); if (!shouldProceed) { @@ -119,7 +78,8 @@ export function useSsoProviderTable( const updatedProvider = await coreClient! .getMyOrganizationApiClient() - .organization.identityProviders.update(selectedIdp.id, apiRequestData); + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES) + .organization.identityProviders.update(selectedIdp.id!, apiRequestData); return updatedProvider as IdentityProvider; }, @@ -141,23 +101,15 @@ export function useSsoProviderTable( ); }); }, - onError: () => { - showToast({ - type: 'error', - message: t('general_error'), - }); - }, + onError: (error) => handleError(error), }); const deleteProviderMutation = useMutation({ mutationFn: async (selectedIdp: IdentityProvider): Promise => { - if (!selectedIdp?.id) { - throw new Error('Invalid provider'); - } - await coreClient! .getMyOrganizationApiClient() - .organization.identityProviders.delete(selectedIdp.id); + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES) + .organization.identityProviders.delete(selectedIdp.id!); }, onSuccess: async (_, selectedIdp) => { if (deleteAction?.onAfter) { @@ -171,23 +123,15 @@ export function useSsoProviderTable( queryClient.invalidateQueries({ queryKey: ssoProviderQueryKeys.list() }); }, - onError: () => { - showToast({ - type: 'error', - message: t('general_error'), - }); - }, + onError: (error) => handleError(error), }); const removeProviderMutation = useMutation({ mutationFn: async (selectedIdp: IdentityProvider): Promise => { - if (!selectedIdp?.id) { - throw new Error('Invalid provider'); - } - await coreClient! .getMyOrganizationApiClient() - .organization.identityProviders.detach(selectedIdp.id); + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES) + .organization.identityProviders.detach(selectedIdp.id!); }, onSuccess: async (_, selectedIdp) => { if (removeFromOrg?.onAfter) { @@ -208,22 +152,12 @@ export function useSsoProviderTable( queryClient.invalidateQueries({ queryKey: ssoProviderQueryKeys.list() }); }, - onError: () => { - showToast({ - type: 'error', - message: t('general_error'), - }); - return; - }, + onError: (error) => handleError(error), }); - // ============================================ - // ACTIONS - Wrappers around mutations - // ============================================ - const onEnableProvider = useCallback( async (selectedIdp: IdentityProvider, enabled: boolean): Promise => { - if (!selectedIdp || !coreClient || !selectedIdp.id) { + if (!selectedIdp || !selectedIdp.id) { return false; } @@ -239,7 +173,7 @@ export function useSsoProviderTable( const onDeleteConfirm = useCallback( async (selectedIdp: IdentityProvider): Promise => { - if (!selectedIdp || !coreClient || !selectedIdp.id) { + if (!selectedIdp || !selectedIdp.id) { return; } @@ -250,7 +184,7 @@ export function useSsoProviderTable( const onRemoveConfirm = useCallback( async (selectedIdp: IdentityProvider): Promise => { - if (!selectedIdp || !coreClient || !selectedIdp.id) { + if (!selectedIdp || !selectedIdp.id) { return; } @@ -263,50 +197,77 @@ export function useSsoProviderTable( await queryClient.invalidateQueries({ queryKey: ssoProviderQueryKeys.list() }); }, [queryClient]); - const fetchOrganizationDetails = useCallback(async (): Promise => { - if (!coreClient) { - return null; - } - + const getOrganizationName = useCallback(async (): Promise => { try { const data = await queryClient.ensureQueryData({ queryKey: ssoProviderQueryKeys.organization, queryFn: async () => { - const response = await coreClient.getMyOrganizationApiClient().organizationDetails.get(); + const response = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES) + .organizationDetails.get(); return OrganizationDetailsMappers.fromAPI(response); }, }); - return data; + return data.display_name; } catch (error) { - showToast({ - type: 'error', - message: t('general_error'), - }); - return null; + handleError(error); + return undefined; } - }, [coreClient, queryClient, t]); + }, [coreClient, queryClient, handleError]); - // ============================================ - // RETURN - // ============================================ + const error = + providersQuery.error || + enableProviderMutation.error || + deleteProviderMutation.error || + removeProviderMutation.error; + + const retry = async () => { + if (providersQuery.error) { + await queryClient.invalidateQueries({ queryKey: ssoProviderQueryKeys.list() }); + return; + } + + const mutations = [ + { + error: enableProviderMutation.error, + retry: () => + enableProviderMutation.variables && + enableProviderMutation.mutateAsync(enableProviderMutation.variables), + }, + { + error: deleteProviderMutation.error, + retry: () => + deleteProviderMutation.variables && + deleteProviderMutation.mutateAsync(deleteProviderMutation.variables), + }, + { + error: removeProviderMutation.error, + retry: () => + removeProviderMutation.variables && + removeProviderMutation.mutateAsync(removeProviderMutation.variables), + }, + ]; + + const failedMutation = mutations.find((m) => m.error); + if (failedMutation) { + await failedMutation.retry(); + } + }; return { - // Data from TanStack Query - single source of truth providers: providersQuery.data ?? [], - organization: organizationQuery.data ?? null, - - // Loading states - all derived from TanStack Query - isLoading: providersQuery.isLoading || organizationQuery.isLoading, + isLoading: providersQuery.isLoading, isDeleting: deleteProviderMutation.isPending, isRemoving: removeProviderMutation.isPending, isUpdating: enableProviderMutation.isPending, isUpdatingId: enableProviderMutation.isPending ? (enableProviderMutation.variables?.selectedIdp?.id ?? null) : null, - - // Actions + error, + retry, fetchProviders, - fetchOrganizationDetails, + getOrganizationName, onDeleteConfirm, onRemoveConfirm, onEnableProvider, diff --git a/packages/react/src/hooks/shared/__tests__/use-error-handler.test.ts b/packages/react/src/hooks/shared/__tests__/use-error-handler.test.ts new file mode 100644 index 000000000..ec3e25217 --- /dev/null +++ b/packages/react/src/hooks/shared/__tests__/use-error-handler.test.ts @@ -0,0 +1,191 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { showToast } from '@/components/auth0/shared/toast'; +import { useErrorHandler } from '@/hooks/shared/use-error-handler'; +import * as useTranslatorModule from '@/hooks/shared/use-translator'; +import { createMockI18nService } from '@/tests/utils'; + +vi.mock('@/components/auth0/shared/toast'); + +describe('useErrorHandler', () => { + const mockT = createMockI18nService().translator('common'); + const mockedShowToast = vi.mocked(showToast); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(useTranslatorModule, 'useTranslator').mockReturnValue({ + t: mockT, + changeLanguage: vi.fn(), + currentLanguage: 'en-US', + fallbackLanguage: 'en-US', + }); + }); + + it('should return null for null/undefined errors', () => { + const { result } = renderHook(() => useErrorHandler()); + + expect(result.current(null)).toBeNull(); + expect(result.current(undefined)).toBeNull(); + expect(mockedShowToast).not.toHaveBeenCalled(); + }); + + it('should return null for MFA errors', () => { + const { result } = renderHook(() => useErrorHandler()); + const mfaError = { + body: { + error: 'mfa_required', + }, + }; + + expect(result.current(mfaError)).toBeNull(); + expect(mockedShowToast).not.toHaveBeenCalled(); + }); + + it('should return null for 500+ errors', () => { + const { result } = renderHook(() => useErrorHandler()); + const serverError = { + body: { + status: 500, + }, + }; + + expect(result.current(serverError)).toBeNull(); + expect(mockedShowToast).not.toHaveBeenCalled(); + }); + + it('should handle API errors with body.detail', () => { + const { result } = renderHook(() => useErrorHandler()); + const apiError = { + body: { + status: 400, + detail: 'Invalid request parameters', + }, + }; + + const errorMessage = result.current(apiError); + + expect(errorMessage).toBe('Invalid request parameters'); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'error', + message: 'Invalid request parameters', + }); + }); + + it('should handle Error instances', () => { + const { result } = renderHook(() => useErrorHandler()); + const error = new Error('Something went wrong'); + + const errorMessage = result.current(error); + + expect(errorMessage).toBe('Something went wrong'); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'error', + message: 'Something went wrong', + }); + }); + + it('should handle string errors', () => { + const { result } = renderHook(() => useErrorHandler()); + const error = 'Network error occurred'; + + const errorMessage = result.current(error); + + expect(errorMessage).toBe('Network error occurred'); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'error', + message: 'Network error occurred', + }); + }); + + it('should use fallback message for unknown error types', () => { + const { result } = renderHook(() => useErrorHandler()); + const error = { unknown: 'object' }; + + const errorMessage = result.current(error); + + expect(errorMessage).toBe('error.generic'); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'error', + message: 'error.generic', + }); + }); + + it('should not show toast when showToast option is false', () => { + const { result } = renderHook(() => useErrorHandler()); + const error = new Error('Test error'); + + const errorMessage = result.current(error, { showToast: false }); + + expect(errorMessage).toBe('Test error'); + expect(mockedShowToast).not.toHaveBeenCalled(); + }); + + it('should use custom error message getter', () => { + const { result } = renderHook(() => useErrorHandler()); + const error = new Error('Original message'); + const getErrorMessage = vi.fn(() => 'Custom error message'); + + const errorMessage = result.current(error, { getErrorMessage }); + + expect(getErrorMessage).toHaveBeenCalledWith(error); + expect(errorMessage).toBe('Custom error message'); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'error', + message: 'Custom error message', + }); + }); + + it('should handle API errors without detail', () => { + const { result } = renderHook(() => useErrorHandler()); + const apiError = { + body: { + status: 400, + }, + }; + + const errorMessage = result.current(apiError); + + expect(errorMessage).toBe('error.generic'); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'error', + message: 'error.generic', + }); + }); + + it('should handle 404 errors', () => { + const { result } = renderHook(() => useErrorHandler()); + const notFoundError = { + body: { + status: 404, + detail: 'Resource not found', + }, + }; + + const errorMessage = result.current(notFoundError); + + expect(errorMessage).toBe('Resource not found'); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'error', + message: 'Resource not found', + }); + }); + + it('should handle 401 errors', () => { + const { result } = renderHook(() => useErrorHandler()); + const unauthorizedError = { + body: { + status: 401, + detail: 'Unauthorized access', + }, + }; + + const errorMessage = result.current(unauthorizedError); + + expect(errorMessage).toBe('Unauthorized access'); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'error', + message: 'Unauthorized access', + }); + }); +}); diff --git a/packages/react/src/hooks/shared/use-error-handler.ts b/packages/react/src/hooks/shared/use-error-handler.ts new file mode 100644 index 000000000..c1111c159 --- /dev/null +++ b/packages/react/src/hooks/shared/use-error-handler.ts @@ -0,0 +1,81 @@ +import { + getStatusCode, + hasApiErrorBody, + isMfaRequiredError, +} from '@auth0/universal-components-core'; +import { useCallback } from 'react'; + +import { showToast } from '@/components/auth0/shared/toast'; +import { useTranslator } from '@/hooks/shared/use-translator'; + +interface ErrorHandlerCallOptions { + getErrorMessage?: (error: unknown) => string; + showToast?: boolean; +} + +// Skips MFA and 500+ errors (handled by GateKeeper) +const shouldHandleError = (error: unknown): boolean => { + if (!error) return false; + + if (isMfaRequiredError(error)) return false; + + const statusCode = getStatusCode(error); + return !(statusCode && statusCode >= 500); +}; + +// Extracts message from API errors, Error instances, or strings +const extractErrorMessage = (error: unknown, fallback: string): string => { + if (hasApiErrorBody(error) && error.body?.detail) { + return error.body.detail; + } + + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + return fallback; +}; + +/** + * Hook for consistent error handling across the app. + * Skips MFA/500+ errors (GateKeeper handles), shows toast for others. + * + * @example + * const handleError = useErrorHandler(); + * + * // With custom message + * onError: (error) => handleError(error, { + * getErrorMessage: (err) => t('my_error', { message: err.message }) + * }); + * + * // With defaults + * onError: handleError; + */ +export function useErrorHandler() { + const { t } = useTranslator('common'); + + return useCallback( + (error: unknown, options: ErrorHandlerCallOptions = {}): string | null => { + if (!shouldHandleError(error)) return null; + + const { getErrorMessage, showToast: shouldShowToast = true } = options; + + const errorMessage = + getErrorMessage?.(error) ?? extractErrorMessage(error, t('error.generic')); + + if (shouldShowToast) { + showToast({ + type: 'error', + message: errorMessage, + }); + } + + return errorMessage; + }, + [t], + ); +} diff --git a/packages/react/src/hooks/shared/use-scope-manager.ts b/packages/react/src/hooks/shared/use-scope-manager.ts deleted file mode 100644 index 2909a389e..000000000 --- a/packages/react/src/hooks/shared/use-scope-manager.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createContext, useContext } from 'react'; - -export type Audience = 'me' | 'my-org'; - -export interface ScopeManagerContextValue { - registerScopes: (audience: Audience, scopes: string) => void; - isReady: boolean; - ensured: Record; -} - -export const ScopeManagerContext = createContext({ - registerScopes: () => {}, - isReady: false, - ensured: { me: '', 'my-org': '' }, -}); - -export const useScopeManager = () => useContext(ScopeManagerContext); diff --git a/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts b/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts index 102991ff4..c25763a31 100644 --- a/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts +++ b/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts @@ -14,5 +14,7 @@ export const createMockUseConfig = (overrides?: Partial): MockUse }, fetchConfig: vi.fn(async () => undefined), filteredStrategies: [], + error: undefined, + retry: vi.fn(async () => undefined), ...overrides, }); diff --git a/packages/react/src/providers/__tests__/proxy-provider.test.tsx b/packages/react/src/providers/__tests__/proxy-provider.test.tsx index cdc3e258a..61fc200d0 100644 --- a/packages/react/src/providers/__tests__/proxy-provider.test.tsx +++ b/packages/react/src/providers/__tests__/proxy-provider.test.tsx @@ -19,12 +19,6 @@ vi.mock('@/components/ui/spinner', () => ({ Spinner: () =>

    , })); -vi.mock('../scope-manager-provider', () => ({ - ScopeManagerProvider: ({ children }: { children: React.ReactNode }) => ( -
    {children}
    - ), -})); - vi.mock('../theme-provider', () => ({ ThemeProvider: ({ children }: { children: React.ReactNode }) => (
    {children}
    @@ -67,16 +61,6 @@ describe('Auth0ComponentProvider', () => { expect(screen.getByTestId('toaster')).toBeInTheDocument(); }); - it('should render ScopeManagerProvider', () => { - render( - -
    Test
    -
    , - ); - - expect(screen.getByTestId('scope-manager-provider')).toBeInTheDocument(); - }); - it('should apply default theme settings when not provided', () => { render( diff --git a/packages/react/src/providers/__tests__/scope-manager-provider.test.tsx b/packages/react/src/providers/__tests__/scope-manager-provider.test.tsx deleted file mode 100644 index 53c350321..000000000 --- a/packages/react/src/providers/__tests__/scope-manager-provider.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { useEffect } from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import * as useCoreClientModule from '@/hooks/shared/use-core-client'; -import { useScopeManager } from '@/hooks/shared/use-scope-manager'; -import { ScopeManagerProvider } from '@/providers/scope-manager-provider'; -import { mockCore, setupMockUseCoreClient } from '@/tests/utils'; - -const { initMockCoreClient } = mockCore(); -let mockCoreClient: ReturnType; - -const TestConsumer = ({ - audience = 'me', - scopes, -}: { - audience?: 'me' | 'my-org'; - scopes?: string; -}) => { - const { registerScopes, isReady, ensured } = useScopeManager(); - - useEffect(() => { - if (scopes) { - registerScopes(audience, scopes); - } - }, [audience, scopes, registerScopes]); - - return ( -
    -
    {isReady.toString()}
    -
    {ensured.me}
    -
    {ensured['my-org']}
    -
    - ); -}; - -describe('ScopeManagerProvider', () => { - const mockEnsureScopes = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - - // Setup mock core client - mockCoreClient = { ...initMockCoreClient(), ...mockEnsureScopes }; - setupMockUseCoreClient(mockCoreClient, useCoreClientModule); - mockEnsureScopes.mockResolvedValue(undefined); - }); - - it('should render children', () => { - render( - -
    Test Content
    -
    , - ); - - expect(screen.getByTestId('child-content')).toBeInTheDocument(); - }); - - it('should provide initial context values', () => { - render( - - - , - ); - - expect(screen.getByTestId('is-ready')).toHaveTextContent('false'); - expect(screen.getByTestId('ensured-me')).toHaveTextContent(''); - expect(screen.getByTestId('ensured-my-organization')).toHaveTextContent(''); - }); - - it('should not register empty scopes', async () => { - render( - - - , - ); - - await waitFor(() => { - expect(mockEnsureScopes).not.toHaveBeenCalled(); - }); - }); - - it('should not register whitespace-only scopes', async () => { - render( - - - , - ); - - await waitFor(() => { - expect(mockEnsureScopes).not.toHaveBeenCalled(); - }); - }); - - it('should set isReady to true after scopes are ensured', async () => { - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('is-ready')).toHaveTextContent('true'); - }); - }); - - it('should update ensured state after scopes are ensured', async () => { - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('ensured-me')).toHaveTextContent('read:profile'); - }); - }); - - it('should not call ensureScopes when coreClient is not available', async () => { - vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ - coreClient: null, - }); - render( - - - , - ); - - await waitFor(() => { - expect(mockEnsureScopes).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/react/src/providers/mfa-error-handler-provider.tsx b/packages/react/src/providers/mfa-error-handler-provider.tsx deleted file mode 100644 index dfb62c454..000000000 --- a/packages/react/src/providers/mfa-error-handler-provider.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { MfaRequiredError } from '@auth0/universal-components-core'; -import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; - -interface MfaErrorHandlerContextValue { - handleMfaError: (error: MfaRequiredError, onRetry: () => void) => void; -} - -const MfaErrorHandlerContext = createContext(null); - -export function useMfaErrorHandler() { - const context = useContext(MfaErrorHandlerContext); - if (!context) { - throw new Error('useMfaErrorHandler must be used within MfaErrorHandlerProvider'); - } - return context; -} - -/** - * Global provider that handles MFA step-up authentication. - * Shows MFA modal when MFA is required and retries operation after success. - */ -export const MfaErrorHandlerProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const [mfaError, setMfaError] = useState(null); - const [onRetry, setOnRetry] = useState<(() => void) | null>(null); - const [isOpen, setIsOpen] = useState(false); - - const handleMfaError = useCallback((error: MfaRequiredError, retry: () => void) => { - setMfaError(error); - setOnRetry(() => retry); - setIsOpen(true); - }, []); - - const handleSuccess = useCallback(() => { - setIsOpen(false); - setMfaError(null); - if (onRetry) { - onRetry(); - } - setOnRetry(null); - }, [onRetry]); - - const handleClose = useCallback(() => { - setIsOpen(false); - setMfaError(null); - setOnRetry(null); - }, []); - - const contextValue = React.useMemo(() => ({ handleMfaError }), [handleMfaError]); - - return ( - - {children} - {/* TODO: Replace with actual MfaStepUpDialog when implemented */} - {mfaError && isOpen && ( -
    -

    MFA Required

    -

    Multi-factor authentication is required.

    -

    - Token: {mfaError.mfa_token} -

    -
    - - -
    -
    - )} -
    - ); -}; diff --git a/packages/react/src/providers/proxy-provider.tsx b/packages/react/src/providers/proxy-provider.tsx index e48008fdd..6e56c0c2f 100644 --- a/packages/react/src/providers/proxy-provider.tsx +++ b/packages/react/src/providers/proxy-provider.tsx @@ -7,9 +7,7 @@ import { Spinner } from '@/components/ui/spinner'; import { CoreClientContext } from '@/hooks/shared/use-core-client'; import { useCoreClientInitialization } from '@/hooks/shared/use-core-client-initialization'; import { useToastProvider } from '@/hooks/shared/use-toast-provider'; -import { MfaErrorHandlerProvider } from '@/providers/mfa-error-handler-provider'; import { QueryProvider } from '@/providers/query-provider'; -import { ScopeManagerProvider } from '@/providers/scope-manager-provider'; import { ThemeProvider } from '@/providers/theme-provider'; import type { Auth0ComponentProviderProps } from '@/types/auth-types'; @@ -68,6 +66,25 @@ export const Auth0ComponentProvider = ({ [coreClient], ); + if (!coreClient) { + return ( + + {loader || ( +
    + +
    + )} +
    + ); + } + return ( - - - {children} - - + {children} diff --git a/packages/react/src/providers/scope-manager-provider.tsx b/packages/react/src/providers/scope-manager-provider.tsx deleted file mode 100644 index a8b745cb3..000000000 --- a/packages/react/src/providers/scope-manager-provider.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { isMfaRequiredError } from '@auth0/universal-components-core'; -import React, { - createContext, - useContext, - useState, - useCallback, - useEffect, - useRef, - type ReactNode, -} from 'react'; - -import { useCoreClient } from '@/hooks/shared/use-core-client'; -import { useMfaErrorHandler } from '@/providers/mfa-error-handler-provider'; - -export type Audience = 'me' | 'my-org'; - -interface ScopeManagerContextValue { - registerScopes: (audience: Audience, scopes: string) => void; - isReady: boolean; - ensured: Record; -} - -const ScopeManagerContext = createContext(null); - -export const useScopeManager = () => { - const context = useContext(ScopeManagerContext); - if (!context) { - throw new Error('useScopeManager must be used within ScopeManagerProvider'); - } - return context; -}; - -const AUDIENCES: readonly Audience[] = ['me', 'my-org'] as const; - -export const ScopeManagerProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const { coreClient } = useCoreClient(); - const { handleMfaError } = useMfaErrorHandler(); - - const [scopeRegistry, setScopeRegistry] = useState>>(() => ({ - me: new Set(), - 'my-org': new Set(), - })); - - const [ensured, setEnsured] = useState>({ - me: '', - 'my-org': '', - }); - - const [isReady, setIsReady] = useState(false); - const lastEnsuredRef = useRef>({ me: '', 'my-org': '' }); - - const registerScopes = useCallback((audience: Audience, scopes: string) => { - if (!scopes?.trim()) return; - - const newScopes = scopes - .split(/\s+/) - .map((s) => s.trim()) - .filter(Boolean); - - if (newScopes.length === 0) return; - - setScopeRegistry((prev) => { - const audienceSet = prev[audience]; - const nextSet = new Set(audienceSet); - let changed = false; - - for (const scope of newScopes) { - if (!nextSet.has(scope)) { - nextSet.add(scope); - changed = true; - } - } - - return changed ? { ...prev, [audience]: nextSet } : prev; - }); - }, []); - - useEffect(() => { - if (!coreClient) return; - - const ensureScopes = async () => { - const scopeData = AUDIENCES.map((audience) => { - const scopes = Array.from(scopeRegistry[audience]).sort(); - const scopeString = scopes.join(' '); - return { audience, scopeString, hasScopes: scopeString.trim().length > 0 }; - }); - - const hasAnyScopes = scopeData.some((data) => data.hasScopes); - const updates = scopeData.filter( - (data) => data.hasScopes && data.scopeString !== lastEnsuredRef.current[data.audience], - ); - - if (updates.length === 0) { - setIsReady(hasAnyScopes); - return; - } - - const results = await Promise.allSettled( - updates.map(({ audience, scopeString }) => - coreClient.ensureScopes(scopeString, audience).then(() => ({ audience, scopeString })), - ), - ); - - const nextEnsured = { ...lastEnsuredRef.current }; - let anyUpdated = false; - - for (const result of results) { - if (result.status === 'fulfilled') { - const { audience, scopeString } = result.value; - nextEnsured[audience] = scopeString; - lastEnsuredRef.current[audience] = scopeString; - anyUpdated = true; - } else { - const error = result.reason; - - if (isMfaRequiredError(error)) { - // Retry by clearing the last ensured ref for this audience - const failedAudience = updates.find( - (u) => u.scopeString === result.reason?.scopeString, - )?.audience; - if (failedAudience) { - handleMfaError(error, () => { - lastEnsuredRef.current[failedAudience] = ''; - }); - } - } else { - console.error('Failed to ensure scopes:', error); - } - } - } - - if (anyUpdated) { - setEnsured(nextEnsured); - } - setIsReady(hasAnyScopes); - }; - - ensureScopes(); - }, [coreClient, scopeRegistry, handleMfaError]); - - const contextValue = React.useMemo( - () => ({ registerScopes, isReady, ensured }), - [registerScopes, isReady, ensured], - ); - - return ( - {children} - ); -}; diff --git a/packages/react/src/providers/spa-provider.tsx b/packages/react/src/providers/spa-provider.tsx index aab842b2d..5b7e26d76 100644 --- a/packages/react/src/providers/spa-provider.tsx +++ b/packages/react/src/providers/spa-provider.tsx @@ -9,9 +9,7 @@ import { Spinner } from '@/components/ui/spinner'; import { CoreClientContext } from '@/hooks/shared/use-core-client'; import { useCoreClientInitialization } from '@/hooks/shared/use-core-client-initialization'; import { useToastProvider } from '@/hooks/shared/use-toast-provider'; -import { MfaErrorHandlerProvider } from '@/providers/mfa-error-handler-provider'; import { QueryProvider } from '@/providers/query-provider'; -import { ScopeManagerProvider } from '@/providers/scope-manager-provider'; import { ThemeProvider } from '@/providers/theme-provider'; import type { Auth0ComponentProviderProps } from '@/types/auth-types'; @@ -38,9 +36,7 @@ export const Auth0ComponentProvider = ({ const auth0ContextInterface = React.useMemo(() => { if (auth0ReactContext && 'isAuthenticated' in auth0ReactContext) { - // Cast via unknown because @auth0/auth0-react's Auth0ContextInterface - // doesn't include getConfiguration which our BasicAuth0ContextInterface requires - return auth0ReactContext as unknown as BasicAuth0ContextInterface; + return auth0ReactContext as BasicAuth0ContextInterface; } if (authDetails?.contextInterface) { @@ -72,6 +68,25 @@ export const Auth0ComponentProvider = ({ [coreClient], ); + if (!coreClient) { + return ( + + {loader || ( +
    + +
    + )} +
    + ); + } + return ( - {mergedToastSettings.provider === 'sonner' && ( - - )} - - - {children} - - + {mergedToastSettings.provider === 'sonner' && ( + + )} + {children} diff --git a/packages/react/src/tests/utils/__mocks__/core/auth.mocks.ts b/packages/react/src/tests/utils/__mocks__/core/auth.mocks.ts index c63442a74..c7777cf9d 100644 --- a/packages/react/src/tests/utils/__mocks__/core/auth.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/core/auth.mocks.ts @@ -29,6 +29,25 @@ export const createMockAuth = (overrides?: Partial): AuthDetails => domain: 'test-domain.auth0.com', clientId: 'test-client-id', }), + mfa: { + getAuthenticators: vi.fn().mockResolvedValue([]), + enroll: vi.fn().mockResolvedValue({ + authenticatorType: 'otp', + secret: 'mock-secret', + barcodeUri: 'otpauth://totp/mock', + id: 'authenticator_123', + }), + challenge: vi.fn().mockResolvedValue({ + challengeType: 'oob', + oobCode: 'mock-oob-code', + }), + getEnrollmentFactors: vi.fn().mockResolvedValue([]), + verify: vi.fn().mockResolvedValue({ + id_token: 'mock-id-token', + access_token: 'mock-access-token', + expires_in: 3600, + }), + }, }, ...overrides, }); diff --git a/packages/react/src/tests/utils/__mocks__/core/core-client.mocks.ts b/packages/react/src/tests/utils/__mocks__/core/core-client.mocks.ts index 567c8f881..092b9cfc3 100644 --- a/packages/react/src/tests/utils/__mocks__/core/core-client.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/core/core-client.mocks.ts @@ -24,9 +24,32 @@ const createMockMyAccountApiService = (): CoreClientInterface['myAccountApiClien mfa: { fetchFactors: vi.fn().mockResolvedValue([]), }, + withScopes: vi.fn().mockReturnThis(), } as unknown as CoreClientInterface['myAccountApiClient']; }; +const createMockStepUpApiService = (): CoreClientInterface['stepUpApiService'] => { + return { + getAuthenticators: vi.fn().mockResolvedValue([]), + enroll: vi.fn().mockResolvedValue({ + authenticatorType: 'otp', + secret: 'mock-secret', + barcodeUri: 'otpauth://totp/mock', + id: 'authenticator_123', + }), + challenge: vi.fn().mockResolvedValue({ + challengeType: 'oob', + oobCode: 'mock-oob-code', + }), + getEnrollmentFactors: vi.fn().mockResolvedValue([]), + verify: vi.fn().mockResolvedValue({ + id_token: 'mock-id-token', + access_token: 'mock-access-token', + expires_in: 3600, + }), + } as unknown as CoreClientInterface['stepUpApiService']; +}; + const createMockMyOrgApiService = (): CoreClientInterface['myOrganizationApiClient'] => { const mockOrganization = createMockOrganization(); const mockProvider = createMockIdentityProvider(); @@ -44,6 +67,7 @@ const createMockMyOrgApiService = (): CoreClientInterface['myOrganizationApiClie update: vi.fn().mockResolvedValue({}), delete: vi.fn().mockResolvedValue(undefined), detach: vi.fn().mockResolvedValue(undefined), + updateAttributes: vi.fn().mockResolvedValue(undefined), domains: { create: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), @@ -52,6 +76,12 @@ const createMockMyOrgApiService = (): CoreClientInterface['myOrganizationApiClie get: vi.fn().mockRejectedValue({ status: 404 }), create: vi.fn().mockResolvedValue({}), delete: vi.fn().mockResolvedValue(undefined), + updateAttributes: vi.fn().mockResolvedValue(undefined), + scimTokens: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({ id: 'token_123', token: 'secret_token' }), + delete: vi.fn().mockResolvedValue(undefined), + }, }, }, domains: { @@ -95,12 +125,14 @@ const createMockMyOrgApiService = (): CoreClientInterface['myOrganizationApiClie }, }, }, + withScopes: vi.fn().mockReturnThis(), } as unknown as CoreClientInterface['myOrganizationApiClient']; }; export const createMockCoreClient = (authDetails?: Partial): CoreClientInterface => { const mockMyAccountApiService = createMockMyAccountApiService(); const mockMyOrgApiService = createMockMyOrgApiService(); + const mockStepUpApiService = createMockStepUpApiService(); const mockAuth = createMockAuth(authDetails); return { @@ -108,19 +140,20 @@ export const createMockCoreClient = (authDetails?: Partial): CoreCl i18nService: createMockI18nService(), myAccountApiClient: mockMyAccountApiService as CoreClientInterface['myAccountApiClient'], myOrganizationApiClient: mockMyOrgApiService as CoreClientInterface['myOrganizationApiClient'], + stepUpApiService: mockStepUpApiService as CoreClientInterface['stepUpApiService'], getMyAccountApiClient: vi.fn( () => mockMyAccountApiService, ) as CoreClientInterface['getMyAccountApiClient'], getMyOrganizationApiClient: vi.fn( () => mockMyOrgApiService, ) as CoreClientInterface['getMyOrganizationApiClient'], + getStepUpApiService: vi.fn( + () => mockStepUpApiService, + ) as CoreClientInterface['getStepUpApiService'], getToken: async () => { return 'mock-access-token'; }, isProxyMode: () => false, - ensureScopes() { - return Promise.resolve(); - }, getDomain: () => mockAuth.domain ?? mockAuth.contextInterface?.getConfiguration()?.domain, }; }; diff --git a/packages/react/src/tests/utils/__mocks__/my-account/mfa/mfa.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-account/mfa/mfa.mocks.ts index dfc592b2e..1cdfaef8a 100644 --- a/packages/react/src/tests/utils/__mocks__/my-account/mfa/mfa.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-account/mfa/mfa.mocks.ts @@ -31,29 +31,24 @@ export const createMockTOTPAuthenticator = (): Authenticator => createMockAuthenticator({ id: 'auth_totp_123', type: 'totp', - enrolled: true, }); export const createMockPhoneAuthenticator = (): Authenticator => createMockAuthenticator({ id: 'auth_phone_123', type: 'phone', - enrolled: true, }); export const createMockPushNotificationAuthenticator = (): Authenticator => createMockAuthenticator({ id: 'auth_push_123', type: 'push-notification', - enrolled: true, }); export const createMockEmailAuthenticator = (): Authenticator => createMockAuthenticator({ id: 'auth_email_123', type: 'email', - email: 'user@example.com', - enrolled: true, }); export const createMockWebAuthnAuthenticator = (): Authenticator => @@ -61,7 +56,6 @@ export const createMockWebAuthnAuthenticator = (): Authenticator => id: 'auth_webauthn_123', type: 'webauthn-roaming', name: 'YubiKey 5', - enrolled: true, }); export const createMockAuthenticationMethodsResponse = ( @@ -82,8 +76,6 @@ export const createMockUnconfirmedAuthenticator = (): Authenticator => createMockAuthenticator({ id: 'auth_unconfirmed_123', type: 'email', - email: 'user@example.com', - enrolled: true, confirmed: false, }); diff --git a/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts index 102991ff4..c25763a31 100644 --- a/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts @@ -14,5 +14,7 @@ export const createMockUseConfig = (overrides?: Partial): MockUse }, fetchConfig: vi.fn(async () => undefined), filteredStrategies: [], + error: undefined, + retry: vi.fn(async () => undefined), ...overrides, }); diff --git a/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/idp-config.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/idp-config.mocks.ts index 038511ddb..c9c0f1f3e 100644 --- a/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/idp-config.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/idp-config.mocks.ts @@ -30,5 +30,7 @@ export const createMockUseIdpConfig = ( fetchIdpConfig: vi.fn(async () => undefined), isProvisioningEnabled: vi.fn(() => false), isProvisioningMethodEnabled: vi.fn(() => false), + error: undefined, + retry: vi.fn(async () => undefined), ...overrides, }); diff --git a/packages/react/src/tests/utils/test-provider.tsx b/packages/react/src/tests/utils/test-provider.tsx index 62c0f7d52..fc3c10304 100644 --- a/packages/react/src/tests/utils/test-provider.tsx +++ b/packages/react/src/tests/utils/test-provider.tsx @@ -6,7 +6,6 @@ import type { FieldValues, UseFormReturn } from 'react-hook-form'; import { Form } from '@/components/ui/form'; import { CoreClientContext } from '@/hooks/shared/use-core-client'; -import { ScopeManagerProvider } from '@/providers/scope-manager-provider'; import { createMockCoreClient } from '@/tests/utils/__mocks__/core/core-client.mocks'; // Create a new QueryClient for each test to avoid shared state @@ -64,9 +63,7 @@ export const TestProvider: React.FC = ({ return ( - - {children} - + {children} ); }; diff --git a/packages/react/src/tests/utils/test-utilities.ts b/packages/react/src/tests/utils/test-utilities.ts index cad33d27d..bbeadc3af 100644 --- a/packages/react/src/tests/utils/test-utilities.ts +++ b/packages/react/src/tests/utils/test-utilities.ts @@ -30,11 +30,7 @@ export const createMockUseTranslator = (_customMessages?: object) => ({ fallbackLanguage: 'en', }); -// Deprecated: useErrorHandler has been removed. Errors are now handled by GateKeeper component. -// Tests should be updated to check for error state exposure instead of handleError calls. -export const createMockUseErrorHandler = (handleError: ReturnType) => ({ - handleError, -}); +export const createMockUseErrorHandler = (handleError: ReturnType) => handleError; // ===== Setup Utilities ===== diff --git a/packages/react/src/types/my-organization/config/config-idp-types.ts b/packages/react/src/types/my-organization/config/config-idp-types.ts index ac630bd61..e6cafd85f 100644 --- a/packages/react/src/types/my-organization/config/config-idp-types.ts +++ b/packages/react/src/types/my-organization/config/config-idp-types.ts @@ -22,4 +22,6 @@ export interface UseConfigIdpResult { isProvisioningEnabled: (strategy: IdpStrategy | undefined) => boolean; isProvisioningMethodEnabled: (strategy: IdpStrategy | undefined) => boolean; isIdpConfigValid: boolean; + error: unknown; + retry: () => Promise; } diff --git a/packages/react/src/types/my-organization/config/config-types.ts b/packages/react/src/types/my-organization/config/config-types.ts index b3fc01032..6acbaf645 100644 --- a/packages/react/src/types/my-organization/config/config-types.ts +++ b/packages/react/src/types/my-organization/config/config-types.ts @@ -10,4 +10,6 @@ export interface UseConfigResult { filteredStrategies: IdpStrategy[]; shouldAllowDeletion: boolean; isConfigValid: boolean; + error: unknown; + retry: () => Promise; } diff --git a/packages/react/src/types/my-organization/domain-management/domain-table-types.ts b/packages/react/src/types/my-organization/domain-management/domain-table-types.ts index 7ab1e2843..99c019dd4 100644 --- a/packages/react/src/types/my-organization/domain-management/domain-table-types.ts +++ b/packages/react/src/types/my-organization/domain-management/domain-table-types.ts @@ -9,8 +9,6 @@ import type { DomainConfigureMessages, DomainVerifyMessages, DomainTableMessages, - CreateOrganizationDomainRequestContent, - EnhancedTranslationFunction, IdentityProviderAssociatedWithDomain, } from '@auth0/universal-components-core'; @@ -80,55 +78,29 @@ export interface UseDomainTableOptions { export interface UseDomainTableResult extends SharedComponentProps { domains: Domain[]; providers: IdentityProviderAssociatedWithDomain[]; + error: unknown; + retry: () => Promise; isFetching: boolean; isLoadingProviders: boolean; isCreating: boolean; isDeleting: boolean; isVerifying: boolean; - fetchProviders: (domain: Domain) => Promise; - fetchDomains: () => Promise; - onCreateDomain: (data: CreateOrganizationDomainRequestContent) => Promise; - onVerifyDomain: (data: Domain) => Promise; - onDeleteDomain: (domain: Domain) => Promise; - onAssociateToProvider: (domain: Domain, provider: IdentityProvider) => Promise; - onDeleteFromProvider: (domain: Domain, provider: IdentityProvider) => Promise; -} - -export interface UseDomainTableLogicOptions { - t: EnhancedTranslationFunction; - onCreateDomain: UseDomainTableResult['onCreateDomain']; - onVerifyDomain: UseDomainTableResult['onVerifyDomain']; - onDeleteDomain: UseDomainTableResult['onDeleteDomain']; - onAssociateToProvider: UseDomainTableResult['onAssociateToProvider']; - onDeleteFromProvider: UseDomainTableResult['onDeleteFromProvider']; - fetchProviders: UseDomainTableResult['fetchProviders']; - fetchDomains: UseDomainTableResult['fetchDomains']; -} - -export interface UseDomainTableLogicResult { - // Error state - error: unknown; - - // Modal state showCreateModal: boolean; showConfigureModal: boolean; showVerifyModal: boolean; showDeleteModal: boolean; verifyError: string | undefined; selectedDomain: Domain | null; + closeModal: () => void; - // State setters - setShowCreateModal: (show: boolean) => void; - setShowConfigureModal: (show: boolean) => void; - setShowVerifyModal: (show: boolean) => void; - setShowDeleteModal: (show: boolean) => void; - - // Handlers handleCreate: (domainUrl: string) => Promise; handleVerify: (domain: Domain) => Promise; - handleDelete: (domain: Domain) => void; - handleToggleSwitch: (domain: Domain, provider: IdentityProvider, checked: boolean) => void; - handleCloseVerifyModal: () => void; + handleDelete: (domain: Domain) => Promise; + handleToggleSwitch: ( + domain: Domain, + provider: IdentityProvider, + checked: boolean, + ) => Promise; handleCreateClick: () => void; handleConfigureClick: (domain: Domain) => void; handleVerifyClick: (domain: Domain) => Promise; diff --git a/packages/react/src/types/my-organization/idp-management/sso-domain/sso-domain-tab-types.ts b/packages/react/src/types/my-organization/idp-management/sso-domain/sso-domain-tab-types.ts index 6c32b54bf..2e77df27c 100644 --- a/packages/react/src/types/my-organization/idp-management/sso-domain/sso-domain-tab-types.ts +++ b/packages/react/src/types/my-organization/idp-management/sso-domain/sso-domain-tab-types.ts @@ -68,7 +68,7 @@ export interface UseSsoDomainTabReturn { domainsList: Domain[]; isLoading: boolean; error: unknown; - refetch: () => void; + retry: () => Promise; showCreateModal: boolean; isCreating: boolean; selectedDomain: Domain | null; diff --git a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts index 9cb0b0006..bdc7abe7c 100644 --- a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts +++ b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts @@ -82,7 +82,7 @@ export interface UseSsoProviderEditOptions extends SharedComponentProps { export interface UseSsoProviderEditReturn { provider: IdentityProvider | null; - organization: OrganizationPrivate | null; + organization: OrganizationPrivate; provisioningConfig: GetIdPProvisioningConfigResponseContent | null; isLoading: boolean; isUpdating: boolean; @@ -98,8 +98,9 @@ export interface UseSsoProviderEditReturn { isProvisioningAttributesSyncing: boolean; hasSsoAttributeSyncWarning: boolean; hasProvisioningAttributeSyncWarning: boolean; + error: unknown; + retry: () => Promise; fetchProvider: () => Promise; - fetchOrganizationDetails: () => Promise; fetchProvisioning: () => Promise; updateProvider: (data: UpdateIdentityProviderRequestContentPrivate) => Promise; createProvisioning: () => Promise; diff --git a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts index 9cd89d23f..97d2de4ac 100644 --- a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts +++ b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts @@ -4,7 +4,6 @@ import type { SsoProviderDeleteSchema, SsoProviderTableMessages, IdentityProvider as CoreIdentityProvider, - OrganizationPrivate, } from '@auth0/universal-components-core'; export type IdentityProvider = CoreIdentityProvider; @@ -34,16 +33,17 @@ export interface SsoProviderTableProps enableProviderAction?: ComponentAction; } -export interface UseSsoProviderTableReturn extends SharedComponentProps { +export interface UseSsoProviderTableReturn { providers: IdentityProvider[]; - organization: OrganizationPrivate | null; isLoading: boolean; isDeleting: boolean; isRemoving: boolean; isUpdating: boolean; isUpdatingId: string | null; + error: unknown; + retry: () => Promise; fetchProviders: () => Promise; - fetchOrganizationDetails: () => Promise; + getOrganizationName: () => Promise; onDeleteConfirm: (selectedIdp: IdentityProvider) => Promise; onRemoveConfirm: (selectedIdp: IdentityProvider) => Promise; onEnableProvider: (selectedIdp: IdentityProvider, enabled: boolean) => Promise; diff --git a/packages/react/src/types/my-organization/organization-management/organization-details-edit-types.ts b/packages/react/src/types/my-organization/organization-management/organization-details-edit-types.ts index 47574eada..f3528830b 100644 --- a/packages/react/src/types/my-organization/organization-management/organization-details-edit-types.ts +++ b/packages/react/src/types/my-organization/organization-management/organization-details-edit-types.ts @@ -56,10 +56,11 @@ export interface UseOrganizationDetailsEditOptions { export interface UseOrganizationDetailsEditResult { organization: OrganizationPrivate; + error: unknown; + retry: () => Promise; + isLoading: boolean; isFetchLoading: boolean; isSaveLoading: boolean; - isInitializing: boolean; formActions: OrganizationDetailsFormActions; - fetchOrgDetails: () => Promise; updateOrgDetails: (data: OrganizationPrivate) => Promise; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 586af4a7b..81bd23f7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,8 +213,8 @@ importers: examples/react-spa-npm: dependencies: '@auth0/auth0-react': - specifier: ^2.12.0 - version: 2.12.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ^2.15.0 + version: 2.15.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@auth0/universal-components-react': specifier: workspace:* version: link:../../packages/react @@ -601,8 +601,8 @@ importers: version: 3.25.76 devDependencies: '@auth0/auth0-react': - specifier: ^2.12.0 - version: 2.12.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ^2.15.0 + version: 2.15.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@tailwindcss/cli': specifier: ^4.1.17 version: 4.1.17 @@ -663,8 +663,8 @@ packages: '@auth0/auth0-auth-js@1.4.0': resolution: {integrity: sha512-ShA7KT4KvcBEtxsXZTcrmoNxai5q1JXhB2aEBFnZD1L6LNLzzmiUWiFTtGMsaaITCylr8TJ/onEQk6XZmUHXbg==} - '@auth0/auth0-react@2.12.0': - resolution: {integrity: sha512-EPLe1OYYlnxoz7GR6hbe+eGCuzllWkq+O9FxZYrH9la9Rn98BhGu515LrK5AtWOhq2iE/RuHFcvb926yK0iNhw==} + '@auth0/auth0-react@2.15.0': + resolution: {integrity: sha512-LbRU87U54/YW/N3UHtNVoj3mCBBz+iYAdAByQjbXOkpI6IYnjMBwIwDusW3N23XNXq9WnihD57Dyi2R3/Q9btw==} peerDependencies: react: 19.2.1 react-dom: 19.2.1 @@ -675,8 +675,8 @@ packages: react: 19.2.1 react-dom: 19.2.1 - '@auth0/auth0-spa-js@2.13.0': - resolution: {integrity: sha512-YGK4e8eTs7LUJKZLNl15V9RRydIDAKeq3CX0kuxCB5It1mw+tMOnPMatLmin2wH6ipt9d0C56JKu9bBK7by69A==} + '@auth0/auth0-spa-js@2.16.0': + resolution: {integrity: sha512-UTP45NqjC2jVc/WaWh+iYOZt6FajpTJc+3WzljbXBiv2f76wDw4Mt9hW/aShBovsRmvKEIHaCifD3c/Gxmo2ZQ==} '@auth0/auth0-spa-js@2.9.1': resolution: {integrity: sha512-GNyypxb8ck32tUacYPHAEZ/L845kLDchqXtFZM3Gt/KcBr9C8/c1ncAhGY1UnkgUw2MctwVnBOEoqCD3oP3SPg==} @@ -6861,9 +6861,9 @@ snapshots: jose: 6.1.3 openid-client: 6.8.1 - '@auth0/auth0-react@2.12.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@auth0/auth0-react@2.15.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: - '@auth0/auth0-spa-js': 2.13.0 + '@auth0/auth0-spa-js': 2.16.0 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) @@ -6873,7 +6873,7 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - '@auth0/auth0-spa-js@2.13.0': + '@auth0/auth0-spa-js@2.16.0': dependencies: '@auth0/auth0-auth-js': 1.4.0 browser-tabs-lock: 1.3.0 From 6c638ac0d4c5a674fca17bf02fb8bdddabd04659 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Wed, 18 Feb 2026 19:14:42 +0530 Subject: [PATCH 05/27] fix(examples): revert testing changes --- examples/next-rwa/src/lib/auth0.ts | 3 +++ examples/react-spa-npm/src/components/side-bar.tsx | 10 ---------- examples/react-spa-npm/vite.config.ts | 3 --- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/examples/next-rwa/src/lib/auth0.ts b/examples/next-rwa/src/lib/auth0.ts index 21e40de46..a1037c1d0 100644 --- a/examples/next-rwa/src/lib/auth0.ts +++ b/examples/next-rwa/src/lib/auth0.ts @@ -25,6 +25,9 @@ export const auth0 = new Auth0Client({ httpTimeout: 20000, // 20 seconds authorizationParameters: { scope: process.env.AUTH0_SCOPE || 'openid profile email offline_access', + ...(process.env.AUTH0_DOMAIN && { + audience: `${process.env.AUTH0_DOMAIN.replace(/\/$/, '')}/my-org/`, + }), }, // Using SDK defaults: rolling: true, absoluteDuration: 3 days, inactivityDuration: 1 day }); diff --git a/examples/react-spa-npm/src/components/side-bar.tsx b/examples/react-spa-npm/src/components/side-bar.tsx index 51025778e..8f6449b23 100644 --- a/examples/react-spa-npm/src/components/side-bar.tsx +++ b/examples/react-spa-npm/src/components/side-bar.tsx @@ -61,16 +61,6 @@ export const Sidebar: React.FC = () => { {t('sidebar.sso-provider')} - -
  • - - - {t('sidebar.sso-provider-create')} - -
  • Date: Wed, 18 Feb 2026 23:36:16 +0530 Subject: [PATCH 06/27] fix(react): reverted unwanted change --- .../react/src/hooks/my-organization/use-domain-table.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/react/src/hooks/my-organization/use-domain-table.ts b/packages/react/src/hooks/my-organization/use-domain-table.ts index 514ae88e6..12869233b 100644 --- a/packages/react/src/hooks/my-organization/use-domain-table.ts +++ b/packages/react/src/hooks/my-organization/use-domain-table.ts @@ -282,11 +282,7 @@ export function useDomainTable({ const handleVerifyClick = useCallback( async (domain: Domain) => { setSelectedDomainId(domain.id); - try { - await verifyAndTransition(domain, 'configure'); - } catch (error) { - // Error handled by mutation's onError callback - } + await verifyAndTransition(domain, 'configure'); }, [verifyAndTransition], ); From 2d7c8395f000bec29541f081fb7ca3a0c17bcd12 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Mon, 2 Mar 2026 19:07:55 +0530 Subject: [PATCH 07/27] fix(react): view and container split --- .../__tests__/user-mfa-management.test.tsx | 23 +- .../auth0/my-account/user-mfa-management.tsx | 280 ++++++++---------- .../auth0/my-organization/domain-table.tsx | 7 +- .../organization-details-edit.tsx | 7 +- .../my-organization/sso-provider-create.tsx | 13 +- .../my-organization/sso-provider-edit.tsx | 29 +- .../my-organization/sso-provider-table.tsx | 5 +- .../__mocks__/my-account/mfa/mfa.mocks.ts | 108 ++++--- .../src/types/my-account/mfa/mfa-types.ts | 16 +- .../domain-management/domain-table-types.ts | 49 ++- .../sso-provider/sso-provider-create-types.ts | 30 +- .../sso-provider/sso-provider-edit-types.ts | 60 +++- .../sso-provider/sso-provider-table-types.ts | 50 +++- .../organization-details-edit-types.ts | 22 +- 14 files changed, 364 insertions(+), 335 deletions(-) diff --git a/packages/react/src/components/auth0/my-account/__tests__/user-mfa-management.test.tsx b/packages/react/src/components/auth0/my-account/__tests__/user-mfa-management.test.tsx index 689869705..ed3f80714 100644 --- a/packages/react/src/components/auth0/my-account/__tests__/user-mfa-management.test.tsx +++ b/packages/react/src/components/auth0/my-account/__tests__/user-mfa-management.test.tsx @@ -9,16 +9,11 @@ import { createMockAuthenticator, createMockAuthenticationMethodsResponse, createMockOTPEnrollmentResponse, - createMockUserMFAMgmtLogic, - createMockUserMFAMgmtHandlers, + createMockUserMFAMgmtViewProps, } from '@/tests/utils/__mocks__'; import { renderWithProviders } from '@/tests/utils/test-provider'; import { mockCore, mockToast } from '@/tests/utils/test-setup'; -import type { - UserMFAMgmtProps, - UserMFAMgmtLogicProps, - UserMFAMgmtHandlerProps, -} from '@/types/my-account/mfa/mfa-types'; +import type { UserMFAMgmtProps, UserMFAMgmtViewProps } from '@/types/my-account/mfa/mfa-types'; // ===== Mock packages ===== @@ -680,15 +675,11 @@ describe('UserMFAMgmt', () => { }); describe('UserMFAMgmtView', () => { - function setupView( - logicOverrides: Partial = {}, - handlerOverrides: Partial = {}, - ) { - const logic = createMockUserMFAMgmtLogic(logicOverrides); - const handlers = createMockUserMFAMgmtHandlers(handlerOverrides); - - renderWithProviders(); - return { logic, handlers }; + function setupView(overrides: Partial = {}) { + const viewProps = createMockUserMFAMgmtViewProps(overrides); + + renderWithProviders(); + return { viewProps }; } it('renders loading state', () => { diff --git a/packages/react/src/components/auth0/my-account/user-mfa-management.tsx b/packages/react/src/components/auth0/my-account/user-mfa-management.tsx index ef7b133d4..13abafb86 100644 --- a/packages/react/src/components/auth0/my-account/user-mfa-management.tsx +++ b/packages/react/src/components/auth0/my-account/user-mfa-management.tsx @@ -8,6 +8,7 @@ import { MFAEmptyState } from '@/components/auth0/my-account/shared/mfa/empty-st import { MFAErrorState } from '@/components/auth0/my-account/shared/mfa/error-state'; import { FactorsList } from '@/components/auth0/my-account/shared/mfa/factors-list'; import { UserMFASetupForm } from '@/components/auth0/my-account/shared/mfa/user-mfa-setup-form'; +import { GateKeeper } from '@/components/auth0/shared/gatekeeper'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'; @@ -18,59 +19,62 @@ import { useMFALogic } from '@/hooks/my-account/use-mfa-logic'; import { useTheme } from '@/hooks/shared/use-theme'; import { useTranslator } from '@/hooks/shared/use-translator'; import { cn } from '@/lib/utils'; -import type { - UserMFAMgmtProps, - UserMFAMgmtLogicProps, - UserMFAMgmtHandlerProps, - UserMFAMgmtViewProps, -} from '@/types/my-account/mfa/mfa-types'; +import type { UserMFAMgmtProps, UserMFAMgmtViewProps } from '@/types/my-account/mfa/mfa-types'; /** - * User MFA management container(logic) component - * Handles loading factors, enroll/delete flows, and UI state. + * Multi-factor authentication management component. * - * @param props - Component props. - * @param props.customMessages - Override i18n messages. - * @param props.styling - CSS variables and class overrides. - * @param props.hideHeader - Hide the header section. - * @param props.showActiveOnly - Show only enrolled factors. - * @param props.disableEnroll - Disable enroll actions. - * @param props.disableDelete - Disable delete actions. - * @param props.readOnly - Render in read-only mode. - * @param props.factorConfig - Per-factor visibility/enabled config. - * @param props.onEnroll - Called after successful enroll. - * @param props.onDelete - Called after successful delete. - * @param props.onFetch - Called after factors load. - * @param props.onErrorAction - Called when actions error. - * @param props.onBeforeAction - Called before actions; return false to cancel. - * @param props.schema - Validation schema overrides. - * @returns MFA management UI. - * @internal + * Complete MFA management interface for enrolling, viewing, and deleting authentication + * factors. Supports TOTP authenticators, SMS, Email, Push notifications, and recovery codes. + * + * @param props - {@link UserMFAMgmtProps} + * @param props.customMessages - Custom i18n message overrides + * @param props.styling - CSS variables and class overrides + * @param props.hideHeader - Hide the header section + * @param props.showActiveOnly - Show only enrolled factors + * @param props.disableEnroll - Disable enroll actions + * @param props.disableDelete - Disable delete actions + * @param props.readOnly - Render in read-only mode + * @param props.factorConfig - Per-factor visibility/enabled configuration + * @param props.onEnroll - Callback after successful enrollment + * @param props.onDelete - Callback after successful deletion + * @param props.onFetch - Callback after factors are loaded + * @param props.onErrorAction - Callback when actions error + * @param props.onBeforeAction - Callback before actions; return false to cancel + * @param props.schema - Validation schema overrides + * @returns MFA management component + * + * @see {@link UserMFAMgmtProps} for full props documentation + * + * @example + * ```tsx + * console.log('Enrolled:', factor)} + * onDelete={(factor) => console.log('Deleted:', factor)} + * factorConfig={{ + * otp: { enabled: true }, + * sms: { enabled: true }, + * email: { enabled: false }, + * }} + * /> + * ``` */ -function UserMFAMgmtContainer(props: UserMFAMgmtProps) { - const { - customMessages = {}, - styling = { - variables: { - common: {}, - light: {}, - dark: {}, - }, - classes: {}, - }, - hideHeader = false, - showActiveOnly = false, - disableEnroll = false, - disableDelete = false, - readOnly = false, - factorConfig = {}, - onEnroll, - onDelete, - onFetch, - onErrorAction, - onBeforeAction, - schema, - } = props; +export function UserMFAMgmt({ + customMessages = {}, + styling = { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + hideHeader = false, + showActiveOnly = false, + disableEnroll = false, + disableDelete = false, + readOnly = false, + factorConfig = {}, + onEnroll, + onDelete, + onFetch, + onErrorAction, + onBeforeAction, + schema, +}: UserMFAMgmtProps): React.JSX.Element { const { fetchFactors, enrollMfa, deleteMfa, confirmEnrollment } = useMFA(); const { @@ -112,84 +116,78 @@ function UserMFAMgmtContainer(props: UserMFAMgmtProps) { loadFactors(); }, []); - const logic: UserMFAMgmtLogicProps = { - isLoading: loading, - isDeleting: isDeletingFactor, - styling, - customMessages, - hideHeader, - showActiveOnly, - disableEnroll, - disableDelete, - readOnly, - factorConfig, - error, - schema, - dialogOpen, - enrollFactor, - isDeleteDialogOpen, - factorToDelete, - factorsByType, - visibleFactorTypes, - hasNoActiveFactors, - confirmEnrollment, - }; - - const handlers: UserMFAMgmtHandlerProps = { - enrollMfa, - onEnrollFactor: handleEnroll, - onDeleteFactor: handleDeleteFactor, - handleCloseDialog, - handleEnrollError, - handleEnrollSuccess, - handleConfirmDelete, - setIsDeleteDialogOpen, - }; - - return ; + return ( + loadFactors()}> + + + ); } /** * UserMFAMgmtView — Presentational component. - * @param props - View props with logic and handlers + * @param props - Flat view props * @returns User Management View element * @internal */ -function UserMFAMgmtView({ logic, handlers }: UserMFAMgmtViewProps) { - const { - isLoading, - isDeleting, - styling, - customMessages, - hideHeader, - showActiveOnly, - disableEnroll, - disableDelete, - readOnly, - factorConfig, - schema, - error, - dialogOpen, - enrollFactor, - isDeleteDialogOpen, - factorToDelete, - factorsByType, - visibleFactorTypes, - hasNoActiveFactors, - confirmEnrollment, - } = logic; - - const { - enrollMfa, - onEnrollFactor, - onDeleteFactor, - handleCloseDialog, - handleEnrollSuccess, - handleEnrollError, - handleConfirmDelete, - setIsDeleteDialogOpen, - } = handlers; - +export function UserMFAMgmtView({ + isLoading, + isDeleting, + styling, + customMessages, + hideHeader, + showActiveOnly, + disableEnroll, + disableDelete, + readOnly, + factorConfig, + schema, + error, + dialogOpen, + enrollFactor, + isDeleteDialogOpen, + factorToDelete, + factorsByType, + visibleFactorTypes, + hasNoActiveFactors, + confirmEnrollment, + enrollMfa, + onEnrollFactor, + onDeleteFactor, + handleCloseDialog, + handleEnrollSuccess, + handleEnrollError, + handleConfirmDelete, + setIsDeleteDialogOpen, +}: UserMFAMgmtViewProps): React.JSX.Element { const { loader, isDarkMode } = useTheme(); const { t } = useTranslator('mfa', customMessages); const currentStyles = React.useMemo( @@ -353,45 +351,3 @@ function UserMFAMgmtView({ logic, handlers }: UserMFAMgmtViewProps) {
  • ); } - -/** - * Multi-factor authentication management component. - * - * Complete MFA management interface for enrolling, viewing, and deleting authentication - * factors. Supports TOTP authenticators, SMS, Email, Push notifications, and recovery codes. - * - * @param props - {@link UserMFAMgmtProps} - * @param props.customMessages - Custom i18n message overrides - * @param props.styling - CSS variables and class overrides - * @param props.hideHeader - Hide the header section - * @param props.showActiveOnly - Show only enrolled factors - * @param props.disableEnroll - Disable enroll actions - * @param props.disableDelete - Disable delete actions - * @param props.readOnly - Render in read-only mode - * @param props.factorConfig - Per-factor visibility/enabled configuration - * @param props.onEnroll - Callback after successful enrollment - * @param props.onDelete - Callback after successful deletion - * @param props.onFetch - Callback after factors are loaded - * @param props.onErrorAction - Callback when actions error - * @param props.onBeforeAction - Callback before actions; return false to cancel - * @param props.schema - Validation schema overrides - * @returns MFA management component - * - * @see {@link UserMFAMgmtProps} for full props documentation - * - * @example - * ```tsx - * console.log('Enrolled:', factor)} - * onDelete={(factor) => console.log('Deleted:', factor)} - * factorConfig={{ - * otp: { enabled: true }, - * sms: { enabled: true }, - * email: { enabled: false }, - * }} - * /> - * ``` - */ -const UserMFAMgmt = UserMFAMgmtContainer; - -export { UserMFAMgmt, UserMFAMgmtView }; diff --git a/packages/react/src/components/auth0/my-organization/domain-table.tsx b/packages/react/src/components/auth0/my-organization/domain-table.tsx index 39347abd9..47bec47f8 100644 --- a/packages/react/src/components/auth0/my-organization/domain-table.tsx +++ b/packages/react/src/components/auth0/my-organization/domain-table.tsx @@ -57,10 +57,7 @@ import type { export function DomainTable({ customMessages = {}, schema, - styling = { - variables: { common: {}, light: {}, dark: {} }, - classes: {}, - }, + styling = { variables: { common: {}, light: {}, dark: {} }, classes: {} }, hideHeader = false, readOnly = false, createAction, @@ -70,7 +67,7 @@ export function DomainTable({ deleteFromProviderAction, onOpenProvider, onCreateProvider, -}: DomainTableProps) { +}: DomainTableProps): React.JSX.Element { const { error, retry, ...hook } = useDomainTable({ createAction, verifyAction, diff --git a/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx b/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx index d805bfdcc..05a7bac37 100644 --- a/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx +++ b/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx @@ -50,10 +50,7 @@ import type { export function OrganizationDetailsEdit({ schema, customMessages = {}, - styling = { - variables: { common: {}, light: {}, dark: {} }, - classes: {}, - }, + styling = { variables: { common: {}, light: {}, dark: {} }, classes: {} }, readOnly = false, saveAction, cancelAction, @@ -68,7 +65,7 @@ export function OrganizationDetailsEdit({ }); return ( - + getComponentStyles(styling, isDarkMode), [styling, isDarkMode], ); - const wizardSteps = useMemo( + const wizardSteps = React.useMemo( () => [ { id: 'provider_select', diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx index f5efece7e..05a18fe0e 100644 --- a/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx +++ b/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx @@ -41,23 +41,18 @@ import type { * @param props.readOnly - Render in read-only mode * @returns SSO provider edit component */ -export function SsoProviderEdit(props: SsoProviderEditProps): React.JSX.Element { - const { - providerId, - backButton, - sso, - provisioning, - domains, - hideHeader = false, - customMessages = {}, - styling = { - variables: { common: {}, light: {}, dark: {} }, - classes: {}, - }, - schema, - readOnly = false, - } = props; - +export function SsoProviderEdit({ + providerId, + backButton, + sso, + provisioning, + domains, + hideHeader = false, + customMessages = {}, + styling = { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + schema, + readOnly = false, +}: SsoProviderEditProps): React.JSX.Element { const { error, retry, ...hookResult } = useSsoProviderEdit(providerId, { sso, provisioning, diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx index 36874b90a..c03296dd1 100644 --- a/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx +++ b/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx @@ -45,7 +45,7 @@ export function SsoProviderTable({ deleteAction, deleteFromOrganizationAction, enableProviderAction, -}: SsoProviderTableProps) { +}: SsoProviderTableProps): React.JSX.Element { const hook = useSsoProviderTable({ readOnly, createAction, @@ -76,7 +76,7 @@ export function SsoProviderTable({ * @internal * @returns JSX element */ -function SsoProviderTableView({ +export function SsoProviderTableView({ styling, customMessages, readOnly, @@ -268,4 +268,3 @@ function SsoProviderTableView({ * /> * ``` */ -export { SsoProviderTableView }; diff --git a/packages/react/src/tests/utils/__mocks__/my-account/mfa/mfa.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-account/mfa/mfa.mocks.ts index 4a629e1db..b93f8b456 100644 --- a/packages/react/src/tests/utils/__mocks__/my-account/mfa/mfa.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-account/mfa/mfa.mocks.ts @@ -1,10 +1,7 @@ import type { Authenticator, MFAType } from '@auth0/universal-components-core'; import { vi } from 'vitest'; -import type { - UserMFAMgmtHandlerProps, - UserMFAMgmtLogicProps, -} from '@/types/my-account/mfa/mfa-types'; +import type { UserMFAMgmtViewProps } from '@/types/my-account/mfa/mfa-types'; export const createMockAuthenticator = (overrides?: Partial): Authenticator => ({ id: 'auth_mock123', @@ -114,57 +111,52 @@ export const createMockAPIError = (message: string, statusCode?: number) => { return error; }; -export const createMockUserMFAMgmtLogic = ( - logicOverrides: Partial = {}, -): UserMFAMgmtLogicProps => ({ - isLoading: false, - isDeleting: false, - customMessages: {}, - hideHeader: false, - showActiveOnly: false, - disableEnroll: false, - disableDelete: false, - readOnly: false, - factorConfig: {}, - error: null, - schema: undefined, - dialogOpen: false, - enrollFactor: null, - isDeleteDialogOpen: false, - factorToDelete: null, - factorsByType: { - email: [ - { - id: '2', - type: 'email', - enrolled: false, - created_at: null, - }, - ], - phone: [], - 'push-notification': [], - totp: [], - 'webauthn-roaming': [], - 'webauthn-platform': [], - 'recovery-code': [], - }, - visibleFactorTypes: ['email'], - hasNoActiveFactors: false, - confirmEnrollment: vi.fn(), - styling: undefined, - ...logicOverrides, -}); - -export const createMockUserMFAMgmtHandlers = ( - handlerOverrides: Partial = {}, -): UserMFAMgmtHandlerProps => ({ - enrollMfa: vi.fn(), - onEnrollFactor: vi.fn(), - onDeleteFactor: vi.fn(), - handleCloseDialog: vi.fn(), - handleEnrollError: vi.fn(), - handleEnrollSuccess: vi.fn(), - handleConfirmDelete: vi.fn(), - setIsDeleteDialogOpen: vi.fn(), - ...handlerOverrides, -}); +export const createMockUserMFAMgmtViewProps = ( + overrides: Partial = {}, +): UserMFAMgmtViewProps => + ({ + isLoading: false, + isDeleting: false, + customMessages: {}, + hideHeader: false, + showActiveOnly: false, + disableEnroll: false, + disableDelete: false, + readOnly: false, + factorConfig: {}, + error: null, + schema: undefined, + dialogOpen: false, + enrollFactor: null, + isDeleteDialogOpen: false, + factorToDelete: null, + factorsByType: { + email: [ + { + id: '2', + type: 'email', + enrolled: false, + created_at: null, + }, + ], + phone: [], + 'push-notification': [], + totp: [], + 'webauthn-roaming': [], + 'webauthn-platform': [], + 'recovery-code': [], + }, + visibleFactorTypes: ['email'], + hasNoActiveFactors: false, + confirmEnrollment: vi.fn(), + styling: undefined, + enrollMfa: vi.fn(), + onEnrollFactor: vi.fn(), + onDeleteFactor: vi.fn(), + handleCloseDialog: vi.fn(), + handleEnrollError: vi.fn(), + handleEnrollSuccess: vi.fn(), + handleConfirmDelete: vi.fn(), + setIsDeleteDialogOpen: vi.fn(), + ...overrides, + }) as UserMFAMgmtViewProps; diff --git a/packages/react/src/types/my-account/mfa/mfa-types.ts b/packages/react/src/types/my-account/mfa/mfa-types.ts index c37010401..9ad96e6b0 100644 --- a/packages/react/src/types/my-account/mfa/mfa-types.ts +++ b/packages/react/src/types/my-account/mfa/mfa-types.ts @@ -285,7 +285,7 @@ export type UseMFAResult = { ) => Promise; }; -export interface UserMFAMgmtLogicProps { +export interface UserMFAMgmtViewProps { error: string | null; schema: | Partial<{ @@ -311,9 +311,6 @@ export interface UserMFAMgmtLogicProps { visibleFactorTypes: MFAType[]; hasNoActiveFactors: boolean; confirmEnrollment: UseMFAResult['confirmEnrollment']; -} - -export interface UserMFAMgmtHandlerProps { enrollMfa: UseMFAResult['enrollMfa']; onEnrollFactor: (factor: MFAType) => void; onDeleteFactor: (factorId: string, factorType: MFAType) => Promise; @@ -321,12 +318,7 @@ export interface UserMFAMgmtHandlerProps { handleEnrollSuccess: () => void; handleEnrollError: (error: Error, stage: typeof ENROLL | typeof CONFIRM) => void; handleConfirmDelete: (factorId: string) => Promise; - setIsDeleteDialogOpen: React.Dispatch>; -} - -export interface UserMFAMgmtViewProps { - logic: UserMFAMgmtLogicProps; - handlers: UserMFAMgmtHandlerProps; + setIsDeleteDialogOpen: (open: boolean) => void; } /** @@ -375,8 +367,8 @@ export interface UseMFALogicResult { visibleFactorTypes: MFAType[]; hasNoActiveFactors: boolean; - setIsDeleteDialogOpen: React.Dispatch>; - setFactorToDelete: React.Dispatch>; + setIsDeleteDialogOpen: (open: boolean) => void; + setFactorToDelete: (factor: { id: string; type: MFAType } | null) => void; loadFactors: () => Promise; handleEnroll: (factor: MFAType) => void; diff --git a/packages/react/src/types/my-organization/domain-management/domain-table-types.ts b/packages/react/src/types/my-organization/domain-management/domain-table-types.ts index ed1fff371..5b7a978b4 100644 --- a/packages/react/src/types/my-organization/domain-management/domain-table-types.ts +++ b/packages/react/src/types/my-organization/domain-management/domain-table-types.ts @@ -54,19 +54,42 @@ export interface DomainTableProps onCreateProvider?: () => void; } -// DomainTableView component props -export type DomainTableViewProps = Omit & - Pick< - DomainTableProps, - | 'schema' - | 'customMessages' - | 'styling' - | 'readOnly' - | 'hideHeader' - | 'createAction' - | 'onOpenProvider' - | 'onCreateProvider' - >; +export interface DomainTableViewProps extends SharedComponentProps { + domains: Domain[]; + providers: IdentityProviderAssociatedWithDomain[]; + isFetching: boolean; + isLoadingProviders: boolean; + isCreating: boolean; + isDeleting: boolean; + isVerifying: boolean; + showCreateModal: boolean; + showConfigureModal: boolean; + showVerifyModal: boolean; + showDeleteModal: boolean; + verifyError: string | undefined; + selectedDomain: Domain | null; + closeModal: () => void; + handleCreate: (domainUrl: string) => Promise; + handleVerify: (domain: Domain) => Promise; + handleDelete: (domain: Domain) => Promise; + handleToggleSwitch: ( + domain: Domain, + provider: IdentityProvider, + checked: boolean, + ) => Promise; + handleCreateClick: () => void; + handleConfigureClick: (domain: Domain) => void; + handleVerifyClick: (domain: Domain) => Promise; + handleDeleteClick: (domain: Domain) => void; + schema?: DomainTableProps['schema']; + customMessages: DomainTableProps['customMessages']; + styling: DomainTableProps['styling']; + readOnly: DomainTableProps['readOnly']; + hideHeader?: DomainTableProps['hideHeader']; + createAction?: DomainTableProps['createAction']; + onOpenProvider?: DomainTableProps['onOpenProvider']; + onCreateProvider?: DomainTableProps['onCreateProvider']; +} /** Props for DomainTable actions column. */ export interface DomainTableActionsColumnProps { diff --git a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-create-types.ts b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-create-types.ts index d55b1ed12..069ffe537 100644 --- a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-create-types.ts +++ b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-create-types.ts @@ -144,8 +144,28 @@ export interface UseSsoProviderCreateReturn { }; } -export type SsoProviderCreateViewProps = Omit & - Pick< - SsoProviderCreateProps, - 'styling' | 'customMessages' | 'backButton' | 'onNext' | 'onPrevious' - >; +export interface SsoProviderCreateViewProps { + createProvider: (data: CreateIdentityProviderRequestContentPrivate) => Promise; + isCreating: boolean; + formData: FormState; + setFormData: React.Dispatch>; + detailsRef: React.RefObject; + configureRef: React.RefObject; + handleCreate: () => Promise; + isLoadingConfig: boolean; + filteredStrategies: IdpStrategy[]; + isLoadingIdpConfig: boolean; + idpConfig?: IdpConfig | null; + createStepActions: ( + stepId: 'provider_details' | 'provider_configure', + ref: React.RefObject, + ) => { + onNextAction: () => Promise; + onPreviousAction: () => Promise; + }; + styling: SsoProviderCreateProps['styling']; + customMessages: SsoProviderCreateProps['customMessages']; + backButton?: SsoProviderCreateProps['backButton']; + onNext?: SsoProviderCreateProps['onNext']; + onPrevious?: SsoProviderCreateProps['onPrevious']; +} diff --git a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts index 87562a041..bb3e81aa7 100644 --- a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts +++ b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts @@ -143,16 +143,50 @@ export interface SsoProviderAttributeSyncAlertProps { customMessages?: Partial; } -/** Props for the SsoProviderEditView component. */ -export type SsoProviderEditViewProps = Omit & - Pick< - SsoProviderEditProps, - | 'styling' - | 'customMessages' - | 'backButton' - | 'schema' - | 'readOnly' - | 'providerId' - | 'domains' - | 'hideHeader' - >; +export interface SsoProviderEditViewProps { + provider: IdentityProvider | null; + organization: OrganizationPrivate; + provisioningConfig: GetIdPProvisioningConfigResponseContent | null; + isLoading: boolean; + isUpdating: boolean; + isDeleting: boolean; + isRemoving: boolean; + isProvisioningUpdating: boolean; + isProvisioningDeleting: boolean; + isProvisioningLoading: boolean; + isScimTokensLoading: boolean; + isScimTokenCreating: boolean; + isScimTokenDeleting: boolean; + isSsoAttributesSyncing: boolean; + isProvisioningAttributesSyncing: boolean; + hasSsoAttributeSyncWarning: boolean; + hasProvisioningAttributeSyncWarning: boolean; + shouldAllowDeletion: boolean; + isLoadingConfig: boolean; + idpConfig: IdpConfig | null; + isLoadingIdpConfig: boolean; + showProvisioningTab: boolean; + fetchProvider: () => Promise; + fetchProvisioning: () => Promise; + updateProvider: (data: UpdateIdentityProviderRequestContentPrivate) => Promise; + handleToggleProvider: (enabled: boolean) => Promise; + createProvisioning: () => Promise; + deleteProvisioning: () => Promise; + listScimTokens: () => Promise; + createScimToken: ( + data: CreateIdpProvisioningScimTokenRequestContent, + ) => Promise; + deleteScimToken: (idpScimTokenId: string) => Promise; + syncSsoAttributes: () => Promise; + syncProvisioningAttributes: () => Promise; + onDeleteConfirm: () => Promise; + onRemoveConfirm: () => Promise; + styling: SsoProviderEditProps['styling']; + customMessages: SsoProviderEditProps['customMessages']; + backButton?: SsoProviderEditProps['backButton']; + schema?: SsoProviderEditProps['schema']; + readOnly: SsoProviderEditProps['readOnly']; + providerId: SsoProviderEditProps['providerId']; + domains?: SsoProviderEditProps['domains']; + hideHeader?: SsoProviderEditProps['hideHeader']; +} diff --git a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts index 5f704fd58..213d0047b 100644 --- a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts +++ b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts @@ -72,9 +72,9 @@ export interface UseSsoProviderTableReturn { onDeleteConfirm: (selectedIdp: IdentityProvider) => Promise; onRemoveConfirm: (selectedIdp: IdentityProvider) => Promise; onEnableProvider: (selectedIdp: IdentityProvider, enabled: boolean) => Promise; - setShowDeleteModal: React.Dispatch>; - setShowRemoveModal: React.Dispatch>; - setSelectedIdp: React.Dispatch>; + setShowDeleteModal: (open: boolean) => void; + setShowRemoveModal: (open: boolean) => void; + setSelectedIdp: (idp: IdentityProvider | null) => void; handleCreate: () => void; handleEdit: (idp: IdentityProvider) => void; handleDelete: (idp: IdentityProvider) => void; @@ -105,10 +105,40 @@ export interface SsoProviderTableActionsColumnProps } /** Props for the SsoProviderTableView component. */ -export type SsoProviderTableViewProps = UseSsoProviderTableReturn & - Pick< - SsoProviderTableProps, - 'styling' | 'customMessages' | 'readOnly' | 'createAction' | 'editAction' - > & { - hideHeader?: boolean; - }; +export interface SsoProviderTableViewProps { + providers: IdentityProvider[]; + isLoading: boolean; + isViewLoading: boolean; + isDeleting: boolean; + isRemoving: boolean; + isUpdating: boolean; + isUpdatingId: string | null; + shouldAllowDeletion: boolean; + shouldHideCreate: boolean; + showDeleteModal: boolean; + showRemoveModal: boolean; + selectedIdp: IdentityProvider | null; + error: unknown; + retry: () => Promise; + fetchProviders: () => Promise; + getOrganizationName: () => Promise; + onDeleteConfirm: (selectedIdp: IdentityProvider) => Promise; + onRemoveConfirm: (selectedIdp: IdentityProvider) => Promise; + onEnableProvider: (selectedIdp: IdentityProvider, enabled: boolean) => Promise; + setShowDeleteModal: (open: boolean) => void; + setShowRemoveModal: (open: boolean) => void; + setSelectedIdp: (idp: IdentityProvider | null) => void; + handleCreate: () => void; + handleEdit: (idp: IdentityProvider) => void; + handleDelete: (idp: IdentityProvider) => void; + handleDeleteFromOrganization: (idp: IdentityProvider) => void; + handleToggleEnabled: (idp: IdentityProvider, enabled: boolean) => Promise; + handleDeleteConfirm: (provider: IdentityProvider) => Promise; + handleRemoveConfirm: (provider: IdentityProvider) => Promise; + styling: SsoProviderTableProps['styling']; + customMessages: SsoProviderTableProps['customMessages']; + readOnly: SsoProviderTableProps['readOnly']; + createAction: SsoProviderTableProps['createAction']; + editAction: SsoProviderTableProps['editAction']; + hideHeader?: boolean; +} diff --git a/packages/react/src/types/my-organization/organization-management/organization-details-edit-types.ts b/packages/react/src/types/my-organization/organization-management/organization-details-edit-types.ts index 08ff39901..634d35942 100644 --- a/packages/react/src/types/my-organization/organization-management/organization-details-edit-types.ts +++ b/packages/react/src/types/my-organization/organization-management/organization-details-edit-types.ts @@ -65,11 +65,17 @@ export interface UseOrganizationDetailsEditResult { updateOrgDetails: (data: OrganizationPrivate) => Promise; } -export type OrganizationDetailsEditViewProps = Omit< - UseOrganizationDetailsEditResult, - 'error' | 'retry' -> & - Pick< - OrganizationDetailsEditProps, - 'schema' | 'customMessages' | 'styling' | 'readOnly' | 'hideHeader' | 'backButton' - >; +export interface OrganizationDetailsEditViewProps { + organization: OrganizationPrivate; + isLoading: boolean; + isFetchLoading: boolean; + isSaveLoading: boolean; + formActions: OrganizationDetailsFormActions; + updateOrgDetails: (data: OrganizationPrivate) => Promise; + schema?: OrganizationDetailsEditProps['schema']; + customMessages: OrganizationDetailsEditProps['customMessages']; + styling: OrganizationDetailsEditProps['styling']; + readOnly: OrganizationDetailsEditProps['readOnly']; + hideHeader: boolean; + backButton?: OrganizationDetailsEditProps['backButton']; +} From bad7c98b6a6b52e5b601d4d93c1c23b1dd7d05ef Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Mon, 2 Mar 2026 19:34:58 +0530 Subject: [PATCH 08/27] fix(react): test cases update --- .../services/step-up/step-up-api-service.ts | 10 ++++-- .../src/services/step-up/step-up-utils.ts | 3 ++ .../organization-details-edit.test.tsx | 4 +-- .../organization-details-edit.tsx | 2 +- .../components/auth0/shared/gatekeeper.tsx | 17 +++++++++- .../hooks/my-organization/use-domain-table.ts | 32 +++++++++++++------ .../src/hooks/shared/use-error-handler.ts | 2 ++ 7 files changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/core/src/services/step-up/step-up-api-service.ts b/packages/core/src/services/step-up/step-up-api-service.ts index 1ae750095..4181276a7 100644 --- a/packages/core/src/services/step-up/step-up-api-service.ts +++ b/packages/core/src/services/step-up/step-up-api-service.ts @@ -20,7 +20,10 @@ import type { export type StepUpApiService = MfaApiClient; /** - * Initializes a Step-Up API service instance based on auth configuration + * Initializes a Step-Up API service instance based on auth configuration. + * + * @param auth - Auth details containing proxy URL or context interface. + * @returns Step-Up API service instance. */ export function initializeStepUpApiService(auth: AuthDetails): StepUpApiService { if (auth.authProxyUrl) { @@ -35,7 +38,10 @@ export function initializeStepUpApiService(auth: AuthDetails): StepUpApiService } /** - * Creates an MFA client for proxy mode + * Creates an MFA client for proxy mode. + * + * @param authProxyUrl - Base URL for the auth proxy. + * @returns Proxy-based MFA client. */ function createProxyMfaClient(authProxyUrl: string): Omit { const baseUrl = authProxyUrl.replace(/\/$/, ''); diff --git a/packages/core/src/services/step-up/step-up-utils.ts b/packages/core/src/services/step-up/step-up-utils.ts index 9cc9d1410..c7a718285 100644 --- a/packages/core/src/services/step-up/step-up-utils.ts +++ b/packages/core/src/services/step-up/step-up-utils.ts @@ -2,6 +2,9 @@ import type { MfaRequiredError } from './step-up-types'; /** * Type guard to check if an error is an MFA required error. + * + * @param error - The error to check. + * @returns True if the error is an MFA required error. */ export function isMfaRequiredError(error: unknown): error is MfaRequiredError { if (typeof error !== 'object' || error === null) return false; diff --git a/packages/react/src/components/auth0/my-organization/__tests__/organization-details-edit.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/organization-details-edit.test.tsx index 59b3ce56a..65bdd21f9 100644 --- a/packages/react/src/components/auth0/my-organization/__tests__/organization-details-edit.test.tsx +++ b/packages/react/src/components/auth0/my-organization/__tests__/organization-details-edit.test.tsx @@ -576,8 +576,8 @@ describe('OrganizationDetailsEditView', () => { expect(screen.getByText(/custom title/i)).toBeInTheDocument(); }); - it('renders loading state when isFetchLoading is true', () => { + it('renders content when isFetchLoading is true (GateKeeper handles loading)', () => { renderWithProviders(); - expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(screen.getByText('header.title')).toBeInTheDocument(); }); }); diff --git a/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx b/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx index 05a7bac37..84d51f18c 100644 --- a/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx +++ b/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx @@ -65,7 +65,7 @@ export function OrganizationDetailsEdit({ }); return ( - + { - const isVerified = await verifyDomainMutation.mutateAsync(domain); - if (isVerified) { - setActiveModal(nextModal); - notifySuccess('domain_verify', { domainName: domain.domain }); - } else { - setVerifyError( - t('domain_verify.modal.errors.verification_failed', { domainName: domain.domain }), - ); + try { + const isVerified = await verifyDomainMutation.mutateAsync(domain); + if (isVerified) { + setActiveModal(nextModal); + notifySuccess('domain_verify', { domainName: domain.domain }); + } else { + setVerifyError( + t('domain_verify.modal.errors.verification_failed', { domainName: domain.domain }), + ); + } + } catch (error) { + if (!isActionCancelledError(error)) throw error; } }, [verifyDomainMutation, t], @@ -257,7 +261,11 @@ export function useDomainTable({ const handleCreate = useCallback( async (domainUrl: string) => { - await createDomainMutation.mutateAsync({ domain: domainUrl }); + try { + await createDomainMutation.mutateAsync({ domain: domainUrl }); + } catch (error) { + if (!isActionCancelledError(error)) throw error; + } }, [createDomainMutation], ); @@ -269,7 +277,11 @@ export function useDomainTable({ const handleDelete = useCallback( async (domain: Domain) => { - await deleteDomainMutation.mutateAsync(domain); + try { + await deleteDomainMutation.mutateAsync(domain); + } catch (error) { + if (!isActionCancelledError(error)) throw error; + } }, [deleteDomainMutation], ); diff --git a/packages/react/src/hooks/shared/use-error-handler.ts b/packages/react/src/hooks/shared/use-error-handler.ts index c1111c159..db5447a58 100644 --- a/packages/react/src/hooks/shared/use-error-handler.ts +++ b/packages/react/src/hooks/shared/use-error-handler.ts @@ -44,6 +44,8 @@ const extractErrorMessage = (error: unknown, fallback: string): string => { * Hook for consistent error handling across the app. * Skips MFA/500+ errors (GateKeeper handles), shows toast for others. * + * @returns Error handler function. + * * @example * const handleError = useErrorHandler(); * From 4d01500855a5dda13cd158830565cc30a873c197 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Mon, 2 Mar 2026 20:03:06 +0530 Subject: [PATCH 09/27] fix(react): add code coverage --- .../src/auth/__tests__/core-client.test.ts | 66 ++++++++++++++++++- .../__tests__/spa-token-retriever.test.ts | 21 ++++++ packages/core/src/auth/core-client.ts | 2 +- .../components/auth0/shared/gatekeeper.tsx | 14 ++-- 4 files changed, 90 insertions(+), 13 deletions(-) diff --git a/packages/core/src/auth/__tests__/core-client.test.ts b/packages/core/src/auth/__tests__/core-client.test.ts index 079385dc5..5d1da899d 100644 --- a/packages/core/src/auth/__tests__/core-client.test.ts +++ b/packages/core/src/auth/__tests__/core-client.test.ts @@ -2,6 +2,8 @@ import { initializeMyAccountClient } from '@core/services/my-account/my-account- import type { MyAccountClientWithScopes } from '@core/services/my-account/my-account-api-service'; import { initializeMyOrganizationClient } from '@core/services/my-organization/my-organization-api-service'; import type { MyOrganizationClientWithScopes } from '@core/services/my-organization/my-organization-api-service'; +import { initializeStepUpApiService } from '@core/services/step-up'; +import type { StepUpApiService } from '@core/services/step-up'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createI18nService } from '../../i18n'; @@ -22,6 +24,7 @@ vi.mock('@core/i18n'); vi.mock('@core/auth/spa-token-retriever'); vi.mock('@core/services/my-organization/my-organization-api-service'); vi.mock('@core/services/my-account/my-account-api-service'); +vi.mock('@core/services/step-up'); describe('createCoreClient', () => { // Create mock instances using mock utilities @@ -29,12 +32,20 @@ describe('createCoreClient', () => { const mockTokenManager = createMockSpaTokenRetriever(); const mockMyOrganizationClient = createMockMyOrganizationClient(); const mockMyAccountClient = createMockMyAccountClient(); + const mockStepUpApiService = { + getAuthenticators: vi.fn(), + enroll: vi.fn(), + challenge: vi.fn(), + getEnrollmentFactors: vi.fn(), + verify: vi.fn(), + } as unknown as StepUpApiService; // Get the mocked functions const createI18nServiceMock = vi.mocked(createI18nService); const createSpaTokenRetrieverMock = vi.mocked(createSpaTokenRetriever); const initializeMyOrganizationClientMock = vi.mocked(initializeMyOrganizationClient); const initializeMyAccountClientMock = vi.mocked(initializeMyAccountClient); + const initializeStepUpApiServiceMock = vi.mocked(initializeStepUpApiService); const createAuthDetails = (overrides: Partial = {}): AuthDetails => { return { @@ -53,6 +64,7 @@ describe('createCoreClient', () => { createSpaTokenRetrieverMock.mockReturnValue(mockTokenManager); initializeMyOrganizationClientMock.mockReturnValue(mockMyOrganizationClient); initializeMyAccountClientMock.mockReturnValue(mockMyAccountClient); + initializeStepUpApiServiceMock.mockReturnValue(mockStepUpApiService); // Reset token manager mock to return successful token vi.mocked(mockTokenManager.getToken).mockResolvedValue('mock-token'); @@ -245,6 +257,49 @@ describe('createCoreClient', () => { }); }); + describe('getDomain', () => { + it('returns domain from authDetails when provided', async () => { + const authDetails = createAuthDetails({ domain: 'custom.auth0.com' }); + const client = await createCoreClient(authDetails); + + expect(client.getDomain()).toBe('custom.auth0.com'); + }); + + it('falls back to contextInterface domain when authDetails domain is undefined', async () => { + const authDetails = createAuthDetails({ domain: undefined }); + const client = await createCoreClient(authDetails); + + expect(client.getDomain()).toBe(TEST_DOMAIN); + }); + }); + + describe('stepUpApiService access', () => { + it('returns stepUpApiService when available via getter', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(client.getStepUpApiService()).toBe(mockStepUpApiService); + }); + + it('throws when stepUpApiService is not available', async () => { + initializeStepUpApiServiceMock.mockReturnValueOnce(undefined as unknown as StepUpApiService); + + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(() => client.getStepUpApiService()).toThrow( + 'stepUpApiService is not enabled. Please use it within Auth0ComponentProvider.', + ); + }); + + it('exposes stepUpApiService directly on the client', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(client.stepUpApiService).toBe(mockStepUpApiService); + }); + }); + // --- New tests for previewMode --- describe('previewMode', () => { it('returns a core client with previewMode and disables API clients', async () => { @@ -287,11 +342,18 @@ describe('createCoreClient', () => { expect(() => client.getMyOrganizationApiClient()).toThrow('Function not implemented.'); }); - it('getDomain not defined in previewMode', async () => { + it('getDomain returns undefined in previewMode', async () => { + const authDetails = { ...createAuthDetails(), previewMode: true }; + const client = await createCoreClient(authDetails); + + expect(client.getDomain()).toBeUndefined(); + }); + + it('getStepUpApiService returns undefined in previewMode', async () => { const authDetails = { ...createAuthDetails(), previewMode: true }; const client = await createCoreClient(authDetails); - expect(() => client.getDomain()).toBeUndefined; + expect(client.getStepUpApiService()).toBeUndefined(); }); }); }); diff --git a/packages/core/src/auth/__tests__/spa-token-retriever.test.ts b/packages/core/src/auth/__tests__/spa-token-retriever.test.ts index c0baceaec..23639a8ac 100644 --- a/packages/core/src/auth/__tests__/spa-token-retriever.test.ts +++ b/packages/core/src/auth/__tests__/spa-token-retriever.test.ts @@ -539,6 +539,27 @@ describe('spa-token-retriever', () => { detailedResponse: true, }); }); + + it('should return empty audience when URL construction fails', async () => { + const contextWithNullDomain = { + ...mockContextInterface, + getConfiguration: vi.fn().mockReturnValue({ domain: null, clientId: TEST_CLIENT_ID }), + }; + const auth = createAuthConfig({ + domain: '://invalid', + contextInterface: contextWithNullDomain, + }); + const tokenManager = createSpaTokenRetriever(auth); + await tokenManager.getToken('read:users', 'management'); + + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationParams: expect.objectContaining({ + audience: '', + }), + }), + ); + }); }); }); }); diff --git a/packages/core/src/auth/core-client.ts b/packages/core/src/auth/core-client.ts index f467ecc6b..be48405f6 100644 --- a/packages/core/src/auth/core-client.ts +++ b/packages/core/src/auth/core-client.ts @@ -54,7 +54,7 @@ export async function createCoreClient( }, stepUpApiService: undefined, getStepUpApiService: function () { - throw new Error('Function not implemented.'); + return undefined as unknown as ReturnType; }, }; diff --git a/packages/react/src/components/auth0/shared/gatekeeper.tsx b/packages/react/src/components/auth0/shared/gatekeeper.tsx index 6424c374e..3a2b02824 100644 --- a/packages/react/src/components/auth0/shared/gatekeeper.tsx +++ b/packages/react/src/components/auth0/shared/gatekeeper.tsx @@ -104,11 +104,8 @@ export function GateKeeper({ isLoading = false, error, onRetry, children }: Gate } = useQuery({ queryKey: ['mfa-enrollment-factors', mfaToken], queryFn: async () => { - if (!coreClient || !mfaToken) { - throw new Error('CoreClient or MFA token not available'); - } - const stepUpService = coreClient.getStepUpApiService(); - return stepUpService.getEnrollmentFactors(mfaToken); + const stepUpService = coreClient!.getStepUpApiService(); + return stepUpService.getEnrollmentFactors(mfaToken!); }, enabled: Boolean( !isProxyMode && @@ -132,11 +129,8 @@ export function GateKeeper({ isLoading = false, error, onRetry, children }: Gate } = useQuery({ queryKey: ['mfa-authenticators', mfaToken], queryFn: async () => { - if (!coreClient || !mfaToken) { - throw new Error('CoreClient or MFA token not available'); - } - const stepUpService = coreClient.getStepUpApiService(); - return stepUpService.getAuthenticators(mfaToken); + const stepUpService = coreClient!.getStepUpApiService(); + return stepUpService.getAuthenticators(mfaToken!); }, enabled: Boolean( error && From 262c27e77b7438adc897a3b85b9f302d8227891b Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Mon, 2 Mar 2026 20:15:04 +0530 Subject: [PATCH 10/27] fix(react): add code cov for providers --- .../__tests__/proxy-provider.test.tsx | 34 +++++++++++++++++++ .../providers/__tests__/spa-provider.test.tsx | 31 +++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/react/src/providers/__tests__/proxy-provider.test.tsx b/packages/react/src/providers/__tests__/proxy-provider.test.tsx index 61fc200d0..05b5af2dd 100644 --- a/packages/react/src/providers/__tests__/proxy-provider.test.tsx +++ b/packages/react/src/providers/__tests__/proxy-provider.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'; import * as React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useCoreClientInitialization } from '@/hooks/shared/use-core-client-initialization'; import { Auth0ComponentProvider } from '@/providers/proxy-provider'; vi.mock('@/hooks/shared/use-core-client-initialization', () => ({ @@ -11,6 +12,8 @@ vi.mock('@/hooks/shared/use-core-client-initialization', () => ({ })), })); +const mockUseCoreClientInitialization = vi.mocked(useCoreClientInitialization); + vi.mock('@/components/auth0/shared/sonner', () => ({ Toaster: () =>
    , })); @@ -104,4 +107,35 @@ describe('Auth0ComponentProvider', () => { expect(screen.getByTestId('theme-provider')).toBeInTheDocument(); }); + + describe('when coreClient is not yet initialized', () => { + beforeEach(() => { + mockUseCoreClientInitialization.mockReturnValue(null as never); + }); + + it('should render default spinner when no custom loader is provided', () => { + render( + +
    Test Content
    +
    , + ); + + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + }); + + it('should render custom loader when provided', () => { + render( + Custom Loading...
    } + > +
    Test Content
    + , + ); + + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument(); + expect(screen.getByTestId('custom-loader')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/react/src/providers/__tests__/spa-provider.test.tsx b/packages/react/src/providers/__tests__/spa-provider.test.tsx index 2570f9cce..802c51c87 100644 --- a/packages/react/src/providers/__tests__/spa-provider.test.tsx +++ b/packages/react/src/providers/__tests__/spa-provider.test.tsx @@ -174,4 +174,35 @@ describe('Auth0ComponentProvider (SPA)', () => { expect(mockUseCoreClientInitialization).toHaveBeenCalled(); expect(screen.getByTestId('child-content')).toBeInTheDocument(); }); + + describe('when coreClient is not yet initialized', () => { + beforeEach(() => { + mockUseCoreClientInitialization.mockReturnValue(null as never); + }); + + it('should render default spinner when no custom loader is provided', () => { + render( + +
    Test Content
    +
    , + ); + + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('should render custom loader when provided', () => { + render( + Custom Loading...
    } + > +
    Test Content
    + , + ); + + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument(); + expect(screen.getByTestId('custom-loader')).toBeInTheDocument(); + }); + }); }); From a9b8016a9cb535d69ac37ec62014d7af959d7499 Mon Sep 17 00:00:00 2001 From: harish-sundar_akto Date: Tue, 3 Mar 2026 11:43:38 +0530 Subject: [PATCH 11/27] feat(react): add mfa step-up authentication flow with enrollment support --- .../core/src/i18n/translations/en-US.json | 30 +- packages/core/src/i18n/translations/ja.json | 41 +- .../src/services/step-up/step-up-types.ts | 4 +- .../components/auth0/shared/gatekeeper.tsx | 186 +++++---- .../step-up-authenticator-list.tsx | 128 ++++++ .../mfa-step-up/step-up-challenge-form.tsx | 140 +++++++ .../step-up-contact-input-form.tsx | 247 +++++++++++ .../step-up-enrollment-setup-form.tsx | 383 ++++++++++++++++++ .../step-up-qr-code-enrollment-form.tsx | 187 +++++++++ .../auth0/shared/use-step-up-challenge.ts | 128 ++++++ 10 files changed, 1401 insertions(+), 73 deletions(-) create mode 100644 packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx create mode 100644 packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx create mode 100644 packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx create mode 100644 packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx create mode 100644 packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx create mode 100644 packages/react/src/hooks/auth0/shared/use-step-up-challenge.ts diff --git a/packages/core/src/i18n/translations/en-US.json b/packages/core/src/i18n/translations/en-US.json index 8f63d28cd..ac00e937b 100644 --- a/packages/core/src/i18n/translations/en-US.json +++ b/packages/core/src/i18n/translations/en-US.json @@ -10,7 +10,35 @@ "error": { "generic": "There was an issue processing your request. Please try again or contact support if the issue persists.", "mfa": { - "title": "Verify its you" + "title": "Verify your account", + "subtitle": "You must provide a second factor from one of the options below to perform this action.", + "verify_button": "Verify", + "verifying": "Verifying...", + "back": "Back", + "cancel": "Cancel", + "continue": "Continue", + "enroll_button": "Set up", + "fetch_failed": "There was an issue loading your authentication methods. Please try again.", + "no_authenticators": "No authentication methods found. Please contact support.", + "enrollment_required": "You need to set up a second factor to continue.", + "factor_available": "Available for setup", + "challenge_error": "Failed to start the verification. Please try again.", + "verify_error": "Verification failed. Please check your code and try again.", + "enroll_error": "Failed to set up the authentication method. Please try again.", + "otp_instruction": "Please enter the one-time code shown in your authenticator app.", + "oob_instruction": "Please enter the one-time code sent to your device.", + "enter_code_label": "One-time passcode", + "registered_on": "Registered on ${date}", + "enroll_title": "Set up a second factor", + "authenticator_type": { + "otp": "Authenticator", + "oob": "Push Notification with Guardian App", + "recovery-code": "Recovery Codes", + "email": "Email OTP", + "sms": "SMS", + "push": "Push Notification with Guardian App", + "voice": "Voice" + } } } }, diff --git a/packages/core/src/i18n/translations/ja.json b/packages/core/src/i18n/translations/ja.json index cb161b784..cce39fc02 100644 --- a/packages/core/src/i18n/translations/ja.json +++ b/packages/core/src/i18n/translations/ja.json @@ -1,7 +1,46 @@ { "common": { "copy": "コピー", - "copied": "コピーしました" + "copied": "コピーしました", + "fallback": { + "title": "情報を読み込めませんでした", + "description": "再度お試しいただくか、問題が解決しない場合はサポートまでお問い合わせください。", + "retry": "再試行" + }, + "error": { + "generic": "リクエストの処理中に問題が発生しました。再度お試しいただくか、問題が解決しない場合はサポートまでお問い合わせください。", + "mfa": { + "title": "アカウントを確認してください", + "subtitle": "このアクションを実行するには、以下のいずれかの方法で第2要素を提供する必要があります。", + "verify_button": "確認", + "verifying": "確認中...", + "back": "戻る", + "cancel": "キャンセル", + "continue": "続ける", + "enroll_button": "設定", + "fetch_failed": "認証方法の読み込み中に問題が発生しました。再度お試しください。", + "no_authenticators": "認証方法が見つかりません。サポートにお問い合わせください。", + "enrollment_required": "続けるには第2要素を設定する必要があります。", + "factor_available": "設定可能", + "challenge_error": "確認の開始に失敗しました。再度お試しください。", + "verify_error": "確認に失敗しました。コードを確認して再度お試しください。", + "enroll_error": "認証方法の設定に失敗しました。再度お試しください。", + "otp_instruction": "認証アプリに表示されているワンタイムコードを入力してください。", + "oob_instruction": "デバイスに送信されたワンタイムコードを入力してください。", + "enter_code_label": "ワンタイムパスコード", + "registered_on": "${date}に登録", + "enroll_title": "第2要素を設定", + "authenticator_type": { + "otp": "認証アプリ", + "oob": "Auth0 Guardianプッシュ通知", + "recovery-code": "リカバリーコード", + "email": "メールOTP", + "sms": "SMS", + "push": "Auth0 Guardianプッシュ通知", + "voice": "音声通話" + } + } + } }, "domain_management": { "domain_table": { diff --git a/packages/core/src/services/step-up/step-up-types.ts b/packages/core/src/services/step-up/step-up-types.ts index 73618fc30..f8f7e7273 100644 --- a/packages/core/src/services/step-up/step-up-types.ts +++ b/packages/core/src/services/step-up/step-up-types.ts @@ -1,8 +1,10 @@ +import type { ChallengeType } from '../../auth/auth-types'; + export interface MfaRequirements { /** Required enrollment types (user needs to enroll new authenticator) */ enroll?: Array<{ type: string }>; /** Available challenge types (existing authenticators) */ - challenge?: Array<{ type: string }>; + challenge?: Array<{ type: ChallengeType }>; } export interface MfaRequiredError extends Error { diff --git a/packages/react/src/components/auth0/shared/gatekeeper.tsx b/packages/react/src/components/auth0/shared/gatekeeper.tsx index 3a2b02824..36ce54b4e 100644 --- a/packages/react/src/components/auth0/shared/gatekeeper.tsx +++ b/packages/react/src/components/auth0/shared/gatekeeper.tsx @@ -9,10 +9,14 @@ import { useQuery } from '@tanstack/react-query'; import { RefreshCcw } from 'lucide-react'; import React, { useState, useMemo } from 'react'; +import { StepUpAuthenticatorList } from '@/components/auth0/shared/mfa-step-up/step-up-authenticator-list'; +import { StepUpChallengeForm } from '@/components/auth0/shared/mfa-step-up/step-up-challenge-form'; +import { StepUpEnrollmentSetupForm } from '@/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form'; import { Button } from '@/components/ui/button'; import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Spinner } from '@/components/ui/spinner'; +import { useStepUpChallenge } from '@/hooks/auth0/shared/use-step-up-challenge'; import { useCoreClient } from '@/hooks/shared/use-core-client'; import { useTranslator } from '@/hooks/shared/use-translator'; @@ -88,15 +92,15 @@ export function GateKeeper({ isLoading = false, error, onRetry, children }: Gate const mfaToken = useMemo(() => { if (error && isMfaRequiredError(error)) { - const err = error as MfaRequiredError & { body?: { mfa_token?: string } }; - return err.mfa_token ?? err.body?.mfa_token ?? null; + const err = error as MfaRequiredError & { body?: { error?: string; mfa_token?: string } }; + const token = err.mfa_token ?? err.body?.mfa_token ?? null; + return token; } return null; }, [error]); const isProxyMode = coreClient?.isProxyMode() ?? false; - // Step 1: Check if user needs to enroll MFA factors (SPA mode only) const { data: enrollmentFactors, isLoading: isFetchingEnrollmentFactors, @@ -118,10 +122,8 @@ export function GateKeeper({ isLoading = false, error, onRetry, children }: Gate retry: false, }); - // Determine if user needs enrollment or has authenticators const needsEnrollment = enrollmentFactors && enrollmentFactors.length > 0; - // Step 2: Fetch authenticators const { data: authenticators, isLoading: isFetchingAuthenticators, @@ -143,6 +145,34 @@ export function GateKeeper({ isLoading = false, error, onRetry, children }: Gate retry: false, }); + const stepUpService = coreClient?.getStepUpApiService(); + + const handleChallengeSuccess = React.useCallback(async () => { + setIsRetrying(true); + try { + await onRetry(); + setIsMfaDialogOpen(false); + } finally { + setIsRetrying(false); + } + }, [onRetry]); + + const { + state: challengeState, + selectedAuthenticator, + challengeResponse, + isChallenging, + isVerifying, + error: challengeError, + handleSelectAuthenticator, + handleVerify, + handleBack: handleChallengeBack, + } = useStepUpChallenge({ + mfaToken: mfaToken ?? '', + stepUpService: stepUpService!, + onSuccess: handleChallengeSuccess, + }); + const handleRetry = async () => { setIsRetrying(true); try { @@ -161,92 +191,108 @@ export function GateKeeper({ isLoading = false, error, onRetry, children }: Gate ); } - const LoadingState = () => ( -
    - -
    - ); + if (error && isMfaRequiredError(error) && isMfaDialogOpen) { + const getMfaFetchState = (): + | 'LOADING' + | 'ERROR' + | 'ENROLLMENT' + | 'AUTHENTICATORS' + | 'EMPTY' => { + if (!isProxyMode) { + if (isFetchingEnrollmentFactors) return 'LOADING'; + if (fetchEnrollmentFactorsError) return 'ERROR'; + if (needsEnrollment) return 'ENROLLMENT'; + } + if (isFetchingAuthenticators) return 'LOADING'; + if (fetchAuthenticatorsError) return 'ERROR'; + if (authenticators?.length) return 'AUTHENTICATORS'; + return 'EMPTY'; + }; - const ErrorState = () => ( -
    {t('error.mfa.fetch_failed')}
    - ); + const fetchState = getMfaFetchState(); - const EmptyState = () => ( -
    {t('error.mfa.no_authenticators')}
    - ); + const dialogTitle = (() => { + if (fetchState === 'ENROLLMENT') return t('error.mfa.enroll_title'); + if (challengeState === 'VERIFY') return t('error.mfa.title'); + return t('error.mfa.title'); + })(); - const AuthenticatorList = ({ items }: { items: StepUpAuthenticator[] }) => ( -
    - {items.map((auth) => ( -
    -
    {auth.name || auth.authenticatorType}
    -
    - Type: {auth.authenticatorType} | Active: {auth.active ? 'Yes' : 'No'} + const renderDialogContent = () => { + if (fetchState === 'LOADING') { + return ( +
    +
    -
    - ))} -
    - ); + ); + } - const EnrollmentList = ({ factors }: { factors: EnrollmentFactor[] }) => ( -
    -
    - {t('error.mfa.enrollment_required')} -
    - {factors.map((factor) => ( -
    -
    {factor.type}
    -
    {t('error.mfa.factor_available')}
    -
    - ))} -
    - ); + if (fetchState === 'ERROR') { + return ( +
    + {t('error.mfa.fetch_failed')} +
    + ); + } - // Determine current MFA state - const getMfaState = () => { - // SPA mode: Check enrollment factors first - if (!isProxyMode) { - if (isFetchingEnrollmentFactors) return 'LOADING'; - if (fetchEnrollmentFactorsError) return 'ERROR'; - if (needsEnrollment) return 'ENROLLMENT'; - } + if (fetchState === 'EMPTY') { + return ( +
    + {t('error.mfa.no_authenticators')} +
    + ); + } - // Both modes: Check authenticators - if (isFetchingAuthenticators) return 'LOADING'; - if (fetchAuthenticatorsError) return 'ERROR'; - if (authenticators?.length) return 'AUTHENTICATORS'; + if (fetchState === 'ENROLLMENT' && enrollmentFactors) { + return ( + setIsMfaDialogOpen(false)} + /> + ); + } - return 'EMPTY'; - }; + if (fetchState === 'AUTHENTICATORS' && challengeState === 'VERIFY' && challengeResponse) { + return ( + + ); + } - const stateComponentMap: Record = { - LOADING: , - ERROR: , - EMPTY: , - AUTHENTICATORS: authenticators ? : , - ENROLLMENT: enrollmentFactors ? : , - }; + if (fetchState === 'AUTHENTICATORS' && authenticators) { + return ( + setIsMfaDialogOpen(false)} + isChallenging={isChallenging} + challengingAuthenticatorId={selectedAuthenticator?.id ?? null} + /> + ); + } - const renderMfaDialogContent = () => { - const state = getMfaState(); - return stateComponentMap[state] || ; - }; + return null; + }; - // Handle MFA errors - show dialog first, then fallback if closed - if (error && isMfaRequiredError(error) && isMfaDialogOpen) { return ( - {t('error.mfa.title')} + {dialogTitle} - {renderMfaDialogContent()} + {renderDialogContent()} ); } - // Handle 500+ errors or MFA errors (when dialog is closed) const statusCode = getStatusCode(error); const shouldShowErrorFallback = error && ((statusCode && statusCode >= 500) || isMfaRequiredError(error)); diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx new file mode 100644 index 000000000..f08b9b4d0 --- /dev/null +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx @@ -0,0 +1,128 @@ +import type { StepUpAuthenticator } from '@auth0/universal-components-core'; + +import { Button } from '@/components/ui/button'; +import { List, ListItem } from '@/components/ui/list'; +import { Spinner } from '@/components/ui/spinner'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { cn } from '@/lib/utils'; + +interface StepUpAuthenticatorListProps { + authenticators: StepUpAuthenticator[]; + onSelectAuthenticator: (auth: StepUpAuthenticator) => void; + onCancel: () => void; + isChallenging: boolean; + challengingAuthenticatorId: string | null; +} + +/** + * Derives a human-readable display name for an authenticator. + * Uses `name` field first; falls back to the type-based translation key. + */ +function getAuthenticatorDisplayName( + auth: StepUpAuthenticator, + t: (key: string) => string, +): string { + if (auth.name) return auth.name; + + const typeKey = `error.mfa.authenticator_type.${auth.authenticatorType}`; + return t(typeKey); +} + +/** + * Formats an ISO date string to a locale-friendly display date. + */ +function formatDate(isoDate: string | undefined): string | undefined { + if (!isoDate) return undefined; + try { + return new Date(isoDate).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return undefined; + } +} + +/** + * StepUpAuthenticatorList + * + * Displays the list of enrolled authenticators for the step-up challenge flow. + * The user picks one authenticator to verify with by clicking the "Verify" button. + */ +export function StepUpAuthenticatorList({ + authenticators, + onSelectAuthenticator, + onCancel, + isChallenging, + challengingAuthenticatorId, +}: StepUpAuthenticatorListProps) { + const { t } = useTranslator('common'); + + return ( +
    +

    + {t('error.mfa.subtitle')} +

    + + + {authenticators.map((auth) => { + const displayName = getAuthenticatorDisplayName(auth, t); + const formattedDate = formatDate(auth.createdAt); + const isCurrentlyChallenging = challengingAuthenticatorId === auth.id; + + return ( + +
    + + {displayName} + + {formattedDate && ( + + {t('error.mfa.registered_on').replace('${date}', formattedDate)} + + )} +
    + + +
    + ); + })} +
    + +
    + +
    +
    + ); +} diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx new file mode 100644 index 000000000..0b1df2829 --- /dev/null +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx @@ -0,0 +1,140 @@ +import type { ChallengeResponse } from '@auth0/universal-components-core'; +import * as React from 'react'; +import { useForm } from 'react-hook-form'; + +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { OTPField } from '@/components/ui/otp-field'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { cn } from '@/lib/utils'; + +interface StepUpChallengeFormProps { + challengeResponse: ChallengeResponse; + onVerify: (code: string) => Promise; + onBack: () => void; + isVerifying: boolean; + error: string | null; +} + +type OtpForm = { + userOtp: string; +}; + +/** + * StepUpChallengeForm + * + * Displayed during the VERIFY phase of the step-up challenge flow. + * A copy of OTPVerificationForm adapted to call verify() on the step-up service + * rather than confirmEnrollment() on the MFA management service. + * + * Handles both OTP (TOTP) and OOB (email/SMS/push) challenge types. + */ +export function StepUpChallengeForm({ + challengeResponse, + onVerify, + onBack, + isVerifying, + error, +}: StepUpChallengeFormProps) { + const { t } = useTranslator('common'); + const form = useForm({ mode: 'onChange' }); + const userOtp = form.watch('userOtp'); + const otpInputRef = React.useRef(null); + + React.useEffect(() => { + otpInputRef.current?.focus(); + }, []); + + const handleSubmit = async (data: OtpForm) => { + await onVerify(data.userOtp); + // Always reset so the user gets a blank field to re-enter on failure. + // On success the dialog closes, so the reset is a no-op. + form.reset(); + }; + + const isOtp = challengeResponse.challengeType === 'otp'; + const instruction = isOtp ? t('error.mfa.otp_instruction') : t('error.mfa.oob_instruction'); + + const buttonText = isVerifying ? t('error.mfa.verifying') : t('error.mfa.verify_button'); + + return ( +
    +
    + +

    + {instruction} +

    + + ( + + + {t('error.mfa.enter_code_label')} + + + + + + + )} + /> + + {error && ( +

    + {t('error.mfa.verify_error')} +

    + )} + +
    + + + +
    + + +
    + ); +} diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx new file mode 100644 index 000000000..8245aa208 --- /dev/null +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx @@ -0,0 +1,247 @@ +import { + FACTOR_TYPE_EMAIL, + createEmailContactSchema, + createSmsContactSchema, + type EmailContactForm, + type SmsContactForm, + getComponentStyles, + type CreateAuthenticationMethodResponseContent, + type MFAType, +} from '@auth0/universal-components-core'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { MailIcon, SmartphoneIcon } from 'lucide-react'; +import * as React from 'react'; +import { useForm } from 'react-hook-form'; + +import { OTPVerificationForm } from '@/components/auth0/my-account/shared/mfa/otp-verification-form'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Spinner } from '@/components/ui/spinner'; +import { TextField } from '@/components/ui/text-field'; +import { useContactEnrollment } from '@/hooks/my-account/use-contact-enrollment'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { ENTER_CONTACT, ENTER_OTP } from '@/lib/constants/my-account/mfa/mfa-constants'; +import type { ENROLL, CONFIRM } from '@/lib/constants/my-account/mfa/mfa-constants'; +import { cn } from '@/lib/utils'; + +type ContactForm = EmailContactForm | SmsContactForm; + +const PHASES = { + ENTER_CONTACT: ENTER_CONTACT, + ENTER_OTP: ENTER_OTP, +} as const; + +type Phase = (typeof PHASES)[keyof typeof PHASES]; + +interface StepUpContactInputFormProps { + factorType: MFAType; + enrollMfa: ( + factorType: MFAType, + options: Record, + ) => Promise; + confirmEnrollment: ( + factorType: MFAType, + authSession: string, + authenticationMethodId: string, + options: { userOtpCode?: string }, + ) => Promise; + onError: (error: Error, stage: typeof ENROLL | typeof CONFIRM) => void; + onSuccess: () => void; + onClose: () => void; + schema?: { email?: RegExp; phone?: RegExp }; +} + +/** + * StepUpContactInputForm + * + * Copy of ContactInputForm for the step-up enrollment flow. + * Receives `enrollMfa` and `confirmEnrollment` adapters from the parent + * (`StepUpEnrollmentSetupForm`) that call the step-up API service methods + * (`enroll()` and `verify()`) instead of the My Account API. + */ +export function StepUpContactInputForm({ + factorType, + enrollMfa, + onError, + confirmEnrollment, + onSuccess, + onClose, + schema, +}: StepUpContactInputFormProps) { + const [phase, setPhase] = React.useState(ENTER_CONTACT); + const { t } = useTranslator('mfa'); + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => + getComponentStyles( + { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + isDarkMode, + ), + [isDarkMode], + ); + + const { onSubmitContact, loading, contactData, setContactData } = useContactEnrollment({ + factorType, + enrollMfa, + onError, + }); + + const ContactSchema = React.useMemo(() => { + return factorType === FACTOR_TYPE_EMAIL + ? createEmailContactSchema(t('errors.invalid_email'), schema?.email) + : createSmsContactSchema(t('errors.invalid_phone_number'), schema?.phone); + }, [factorType, t, schema]); + + const form = useForm({ + resolver: zodResolver(ContactSchema), + mode: 'onTouched', + reValidateMode: 'onChange', + defaultValues: { contact: contactData.contact || '' }, + }); + + const handleCancel = () => { + form.reset(); + setContactData({ contact: '', authSession: '', authenticationMethodId: '' }); + onClose?.(); + }; + + const handleBack = React.useCallback(() => { + setPhase(ENTER_CONTACT); + }, []); + + const handleSubmit = React.useCallback( + async (data: ContactForm) => { + await onSubmitContact(data); + setPhase(ENTER_OTP); + }, + [onSubmitContact], + ); + + const renderContactScreen = () => ( +
    +
    + {loading ? ( +
    + +
    + ) : ( + <> +

    + {factorType === FACTOR_TYPE_EMAIL + ? t('enrollment_form.enroll_email_description') + : t('enrollment_form.enroll_sms_description')} +

    + +
    +
    + + ( + + + {factorType === FACTOR_TYPE_EMAIL + ? t('enrollment_form.email_address') + : t('enrollment_form.phone_number')} + + +
    + } + placeholder={ + factorType === FACTOR_TYPE_EMAIL + ? t('enrollment_form.enroll_email_placeholder') + : t('enrollment_form.enroll_sms_placeholder') + } + error={Boolean(form.formState.errors.contact)} + aria-invalid={Boolean(form.formState.errors.contact)} + {...field} + /> + + + + )} + /> +
    + + +
    + + +
    + + )} +
    +
    + ); + + const renderOtpScreen = () => ( + + ); + + return phase === ENTER_CONTACT ? renderContactScreen() : renderOtpScreen(); +} diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx new file mode 100644 index 000000000..247d97fb3 --- /dev/null +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx @@ -0,0 +1,383 @@ +import { + type MFAType, + type EnrollmentFactor, + type CreateAuthenticationMethodResponseContent, + FACTOR_TYPE_EMAIL, + FACTOR_TYPE_PHONE, + FACTOR_TYPE_TOTP, + FACTOR_TYPE_PUSH_NOTIFICATION, + FACTOR_TYPE_RECOVERY_CODE, +} from '@auth0/universal-components-core'; +import type { StepUpApiService } from '@auth0/universal-components-core'; +import * as React from 'react'; + +import AppleLogo from '@/assets/icons/apple-logo'; +import GoogleLogo from '@/assets/icons/google-logo'; +import { StepUpContactInputForm } from '@/components/auth0/shared/mfa-step-up/step-up-contact-input-form'; +import { StepUpQRCodeEnrollmentForm } from '@/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { List, ListItem } from '@/components/ui/list'; +import { Spinner } from '@/components/ui/spinner'; +import { useRecoveryCodeGeneration } from '@/hooks/my-account/use-recovery-code'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import type { ENROLL, CONFIRM } from '@/lib/constants/my-account/mfa/mfa-constants'; +import { + ENTER_QR, + ENTER_CONTACT, + QR_PHASE_INSTALLATION, + SHOW_RECOVERY_CODE, +} from '@/lib/constants/my-account/mfa/mfa-constants'; +import { cn } from '@/lib/utils'; + +type EnrollmentFormPhase = + | 'PICK' + | typeof ENTER_CONTACT + | typeof ENTER_QR + | typeof QR_PHASE_INSTALLATION + | typeof SHOW_RECOVERY_CODE; + +/** + * Maps EnrollmentFactor.type (from step-up API) → MFAType (My Account API / UI components). + */ +function mapEnrollmentFactorTypeToMFAType(type: string): MFAType | null { + const map: Record = { + otp: FACTOR_TYPE_TOTP, + push: FACTOR_TYPE_PUSH_NOTIFICATION, + sms: FACTOR_TYPE_PHONE, + email: FACTOR_TYPE_EMAIL, + 'recovery-code': FACTOR_TYPE_RECOVERY_CODE, + }; + return map[type] ?? null; +} + +/** + * Maps MFAType (My Account / UI) → step-up API factorType (used in enroll() params). + */ +function mapMFATypeToStepUpFactorType( + mfaType: MFAType, +): 'otp' | 'sms' | 'email' | 'push' | 'voice' { + const map: Record = { + [FACTOR_TYPE_TOTP]: 'otp', + [FACTOR_TYPE_PUSH_NOTIFICATION]: 'push', + [FACTOR_TYPE_PHONE]: 'sms', + [FACTOR_TYPE_EMAIL]: 'email', + }; + return map[mfaType] ?? 'otp'; +} + +interface StepUpEnrollmentSetupFormProps { + mfaToken: string; + enrollmentFactors: EnrollmentFactor[]; + stepUpService: StepUpApiService; + onSuccess: () => void; + onClose: () => void; +} + +/** + * StepUpEnrollmentSetupForm + * + * Copy + adapted version of UserMFASetupForm for the step-up enrollment flow. + * + * Key differences: + * - No Dialog wrapper (rendered inside GateKeeper's dialog) + * - Adds a PICK phase: shows the list of available enrollment factors first + * - Builds `enrollMfa` and `confirmEnrollment` adapters that translate + * the My Account API interface expected by sub-forms into step-up + * service calls (stepUpService.enroll() / stepUpService.verify()) + * - Passes selected MFAType → StepUpContactInputForm or StepUpQRCodeEnrollmentForm + */ +export function StepUpEnrollmentSetupForm({ + mfaToken, + enrollmentFactors, + stepUpService, + onSuccess, + onClose, +}: StepUpEnrollmentSetupFormProps) { + const { t } = useTranslator('common'); + const tMfa = useTranslator('mfa').t; + + const [phase, setPhase] = React.useState('PICK'); + const [selectedFactor, setSelectedFactor] = React.useState(null); + + // We repurpose `auth_session` to carry the oobCode back from enroll → to verify. + // This ref stores the oobCode between the enroll and verify calls. + const oobCodeRef = React.useRef(null); + + /** + * enrollMfa adapter + * Translates (MFAType, options) → stepUpService.enroll() + * Returns a normalized object that matches what sub-component hooks expect: + * auth_session → carries oobCode (for OOB verify call) + * barcode_uri → barcodeUri from step-up enroll response + * id → authenticator id + * manual_input_code → secret for TOTP manual entry + */ + const enrollMfa = React.useCallback( + async ( + factorType: MFAType, + options: Record, + ): Promise => { + const stepUpFactorType = mapMFATypeToStepUpFactorType(factorType); + + type AnyParams = Parameters[0]; + let params: AnyParams; + + if (stepUpFactorType === 'sms' || stepUpFactorType === 'voice') { + params = { + mfaToken, + factorType: stepUpFactorType, + phoneNumber: options.phone_number ?? '', + }; + } else if (stepUpFactorType === 'email') { + params = { mfaToken, factorType: 'email', email: options.email ?? '' }; + } else if (stepUpFactorType === 'push') { + params = { mfaToken, factorType: 'push' }; + } else { + params = { mfaToken, factorType: 'otp' }; + } + + const response = await stepUpService.enroll(params); + + // For OOB factors the oobCode must reach verify(); store it in auth_session. + const oobCode = 'oobCode' in response ? (response.oobCode ?? '') : ''; + oobCodeRef.current = oobCode || null; + + const barcodeUri = 'barcodeUri' in response ? (response.barcodeUri ?? '') : ''; + const secret = 'secret' in response ? (response.secret ?? '') : ''; + + // Return shape compatible with CreateAuthenticationMethodResponseContent + return { + id: response.id ?? '', + auth_session: oobCode, // repurposed: carries oobCode for OOB verify + barcode_uri: barcodeUri, + manual_input_code: secret, + } as unknown as CreateAuthenticationMethodResponseContent; + }, + [mfaToken, stepUpService], + ); + + /** + * confirmEnrollment adapter + * Translates (factorType, authSession, authId, { userOtpCode }) → stepUpService.verify() + * + * For OTP/TOTP: verify({ mfaToken, otp: userOtpCode }) + * For OOB: verify({ mfaToken, oobCode: authSession, bindingCode: userOtpCode }) + * (authSession carries the oobCode from the enroll response via enrollMfa adapter above) + */ + const confirmEnrollment = React.useCallback( + async ( + factorType: MFAType, + authSession: string, + _authenticationMethodId: string, + options: { userOtpCode?: string }, + ): Promise => { + const isOtp = factorType === FACTOR_TYPE_TOTP; + + if (isOtp) { + await stepUpService.verify({ mfaToken, otp: options.userOtpCode }); + } else { + await stepUpService.verify({ + mfaToken, + oobCode: authSession, + bindingCode: options.userOtpCode, + }); + } + + return {}; + }, + [mfaToken, stepUpService], + ); + + const handleEnrollError = React.useCallback( + (_error: Error, _stage: typeof ENROLL | typeof CONFIRM) => { + // Errors are displayed inside the sub-forms; no top-level toast needed here + }, + [], + ); + + // Fake dummy hook call: useRecoveryCodeGeneration is only invoked if SHOW_RECOVERY_CODE is needed. + // Keep the hook call unconditional (rules of hooks) but we skip RECOVERY_CODE in step-up for now. + const { fetchRecoveryCode, loading: recoveryLoading } = useRecoveryCodeGeneration({ + factorType: selectedFactor ?? FACTOR_TYPE_RECOVERY_CODE, + enrollMfa, + onError: handleEnrollError, + onClose, + }); + + React.useEffect(() => { + if (phase === SHOW_RECOVERY_CODE) { + fetchRecoveryCode(); + } + }, [phase]); + + const handlePickFactor = (enrollmentFactor: EnrollmentFactor) => { + const mfaType = mapEnrollmentFactorTypeToMFAType(enrollmentFactor.type); + if (!mfaType) return; + + setSelectedFactor(mfaType); + + const phaseMap: Partial> = { + [FACTOR_TYPE_EMAIL]: ENTER_CONTACT, + [FACTOR_TYPE_PHONE]: ENTER_CONTACT, + [FACTOR_TYPE_PUSH_NOTIFICATION]: QR_PHASE_INSTALLATION, + [FACTOR_TYPE_TOTP]: ENTER_QR, + [FACTOR_TYPE_RECOVERY_CODE]: SHOW_RECOVERY_CODE, + }; + + setPhase(phaseMap[mfaType] ?? ENTER_CONTACT); + }; + + const renderPickPhase = () => ( +
    +

    + {t('error.mfa.enrollment_required')} +

    + + + {enrollmentFactors.map((factor) => { + const mfaType = mapEnrollmentFactorTypeToMFAType(factor.type); + const displayKey = `error.mfa.authenticator_type.${factor.type}`; + const displayName = t(displayKey); + + return ( + + {displayName} + + + ); + })} + + +
    + +
    +
    + ); + + const renderInstallationPhase = () => ( +
    +
    +

    + {tMfa('enrollment_form.show_otp.install_guardian_description')} +

    + +
    + + +
    +
    +
    + ); + + const renderRecoveryCodePhase = () => { + if (recoveryLoading) { + return ( +
    + +
    + ); + } + return null; + }; + + if (!selectedFactor || phase === 'PICK') { + return renderPickPhase(); + } + + switch (phase) { + case QR_PHASE_INSTALLATION: + return renderInstallationPhase(); + + case ENTER_CONTACT: + return ( + + ); + + case ENTER_QR: + return ( + + ); + + case SHOW_RECOVERY_CODE: + return renderRecoveryCodePhase(); + + default: + return null; + } +} diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx new file mode 100644 index 000000000..7672abc1d --- /dev/null +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx @@ -0,0 +1,187 @@ +import { + getComponentStyles, + FACTOR_TYPE_TOTP, + FACTOR_TYPE_PUSH_NOTIFICATION, + type MFAType, + type CreateAuthenticationMethodResponseContent, +} from '@auth0/universal-components-core'; +import * as React from 'react'; + +import { OTPVerificationForm } from '@/components/auth0/my-account/shared/mfa/otp-verification-form'; +import { CopyableTextField } from '@/components/auth0/shared/copyable-text-field'; +import { Button } from '@/components/ui/button'; +import { QRCodeDisplayer } from '@/components/ui/qr-code'; +import { Spinner } from '@/components/ui/spinner'; +import { useOtpEnrollment } from '@/hooks/my-account/use-otp-enrollment'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { QR_PHASE_ENTER_OTP, QR_PHASE_SCAN } from '@/lib/constants/my-account/mfa/mfa-constants'; +import type { ENROLL, CONFIRM } from '@/lib/constants/my-account/mfa/mfa-constants'; +import { cn } from '@/lib/utils'; + +const PHASES = { + SCAN: QR_PHASE_SCAN, + ENTER_OTP: QR_PHASE_ENTER_OTP, +} as const; + +type Phase = (typeof PHASES)[keyof typeof PHASES]; + +interface StepUpQRCodeEnrollmentFormProps { + factorType: MFAType; + enrollMfa: ( + factorType: MFAType, + options: Record, + ) => Promise; + confirmEnrollment: ( + factorType: MFAType, + authSession: string, + authenticationMethodId: string, + options: { userOtpCode?: string }, + ) => Promise; + onError: (error: Error, stage: typeof ENROLL | typeof CONFIRM) => void; + onSuccess: () => void; + onClose: () => void; +} + +/** + * StepUpQRCodeEnrollmentForm + * + * Copy of QRCodeEnrollmentForm for the step-up enrollment flow. + * Receives `enrollMfa` and `confirmEnrollment` adapters from the parent + * (`StepUpEnrollmentSetupForm`) that call the step-up API service methods + * (`enroll()` and `verify()`) instead of the My Account API. + */ +export function StepUpQRCodeEnrollmentForm({ + factorType, + enrollMfa, + confirmEnrollment, + onError, + onSuccess, + onClose, +}: StepUpQRCodeEnrollmentFormProps) { + const [phase, setPhase] = React.useState(QR_PHASE_SCAN); + const { t } = useTranslator('mfa'); + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => + getComponentStyles( + { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + isDarkMode, + ), + [isDarkMode], + ); + + const { fetchOtpEnrollment, otpData, resetOtpData, loading } = useOtpEnrollment({ + factorType, + enrollMfa, + onError, + onClose, + }); + + React.useEffect(() => { + if (!otpData?.barcodeUri) { + fetchOtpEnrollment(); + } + }, [otpData?.barcodeUri, fetchOtpEnrollment]); + + const handleContinue = React.useCallback(async () => { + if (factorType === FACTOR_TYPE_PUSH_NOTIFICATION) { + try { + await confirmEnrollment( + factorType, + otpData.authSession, + otpData.authenticationMethodId, + {}, + ); + onSuccess(); + resetOtpData(); + onClose(); + } catch (error) { + onError(error instanceof Error ? error : new Error('Unknown error'), 'confirm'); + } + } else { + setPhase(QR_PHASE_ENTER_OTP); + } + }, [factorType, otpData, confirmEnrollment, onSuccess, onError, resetOtpData, onClose]); + + const handleBack = React.useCallback(() => { + setPhase(QR_PHASE_SCAN); + }, []); + + const renderQrScreen = () => ( +
    + {loading ? ( +
    + +
    + ) : ( +
    +
    +
    + +
    +

    + {factorType === FACTOR_TYPE_TOTP + ? t('enrollment_form.show_otp.title') + : t('enrollment_form.show_auth0_guardian_title')} +

    +
    + +
    + + +
    + +
    + + +
    +
    +
    + )} +
    + ); + + const renderOtpScreen = () => ( + + ); + + return phase === QR_PHASE_SCAN ? renderQrScreen() : renderOtpScreen(); +} diff --git a/packages/react/src/hooks/auth0/shared/use-step-up-challenge.ts b/packages/react/src/hooks/auth0/shared/use-step-up-challenge.ts new file mode 100644 index 000000000..61caed2aa --- /dev/null +++ b/packages/react/src/hooks/auth0/shared/use-step-up-challenge.ts @@ -0,0 +1,128 @@ +import type { + StepUpAuthenticator, + ChallengeResponse, + StepUpApiService, +} from '@auth0/universal-components-core'; +import { useCallback, useState } from 'react'; + +export type StepUpChallengeState = 'LIST' | 'CHALLENGING' | 'VERIFY'; + +interface UseStepUpChallengeProps { + mfaToken: string; + stepUpService: StepUpApiService; + onSuccess: () => Promise; +} + +export interface UseStepUpChallengeResult { + state: StepUpChallengeState; + selectedAuthenticator: StepUpAuthenticator | null; + challengeResponse: ChallengeResponse | null; + isChallenging: boolean; + isVerifying: boolean; + error: string | null; + handleSelectAuthenticator: (auth: StepUpAuthenticator) => Promise; + handleVerify: (code: string) => Promise; + handleBack: () => void; + clearError: () => void; +} + +/** + * Stores the mfa_token that was active when a challenge was issued so that + * verify() always uses the token that matches the oob_code, even if the parent + * component receives a fresh mfa_required error with a new mfa_token before the + * user finishes entering their code. + */ +interface FrozenChallenge { + mfaToken: string; + response: ChallengeResponse; +} + +/** + * Manages the List → Challenge → Verify state machine for MFA step-up authentication. + */ +export function useStepUpChallenge({ + mfaToken, + stepUpService, + onSuccess, +}: UseStepUpChallengeProps): UseStepUpChallengeResult { + const [state, setState] = useState('LIST'); + const [selectedAuthenticator, setSelectedAuthenticator] = useState( + null, + ); + // Frozen at challenge-time so verify always uses the oob_code / mfa_token pair + // that Auth0 issued together, even if the parent re-renders with a fresh mfa_token. + const [frozenChallenge, setFrozenChallenge] = useState(null); + const [isChallenging, setIsChallenging] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [error, setError] = useState(null); + + const handleSelectAuthenticator = useCallback( + async (auth: StepUpAuthenticator) => { + setIsChallenging(true); + setError(null); + try { + const challengeType = auth.authenticatorType === 'otp' ? 'otp' : 'oob'; + const response = await stepUpService.challenge({ + mfaToken, + challengeType, + authenticatorId: auth.id, + }); + setSelectedAuthenticator(auth); + // Freeze the mfaToken used for this challenge so verify uses the same pair. + setFrozenChallenge({ mfaToken, response }); + setState('VERIFY'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start challenge'); + } finally { + setIsChallenging(false); + } + }, + [mfaToken, stepUpService], + ); + + const handleVerify = useCallback( + async (code: string) => { + if (!frozenChallenge) return; + const { mfaToken: frozenToken, response: challengeResponse } = frozenChallenge; + setIsVerifying(true); + setError(null); + try { + const params = + challengeResponse.challengeType === 'otp' + ? { mfaToken: frozenToken, otp: code } + : { mfaToken: frozenToken, oobCode: challengeResponse.oobCode, bindingCode: code }; + await stepUpService.verify(params); + await onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Verification failed'); + } finally { + setIsVerifying(false); + } + }, + [frozenChallenge, stepUpService, onSuccess], + ); + + const handleBack = useCallback(() => { + setState('LIST'); + setSelectedAuthenticator(null); + setFrozenChallenge(null); + setError(null); + }, []); + + const clearError = useCallback(() => { + setError(null); + }, []); + + return { + state, + selectedAuthenticator, + challengeResponse: frozenChallenge?.response ?? null, + isChallenging, + isVerifying, + error, + handleSelectAuthenticator, + handleVerify, + handleBack, + clearError, + }; +} From d8afde15ce3724e8213280a787f2238dedded3ff Mon Sep 17 00:00:00 2001 From: harish-sundar_akto Date: Tue, 3 Mar 2026 12:11:52 +0530 Subject: [PATCH 12/27] fix(react): add missing jsdoc @param and @returns to step-up components --- .../shared/mfa-step-up/step-up-authenticator-list.tsx | 7 +++++++ .../shared/mfa-step-up/step-up-challenge-form.tsx | 2 ++ .../shared/mfa-step-up/step-up-contact-input-form.tsx | 2 ++ .../mfa-step-up/step-up-enrollment-setup-form.tsx | 10 ++++++++-- .../mfa-step-up/step-up-qr-code-enrollment-form.tsx | 2 ++ .../src/hooks/auth0/shared/use-step-up-challenge.ts | 2 ++ 6 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx index f08b9b4d0..8af5cca8d 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx @@ -17,6 +17,9 @@ interface StepUpAuthenticatorListProps { /** * Derives a human-readable display name for an authenticator. * Uses `name` field first; falls back to the type-based translation key. + * @param auth - The authenticator to derive a display name for. + * @param t - Translation function. + * @returns Human-readable display name string. */ function getAuthenticatorDisplayName( auth: StepUpAuthenticator, @@ -30,6 +33,8 @@ function getAuthenticatorDisplayName( /** * Formats an ISO date string to a locale-friendly display date. + * @param isoDate - ISO date string to format. + * @returns Locale-friendly display date, or undefined if the input is absent or invalid. */ function formatDate(isoDate: string | undefined): string | undefined { if (!isoDate) return undefined; @@ -49,6 +54,8 @@ function formatDate(isoDate: string | undefined): string | undefined { * * Displays the list of enrolled authenticators for the step-up challenge flow. * The user picks one authenticator to verify with by clicking the "Verify" button. + * @param root0 - Component props. + * @returns Authenticator list element. */ export function StepUpAuthenticatorList({ authenticators, diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx index 0b1df2829..921a57959 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx @@ -35,6 +35,8 @@ type OtpForm = { * rather than confirmEnrollment() on the MFA management service. * * Handles both OTP (TOTP) and OOB (email/SMS/push) challenge types. + * @param root0 - Component props. + * @returns Challenge form element. */ export function StepUpChallengeForm({ challengeResponse, diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx index 8245aa208..0a5b92067 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx @@ -66,6 +66,8 @@ interface StepUpContactInputFormProps { * Receives `enrollMfa` and `confirmEnrollment` adapters from the parent * (`StepUpEnrollmentSetupForm`) that call the step-up API service methods * (`enroll()` and `verify()`) instead of the My Account API. + * @param root0 - Component props. + * @returns Contact input form element. */ export function StepUpContactInputForm({ factorType, diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx index 247d97fb3..89eb494fd 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx @@ -39,6 +39,8 @@ type EnrollmentFormPhase = /** * Maps EnrollmentFactor.type (from step-up API) → MFAType (My Account API / UI components). + * @param type - EnrollmentFactor type string from the step-up API. + * @returns Corresponding MFAType, or null if the type is not recognised. */ function mapEnrollmentFactorTypeToMFAType(type: string): MFAType | null { const map: Record = { @@ -53,6 +55,8 @@ function mapEnrollmentFactorTypeToMFAType(type: string): MFAType | null { /** * Maps MFAType (My Account / UI) → step-up API factorType (used in enroll() params). + * @param mfaType - My Account MFA type. + * @returns Corresponding step-up API factor type string. */ function mapMFATypeToStepUpFactorType( mfaType: MFAType, @@ -86,6 +90,8 @@ interface StepUpEnrollmentSetupFormProps { * the My Account API interface expected by sub-forms into step-up * service calls (stepUpService.enroll() / stepUpService.verify()) * - Passes selected MFAType → StepUpContactInputForm or StepUpQRCodeEnrollmentForm + * @param root0 - Component props. + * @returns Enrollment setup form element. */ export function StepUpEnrollmentSetupForm({ mfaToken, @@ -104,7 +110,7 @@ export function StepUpEnrollmentSetupForm({ // This ref stores the oobCode between the enroll and verify calls. const oobCodeRef = React.useRef(null); - /** + /* * enrollMfa adapter * Translates (MFAType, options) → stepUpService.enroll() * Returns a normalized object that matches what sub-component hooks expect: @@ -157,7 +163,7 @@ export function StepUpEnrollmentSetupForm({ [mfaToken, stepUpService], ); - /** + /* * confirmEnrollment adapter * Translates (factorType, authSession, authId, { userOtpCode }) → stepUpService.verify() * diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx index 7672abc1d..b8896ed85 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx @@ -50,6 +50,8 @@ interface StepUpQRCodeEnrollmentFormProps { * Receives `enrollMfa` and `confirmEnrollment` adapters from the parent * (`StepUpEnrollmentSetupForm`) that call the step-up API service methods * (`enroll()` and `verify()`) instead of the My Account API. + * @param root0 - Component props. + * @returns QR code enrollment form element. */ export function StepUpQRCodeEnrollmentForm({ factorType, diff --git a/packages/react/src/hooks/auth0/shared/use-step-up-challenge.ts b/packages/react/src/hooks/auth0/shared/use-step-up-challenge.ts index 61caed2aa..309e41cd8 100644 --- a/packages/react/src/hooks/auth0/shared/use-step-up-challenge.ts +++ b/packages/react/src/hooks/auth0/shared/use-step-up-challenge.ts @@ -39,6 +39,8 @@ interface FrozenChallenge { /** * Manages the List → Challenge → Verify state machine for MFA step-up authentication. + * @param root0 - Hook options. + * @returns Step-up challenge state and action handlers. */ export function useStepUpChallenge({ mfaToken, From a6ee38938b6136717b4d063c063b547f2c14bb77 Mon Sep 17 00:00:00 2001 From: harish-sundar_akto Date: Tue, 3 Mar 2026 12:17:30 +0530 Subject: [PATCH 13/27] test(react): update gatekeeper mfa tests to match step-up output --- .../auth0/shared/__tests__/gatekeeper.test.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx b/packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx index 29e7669e3..93448f04f 100644 --- a/packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx +++ b/packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx @@ -164,8 +164,8 @@ describe('GateKeeper', () => { }); await waitFor(() => { - expect(screen.getByText('otp')).toBeInTheDocument(); - expect(screen.getByText('sms')).toBeInTheDocument(); + expect(screen.getByText('error.mfa.authenticator_type.otp')).toBeInTheDocument(); + expect(screen.getByText('error.mfa.authenticator_type.sms')).toBeInTheDocument(); }); }); @@ -291,13 +291,12 @@ describe('GateKeeper', () => { await waitFor(() => { expect(screen.getByText('Test Authenticator')).toBeInTheDocument(); - expect(screen.getByText(/Type: otp/)).toBeInTheDocument(); - expect(screen.getByText(/Active: Yes/)).toBeInTheDocument(); }); await waitFor(() => { - expect(screen.getByText('webauthn-roaming')).toBeInTheDocument(); - expect(screen.getByText(/Active: No/)).toBeInTheDocument(); + expect( + screen.getByText('error.mfa.authenticator_type.webauthn-roaming'), + ).toBeInTheDocument(); }); }); }); From f6a16beef76b443f92dc5cdf891fe8d000ce7807 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Tue, 3 Mar 2026 22:35:13 +0530 Subject: [PATCH 14/27] fix(react): update list ui for step up --- .../components/auth0/shared/gatekeeper.tsx | 351 ++++++++++-------- .../step-up-authenticator-list.tsx | 102 +++-- .../mfa-step-up/step-up-challenge-form.tsx | 44 +-- .../step-up-contact-input-form.tsx | 8 +- .../step-up-enrollment-setup-form.tsx | 33 +- .../step-up-qr-code-enrollment-form.tsx | 14 +- .../auth0/shared/use-step-up-challenge.ts | 130 ------- .../src/hooks/shared/use-step-up-challenge.ts | 134 +++++++ 8 files changed, 412 insertions(+), 404 deletions(-) delete mode 100644 packages/react/src/hooks/auth0/shared/use-step-up-challenge.ts create mode 100644 packages/react/src/hooks/shared/use-step-up-challenge.ts diff --git a/packages/react/src/components/auth0/shared/gatekeeper.tsx b/packages/react/src/components/auth0/shared/gatekeeper.tsx index 36ce54b4e..130fd53ab 100644 --- a/packages/react/src/components/auth0/shared/gatekeeper.tsx +++ b/packages/react/src/components/auth0/shared/gatekeeper.tsx @@ -1,10 +1,9 @@ -import { - getStatusCode, - isMfaRequiredError, - type MfaRequiredError, - type StepUpAuthenticator, - type EnrollmentFactor, +import type { + MfaRequiredError, + StepUpAuthenticator, + EnrollmentFactor, } from '@auth0/universal-components-core'; +import { getStatusCode, isMfaRequiredError } from '@auth0/universal-components-core'; import { useQuery } from '@tanstack/react-query'; import { RefreshCcw } from 'lucide-react'; import React, { useState, useMemo } from 'react'; @@ -14,12 +13,21 @@ import { StepUpChallengeForm } from '@/components/auth0/shared/mfa-step-up/step- import { StepUpEnrollmentSetupForm } from '@/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form'; import { Button } from '@/components/ui/button'; import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Separator } from '@/components/ui/separator'; import { Spinner } from '@/components/ui/spinner'; -import { useStepUpChallenge } from '@/hooks/auth0/shared/use-step-up-challenge'; import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useStepUpChallenge } from '@/hooks/shared/use-step-up-challenge'; import { useTranslator } from '@/hooks/shared/use-translator'; +type MfaFetchState = 'LOADING' | 'ERROR' | 'ENROLLMENT' | 'AUTHENTICATORS' | 'EMPTY'; + interface GateKeeperProps { isLoading?: boolean; error: unknown; @@ -27,6 +35,17 @@ interface GateKeeperProps { children: React.ReactNode; } +/** + * Extracts the mfa_token from an MFA-required error. + * @param error - The error to extract the token from. + * @returns The mfa_token string, or null. + */ +function extractMfaToken(error: unknown): string | null { + if (!error || !isMfaRequiredError(error)) return null; + const err = error as MfaRequiredError & { body?: { mfa_token?: string } }; + return err.mfa_token ?? err.body?.mfa_token ?? null; +} + /** * Renders error fallback UI with retry button. * @@ -72,34 +91,30 @@ function ErrorFallback({ } /** - * GateKeeper guards children from rendering during loading/error states. - * Handles: - * - MFA errors → Shows MFA Step up dialog, then retries on completion - * - 500+ errors → Shows blocking fallback UI with retry + * MFA step-up dialog. Fetches authenticators/enrollment factors, + * handles challenge + verify flow, and renders the dialog UI. * * @param props - Component props. - * @param props.isLoading - Whether content is loading. - * @param props.error - Error object, if any. - * @param props.onRetry - Retry handler. - * @param props.children - Child elements to render on success. - * @returns GateKeeper element. + * @param props.error - The MFA-required error. + * @param props.onSuccess - Callback after successful verification. + * @param props.onClose - Callback when the dialog is dismissed. + * @returns MFA step-up dialog element. */ -export function GateKeeper({ isLoading = false, error, onRetry, children }: GateKeeperProps) { +function MfaStepUpDialog({ + error, + onSuccess, + onClose, +}: { + error: unknown; + onSuccess: () => Promise; + onClose: () => void; +}): React.JSX.Element { const { t } = useTranslator('common'); const { coreClient } = useCoreClient(); - const [isRetrying, setIsRetrying] = useState(false); - const [isMfaDialogOpen, setIsMfaDialogOpen] = useState(true); - - const mfaToken = useMemo(() => { - if (error && isMfaRequiredError(error)) { - const err = error as MfaRequiredError & { body?: { error?: string; mfa_token?: string } }; - const token = err.mfa_token ?? err.body?.mfa_token ?? null; - return token; - } - return null; - }, [error]); + const mfaToken = useMemo(() => extractMfaToken(error), [error]); const isProxyMode = coreClient?.isProxyMode() ?? false; + const stepUpService = coreClient?.getStepUpApiService(); const { data: enrollmentFactors, @@ -107,18 +122,8 @@ export function GateKeeper({ isLoading = false, error, onRetry, children }: Gate error: fetchEnrollmentFactorsError, } = useQuery({ queryKey: ['mfa-enrollment-factors', mfaToken], - queryFn: async () => { - const stepUpService = coreClient!.getStepUpApiService(); - return stepUpService.getEnrollmentFactors(mfaToken!); - }, - enabled: Boolean( - !isProxyMode && - error && - isMfaRequiredError(error) && - mfaToken && - coreClient && - isMfaDialogOpen, - ), + queryFn: () => stepUpService!.getEnrollmentFactors(mfaToken!), + enabled: Boolean(!isProxyMode && mfaToken && stepUpService), retry: false, }); @@ -130,33 +135,15 @@ export function GateKeeper({ isLoading = false, error, onRetry, children }: Gate error: fetchAuthenticatorsError, } = useQuery({ queryKey: ['mfa-authenticators', mfaToken], - queryFn: async () => { - const stepUpService = coreClient!.getStepUpApiService(); - return stepUpService.getAuthenticators(mfaToken!); - }, + queryFn: () => stepUpService!.getAuthenticators(mfaToken!), enabled: Boolean( - error && - isMfaRequiredError(error) && - isMfaDialogOpen && - coreClient && - mfaToken && + mfaToken && + stepUpService && (isProxyMode || (!needsEnrollment && enrollmentFactors !== undefined)), ), retry: false, }); - const stepUpService = coreClient?.getStepUpApiService(); - - const handleChallengeSuccess = React.useCallback(async () => { - setIsRetrying(true); - try { - await onRetry(); - setIsMfaDialogOpen(false); - } finally { - setIsRetrying(false); - } - }, [onRetry]); - const { state: challengeState, selectedAuthenticator, @@ -169,15 +156,145 @@ export function GateKeeper({ isLoading = false, error, onRetry, children }: Gate handleBack: handleChallengeBack, } = useStepUpChallenge({ mfaToken: mfaToken ?? '', - stepUpService: stepUpService!, - onSuccess: handleChallengeSuccess, + onSuccess, }); + const fetchState: MfaFetchState = useMemo(() => { + if (!isProxyMode) { + if (isFetchingEnrollmentFactors) return 'LOADING'; + if (fetchEnrollmentFactorsError) return 'ERROR'; + if (needsEnrollment) return 'ENROLLMENT'; + } + if (isFetchingAuthenticators) return 'LOADING'; + if (fetchAuthenticatorsError) return 'ERROR'; + if (authenticators?.length) return 'AUTHENTICATORS'; + return 'EMPTY'; + }, [ + isProxyMode, + isFetchingEnrollmentFactors, + fetchEnrollmentFactorsError, + needsEnrollment, + isFetchingAuthenticators, + fetchAuthenticatorsError, + authenticators, + ]); + + const dialogTitle = + fetchState === 'ENROLLMENT' ? t('error.mfa.enroll_title') : t('error.mfa.title'); + + const renderContent = () => { + if (fetchState === 'LOADING') { + return ( +
    + +
    + ); + } + + if (fetchState === 'ERROR') { + return ( +
    + {t('error.mfa.fetch_failed')} +
    + ); + } + + if (fetchState === 'EMPTY') { + return ( +
    + {t('error.mfa.no_authenticators')} +
    + ); + } + + if (fetchState === 'ENROLLMENT' && enrollmentFactors) { + return ( + + ); + } + + if (fetchState === 'AUTHENTICATORS' && challengeState === 'VERIFY' && challengeResponse) { + return ( + + ); + } + + if (fetchState === 'AUTHENTICATORS' && authenticators) { + return ( + + ); + } + + return null; + }; + + const isListScreen = fetchState === 'AUTHENTICATORS' && challengeState !== 'VERIFY'; + + return ( + !open && onClose()}> + + + {dialogTitle} + {isListScreen && {t('error.mfa.subtitle')}} + + + {renderContent()} + + + ); +} + +/** + * GateKeeper guards children from rendering during loading/error states. + * Handles: + * - MFA errors → Shows MFA step-up dialog, then retries on completion + * - 500+ errors → Shows blocking fallback UI with retry + * + * @param props - Component props. + * @param props.isLoading - Whether content is loading. + * @param props.error - Error object, if any. + * @param props.onRetry - Retry handler. + * @param props.children - Child elements to render on success. + * @returns GateKeeper element. + */ +export function GateKeeper({ isLoading = false, error, onRetry, children }: GateKeeperProps) { + const { t } = useTranslator('common'); + const [isRetrying, setIsRetrying] = useState(false); + const [isMfaDismissed, setIsMfaDismissed] = useState(false); + + const handleMfaSuccess = React.useCallback(async () => { + setIsRetrying(true); + try { + await onRetry(); + setIsMfaDismissed(true); + } finally { + setIsRetrying(false); + } + }, [onRetry]); + const handleRetry = async () => { setIsRetrying(true); try { await onRetry(); - setIsMfaDialogOpen(true); + setIsMfaDismissed(false); } finally { setIsRetrying(false); } @@ -191,105 +308,13 @@ export function GateKeeper({ isLoading = false, error, onRetry, children }: Gate ); } - if (error && isMfaRequiredError(error) && isMfaDialogOpen) { - const getMfaFetchState = (): - | 'LOADING' - | 'ERROR' - | 'ENROLLMENT' - | 'AUTHENTICATORS' - | 'EMPTY' => { - if (!isProxyMode) { - if (isFetchingEnrollmentFactors) return 'LOADING'; - if (fetchEnrollmentFactorsError) return 'ERROR'; - if (needsEnrollment) return 'ENROLLMENT'; - } - if (isFetchingAuthenticators) return 'LOADING'; - if (fetchAuthenticatorsError) return 'ERROR'; - if (authenticators?.length) return 'AUTHENTICATORS'; - return 'EMPTY'; - }; - - const fetchState = getMfaFetchState(); - - const dialogTitle = (() => { - if (fetchState === 'ENROLLMENT') return t('error.mfa.enroll_title'); - if (challengeState === 'VERIFY') return t('error.mfa.title'); - return t('error.mfa.title'); - })(); - - const renderDialogContent = () => { - if (fetchState === 'LOADING') { - return ( -
    - -
    - ); - } - - if (fetchState === 'ERROR') { - return ( -
    - {t('error.mfa.fetch_failed')} -
    - ); - } - - if (fetchState === 'EMPTY') { - return ( -
    - {t('error.mfa.no_authenticators')} -
    - ); - } - - if (fetchState === 'ENROLLMENT' && enrollmentFactors) { - return ( - setIsMfaDialogOpen(false)} - /> - ); - } - - if (fetchState === 'AUTHENTICATORS' && challengeState === 'VERIFY' && challengeResponse) { - return ( - - ); - } - - if (fetchState === 'AUTHENTICATORS' && authenticators) { - return ( - setIsMfaDialogOpen(false)} - isChallenging={isChallenging} - challengingAuthenticatorId={selectedAuthenticator?.id ?? null} - /> - ); - } - - return null; - }; - + if (error && isMfaRequiredError(error) && !isMfaDismissed) { return ( - - - - {dialogTitle} - - {renderDialogContent()} - - + setIsMfaDismissed(true)} + /> ); } diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx index 8af5cca8d..edfab8354 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx @@ -1,10 +1,11 @@ import type { StepUpAuthenticator } from '@auth0/universal-components-core'; import { Button } from '@/components/ui/button'; +import { Card, CardAction, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { List, ListItem } from '@/components/ui/list'; +import { Separator } from '@/components/ui/separator'; import { Spinner } from '@/components/ui/spinner'; import { useTranslator } from '@/hooks/shared/use-translator'; -import { cn } from '@/lib/utils'; interface StepUpAuthenticatorListProps { authenticators: StepUpAuthenticator[]; @@ -15,19 +16,17 @@ interface StepUpAuthenticatorListProps { } /** - * Derives a human-readable display name for an authenticator. - * Uses `name` field first; falls back to the type-based translation key. - * @param auth - The authenticator to derive a display name for. + * Returns the translated display name for an authenticator. + * @param auth - The authenticator. * @param t - Translation function. - * @returns Human-readable display name string. + * @returns Display name string. */ function getAuthenticatorDisplayName( auth: StepUpAuthenticator, t: (key: string) => string, ): string { - if (auth.name) return auth.name; - - const typeKey = `error.mfa.authenticator_type.${auth.authenticatorType}`; + const key = auth.type ?? auth.authenticatorType; + const typeKey = `error.mfa.authenticator_type.${key}`; return t(typeKey); } @@ -50,11 +49,15 @@ function formatDate(isoDate: string | undefined): string | undefined { } /** - * StepUpAuthenticatorList + * Displays enrolled authenticators as a list of cards for the step-up challenge flow. + * Each card shows the authenticator name and registration date, with a Verify action on the right. * - * Displays the list of enrolled authenticators for the step-up challenge flow. - * The user picks one authenticator to verify with by clicking the "Verify" button. - * @param root0 - Component props. + * @param props - Component props. + * @param props.authenticators - List of enrolled authenticators. + * @param props.onSelectAuthenticator - Callback when the user picks an authenticator to verify. + * @param props.onCancel - Callback when the user cancels. + * @param props.isChallenging - Whether a challenge is in progress. + * @param props.challengingAuthenticatorId - ID of the authenticator currently being challenged. * @returns Authenticator list element. */ export function StepUpAuthenticatorList({ @@ -68,61 +71,50 @@ export function StepUpAuthenticatorList({ return (
    -

    - {t('error.mfa.subtitle')} -

    - - + {authenticators.map((auth) => { const displayName = getAuthenticatorDisplayName(auth, t); const formattedDate = formatDate(auth.createdAt); const isCurrentlyChallenging = challengingAuthenticatorId === auth.id; return ( - -
    - - {displayName} - - {formattedDate && ( - - {t('error.mfa.registered_on').replace('${date}', formattedDate)} - - )} -
    - - + + + + {displayName} + {formattedDate && ( + + {t('error.mfa.registered_on').replace('${date}', formattedDate)} + + )} + + + + + ); })}
    -
    + + +
    +
    + +
    + - + +
    diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx index 0a5b92067..36632e9d9 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx @@ -203,10 +203,8 @@ export function StepUpContactInputForm({ />
    -
    - - +
    + +
    + + +
    diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx index b8896ed85..14d0a05d3 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx @@ -146,20 +146,12 @@ export function StepUpQRCodeEnrollmentForm({
    -
    -
    +
    diff --git a/packages/react/src/hooks/shared/use-step-up-challenge.ts b/packages/react/src/hooks/shared/use-step-up-challenge.ts index 9df2dc8c3..598d2ad12 100644 --- a/packages/react/src/hooks/shared/use-step-up-challenge.ts +++ b/packages/react/src/hooks/shared/use-step-up-challenge.ts @@ -23,20 +23,10 @@ export interface UseStepUpChallengeResult { clearError: () => void; } -/** - * Frozen mfa_token + challenge response pair. - * Verify always uses the token that was active when the challenge was issued, - * even if the parent re-renders with a fresh mfa_token before the user enters their code. - */ -interface FrozenChallenge { - mfaToken: string; - response: ChallengeResponse; -} - interface StepUpState { step: StepUpChallengeState; selectedAuthenticator: StepUpAuthenticator | null; - frozenChallenge: FrozenChallenge | null; + challengeResponse: ChallengeResponse | null; isChallenging: boolean; isVerifying: boolean; error: string | null; @@ -45,7 +35,7 @@ interface StepUpState { const INITIAL_STATE: StepUpState = { step: 'LIST', selectedAuthenticator: null, - frozenChallenge: null, + challengeResponse: null, isChallenging: false, isVerifying: false, error: null, @@ -67,7 +57,24 @@ export function useStepUpChallenge({ const handleSelectAuthenticator = useCallback( async (auth: StepUpAuthenticator) => { if (!stepUpService) return; - setChallengeState((prev) => ({ ...prev, isChallenging: true, error: null })); + + // Recovery codes skip the challenge step — go straight to verify + if (auth.authenticatorType === 'recovery-code') { + setChallengeState((prev) => ({ + ...prev, + step: 'VERIFY', + selectedAuthenticator: auth, + challengeResponse: null, + })); + return; + } + + setChallengeState((prev) => ({ + ...prev, + isChallenging: true, + selectedAuthenticator: auth, + error: null, + })); try { const challengeType = auth.authenticatorType === 'otp' ? 'otp' : 'oob'; const response = await stepUpService.challenge({ @@ -79,7 +86,7 @@ export function useStepUpChallenge({ ...prev, step: 'VERIFY', selectedAuthenticator: auth, - frozenChallenge: { mfaToken, response }, + challengeResponse: response, isChallenging: false, })); } catch (err) { @@ -95,14 +102,20 @@ export function useStepUpChallenge({ const handleVerify = useCallback( async (code: string) => { - if (!challengeState.frozenChallenge || !stepUpService) return; - const { mfaToken: frozenToken, response: challengeResponse } = challengeState.frozenChallenge; + if (!stepUpService || !challengeState.selectedAuthenticator) return; + const { selectedAuthenticator, challengeResponse } = challengeState; + + // Recovery codes verify directly; OTP/OOB require a prior challenge response + if (selectedAuthenticator.authenticatorType !== 'recovery-code' && !challengeResponse) return; + setChallengeState((prev) => ({ ...prev, isVerifying: true, error: null })); try { const params = - challengeResponse.challengeType === 'otp' - ? { mfaToken: frozenToken, otp: code } - : { mfaToken: frozenToken, oobCode: challengeResponse.oobCode, bindingCode: code }; + selectedAuthenticator.authenticatorType === 'recovery-code' + ? { mfaToken, recoveryCode: code } + : challengeResponse!.challengeType === 'otp' + ? { mfaToken, otp: code } + : { mfaToken, oobCode: challengeResponse!.oobCode, bindingCode: code }; await stepUpService.verify(params); await onSuccess(); } catch (err) { @@ -113,7 +126,13 @@ export function useStepUpChallenge({ })); } }, - [challengeState.frozenChallenge, stepUpService, onSuccess], + [ + challengeState.selectedAuthenticator, + challengeState.challengeResponse, + mfaToken, + stepUpService, + onSuccess, + ], ); const handleBack = useCallback(() => setChallengeState(INITIAL_STATE), []); @@ -122,7 +141,7 @@ export function useStepUpChallenge({ return { state: challengeState.step, selectedAuthenticator: challengeState.selectedAuthenticator, - challengeResponse: challengeState.frozenChallenge?.response ?? null, + challengeResponse: challengeState.challengeResponse, isChallenging: challengeState.isChallenging, isVerifying: challengeState.isVerifying, error: challengeState.error, From a992c20a13742582aa74ebe214c03b50923ee70b Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Wed, 4 Mar 2026 09:39:40 +0530 Subject: [PATCH 16/27] feat(react): create scim tokens and provisioning hooks --- .../__tests__/use-scim-tokens.test.ts | 390 ++++++++++++ .../__tests__/use-sso-provisioning.test.ts | 601 ++++++++++++++++++ .../hooks/my-organization/use-scim-tokens.ts | 176 +++++ .../my-organization/use-sso-provider-edit.ts | 396 ++---------- .../my-organization/use-sso-provisioning.ts | 229 +++++++ 5 files changed, 1462 insertions(+), 330 deletions(-) create mode 100644 packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts create mode 100644 packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts create mode 100644 packages/react/src/hooks/my-organization/use-scim-tokens.ts create mode 100644 packages/react/src/hooks/my-organization/use-sso-provisioning.ts diff --git a/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts new file mode 100644 index 000000000..aacb59f51 --- /dev/null +++ b/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts @@ -0,0 +1,390 @@ +import type { + IdentityProvider, + CreateIdpProvisioningScimTokenRequestContent, +} from '@auth0/universal-components-core'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { useScimTokens } from '@/hooks/my-organization/use-scim-tokens'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import * as useErrorHandlerModule from '@/hooks/shared/use-error-handler'; +import * as useTranslatorModule from '@/hooks/shared/use-translator'; +import { mockCore, mockToast, setupAllCommonMocks } from '@/tests/utils'; +import { createTestQueryClientWrapper } from '@/tests/utils/test-provider'; + +const { mockedShowToast } = mockToast(); +const { initMockCoreClient } = mockCore(); + +describe('useScimTokens', () => { + const mockIdpId = 'idp_123'; + let mockCoreClient: ReturnType; + let mockHandleError: ReturnType; + + const mockProvider: IdentityProvider = { + id: mockIdpId, + name: 'test-provider', + strategy: 'samlp', + display_name: 'Test Provider', + options: {}, + }; + + const renderUseScimTokens = (...args: Parameters) => { + const { wrapper } = createTestQueryClientWrapper(); + return renderHook(() => useScimTokens(...args), { wrapper }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCoreClient = initMockCoreClient(); + + const apiService = mockCoreClient.getMyOrganizationApiClient(); + + ( + apiService.organization.identityProviders.provisioning.scimTokens.list as ReturnType< + typeof vi.fn + > + ).mockResolvedValue([]); + ( + apiService.organization.identityProviders.provisioning.scimTokens.create as ReturnType< + typeof vi.fn + > + ).mockResolvedValue({ id: 'token_123', token: 'secret_token' }); + ( + apiService.organization.identityProviders.provisioning.scimTokens.delete as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(undefined); + + const { mockHandleError: setupMockHandleError } = setupAllCommonMocks({ + coreClient: mockCoreClient, + useCoreClientModule, + useTranslatorModule, + useErrorHandlerModule, + }); + + mockHandleError = setupMockHandleError; + vi.spyOn(useErrorHandlerModule, 'useErrorHandler').mockReturnValue(mockHandleError); + }); + + it('should initialize with correct default states', () => { + const { result } = renderUseScimTokens(mockIdpId, null); + + expect(result.current.isScimTokensLoading).toBe(false); + expect(result.current.isScimTokenCreating).toBe(false); + expect(result.current.isScimTokenDeleting).toBe(false); + expect(result.current.scimTokensError).toBeNull(); + expect(typeof result.current.listScimTokens).toBe('function'); + expect(typeof result.current.createScimToken).toBe('function'); + expect(typeof result.current.deleteScimToken).toBe('function'); + }); + + describe('listScimTokens', () => { + it('should list SCIM tokens successfully', async () => { + const mockTokens = [ + { id: 'token_1', scopes: ['read'] }, + { id: 'token_2', scopes: ['write'] }, + ]; + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.list as ReturnType + ).mockResolvedValue(mockTokens); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + const tokens = await result.current.listScimTokens(); + + expect(tokens).toEqual(mockTokens); + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.list, + ).toHaveBeenCalledWith(mockIdpId); + expect(result.current.isScimTokensLoading).toBe(false); + }); + }); + + it('should return null on list error', async () => { + const error = new Error('List failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.list as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + const tokens = await result.current.listScimTokens(); + + expect(tokens).toBeNull(); + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); + }); + }); + + it('should return null when coreClient is not available', async () => { + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + const tokens = await result.current.listScimTokens(); + + expect(tokens).toBeNull(); + }); + }); + + describe('createScimToken', () => { + it('should create SCIM token successfully', async () => { + const mockToken = { id: 'token_123', token: 'secret_token' }; + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.create as ReturnType + ).mockResolvedValue(mockToken); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + const tokenData: CreateIdpProvisioningScimTokenRequestContent = {}; + const token = await result.current.createScimToken(tokenData); + + expect(token).toEqual(mockToken); + await waitFor(() => { + expect(result.current.isScimTokenCreating).toBe(false); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'success', + message: 'scim_token_create_success', + }); + }); + }); + + it('should return undefined when provider is null', async () => { + const { result } = renderUseScimTokens(mockIdpId, null); + + const token = await result.current.createScimToken({}); + + expect(token).toBeUndefined(); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.create, + ).not.toHaveBeenCalled(); + }); + + it('should return undefined when coreClient is not available', async () => { + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + const token = await result.current.createScimToken({}); + + expect(token).toBeUndefined(); + }); + + it('should call onBefore callback and abort when it returns false', async () => { + const onBefore = vi.fn().mockReturnValue(false); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider, { + provisioning: { + createScimTokenAction: { onBefore }, + }, + }); + + const token = await result.current.createScimToken({}); + + expect(token).toBeUndefined(); + expect(onBefore).toHaveBeenCalledWith(mockProvider); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.create, + ).not.toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); + }); + + it('should call onAfter callback after successful creation', async () => { + const onAfter = vi.fn(); + const mockToken = { id: 'token_123', token: 'secret_token' }; + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.create as ReturnType + ).mockResolvedValue(mockToken); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider, { + provisioning: { + createScimTokenAction: { onAfter }, + }, + }); + + await result.current.createScimToken({}); + + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider, mockToken); + }); + }); + + it('should handle create error', async () => { + const error = new Error('Create failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.create as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + await expect(result.current.createScimToken({})).rejects.toThrow('Create failed'); + + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); + }); + }); + }); + + describe('deleteScimToken', () => { + it('should delete SCIM token successfully', async () => { + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + await result.current.deleteScimToken('token_123'); + + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.delete, + ).toHaveBeenCalledWith(mockIdpId, 'token_123'); + expect(result.current.isScimTokenDeleting).toBe(false); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'success', + message: 'scim_token_delete_sucess', + }); + }); + }); + + it('should return early when provider is null', async () => { + const { result } = renderUseScimTokens(mockIdpId, null); + + await result.current.deleteScimToken('token_123'); + + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.delete, + ).not.toHaveBeenCalled(); + }); + + it('should return early when coreClient is not available', async () => { + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + await result.current.deleteScimToken('token_123'); + + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.delete, + ).not.toHaveBeenCalled(); + }); + + it('should call onBefore callback and abort when it returns false', async () => { + const onBefore = vi.fn().mockReturnValue(false); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider, { + provisioning: { + deleteScimTokenAction: { onBefore }, + }, + }); + + await result.current.deleteScimToken('token_123'); + + expect(onBefore).toHaveBeenCalledWith(mockProvider); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.delete, + ).not.toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); + }); + + it('should call onAfter callback after successful deletion', async () => { + const onAfter = vi.fn(); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider, { + provisioning: { + deleteScimTokenAction: { onAfter }, + }, + }); + + await result.current.deleteScimToken('token_123'); + + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider); + }); + }); + + it('should handle delete error', async () => { + const error = new Error('Delete failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.delete as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + await expect(result.current.deleteScimToken('token_123')).rejects.toThrow('Delete failed'); + + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); + }); + }); + }); + + describe('scimTokensError', () => { + it('should expose error from list mutation', async () => { + const error = new Error('List error'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.list as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + await result.current.listScimTokens(); + + await waitFor(() => { + expect(result.current.scimTokensError).toEqual(error); + }); + }); + + it('should expose error from create mutation', async () => { + const error = new Error('Create error'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.create as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + try { + await result.current.createScimToken({}); + } catch { + // Expected to throw + } + + await waitFor(() => { + expect(result.current.scimTokensError).toEqual(error); + }); + }); + + it('should expose error from delete mutation', async () => { + const error = new Error('Delete error'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.delete as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider); + + try { + await result.current.deleteScimToken('token_123'); + } catch { + // Expected to throw + } + + await waitFor(() => { + expect(result.current.scimTokensError).toEqual(error); + }); + }); + }); +}); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts new file mode 100644 index 000000000..481bff460 --- /dev/null +++ b/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts @@ -0,0 +1,601 @@ +import type { + IdentityProvider, + GetIdPProvisioningConfigResponseContent, +} from '@auth0/universal-components-core'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { useSsoProvisioning } from '@/hooks/my-organization/use-sso-provisioning'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import * as useErrorHandlerModule from '@/hooks/shared/use-error-handler'; +import * as useTranslatorModule from '@/hooks/shared/use-translator'; +import { mockCore, mockToast, setupAllCommonMocks } from '@/tests/utils'; +import { createTestQueryClientWrapper } from '@/tests/utils/test-provider'; + +const { mockedShowToast } = mockToast(); +const { initMockCoreClient } = mockCore(); + +describe('useSsoProvisioning', () => { + const mockIdpId = 'idp_123'; + let mockCoreClient: ReturnType; + let mockHandleError: ReturnType; + + const mockProvider: IdentityProvider = { + id: mockIdpId, + name: 'test-provider', + strategy: 'samlp', + display_name: 'Test Provider', + options: {}, + }; + + const mockProvisioningConfig: GetIdPProvisioningConfigResponseContent = { + enabled: true, + attributes: [], + } as unknown as GetIdPProvisioningConfigResponseContent; + + const mockProvisioningConfigWithWarning: GetIdPProvisioningConfigResponseContent = { + enabled: true, + attributes: [ + { id: 'attr_1', is_extra: true, is_missing: false }, + { id: 'attr_2', is_extra: false, is_missing: false }, + ], + } as unknown as GetIdPProvisioningConfigResponseContent; + + const mockProvisioningConfigWithMissing: GetIdPProvisioningConfigResponseContent = { + enabled: true, + attributes: [{ id: 'attr_1', is_extra: false, is_missing: true }], + } as unknown as GetIdPProvisioningConfigResponseContent; + + const mockProvisioningConfigNoWarning: GetIdPProvisioningConfigResponseContent = { + enabled: true, + attributes: [ + { id: 'attr_1', is_extra: false, is_missing: false }, + { id: 'attr_2', is_extra: false, is_missing: false }, + ], + } as unknown as GetIdPProvisioningConfigResponseContent; + + const renderUseSsoProvisioning = (...args: Parameters) => { + const { wrapper, queryClient } = createTestQueryClientWrapper(); + return { ...renderHook(() => useSsoProvisioning(...args), { wrapper }), queryClient }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCoreClient = initMockCoreClient(); + + const apiService = mockCoreClient.getMyOrganizationApiClient(); + + // Default: provisioning returns 404 (not configured) + ( + apiService.organization.identityProviders.provisioning.get as ReturnType + ).mockRejectedValue({ status: 404 }); + ( + apiService.organization.identityProviders.provisioning.create as ReturnType + ).mockResolvedValue(mockProvisioningConfig); + ( + apiService.organization.identityProviders.provisioning.delete as ReturnType + ).mockResolvedValue(undefined); + ( + apiService.organization.identityProviders.provisioning.updateAttributes as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(undefined); + + const { mockHandleError: setupMockHandleError } = setupAllCommonMocks({ + coreClient: mockCoreClient, + useCoreClientModule, + useTranslatorModule, + useErrorHandlerModule, + }); + + mockHandleError = setupMockHandleError; + vi.spyOn(useErrorHandlerModule, 'useErrorHandler').mockReturnValue(mockHandleError); + }); + + it('should initialize with correct default states', () => { + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + expect(result.current.isProvisioningUpdating).toBe(false); + expect(result.current.isProvisioningDeleting).toBe(false); + expect(result.current.isProvisioningAttributesSyncing).toBe(false); + expect(typeof result.current.createProvisioning).toBe('function'); + expect(typeof result.current.deleteProvisioning).toBe('function'); + expect(typeof result.current.syncProvisioningAttributes).toBe('function'); + expect(typeof result.current.fetchProvisioning).toBe('function'); + }); + + describe('provisioningQuery', () => { + it('should handle 404 and return null when provisioning is not configured', async () => { + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => { + expect(result.current.provisioningConfig).toBe(null); + expect(result.current.isProvisioningLoading).toBe(false); + }); + }); + + it('should fetch provisioning config successfully', async () => { + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .get as ReturnType + ).mockResolvedValue(mockProvisioningConfig); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => { + expect(result.current.provisioningConfig).toEqual(mockProvisioningConfig); + expect(result.current.isProvisioningLoading).toBe(false); + }); + }); + + it('should handle non-404 errors with handleError', async () => { + const error = new Error('Server error'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .get as ReturnType + ).mockRejectedValue(error); + + renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); + }); + }); + + it('should not fetch when coreClient is not available', () => { + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + expect(result.current.provisioningConfig).toBe(null); + expect(result.current.isProvisioningLoading).toBe(false); + }); + + it('should not fetch when idpId is empty', () => { + const { result } = renderUseSsoProvisioning('', mockProvider); + + expect(result.current.provisioningConfig).toBe(null); + expect(result.current.isProvisioningLoading).toBe(false); + }); + }); + + describe('createProvisioning', () => { + it('should create provisioning successfully', async () => { + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + await result.current.createProvisioning(); + + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .create, + ).toHaveBeenCalledWith(mockIdpId); + expect(result.current.isProvisioningUpdating).toBe(false); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'success', + message: 'update_success', + }); + }); + }); + + it('should return early when provider is null', async () => { + const { result } = renderUseSsoProvisioning(mockIdpId, null); + + await result.current.createProvisioning(); + + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .create, + ).not.toHaveBeenCalled(); + }); + + it('should return early when coreClient is not available', async () => { + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await result.current.createProvisioning(); + + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .create, + ).not.toHaveBeenCalled(); + }); + + it('should call onBefore callback and abort when it returns false', async () => { + const onBefore = vi.fn().mockReturnValue(false); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider, { + provisioning: { + createAction: { onBefore }, + }, + }); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + await result.current.createProvisioning(); + + expect(onBefore).toHaveBeenCalledWith(mockProvider); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .create, + ).not.toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); + }); + + it('should call onAfter callback after successful creation', async () => { + const onAfter = vi.fn(); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider, { + provisioning: { + createAction: { onAfter }, + }, + }); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + await result.current.createProvisioning(); + + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider, mockProvisioningConfig); + }); + }); + + it('should handle create error', async () => { + const error = new Error('Create failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .create as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + await expect(result.current.createProvisioning()).rejects.toThrow('Create failed'); + + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); + }); + }); + }); + + describe('deleteProvisioning', () => { + it('should delete provisioning successfully', async () => { + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + await result.current.deleteProvisioning(); + + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .delete, + ).toHaveBeenCalledWith(mockIdpId); + expect(result.current.isProvisioningDeleting).toBe(false); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'success', + message: 'update_success', + }); + }); + }); + + it('should return early when provider is null', async () => { + const { result } = renderUseSsoProvisioning(mockIdpId, null); + + await result.current.deleteProvisioning(); + + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .delete, + ).not.toHaveBeenCalled(); + }); + + it('should return early when coreClient is not available', async () => { + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await result.current.deleteProvisioning(); + + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .delete, + ).not.toHaveBeenCalled(); + }); + + it('should call onBefore callback and abort when it returns false', async () => { + const onBefore = vi.fn().mockReturnValue(false); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider, { + provisioning: { + deleteAction: { onBefore }, + }, + }); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + await result.current.deleteProvisioning(); + + expect(onBefore).toHaveBeenCalledWith(mockProvider); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .delete, + ).not.toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); + }); + + it('should call onAfter callback after successful deletion', async () => { + const onAfter = vi.fn(); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider, { + provisioning: { + deleteAction: { onAfter }, + }, + }); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + await result.current.deleteProvisioning(); + + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider); + }); + }); + + it('should handle delete error', async () => { + const error = new Error('Delete failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .delete as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + await expect(result.current.deleteProvisioning()).rejects.toThrow('Delete failed'); + + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); + }); + }); + }); + + describe('syncProvisioningAttributes', () => { + it('should sync provisioning attributes successfully', async () => { + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + await result.current.syncProvisioningAttributes(); + + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .updateAttributes, + ).toHaveBeenCalledWith(mockIdpId, {}); + expect(result.current.isProvisioningAttributesSyncing).toBe(false); + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'success', + message: 'provisioning_attributes_sync_success', + }); + }); + }); + + it('should not sync when coreClient is not available', async () => { + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await result.current.syncProvisioningAttributes(); + + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .updateAttributes, + ).not.toHaveBeenCalled(); + }); + + it('should handle sync error', async () => { + const error = new Error('Sync failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .updateAttributes as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + try { + await result.current.syncProvisioningAttributes(); + } catch { + // Expected to throw + } + + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); + }); + }); + }); + + describe('fetchProvisioning', () => { + it('should fetch provisioning config imperatively', async () => { + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .get as ReturnType + ).mockResolvedValue(mockProvisioningConfig); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + const config = await result.current.fetchProvisioning(); + + expect(config).toEqual(mockProvisioningConfig); + }); + + it('should return null on fetch error', async () => { + const error = new Error('Fetch failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .get as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + const config = await result.current.fetchProvisioning(); + + expect(config).toBeNull(); + }); + + it('should handle 404 and return null', async () => { + // Default mock already rejects with 404 + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + const config = await result.current.fetchProvisioning(); + + expect(config).toBeNull(); + }); + }); + + describe('hasProvisioningAttributeSyncWarning', () => { + it('should return false when no provisioning config', async () => { + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + expect(result.current.hasProvisioningAttributeSyncWarning).toBe(false); + }); + + it('should return true when attributes have is_extra flag', async () => { + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .get as ReturnType + ).mockResolvedValue(mockProvisioningConfigWithWarning); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => { + expect(result.current.hasProvisioningAttributeSyncWarning).toBe(true); + }); + }); + + it('should return true when attributes have is_missing flag', async () => { + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .get as ReturnType + ).mockResolvedValue(mockProvisioningConfigWithMissing); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => { + expect(result.current.hasProvisioningAttributeSyncWarning).toBe(true); + }); + }); + + it('should return false when no attributes have warnings', async () => { + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .get as ReturnType + ).mockResolvedValue(mockProvisioningConfigNoWarning); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => { + expect(result.current.provisioningConfig).toEqual(mockProvisioningConfigNoWarning); + expect(result.current.hasProvisioningAttributeSyncWarning).toBe(false); + }); + }); + }); + + describe('provisioningError', () => { + it('should expose error from provisioning query (non-404)', async () => { + const error = new Error('Query error'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .get as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => { + expect(result.current.provisioningError).toBe(error); + }); + }); + + it('should expose error from create mutation', async () => { + const error = new Error('Create error'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .create as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + try { + await result.current.createProvisioning(); + } catch { + // Expected to throw + } + + await waitFor(() => { + expect(result.current.provisioningError).toEqual(error); + }); + }); + + it('should expose error from delete mutation', async () => { + const error = new Error('Delete error'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .delete as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + try { + await result.current.deleteProvisioning(); + } catch { + // Expected to throw + } + + await waitFor(() => { + expect(result.current.provisioningError).toEqual(error); + }); + }); + + it('should expose error from sync attributes mutation', async () => { + const error = new Error('Sync error'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .updateAttributes as ReturnType + ).mockRejectedValue(error); + + const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); + + await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); + + try { + await result.current.syncProvisioningAttributes(); + } catch { + // Expected to throw + } + + await waitFor(() => { + expect(result.current.provisioningError).toEqual(error); + }); + }); + }); +}); diff --git a/packages/react/src/hooks/my-organization/use-scim-tokens.ts b/packages/react/src/hooks/my-organization/use-scim-tokens.ts new file mode 100644 index 000000000..daf63a1de --- /dev/null +++ b/packages/react/src/hooks/my-organization/use-scim-tokens.ts @@ -0,0 +1,176 @@ +/** + * SCIM token management hook. + * @module use-scim-tokens + */ + +import { + MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES, + type IdentityProvider, + type IdpId, + type CreateIdpProvisioningScimTokenRequestContent, + type CreateIdpProvisioningScimTokenResponseContent, + type ListIdpProvisioningScimTokensResponseContent, +} from '@auth0/universal-components-core'; +import { useMutation } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +import { showToast } from '@/components/auth0/shared/toast'; +import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useErrorHandler } from '@/hooks/shared/use-error-handler'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import type { SsoProvisioningTabEditProps } from '@/types/my-organization/idp-management/sso-provisioning/sso-provisioning-tab-types'; + +const ACTION_CANCELLED_ERROR = 'ACTION_CANCELLED'; + +const isActionCancelledError = (error: unknown): boolean => { + return error instanceof Error && error.message === ACTION_CANCELLED_ERROR; +}; + +export interface UseScimTokensOptions { + provisioning?: SsoProvisioningTabEditProps; + customMessages?: Record; +} + +export interface UseScimTokensReturn { + listScimTokens: () => Promise; + createScimToken: ( + data: CreateIdpProvisioningScimTokenRequestContent, + ) => Promise; + deleteScimToken: (idpScimTokenId: string) => Promise; + isScimTokensLoading: boolean; + isScimTokenCreating: boolean; + isScimTokenDeleting: boolean; + scimTokensError: unknown; +} + +/** + * Hook for managing SCIM tokens for an identity provider. + * @param idpId - Identity provider ID. + * @param provider - The current identity provider (may be null while loading). + * @param options - Hook options. + * @returns SCIM token operations and loading states. + */ +export function useScimTokens( + idpId: IdpId, + provider: IdentityProvider | null, + { provisioning, customMessages = {} }: UseScimTokensOptions = {}, +): UseScimTokensReturn { + const { coreClient } = useCoreClient(); + const { t } = useTranslator('idp_management.notifications', customMessages); + const handleError = useErrorHandler(); + + const listScimTokensMutation = useMutation({ + mutationFn: async () => { + if (!coreClient || !idpId) return null; + + const result = await coreClient + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.scimTokens.list(idpId); + return result; + }, + onError: (error) => handleError(error), + }); + + const createScimTokenMutation = useMutation({ + mutationFn: async (data: CreateIdpProvisioningScimTokenRequestContent) => { + if (!provider) throw new Error('Provider not loaded'); + + if ( + provisioning?.createScimTokenAction?.onBefore && + !provisioning.createScimTokenAction.onBefore(provider) + ) { + throw new Error(ACTION_CANCELLED_ERROR); + } + + const result = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.scimTokens.create(idpId, data); + + return result; + }, + onSuccess: async (result) => { + showToast({ type: 'success', message: t('scim_token_create_success') }); + if (provisioning?.createScimTokenAction?.onAfter && provider) { + await provisioning.createScimTokenAction.onAfter(provider, result); + } + }, + onError: (error) => { + if (!isActionCancelledError(error)) handleError(error); + }, + }); + + const deleteScimTokenMutation = useMutation({ + mutationFn: async (idpScimTokenId: string): Promise => { + if (!provider) throw new Error('Provider not loaded'); + + if ( + provisioning?.deleteScimTokenAction?.onBefore && + !provisioning.deleteScimTokenAction.onBefore(provider) + ) { + throw new Error(ACTION_CANCELLED_ERROR); + } + + await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.scimTokens.delete(idpId, idpScimTokenId); + }, + onSuccess: async () => { + showToast({ type: 'success', message: t('scim_token_delete_sucess') }); + if (provisioning?.deleteScimTokenAction?.onAfter && provider) { + await provisioning.deleteScimTokenAction.onAfter(provider); + } + }, + onError: (error) => { + if (!isActionCancelledError(error)) handleError(error); + }, + }); + + const listScimTokens = useCallback(async () => { + try { + return await listScimTokensMutation.mutateAsync(); + } catch { + return null; + } + }, [listScimTokensMutation]); + + const createScimToken = useCallback( + async (data: CreateIdpProvisioningScimTokenRequestContent) => { + if (!coreClient || !provider) return undefined; + try { + return await createScimTokenMutation.mutateAsync(data); + } catch (error) { + if (!isActionCancelledError(error)) throw error; + return undefined; + } + }, + [coreClient, createScimTokenMutation, provider], + ); + + const deleteScimToken = useCallback( + async (idpScimTokenId: string) => { + if (!coreClient || !provider) return; + try { + await deleteScimTokenMutation.mutateAsync(idpScimTokenId); + } catch (error) { + if (!isActionCancelledError(error)) throw error; + } + }, + [coreClient, deleteScimTokenMutation, provider], + ); + + return { + listScimTokens, + createScimToken, + deleteScimToken, + isScimTokensLoading: listScimTokensMutation.isPending, + isScimTokenCreating: createScimTokenMutation.isPending, + isScimTokenDeleting: deleteScimTokenMutation.isPending, + scimTokensError: + listScimTokensMutation.error || + createScimTokenMutation.error || + deleteScimTokenMutation.error, + }; +} diff --git a/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts b/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts index 2ce88baab..373391285 100644 --- a/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts +++ b/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts @@ -13,9 +13,6 @@ import { type OrganizationPrivate, type UpdateIdentityProviderRequestContent, type UpdateIdentityProviderRequestContentPrivate, - type CreateIdpProvisioningScimTokenRequestContent, - type GetIdPProvisioningConfigResponseContent, - getStatusCode, } from '@auth0/universal-components-core'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo } from 'react'; @@ -23,6 +20,8 @@ import { useCallback, useEffect, useMemo } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; import { useConfig } from '@/hooks/my-organization/use-config'; import { useIdpConfig } from '@/hooks/my-organization/use-idp-config'; +import { useScimTokens } from '@/hooks/my-organization/use-scim-tokens'; +import { useSsoProvisioning } from '@/hooks/my-organization/use-sso-provisioning'; import { useCoreClient } from '@/hooks/shared/use-core-client'; import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; @@ -106,23 +105,6 @@ export function useSsoProviderEdit( initialData: OrganizationDetailsFactory.create(), }); - const provisioningQuery = useQuery({ - queryKey: ssoProviderEditQueryKeys.provisioning(idpId), - queryFn: async (): Promise => { - try { - const result = await coreClient! - .getMyOrganizationApiClient() - .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) - .organization.identityProviders.provisioning.get(idpId); - return result; - } catch (error) { - if (getStatusCode(error) === 404) return null; - throw error; - } - }, - enabled: !!coreClient && !!idpId, - }); - useEffect(() => { if (providerQuery.error) handleError(providerQuery.error); }, [providerQuery.error, handleError]); @@ -131,21 +113,43 @@ export function useSsoProviderEdit( if (organizationQuery.error) handleError(organizationQuery.error); }, [organizationQuery.error, handleError]); - useEffect(() => { - if (provisioningQuery.error) handleError(provisioningQuery.error); - }, [provisioningQuery.error, handleError]); + const provider = providerQuery.data ?? null; + + const { + provisioningConfig, + isProvisioningLoading, + isProvisioningUpdating, + isProvisioningDeleting, + isProvisioningAttributesSyncing, + hasProvisioningAttributeSyncWarning, + provisioningError, + fetchProvisioning, + createProvisioning, + deleteProvisioning, + syncProvisioningAttributes, + } = useSsoProvisioning(idpId, provider, { provisioning, customMessages }); + + const { + listScimTokens, + createScimToken, + deleteScimToken, + isScimTokensLoading, + isScimTokenCreating, + isScimTokenDeleting, + scimTokensError, + } = useScimTokens(idpId, provider, { provisioning, customMessages }); const updateProviderMutation = useMutation({ mutationFn: async (data: UpdateIdentityProviderRequestContent): Promise => { - const provider = providerQuery.data; - if (!provider) throw new Error('Provider not loaded'); + const currentProvider = providerQuery.data; + if (!currentProvider) throw new Error('Provider not loaded'); - if (sso?.updateAction?.onBefore && !sso.updateAction.onBefore(provider)) { + if (sso?.updateAction?.onBefore && !sso.updateAction.onBefore(currentProvider)) { throw new Error(ACTION_CANCELLED_ERROR); } const apiRequestData = SsoProviderMappers.updateToAPI({ - strategy: provider.strategy, + strategy: currentProvider.strategy, ...data, }); @@ -157,14 +161,14 @@ export function useSsoProviderEdit( return result; }, onSuccess: async (result) => { - const provider = providerQuery.data; + const currentProvider = providerQuery.data; showToast({ type: 'success', - message: t('update_success', { providerName: provider?.display_name }), + message: t('update_success', { providerName: currentProvider?.display_name }), }); queryClient.setQueryData(ssoProviderEditQueryKeys.detail(idpId), result); - if (sso?.updateAction?.onAfter && provider) { - await sso.updateAction.onAfter(provider, result); + if (sso?.updateAction?.onAfter && currentProvider) { + await sso.updateAction.onAfter(currentProvider, result); } }, onError: (error) => { @@ -174,24 +178,24 @@ export function useSsoProviderEdit( const deleteProviderMutation = useMutation({ mutationFn: async (): Promise => { - const provider = providerQuery.data; - if (!provider?.id) throw new Error('Provider not loaded or missing ID'); + const currentProvider = providerQuery.data; + if (!currentProvider?.id) throw new Error('Provider not loaded or missing ID'); await coreClient! .getMyOrganizationApiClient() .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) - .organization.identityProviders.delete(provider.id); + .organization.identityProviders.delete(currentProvider.id); }, onSuccess: async () => { - const provider = providerQuery.data; + const currentProvider = providerQuery.data; showToast({ type: 'success', - message: t('delete_success', { providerName: provider?.display_name }), + message: t('delete_success', { providerName: currentProvider?.display_name }), }); queryClient.removeQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); queryClient.removeQueries({ queryKey: ssoProviderEditQueryKeys.provisioning(idpId) }); - if (sso?.deleteAction?.onAfter && provider) { - await sso.deleteAction.onAfter(provider); + if (sso?.deleteAction?.onAfter && currentProvider) { + await sso.deleteAction.onAfter(currentProvider); } }, onError: (error) => handleError(error), @@ -199,12 +203,12 @@ export function useSsoProviderEdit( const detachProviderMutation = useMutation({ mutationFn: async (): Promise => { - const provider = providerQuery.data; - if (!provider?.id) throw new Error('Provider not loaded or missing ID'); + const currentProvider = providerQuery.data; + if (!currentProvider?.id) throw new Error('Provider not loaded or missing ID'); if ( sso?.deleteFromOrganizationAction?.onBefore && - !sso.deleteFromOrganizationAction.onBefore(provider) + !sso.deleteFromOrganizationAction.onBefore(currentProvider) ) { throw new Error(ACTION_CANCELLED_ERROR); } @@ -212,145 +216,21 @@ export function useSsoProviderEdit( await coreClient! .getMyOrganizationApiClient() .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) - .organization.identityProviders.detach(provider.id); + .organization.identityProviders.detach(currentProvider.id); }, onSuccess: async () => { - const provider = providerQuery.data; + const currentProvider = providerQuery.data; const organization = organizationQuery.data; showToast({ type: 'success', message: t('remove_success', { - providerName: provider?.display_name, + providerName: currentProvider?.display_name, organizationName: organization?.display_name, }), }); queryClient.removeQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); - if (sso?.deleteFromOrganizationAction?.onAfter && provider) { - await sso.deleteFromOrganizationAction.onAfter(provider); - } - }, - onError: (error) => { - if (!isActionCancelledError(error)) handleError(error); - }, - }); - - const createProvisioningMutation = useMutation({ - mutationFn: async (): Promise => { - const provider = providerQuery.data; - if (!provider) throw new Error('Provider not loaded'); - - if (provisioning?.createAction?.onBefore && !provisioning.createAction.onBefore(provider)) { - throw new Error(ACTION_CANCELLED_ERROR); - } - - const result = await coreClient! - .getMyOrganizationApiClient() - .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) - .organization.identityProviders.provisioning.create(idpId); - - return result; - }, - onSuccess: async (result) => { - const provider = providerQuery.data; - showToast({ - type: 'success', - message: t('update_success', { providerName: provider?.display_name }), - }); - await queryClient.invalidateQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); - queryClient.setQueryData(ssoProviderEditQueryKeys.provisioning(idpId), result); - if (provisioning?.createAction?.onAfter && provider) { - await provisioning.createAction.onAfter(provider, result); - } - }, - onError: (error) => { - if (!isActionCancelledError(error)) handleError(error); - }, - }); - - const deleteProvisioningMutation = useMutation({ - mutationFn: async (): Promise => { - const provider = providerQuery.data; - if (!provider) throw new Error('Provider not loaded'); - - if (provisioning?.deleteAction?.onBefore && !provisioning.deleteAction.onBefore(provider)) { - throw new Error(ACTION_CANCELLED_ERROR); - } - - await coreClient! - .getMyOrganizationApiClient() - .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) - .organization.identityProviders.provisioning.delete(idpId); - }, - onSuccess: async () => { - const provider = providerQuery.data; - showToast({ - type: 'success', - message: t('update_success', { providerName: provider?.display_name }), - }); - queryClient.setQueryData(ssoProviderEditQueryKeys.provisioning(idpId), null); - await queryClient.invalidateQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); - if (provisioning?.deleteAction?.onAfter && provider) { - await provisioning.deleteAction.onAfter(provider); - } - }, - onError: (error) => { - if (!isActionCancelledError(error)) handleError(error); - }, - }); - - const createScimTokenMutation = useMutation({ - mutationFn: async (data: CreateIdpProvisioningScimTokenRequestContent) => { - const provider = providerQuery.data; - if (!provider) throw new Error('Provider not loaded'); - - if ( - provisioning?.createScimTokenAction?.onBefore && - !provisioning.createScimTokenAction.onBefore(provider) - ) { - throw new Error(ACTION_CANCELLED_ERROR); - } - - const result = await coreClient! - .getMyOrganizationApiClient() - .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) - .organization.identityProviders.provisioning.scimTokens.create(idpId, data); - - return result; - }, - onSuccess: async (result) => { - const provider = providerQuery.data; - showToast({ type: 'success', message: t('scim_token_create_success') }); - if (provisioning?.createScimTokenAction?.onAfter && provider) { - await provisioning.createScimTokenAction.onAfter(provider, result); - } - }, - onError: (error) => { - if (!isActionCancelledError(error)) handleError(error); - }, - }); - - const deleteScimTokenMutation = useMutation({ - mutationFn: async (idpScimTokenId: string): Promise => { - const provider = providerQuery.data; - if (!provider) throw new Error('Provider not loaded'); - - if ( - provisioning?.deleteScimTokenAction?.onBefore && - !provisioning.deleteScimTokenAction.onBefore(provider) - ) { - throw new Error(ACTION_CANCELLED_ERROR); - } - - await coreClient! - .getMyOrganizationApiClient() - .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) - .organization.identityProviders.provisioning.scimTokens.delete(idpId, idpScimTokenId); - }, - onSuccess: async () => { - const provider = providerQuery.data; - showToast({ type: 'success', message: t('scim_token_delete_sucess') }); - if (provisioning?.deleteScimTokenAction?.onAfter && provider) { - await provisioning.deleteScimTokenAction.onAfter(provider); + if (sso?.deleteFromOrganizationAction?.onAfter && currentProvider) { + await sso.deleteFromOrganizationAction.onAfter(currentProvider); } }, onError: (error) => { @@ -358,25 +238,6 @@ export function useSsoProviderEdit( }, }); - /** - * List SCIM tokens mutation - fetches SCIM tokens for provisioning. - * Note: This uses imperative fetching rather than a query because tokens - * are typically fetched on-demand and the response includes sensitive data - * that shouldn't be automatically cached. - */ - const listScimTokensMutation = useMutation({ - mutationFn: async () => { - if (!coreClient || !idpId) return null; - - const result = await coreClient - .getMyOrganizationApiClient() - .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) - .organization.identityProviders.provisioning.scimTokens.list(idpId); - return result; - }, - onError: (error) => handleError(error), - }); - const syncSsoAttributesMutation = useMutation({ mutationFn: async () => { await coreClient! @@ -391,20 +252,6 @@ export function useSsoProviderEdit( onError: (error) => handleError(error), }); - const syncProvisioningAttributesMutation = useMutation({ - mutationFn: async () => { - await coreClient! - .getMyOrganizationApiClient() - .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) - .organization.identityProviders.provisioning.updateAttributes(idpId, {}); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ssoProviderEditQueryKeys.provisioning(idpId) }); - showToast({ type: 'success', message: t('provisioning_attributes_sync_success') }); - }, - onError: (error) => handleError(error), - }); - const updateProvider = useCallback( async (data: UpdateIdentityProviderRequestContentPrivate) => { if (!coreClient || !providerQuery.data) return; @@ -433,67 +280,11 @@ export function useSsoProviderEdit( } }, [coreClient, detachProviderMutation, providerQuery.data?.id]); - const createProvisioning = useCallback(async () => { - if (!coreClient || !providerQuery.data) return; - try { - await createProvisioningMutation.mutateAsync(); - } catch (error) { - if (!isActionCancelledError(error)) throw error; - } - }, [coreClient, createProvisioningMutation, providerQuery.data]); - - const deleteProvisioning = useCallback(async () => { - if (!coreClient || !providerQuery.data) return; - try { - await deleteProvisioningMutation.mutateAsync(); - } catch (error) { - if (!isActionCancelledError(error)) throw error; - } - }, [coreClient, deleteProvisioningMutation, providerQuery.data]); - - const createScimToken = useCallback( - async (data: CreateIdpProvisioningScimTokenRequestContent) => { - if (!coreClient || !providerQuery.data) return undefined; - try { - return await createScimTokenMutation.mutateAsync(data); - } catch (error) { - if (!isActionCancelledError(error)) throw error; - return undefined; - } - }, - [coreClient, createScimTokenMutation, providerQuery.data], - ); - - const deleteScimToken = useCallback( - async (idpScimTokenId: string) => { - if (!coreClient || !providerQuery.data) return; - try { - await deleteScimTokenMutation.mutateAsync(idpScimTokenId); - } catch (error) { - if (!isActionCancelledError(error)) throw error; - } - }, - [coreClient, deleteScimTokenMutation, providerQuery.data], - ); - - const listScimTokens = useCallback(async () => { - try { - return await listScimTokensMutation.mutateAsync(); - } catch (error) { - return null; - } - }, [listScimTokensMutation]); - const syncSsoAttributes = useCallback(async () => { if (!coreClient) return; await syncSsoAttributesMutation.mutateAsync(); }, [coreClient, syncSsoAttributesMutation]); - const syncProvisioningAttributes = useCallback(async () => { - if (!coreClient) return; - await syncProvisioningAttributesMutation.mutateAsync(); - }, [coreClient, syncProvisioningAttributesMutation]); - const fetchProvider = useCallback(async () => { const result = await queryClient.fetchQuery({ queryKey: ssoProviderEditQueryKeys.detail(idpId), @@ -508,40 +299,13 @@ export function useSsoProviderEdit( return result; }, [queryClient, idpId, coreClient]); - const fetchProvisioning = useCallback(async () => { - try { - const result = await queryClient.fetchQuery({ - queryKey: ssoProviderEditQueryKeys.provisioning(idpId), - queryFn: async (): Promise => { - try { - const response = await coreClient! - .getMyOrganizationApiClient() - .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) - .organization.identityProviders.provisioning.get(idpId); - return response; - } catch (error) { - if (getStatusCode(error) === 404) return null; - throw error; - } - }, - }); - return result; - } catch (error) { - return null; - } - }, [queryClient, idpId, coreClient]); - const hasSsoAttributeSyncWarning = useMemo(() => { - const provider = providerQuery.data; - const attributes = provider && 'attributes' in provider ? (provider.attributes ?? []) : []; + const currentProvider = providerQuery.data; + const attributes = + currentProvider && 'attributes' in currentProvider ? (currentProvider.attributes ?? []) : []; return attributes.some((attr) => attr.is_extra || attr.is_missing); }, [providerQuery.data]); - const hasProvisioningAttributeSyncWarning = useMemo(() => { - const attributes = provisioningQuery.data?.attributes ?? []; - return attributes.some((attr) => attr.is_extra || attr.is_missing); - }, [provisioningQuery.data]); - const showProvisioningTab = isProvisioningEnabled(providerQuery.data?.strategy) && isProvisioningMethodEnabled(providerQuery.data?.strategy); @@ -560,18 +324,14 @@ export function useSsoProviderEdit( const error = providerQuery.error || organizationQuery.error || - provisioningQuery.error || configError || idpConfigError || + provisioningError || + scimTokensError || updateProviderMutation.error || deleteProviderMutation.error || detachProviderMutation.error || - createProvisioningMutation.error || - deleteProvisioningMutation.error || - createScimTokenMutation.error || - deleteScimTokenMutation.error || - syncSsoAttributesMutation.error || - syncProvisioningAttributesMutation.error; + syncSsoAttributesMutation.error; const retry = async () => { if (configError) { @@ -586,7 +346,7 @@ export function useSsoProviderEdit( const queries = [ { error: providerQuery.error, key: ssoProviderEditQueryKeys.detail(idpId) }, { error: organizationQuery.error, key: ssoProviderEditQueryKeys.organization() }, - { error: provisioningQuery.error, key: ssoProviderEditQueryKeys.provisioning(idpId) }, + { error: provisioningError, key: ssoProviderEditQueryKeys.provisioning(idpId) }, ]; const failedQuery = queries.find((q) => q.error); @@ -610,34 +370,10 @@ export function useSsoProviderEdit( error: detachProviderMutation.error, retry: () => detachProviderMutation.mutateAsync(), }, - { - error: createProvisioningMutation.error, - retry: () => createProvisioningMutation.mutateAsync(), - }, - { - error: deleteProvisioningMutation.error, - retry: () => deleteProvisioningMutation.mutateAsync(), - }, - { - error: createScimTokenMutation.error, - retry: () => - createScimTokenMutation.variables && - createScimTokenMutation.mutateAsync(createScimTokenMutation.variables), - }, - { - error: deleteScimTokenMutation.error, - retry: () => - deleteScimTokenMutation.variables && - deleteScimTokenMutation.mutateAsync(deleteScimTokenMutation.variables), - }, { error: syncSsoAttributesMutation.error, retry: () => syncSsoAttributesMutation.mutateAsync(), }, - { - error: syncProvisioningAttributesMutation.error, - retry: () => syncProvisioningAttributesMutation.mutateAsync(), - }, ]; const failedMutation = mutations.find((m) => m.error); @@ -647,21 +383,21 @@ export function useSsoProviderEdit( }; return { - provider: providerQuery.data ?? null, + provider, organization: organizationQuery.data ?? OrganizationDetailsFactory.create(), - provisioningConfig: provisioningQuery.data ?? null, + provisioningConfig, isLoading: providerQuery.isLoading || organizationQuery.isLoading, isUpdating: updateProviderMutation.isPending, isDeleting: deleteProviderMutation.isPending, isRemoving: detachProviderMutation.isPending, - isProvisioningUpdating: createProvisioningMutation.isPending, - isProvisioningDeleting: deleteProvisioningMutation.isPending, - isProvisioningLoading: provisioningQuery.isLoading || provisioningQuery.isFetching, - isScimTokensLoading: listScimTokensMutation.isPending, - isScimTokenCreating: createScimTokenMutation.isPending, - isScimTokenDeleting: deleteScimTokenMutation.isPending, + isProvisioningUpdating, + isProvisioningDeleting, + isProvisioningLoading, + isScimTokensLoading, + isScimTokenCreating, + isScimTokenDeleting, isSsoAttributesSyncing: syncSsoAttributesMutation.isPending, - isProvisioningAttributesSyncing: syncProvisioningAttributesMutation.isPending, + isProvisioningAttributesSyncing, hasSsoAttributeSyncWarning, hasProvisioningAttributeSyncWarning, shouldAllowDeletion, diff --git a/packages/react/src/hooks/my-organization/use-sso-provisioning.ts b/packages/react/src/hooks/my-organization/use-sso-provisioning.ts new file mode 100644 index 000000000..d36a1bc8e --- /dev/null +++ b/packages/react/src/hooks/my-organization/use-sso-provisioning.ts @@ -0,0 +1,229 @@ +/** + * SSO provisioning hook. + * @module use-sso-provisioning + */ + +import { + MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES, + type IdentityProvider, + type IdpId, + type GetIdPProvisioningConfigResponseContent, + getStatusCode, +} from '@auth0/universal-components-core'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo } from 'react'; + +import { showToast } from '@/components/auth0/shared/toast'; +import { ssoProviderEditQueryKeys } from '@/hooks/my-organization/use-sso-provider-edit'; +import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useErrorHandler } from '@/hooks/shared/use-error-handler'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import type { SsoProvisioningTabEditProps } from '@/types/my-organization/idp-management/sso-provisioning/sso-provisioning-tab-types'; + +const ACTION_CANCELLED_ERROR = 'ACTION_CANCELLED'; + +const isActionCancelledError = (error: unknown): boolean => { + return error instanceof Error && error.message === ACTION_CANCELLED_ERROR; +}; + +export interface UseSsoProvisioningOptions { + provisioning?: SsoProvisioningTabEditProps; + customMessages?: Record; +} + +/** Return type of the useSsoProvisioning hook. */ +export interface UseSsoProvisioningReturn { + provisioningConfig: GetIdPProvisioningConfigResponseContent | null; + isProvisioningLoading: boolean; + isProvisioningUpdating: boolean; + isProvisioningDeleting: boolean; + isProvisioningAttributesSyncing: boolean; + hasProvisioningAttributeSyncWarning: boolean; + provisioningError: unknown; + fetchProvisioning: () => Promise; + createProvisioning: () => Promise; + deleteProvisioning: () => Promise; + syncProvisioningAttributes: () => Promise; +} + +/** + * Hook for managing SSO provisioning configuration for an identity provider. + * @param idpId - Identity provider ID. + * @param provider - The current identity provider (may be null while loading). + * @param options - Hook options. + * @returns Provisioning operations, config state, and loading states. + */ +export function useSsoProvisioning( + idpId: IdpId, + provider: IdentityProvider | null, + { provisioning, customMessages = {} }: UseSsoProvisioningOptions = {}, +): UseSsoProvisioningReturn { + const { coreClient } = useCoreClient(); + const { t } = useTranslator('idp_management.notifications', customMessages); + const queryClient = useQueryClient(); + const handleError = useErrorHandler(); + + const provisioningQuery = useQuery({ + queryKey: ssoProviderEditQueryKeys.provisioning(idpId), + queryFn: async (): Promise => { + try { + const result = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.get(idpId); + return result; + } catch (error) { + if (getStatusCode(error) === 404) return null; + throw error; + } + }, + enabled: !!coreClient && !!idpId, + }); + + useEffect(() => { + if (provisioningQuery.error) handleError(provisioningQuery.error); + }, [provisioningQuery.error, handleError]); + + const createProvisioningMutation = useMutation({ + mutationFn: async (): Promise => { + if (!provider) throw new Error('Provider not loaded'); + + if (provisioning?.createAction?.onBefore && !provisioning.createAction.onBefore(provider)) { + throw new Error(ACTION_CANCELLED_ERROR); + } + + const result = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.create(idpId); + + return result; + }, + onSuccess: async (result) => { + showToast({ + type: 'success', + message: t('update_success', { providerName: provider?.display_name }), + }); + await queryClient.invalidateQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); + queryClient.setQueryData(ssoProviderEditQueryKeys.provisioning(idpId), result); + if (provisioning?.createAction?.onAfter && provider) { + await provisioning.createAction.onAfter(provider, result); + } + }, + onError: (error) => { + if (!isActionCancelledError(error)) handleError(error); + }, + }); + + const deleteProvisioningMutation = useMutation({ + mutationFn: async (): Promise => { + if (!provider) throw new Error('Provider not loaded'); + + if (provisioning?.deleteAction?.onBefore && !provisioning.deleteAction.onBefore(provider)) { + throw new Error(ACTION_CANCELLED_ERROR); + } + + await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.delete(idpId); + }, + onSuccess: async () => { + showToast({ + type: 'success', + message: t('update_success', { providerName: provider?.display_name }), + }); + queryClient.setQueryData(ssoProviderEditQueryKeys.provisioning(idpId), null); + await queryClient.invalidateQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); + if (provisioning?.deleteAction?.onAfter && provider) { + await provisioning.deleteAction.onAfter(provider); + } + }, + onError: (error) => { + if (!isActionCancelledError(error)) handleError(error); + }, + }); + + const syncProvisioningAttributesMutation = useMutation({ + mutationFn: async () => { + await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.updateAttributes(idpId, {}); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ssoProviderEditQueryKeys.provisioning(idpId) }); + showToast({ type: 'success', message: t('provisioning_attributes_sync_success') }); + }, + onError: (error) => handleError(error), + }); + + const fetchProvisioning = useCallback(async () => { + try { + const result = await queryClient.fetchQuery({ + queryKey: ssoProviderEditQueryKeys.provisioning(idpId), + queryFn: async (): Promise => { + try { + const response = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.get(idpId); + return response; + } catch (error) { + if (getStatusCode(error) === 404) return null; + throw error; + } + }, + }); + return result; + } catch { + return null; + } + }, [queryClient, idpId, coreClient]); + + const createProvisioning = useCallback(async () => { + if (!coreClient || !provider) return; + try { + await createProvisioningMutation.mutateAsync(); + } catch (error) { + if (!isActionCancelledError(error)) throw error; + } + }, [coreClient, createProvisioningMutation, provider]); + + const deleteProvisioning = useCallback(async () => { + if (!coreClient || !provider) return; + try { + await deleteProvisioningMutation.mutateAsync(); + } catch (error) { + if (!isActionCancelledError(error)) throw error; + } + }, [coreClient, deleteProvisioningMutation, provider]); + + const syncProvisioningAttributes = useCallback(async () => { + if (!coreClient) return; + await syncProvisioningAttributesMutation.mutateAsync(); + }, [coreClient, syncProvisioningAttributesMutation]); + + const hasProvisioningAttributeSyncWarning = useMemo(() => { + const attributes = provisioningQuery.data?.attributes ?? []; + return attributes.some((attr) => attr.is_extra || attr.is_missing); + }, [provisioningQuery.data]); + + return { + provisioningConfig: provisioningQuery.data ?? null, + isProvisioningLoading: provisioningQuery.isLoading || provisioningQuery.isFetching, + isProvisioningUpdating: createProvisioningMutation.isPending, + isProvisioningDeleting: deleteProvisioningMutation.isPending, + isProvisioningAttributesSyncing: syncProvisioningAttributesMutation.isPending, + hasProvisioningAttributeSyncWarning, + provisioningError: + provisioningQuery.error || + createProvisioningMutation.error || + deleteProvisioningMutation.error || + syncProvisioningAttributesMutation.error, + fetchProvisioning, + createProvisioning, + deleteProvisioning, + syncProvisioningAttributes, + }; +} From 55b8cc5583b6fa637073542dac146a2a4cf6d489 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Wed, 4 Mar 2026 09:46:49 +0530 Subject: [PATCH 17/27] feat(react): update scim tokens and provisioning hooks --- .../__tests__/use-scim-tokens.test.ts | 36 +++---------- .../__tests__/use-sso-provisioning.test.ts | 54 +------------------ .../hooks/my-organization/use-scim-tokens.ts | 18 ++----- .../my-organization/use-sso-provisioning.ts | 42 +++++++-------- 4 files changed, 32 insertions(+), 118 deletions(-) diff --git a/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts index aacb59f51..8c96a6399 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts @@ -104,7 +104,7 @@ describe('useScimTokens', () => { }); }); - it('should return null on list error', async () => { + it('should propagate list error', async () => { const error = new Error('List failed'); ( mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning @@ -113,9 +113,8 @@ describe('useScimTokens', () => { const { result } = renderUseScimTokens(mockIdpId, mockProvider); - const tokens = await result.current.listScimTokens(); + await expect(result.current.listScimTokens()).rejects.toThrow('List failed'); - expect(tokens).toBeNull(); await waitFor(() => { expect(mockHandleError).toHaveBeenCalledWith(error); }); @@ -155,28 +154,6 @@ describe('useScimTokens', () => { }); }); - it('should return undefined when provider is null', async () => { - const { result } = renderUseScimTokens(mockIdpId, null); - - const token = await result.current.createScimToken({}); - - expect(token).toBeUndefined(); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning - .scimTokens.create, - ).not.toHaveBeenCalled(); - }); - - it('should return undefined when coreClient is not available', async () => { - vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); - - const { result } = renderUseScimTokens(mockIdpId, mockProvider); - - const token = await result.current.createScimToken({}); - - expect(token).toBeUndefined(); - }); - it('should call onBefore callback and abort when it returns false', async () => { const onBefore = vi.fn().mockReturnValue(false); @@ -186,9 +163,8 @@ describe('useScimTokens', () => { }, }); - const token = await result.current.createScimToken({}); + await expect(result.current.createScimToken({})).rejects.toThrow('ACTION_CANCELLED'); - expect(token).toBeUndefined(); expect(onBefore).toHaveBeenCalledWith(mockProvider); expect( mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning @@ -340,7 +316,11 @@ describe('useScimTokens', () => { const { result } = renderUseScimTokens(mockIdpId, mockProvider); - await result.current.listScimTokens(); + try { + await result.current.listScimTokens(); + } catch { + // Expected to throw + } await waitFor(() => { expect(result.current.scimTokensError).toEqual(error); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts index 481bff460..02ad3e445 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts @@ -181,30 +181,6 @@ describe('useSsoProvisioning', () => { }); }); - it('should return early when provider is null', async () => { - const { result } = renderUseSsoProvisioning(mockIdpId, null); - - await result.current.createProvisioning(); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning - .create, - ).not.toHaveBeenCalled(); - }); - - it('should return early when coreClient is not available', async () => { - vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); - - const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); - - await result.current.createProvisioning(); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning - .create, - ).not.toHaveBeenCalled(); - }); - it('should call onBefore callback and abort when it returns false', async () => { const onBefore = vi.fn().mockReturnValue(false); @@ -284,30 +260,6 @@ describe('useSsoProvisioning', () => { }); }); - it('should return early when provider is null', async () => { - const { result } = renderUseSsoProvisioning(mockIdpId, null); - - await result.current.deleteProvisioning(); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning - .delete, - ).not.toHaveBeenCalled(); - }); - - it('should return early when coreClient is not available', async () => { - vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); - - const { result } = renderUseSsoProvisioning(mockIdpId, mockProvider); - - await result.current.deleteProvisioning(); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning - .delete, - ).not.toHaveBeenCalled(); - }); - it('should call onBefore callback and abort when it returns false', async () => { const onBefore = vi.fn().mockReturnValue(false); @@ -439,7 +391,7 @@ describe('useSsoProvisioning', () => { expect(config).toEqual(mockProvisioningConfig); }); - it('should return null on fetch error', async () => { + it('should propagate non-404 fetch error', async () => { const error = new Error('Fetch failed'); ( mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning @@ -450,9 +402,7 @@ describe('useSsoProvisioning', () => { await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); - const config = await result.current.fetchProvisioning(); - - expect(config).toBeNull(); + await expect(result.current.fetchProvisioning()).rejects.toThrow('Fetch failed'); }); it('should handle 404 and return null', async () => { diff --git a/packages/react/src/hooks/my-organization/use-scim-tokens.ts b/packages/react/src/hooks/my-organization/use-scim-tokens.ts index daf63a1de..9b8e4eb24 100644 --- a/packages/react/src/hooks/my-organization/use-scim-tokens.ts +++ b/packages/react/src/hooks/my-organization/use-scim-tokens.ts @@ -35,7 +35,7 @@ export interface UseScimTokensReturn { listScimTokens: () => Promise; createScimToken: ( data: CreateIdpProvisioningScimTokenRequestContent, - ) => Promise; + ) => Promise; deleteScimToken: (idpScimTokenId: string) => Promise; isScimTokensLoading: boolean; isScimTokenCreating: boolean; @@ -129,24 +129,14 @@ export function useScimTokens( }); const listScimTokens = useCallback(async () => { - try { - return await listScimTokensMutation.mutateAsync(); - } catch { - return null; - } + return await listScimTokensMutation.mutateAsync(); }, [listScimTokensMutation]); const createScimToken = useCallback( async (data: CreateIdpProvisioningScimTokenRequestContent) => { - if (!coreClient || !provider) return undefined; - try { - return await createScimTokenMutation.mutateAsync(data); - } catch (error) { - if (!isActionCancelledError(error)) throw error; - return undefined; - } + return await createScimTokenMutation.mutateAsync(data); }, - [coreClient, createScimTokenMutation, provider], + [createScimTokenMutation], ); const deleteScimToken = useCallback( diff --git a/packages/react/src/hooks/my-organization/use-sso-provisioning.ts b/packages/react/src/hooks/my-organization/use-sso-provisioning.ts index d36a1bc8e..2a36a3f46 100644 --- a/packages/react/src/hooks/my-organization/use-sso-provisioning.ts +++ b/packages/react/src/hooks/my-organization/use-sso-provisioning.ts @@ -159,45 +159,39 @@ export function useSsoProvisioning( }); const fetchProvisioning = useCallback(async () => { - try { - const result = await queryClient.fetchQuery({ - queryKey: ssoProviderEditQueryKeys.provisioning(idpId), - queryFn: async (): Promise => { - try { - const response = await coreClient! - .getMyOrganizationApiClient() - .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) - .organization.identityProviders.provisioning.get(idpId); - return response; - } catch (error) { - if (getStatusCode(error) === 404) return null; - throw error; - } - }, - }); - return result; - } catch { - return null; - } + const result = await queryClient.fetchQuery({ + queryKey: ssoProviderEditQueryKeys.provisioning(idpId), + queryFn: async (): Promise => { + try { + const response = await coreClient! + .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .organization.identityProviders.provisioning.get(idpId); + return response; + } catch (error) { + if (getStatusCode(error) === 404) return null; + throw error; + } + }, + }); + return result; }, [queryClient, idpId, coreClient]); const createProvisioning = useCallback(async () => { - if (!coreClient || !provider) return; try { await createProvisioningMutation.mutateAsync(); } catch (error) { if (!isActionCancelledError(error)) throw error; } - }, [coreClient, createProvisioningMutation, provider]); + }, [createProvisioningMutation]); const deleteProvisioning = useCallback(async () => { - if (!coreClient || !provider) return; try { await deleteProvisioningMutation.mutateAsync(); } catch (error) { if (!isActionCancelledError(error)) throw error; } - }, [coreClient, deleteProvisioningMutation, provider]); + }, [deleteProvisioningMutation]); const syncProvisioningAttributes = useCallback(async () => { if (!coreClient) return; From 3349a8252fb3018076477e44fc7fe62235758314 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Wed, 4 Mar 2026 10:08:16 +0530 Subject: [PATCH 18/27] feat(react): test case update --- .../__tests__/use-domain-table.test.ts | 290 ++++++++++++ .../__tests__/use-scim-tokens.test.ts | 18 +- .../__tests__/use-sso-provider-edit.test.ts | 416 +++++++++++++++++- .../__tests__/use-sso-provisioning.test.ts | 24 +- 4 files changed, 701 insertions(+), 47 deletions(-) diff --git a/packages/react/src/hooks/my-organization/__tests__/use-domain-table.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-domain-table.test.ts index e36776144..56054bfb5 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-domain-table.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-domain-table.test.ts @@ -370,6 +370,54 @@ describe('useDomainTable', () => { mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete, ).toHaveBeenCalledWith(mockProvider.id, mockDomain.domain); }); + + it('should abort associate when onBefore returns false', async () => { + const mockDomain = createMockDomain(); + const mockProvider = createMockIdentityProvider(); + + const options = createMockOptions({ + associateToProviderAction: { + onBefore: vi.fn().mockReturnValue(false), + onAfter: vi.fn(), + }, + }); + + const { result } = renderUseDomainTable(options); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await expect( + result.current.handleToggleSwitch(mockDomain, mockProvider, true), + ).rejects.toThrow(); + + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.create, + ).not.toHaveBeenCalled(); + }); + + it('should abort delete from provider when onBefore returns false', async () => { + const mockDomain = createMockDomain(); + const mockProvider = createMockIdentityProvider(); + + const options = createMockOptions({ + deleteFromProviderAction: { + onBefore: vi.fn().mockReturnValue(false), + onAfter: vi.fn(), + }, + }); + + const { result } = renderUseDomainTable(options); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await expect( + result.current.handleToggleSwitch(mockDomain, mockProvider, false), + ).rejects.toThrow(); + + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete, + ).not.toHaveBeenCalled(); + }); }); describe('Error Handling', () => { @@ -427,5 +475,247 @@ describe('useDomainTable', () => { expect(result.current.error).toBeNull(); }); }); + + it('should retry failed create mutation', async () => { + const error = new Error('Create failed'); + const mockDomain = createMockDomain(); + + mockCoreClient.getMyOrganizationApiClient().organization.domains.create = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue(mockDomain); + + const { result } = renderUseDomainTable(mockOptions); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await expect(result.current.handleCreate(mockDomain.domain)).rejects.toThrow('Create failed'); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it('should retry failed verify mutation', async () => { + const error = new Error('Verify failed'); + const mockDomain = createMockDomain(); + + mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue({ status: 'verified' }); + + const { result } = renderUseDomainTable(mockOptions); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await expect(result.current.handleVerify(mockDomain)).rejects.toThrow('Verify failed'); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it('should retry failed delete mutation', async () => { + const error = new Error('Delete failed'); + const mockDomain = createMockDomain(); + + mockCoreClient.getMyOrganizationApiClient().organization.domains.delete = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue(undefined); + + const { result } = renderUseDomainTable(mockOptions); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await expect(result.current.handleDelete(mockDomain)).rejects.toThrow('Delete failed'); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it('should retry failed associate to provider mutation', async () => { + const error = new Error('Associate failed'); + const mockDomain = createMockDomain(); + const mockProvider = createMockIdentityProvider(); + + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.create = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue(undefined); + + const { result } = renderUseDomainTable(mockOptions); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await expect( + result.current.handleToggleSwitch(mockDomain, mockProvider, true), + ).rejects.toThrow('Associate failed'); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it('should retry failed delete from provider mutation', async () => { + const error = new Error('Disassociate failed'); + const mockDomain = createMockDomain(); + const mockProvider = createMockIdentityProvider(); + + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue(undefined); + + const { result } = renderUseDomainTable(mockOptions); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await expect( + result.current.handleToggleSwitch(mockDomain, mockProvider, false), + ).rejects.toThrow('Disassociate failed'); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it('should retry providers query error', async () => { + const error = new Error('Providers fetch failed'); + const mockDomain = createMockDomain({ status: 'verified', id: 'domain-1' }); + + mockCoreClient.getMyOrganizationApiClient().organization.domains.list = vi + .fn() + .mockResolvedValue({ organization_domains: [mockDomain] }); + + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue({ identity_providers: [] }); + + mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get = vi + .fn() + .mockResolvedValue({ identity_providers: [] }); + + const { result } = renderUseDomainTable(mockOptions); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + act(() => { + result.current.handleConfigureClick(mockDomain); + }); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + }); + + describe('handleVerifyClick', () => { + it('should verify and transition to configure modal on success', async () => { + const mockDomain = createMockDomain({ id: 'domain-1' }); + + mockCoreClient.getMyOrganizationApiClient().organization.domains.list = vi + .fn() + .mockResolvedValue({ organization_domains: [mockDomain] }); + + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi + .fn() + .mockResolvedValue({ identity_providers: [] }); + + mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get = vi + .fn() + .mockResolvedValue({ identity_providers: [] }); + + const { result } = renderUseDomainTable(mockOptions); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await act(async () => { + await result.current.handleVerifyClick(mockDomain); + }); + + await waitFor(() => { + expect(result.current.isVerifying).toBe(false); + }); + + expect(result.current.showConfigureModal).toBe(true); + }); + + it('should set verifyError when verification fails during handleVerifyClick', async () => { + const mockDomain = createMockDomain({ id: 'domain-1' }); + + mockCoreClient.getMyOrganizationApiClient().organization.domains.list = vi + .fn() + .mockResolvedValue({ organization_domains: [mockDomain] }); + + mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create = vi + .fn() + .mockResolvedValue({ status: 'pending' }); + + const { result } = renderUseDomainTable(mockOptions); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await act(async () => { + await result.current.handleVerifyClick(mockDomain); + }); + + await waitFor(() => { + expect(result.current.verifyError).toBeDefined(); + }); + + expect(result.current.showConfigureModal).toBe(false); + }); }); }); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts index 8c96a6399..d19ed8f63 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts @@ -316,11 +316,7 @@ describe('useScimTokens', () => { const { result } = renderUseScimTokens(mockIdpId, mockProvider); - try { - await result.current.listScimTokens(); - } catch { - // Expected to throw - } + await expect(result.current.listScimTokens()).rejects.toThrow('List error'); await waitFor(() => { expect(result.current.scimTokensError).toEqual(error); @@ -336,11 +332,7 @@ describe('useScimTokens', () => { const { result } = renderUseScimTokens(mockIdpId, mockProvider); - try { - await result.current.createScimToken({}); - } catch { - // Expected to throw - } + await expect(result.current.createScimToken({})).rejects.toThrow('Create error'); await waitFor(() => { expect(result.current.scimTokensError).toEqual(error); @@ -356,11 +348,7 @@ describe('useScimTokens', () => { const { result } = renderUseScimTokens(mockIdpId, mockProvider); - try { - await result.current.deleteScimToken('token_123'); - } catch { - // Expected to throw - } + await expect(result.current.deleteScimToken('token_123')).rejects.toThrow('Delete error'); await waitFor(() => { expect(result.current.scimTokensError).toEqual(error); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-edit.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-edit.test.ts index ee5e376bc..f388eee99 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-edit.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-edit.test.ts @@ -6,11 +6,15 @@ import type { import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; +import * as useConfigModule from '@/hooks/my-organization/use-config'; +import * as useIdpConfigModule from '@/hooks/my-organization/use-idp-config'; import { useSsoProviderEdit } from '@/hooks/my-organization/use-sso-provider-edit'; import * as useCoreClientModule from '@/hooks/shared/use-core-client'; import * as useErrorHandlerModule from '@/hooks/shared/use-error-handler'; import * as useTranslatorModule from '@/hooks/shared/use-translator'; import { mockCore, setupAllCommonMocks } from '@/tests/utils'; +import { createMockUseConfig } from '@/tests/utils/__mocks__/my-organization/config/config.mocks'; +import { createMockUseIdpConfig } from '@/tests/utils/__mocks__/my-organization/idp-management/idp-config.mocks'; import { createTestQueryClientWrapper } from '@/tests/utils/test-provider'; const { initMockCoreClient } = mockCore(); @@ -207,11 +211,9 @@ describe('useSsoProviderEdit', () => { await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - try { - await result.current.updateProvider({ strategy: 'samlp', is_enabled: true }); - } catch (e) { - // Expected to throw - } + await expect( + result.current.updateProvider({ strategy: 'samlp', is_enabled: true }), + ).rejects.toThrow('Update failed'); await waitFor(() => { expect(mockHandleError).toHaveBeenCalledWith(error); @@ -338,9 +340,8 @@ describe('useSsoProviderEdit', () => { await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - const tokens = await result.current.listScimTokens(); + await expect(result.current.listScimTokens()).rejects.toThrow('List failed'); - expect(tokens).toBeNull(); await waitFor(() => { expect(mockHandleError).toHaveBeenCalledWith(error); }); @@ -423,11 +424,7 @@ describe('useSsoProviderEdit', () => { await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); await act(async () => { - try { - await result.current.updateProvider({ strategy: 'samlp', is_enabled: true }); - } catch (e) { - // Expected to throw - } + await result.current.updateProvider({ strategy: 'samlp', is_enabled: true }); }); await waitFor(() => { @@ -490,4 +487,399 @@ describe('useSsoProviderEdit', () => { expect(result.current.provider).toEqual(mockProvider); }); }); + + it('should fetch provider imperatively', async () => { + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + const provider = await result.current.fetchProvider(); + + expect(provider).toEqual(mockProvider); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.get, + ).toHaveBeenCalledWith(mockIdpId); + }); + + it('should compute hasSsoAttributeSyncWarning as false when no attributes', async () => { + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + expect(result.current.hasSsoAttributeSyncWarning).toBe(false); + }); + + it('should compute hasSsoAttributeSyncWarning as true when attributes have warnings', async () => { + const providerWithAttributes: IdentityProvider = { + ...mockProvider, + attributes: [ + { id: 'attr_1', is_extra: true, is_missing: false }, + { id: 'attr_2', is_extra: false, is_missing: false }, + ], + } as unknown as IdentityProvider; + + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.get as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(providerWithAttributes); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => { + expect(result.current.hasSsoAttributeSyncWarning).toBe(true); + }); + }); + + it('should toggle provider enabled state', async () => { + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + await result.current.handleToggleProvider(false); + + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.update, + ).toHaveBeenCalledWith(mockIdpId, expect.objectContaining({ is_enabled: false })); + }); + }); + + it('should not toggle provider if strategy is missing', async () => { + const providerNoStrategy = { + ...mockProvider, + strategy: undefined, + } as unknown as IdentityProvider; + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.get as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(providerNoStrategy); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(providerNoStrategy)); + + await result.current.handleToggleProvider(true); + + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.update, + ).not.toHaveBeenCalled(); + }); + + it('should show provisioning tab when provisioning is enabled', async () => { + vi.spyOn(useIdpConfigModule, 'useIdpConfig').mockReturnValue( + createMockUseIdpConfig({ + isProvisioningEnabled: vi.fn(() => true), + isProvisioningMethodEnabled: vi.fn(() => true), + }), + ); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + expect(result.current.showProvisioningTab).toBe(true); + }); + + it('should hide provisioning tab when provisioning is not enabled', async () => { + vi.spyOn(useIdpConfigModule, 'useIdpConfig').mockReturnValue( + createMockUseIdpConfig({ + isProvisioningEnabled: vi.fn(() => false), + isProvisioningMethodEnabled: vi.fn(() => false), + }), + ); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + expect(result.current.showProvisioningTab).toBe(false); + }); + + it('should retry configError by calling configRetry', async () => { + const mockConfigRetry = vi.fn(async () => undefined); + const configError = new Error('Config error'); + vi.spyOn(useConfigModule, 'useConfig').mockReturnValue( + createMockUseConfig({ + error: configError, + retry: mockConfigRetry, + }), + ); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.error).toBe(configError)); + + await result.current.retry(); + + expect(mockConfigRetry).toHaveBeenCalled(); + }); + + it('should retry idpConfigError by calling idpConfigRetry', async () => { + const mockIdpConfigRetry = vi.fn(async () => undefined); + const idpConfigError = new Error('IDP config error'); + vi.spyOn(useIdpConfigModule, 'useIdpConfig').mockReturnValue( + createMockUseIdpConfig({ + error: idpConfigError, + retry: mockIdpConfigRetry, + }), + ); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.error).toBe(idpConfigError)); + + await result.current.retry(); + + expect(mockIdpConfigRetry).toHaveBeenCalled(); + }); + + it('should retry failed update mutation', async () => { + const error = new Error('Update failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .update as ReturnType + ).mockRejectedValueOnce(error); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + await expect( + result.current.updateProvider({ strategy: 'samlp', is_enabled: true }), + ).rejects.toThrow('Update failed'); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + // Reset the mock to succeed on retry + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .update as ReturnType + ).mockResolvedValue(mockProvider); + + await result.current.retry(); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it('should retry failed delete mutation', async () => { + const error = new Error('Delete failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .delete as ReturnType + ).mockRejectedValueOnce(error); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + await expect(result.current.onDeleteConfirm()).rejects.toThrow('Delete failed'); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .delete as ReturnType + ).mockResolvedValue(undefined); + + await result.current.retry(); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it('should retry failed detach mutation', async () => { + const error = new Error('Detach failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .detach as ReturnType + ).mockRejectedValueOnce(error); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + await expect(result.current.onRemoveConfirm()).rejects.toThrow('Detach failed'); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .detach as ReturnType + ).mockResolvedValue(undefined); + + await result.current.retry(); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it('should retry failed sync SSO attributes mutation', async () => { + const error = new Error('Sync failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .updateAttributes as ReturnType + ).mockRejectedValueOnce(error); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + await expect(result.current.syncSsoAttributes()).rejects.toThrow('Sync failed'); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .updateAttributes as ReturnType + ).mockResolvedValue(undefined); + + await result.current.retry(); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it('should call onAfter callback after successful delete', async () => { + const onAfter = vi.fn(); + + const { result } = renderUseSsoProviderEdit(mockIdpId, { + sso: { + updateAction: {}, + deleteAction: { onAfter }, + deleteFromOrganizationAction: {}, + }, + }); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + await result.current.onDeleteConfirm(); + + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider); + }); + }); + + it('should call onBefore and abort detach when it returns false', async () => { + const onBefore = vi.fn().mockReturnValue(false); + + const { result } = renderUseSsoProviderEdit(mockIdpId, { + sso: { + updateAction: {}, + deleteAction: {}, + deleteFromOrganizationAction: { onBefore }, + }, + }); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + await result.current.onRemoveConfirm(); + + expect(onBefore).toHaveBeenCalled(); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.detach, + ).not.toHaveBeenCalled(); + }); + + it('should call onAfter callback after successful detach', async () => { + const onAfter = vi.fn(); + + const { result } = renderUseSsoProviderEdit(mockIdpId, { + sso: { + updateAction: {}, + deleteAction: {}, + deleteFromOrganizationAction: { onAfter }, + }, + }); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + await result.current.onRemoveConfirm(); + + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider); + }); + }); + + it('should handle organization query error', async () => { + const error = new Error('Org fetch failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organizationDetails.get as ReturnType< + typeof vi.fn + > + ).mockRejectedValue(error); + + renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); + }); + }); + + it('should compute hasSsoAttributeSyncWarning as false when attributes key exists but is null', async () => { + const providerWithNullAttributes = { + ...mockProvider, + attributes: null, + } as unknown as IdentityProvider; + + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.get as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(providerWithNullAttributes); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(providerWithNullAttributes)); + + expect(result.current.hasSsoAttributeSyncWarning).toBe(false); + }); + + it('should compute hasSsoAttributeSyncWarning as true when attributes have is_missing flag', async () => { + const providerWithMissingAttributes: IdentityProvider = { + ...mockProvider, + attributes: [{ id: 'attr_1', is_extra: false, is_missing: true }], + } as unknown as IdentityProvider; + + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.get as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(providerWithMissingAttributes); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => { + expect(result.current.hasSsoAttributeSyncWarning).toBe(true); + }); + }); + + it('should return default organization when organization query has no data', async () => { + ( + mockCoreClient.getMyOrganizationApiClient().organizationDetails.get as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(undefined); + + const { result } = renderUseSsoProviderEdit(mockIdpId); + + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + expect(result.current.organization).toBeDefined(); + expect(result.current.organization.id).toBeDefined(); + }); }); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts index 02ad3e445..1a847554a 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts @@ -363,11 +363,7 @@ describe('useSsoProvisioning', () => { await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); - try { - await result.current.syncProvisioningAttributes(); - } catch { - // Expected to throw - } + await expect(result.current.syncProvisioningAttributes()).rejects.toThrow('Sync failed'); await waitFor(() => { expect(mockHandleError).toHaveBeenCalledWith(error); @@ -493,11 +489,7 @@ describe('useSsoProvisioning', () => { await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); - try { - await result.current.createProvisioning(); - } catch { - // Expected to throw - } + await expect(result.current.createProvisioning()).rejects.toThrow('Create error'); await waitFor(() => { expect(result.current.provisioningError).toEqual(error); @@ -515,11 +507,7 @@ describe('useSsoProvisioning', () => { await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); - try { - await result.current.deleteProvisioning(); - } catch { - // Expected to throw - } + await expect(result.current.deleteProvisioning()).rejects.toThrow('Delete error'); await waitFor(() => { expect(result.current.provisioningError).toEqual(error); @@ -537,11 +525,7 @@ describe('useSsoProvisioning', () => { await waitFor(() => expect(result.current.isProvisioningLoading).toBe(false)); - try { - await result.current.syncProvisioningAttributes(); - } catch { - // Expected to throw - } + await expect(result.current.syncProvisioningAttributes()).rejects.toThrow('Sync error'); await waitFor(() => { expect(result.current.provisioningError).toEqual(error); From 32b664770c139864a9483c6b3751e64317ea579e Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Wed, 4 Mar 2026 10:28:12 +0530 Subject: [PATCH 19/27] fix(react,core): fix typos --- docs-site/src/pages/SsoProviderEditDocs.tsx | 2 +- .../sso-provider/sso-provider-edit-types.ts | 2 +- .../core/src/i18n/translations/en-US.json | 2 +- packages/core/src/i18n/translations/ja.json | 2 +- .../__tests__/use-scim-tokens.test.ts | 2 +- .../hooks/my-organization/use-scim-tokens.ts | 2 +- .../__tests__/use-error-handler.test.ts | 41 ++++++++----------- .../src/hooks/shared/use-error-handler.ts | 6 +-- 8 files changed, 24 insertions(+), 35 deletions(-) diff --git a/docs-site/src/pages/SsoProviderEditDocs.tsx b/docs-site/src/pages/SsoProviderEditDocs.tsx index 83d8e3550..703e4e173 100644 --- a/docs-site/src/pages/SsoProviderEditDocs.tsx +++ b/docs-site/src/pages/SsoProviderEditDocs.tsx @@ -893,7 +893,7 @@ interface ComponentAction {
  • tabs.provisioning.content.notifications.* – Notification messages (delete_success, remove_success, update_success, general_error, - provisioning_disabled_success, scim_token_delete_sucess) + provisioning_disabled_success, scim_token_delete_success)
  • diff --git a/packages/core/src/i18n/custom-messages/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts b/packages/core/src/i18n/custom-messages/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts index 3255ac6ae..2f40f00fd 100644 --- a/packages/core/src/i18n/custom-messages/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts +++ b/packages/core/src/i18n/custom-messages/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts @@ -73,7 +73,7 @@ export interface SsoProviderNotificationMessages { update_success?: string; general_error?: string; provisioning_disabled_success?: string; - scim_token_delete_sucess?: string; + scim_token_delete_success?: string; sso_attributes_sync_success?: string; provisioning_attributes_sync_success?: string; } diff --git a/packages/core/src/i18n/translations/en-US.json b/packages/core/src/i18n/translations/en-US.json index 8f63d28cd..d1d242956 100644 --- a/packages/core/src/i18n/translations/en-US.json +++ b/packages/core/src/i18n/translations/en-US.json @@ -865,7 +865,7 @@ "success": "${domain} disabled for ${idp}" }, "provisioning_disabled_success": "Provisioning has been disabled.", - "scim_token_delete_sucess": "Token has been deleted.", + "scim_token_delete_success": "Token has been deleted.", "scim_token_create_success": "Token generated successfully", "sso_attributes_sync_success": "The provider mappings have been updated.", "provisioning_attributes_sync_success": "The provisioning mappings have been updated." diff --git a/packages/core/src/i18n/translations/ja.json b/packages/core/src/i18n/translations/ja.json index cb161b784..b5aa961c0 100644 --- a/packages/core/src/i18n/translations/ja.json +++ b/packages/core/src/i18n/translations/ja.json @@ -855,7 +855,7 @@ "success": "${domain}が${idp}で無効になりました" }, "provisioning_disabled_success": "プロビジョニングが無効になりました。", - "scim_token_delete_sucess": "トークンが削除されました。", + "scim_token_delete_success": "トークンが削除されました。", "scim_token_create_success": "トークンが正常に生成されました", "sso_attributes_sync_success": "プロバイダーマッピングが更新されました。", "provisioning_attributes_sync_success": "プロビジョニングマッピングが更新されました。" diff --git a/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts index d19ed8f63..2c9676d55 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts @@ -225,7 +225,7 @@ describe('useScimTokens', () => { expect(result.current.isScimTokenDeleting).toBe(false); expect(mockedShowToast).toHaveBeenCalledWith({ type: 'success', - message: 'scim_token_delete_sucess', + message: 'scim_token_delete_success', }); }); }); diff --git a/packages/react/src/hooks/my-organization/use-scim-tokens.ts b/packages/react/src/hooks/my-organization/use-scim-tokens.ts index 9b8e4eb24..4371199d3 100644 --- a/packages/react/src/hooks/my-organization/use-scim-tokens.ts +++ b/packages/react/src/hooks/my-organization/use-scim-tokens.ts @@ -118,7 +118,7 @@ export function useScimTokens( .organization.identityProviders.provisioning.scimTokens.delete(idpId, idpScimTokenId); }, onSuccess: async () => { - showToast({ type: 'success', message: t('scim_token_delete_sucess') }); + showToast({ type: 'success', message: t('scim_token_delete_success') }); if (provisioning?.deleteScimTokenAction?.onAfter && provider) { await provisioning.deleteScimTokenAction.onAfter(provider); } diff --git a/packages/react/src/hooks/shared/__tests__/use-error-handler.test.ts b/packages/react/src/hooks/shared/__tests__/use-error-handler.test.ts index ec3e25217..8ea17f7c9 100644 --- a/packages/react/src/hooks/shared/__tests__/use-error-handler.test.ts +++ b/packages/react/src/hooks/shared/__tests__/use-error-handler.test.ts @@ -22,15 +22,15 @@ describe('useErrorHandler', () => { }); }); - it('should return null for null/undefined errors', () => { + it('should not show toast for null/undefined errors', () => { const { result } = renderHook(() => useErrorHandler()); - expect(result.current(null)).toBeNull(); - expect(result.current(undefined)).toBeNull(); + result.current(null); + result.current(undefined); expect(mockedShowToast).not.toHaveBeenCalled(); }); - it('should return null for MFA errors', () => { + it('should not show toast for MFA errors', () => { const { result } = renderHook(() => useErrorHandler()); const mfaError = { body: { @@ -38,11 +38,11 @@ describe('useErrorHandler', () => { }, }; - expect(result.current(mfaError)).toBeNull(); + result.current(mfaError); expect(mockedShowToast).not.toHaveBeenCalled(); }); - it('should return null for 500+ errors', () => { + it('should not show toast for 500+ errors', () => { const { result } = renderHook(() => useErrorHandler()); const serverError = { body: { @@ -50,7 +50,7 @@ describe('useErrorHandler', () => { }, }; - expect(result.current(serverError)).toBeNull(); + result.current(serverError); expect(mockedShowToast).not.toHaveBeenCalled(); }); @@ -63,9 +63,8 @@ describe('useErrorHandler', () => { }, }; - const errorMessage = result.current(apiError); + result.current(apiError); - expect(errorMessage).toBe('Invalid request parameters'); expect(mockedShowToast).toHaveBeenCalledWith({ type: 'error', message: 'Invalid request parameters', @@ -76,9 +75,8 @@ describe('useErrorHandler', () => { const { result } = renderHook(() => useErrorHandler()); const error = new Error('Something went wrong'); - const errorMessage = result.current(error); + result.current(error); - expect(errorMessage).toBe('Something went wrong'); expect(mockedShowToast).toHaveBeenCalledWith({ type: 'error', message: 'Something went wrong', @@ -89,9 +87,8 @@ describe('useErrorHandler', () => { const { result } = renderHook(() => useErrorHandler()); const error = 'Network error occurred'; - const errorMessage = result.current(error); + result.current(error); - expect(errorMessage).toBe('Network error occurred'); expect(mockedShowToast).toHaveBeenCalledWith({ type: 'error', message: 'Network error occurred', @@ -102,9 +99,8 @@ describe('useErrorHandler', () => { const { result } = renderHook(() => useErrorHandler()); const error = { unknown: 'object' }; - const errorMessage = result.current(error); + result.current(error); - expect(errorMessage).toBe('error.generic'); expect(mockedShowToast).toHaveBeenCalledWith({ type: 'error', message: 'error.generic', @@ -115,9 +111,8 @@ describe('useErrorHandler', () => { const { result } = renderHook(() => useErrorHandler()); const error = new Error('Test error'); - const errorMessage = result.current(error, { showToast: false }); + result.current(error, { showToast: false }); - expect(errorMessage).toBe('Test error'); expect(mockedShowToast).not.toHaveBeenCalled(); }); @@ -126,10 +121,9 @@ describe('useErrorHandler', () => { const error = new Error('Original message'); const getErrorMessage = vi.fn(() => 'Custom error message'); - const errorMessage = result.current(error, { getErrorMessage }); + result.current(error, { getErrorMessage }); expect(getErrorMessage).toHaveBeenCalledWith(error); - expect(errorMessage).toBe('Custom error message'); expect(mockedShowToast).toHaveBeenCalledWith({ type: 'error', message: 'Custom error message', @@ -144,9 +138,8 @@ describe('useErrorHandler', () => { }, }; - const errorMessage = result.current(apiError); + result.current(apiError); - expect(errorMessage).toBe('error.generic'); expect(mockedShowToast).toHaveBeenCalledWith({ type: 'error', message: 'error.generic', @@ -162,9 +155,8 @@ describe('useErrorHandler', () => { }, }; - const errorMessage = result.current(notFoundError); + result.current(notFoundError); - expect(errorMessage).toBe('Resource not found'); expect(mockedShowToast).toHaveBeenCalledWith({ type: 'error', message: 'Resource not found', @@ -180,9 +172,8 @@ describe('useErrorHandler', () => { }, }; - const errorMessage = result.current(unauthorizedError); + result.current(unauthorizedError); - expect(errorMessage).toBe('Unauthorized access'); expect(mockedShowToast).toHaveBeenCalledWith({ type: 'error', message: 'Unauthorized access', diff --git a/packages/react/src/hooks/shared/use-error-handler.ts b/packages/react/src/hooks/shared/use-error-handler.ts index db5447a58..7061183e5 100644 --- a/packages/react/src/hooks/shared/use-error-handler.ts +++ b/packages/react/src/hooks/shared/use-error-handler.ts @@ -61,8 +61,8 @@ export function useErrorHandler() { const { t } = useTranslator('common'); return useCallback( - (error: unknown, options: ErrorHandlerCallOptions = {}): string | null => { - if (!shouldHandleError(error)) return null; + (error: unknown, options: ErrorHandlerCallOptions = {}): void => { + if (!shouldHandleError(error)) return; const { getErrorMessage, showToast: shouldShowToast = true } = options; @@ -75,8 +75,6 @@ export function useErrorHandler() { message: errorMessage, }); } - - return errorMessage; }, [t], ); From 7aa45cdf2b95d49cc601b38d4a1e83c7ba3b65da Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Wed, 4 Mar 2026 10:43:23 +0530 Subject: [PATCH 20/27] fix(react,core): test case update for use domain tab --- .../__tests__/use-sso-domain-tab.test.ts | 247 ++++++++++++++++-- 1 file changed, 222 insertions(+), 25 deletions(-) diff --git a/packages/react/src/hooks/my-organization/__tests__/use-sso-domain-tab.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-domain-tab.test.ts index 04f8337f6..8dd8dc218 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-sso-domain-tab.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-sso-domain-tab.test.ts @@ -216,11 +216,7 @@ describe('useSsoDomainTab', () => { const { result } = await renderUseSsoDomainTab('idp-1'); await act(async () => { - try { - await result.current.handleCreate('newdomain.com'); - } catch (e) { - // Expected to throw - } + await expect(result.current.handleCreate('newdomain.com')).rejects.toThrow(); }); await waitFor(() => { @@ -274,11 +270,7 @@ describe('useSsoDomainTab', () => { }); await act(async () => { - try { - await result.current.handleCreate('newdomain.com'); - } catch (e) { - // Expected to throw - } + await expect(result.current.handleCreate('newdomain.com')).rejects.toThrow(); }); await waitFor(() => { @@ -326,11 +318,7 @@ describe('useSsoDomainTab', () => { const { result } = await renderUseSsoDomainTab('idp-1'); await act(async () => { - try { - await result.current.handleVerify(mockDomain); - } catch (e) { - // Expected to throw - } + await expect(result.current.handleVerify(mockDomain)).rejects.toThrow(); }); await waitFor(() => { @@ -436,11 +424,7 @@ describe('useSsoDomainTab', () => { const { result } = await renderUseSsoDomainTab('idp-1'); await act(async () => { - try { - await result.current.handleDelete(mockDomain); - } catch (e) { - // Expected to throw - } + await expect(result.current.handleDelete(mockDomain)).rejects.toThrow(); }); await waitFor(() => { @@ -537,11 +521,7 @@ describe('useSsoDomainTab', () => { }); await act(async () => { - try { - await result.current.handleToggleSwitch(mockDomain, true); - } catch (e) { - // Expected to throw - } + await expect(result.current.handleToggleSwitch(mockDomain, true)).rejects.toThrow(); }); await waitFor(() => { @@ -571,6 +551,49 @@ describe('useSsoDomainTab', () => { expect(onAfter).toHaveBeenCalledWith(mockDomain, mockProvider); }); }); + + it('should call deleteFromProvider callbacks when toggling off', async () => { + const onBefore = vi.fn().mockReturnValue(true); + const onAfter = vi.fn(); + mockIdentityProviderDomainsDelete.mockResolvedValue({}); + + const { result } = await renderUseSsoDomainTab('idp-1', { + provider: mockProvider, + domains: { + deleteFromProviderAction: { onBefore, onAfter }, + }, + }); + + await act(async () => { + await result.current.handleToggleSwitch(mockDomain, false); + }); + + await waitFor(() => { + expect(onBefore).toHaveBeenCalledWith(mockDomain, mockProvider); + expect(onAfter).toHaveBeenCalledWith(mockDomain); + }); + }); + + it('should abort deleteFromProvider when onBefore returns false', async () => { + const onBefore = vi.fn().mockReturnValue(false); + mockIdentityProviderDomainsDelete.mockResolvedValue({}); + + const { result } = await renderUseSsoDomainTab('idp-1', { + provider: mockProvider, + domains: { + deleteFromProviderAction: { onBefore }, + }, + }); + + await act(async () => { + await expect(result.current.handleToggleSwitch(mockDomain, false)).rejects.toThrow(); + }); + + await waitFor(() => { + expect(onBefore).toHaveBeenCalledWith(mockDomain, mockProvider); + expect(mockIdentityProviderDomainsDelete).not.toHaveBeenCalled(); + }); + }); }); describe('modal state management', () => { @@ -684,4 +707,178 @@ describe('useSsoDomainTab', () => { expect(domainCount).toBe(1); }); }); + + describe('retry', () => { + it('should retry domainsQuery when it has an error', async () => { + const error = new Error('Domains fetch failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.domains.list as ReturnType< + typeof vi.fn + > + ).mockRejectedValueOnce(error); + + const { wrapper } = createTestQueryClientWrapper(); + const { result } = renderHook(() => useSsoDomainTab('idp-1'), { wrapper }); + + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); + }); + + // Fix the mock for retry + ( + mockCoreClient.getMyOrganizationApiClient().organization.domains.list as ReturnType< + typeof vi.fn + > + ).mockResolvedValue({ organization_domains: [mockDomain] }); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.domainsList).toEqual([mockDomain]); + }); + }); + + it('should retry createDomainMutation when it has an error', async () => { + const error = new Error('Create failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.domains.create as ReturnType< + typeof vi.fn + > + ).mockRejectedValueOnce(error); + + const { result } = await renderUseSsoDomainTab('idp-1'); + + // Trigger a failed create + await act(async () => { + await expect(result.current.handleCreate('newdomain.com')).rejects.toThrow(); + }); + + await waitFor(() => expect(mockHandleError).toHaveBeenCalledWith(error)); + + // Fix the mock and retry + ( + mockCoreClient.getMyOrganizationApiClient().organization.domains.create as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(mockDomain); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.domains.create, + ).toHaveBeenCalledTimes(2); + }); + }); + + it('should retry verifyDomainMutation when it has an error', async () => { + const error = new Error('Verify failed'); + mockDomainVerifyCreate.mockRejectedValueOnce(error); + + const { result } = await renderUseSsoDomainTab('idp-1'); + + await act(async () => { + await expect(result.current.handleVerify(mockDomain)).rejects.toThrow(); + }); + + await waitFor(() => expect(mockHandleError).toHaveBeenCalledWith(error)); + + mockDomainVerifyCreate.mockResolvedValue(mockVerifiedDomain); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(mockDomainVerifyCreate).toHaveBeenCalledTimes(2); + }); + }); + + it('should retry deleteDomainMutation when it has an error', async () => { + const error = new Error('Delete failed'); + ( + mockCoreClient.getMyOrganizationApiClient().organization.domains.delete as ReturnType< + typeof vi.fn + > + ).mockRejectedValueOnce(error); + + const { result } = await renderUseSsoDomainTab('idp-1'); + + await act(async () => { + await expect(result.current.handleDelete(mockDomain)).rejects.toThrow(); + }); + + await waitFor(() => expect(mockHandleError).toHaveBeenCalledWith(error)); + + ( + mockCoreClient.getMyOrganizationApiClient().organization.domains.delete as ReturnType< + typeof vi.fn + > + ).mockResolvedValue({}); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.domains.delete, + ).toHaveBeenCalledTimes(2); + }); + }); + + it('should retry associateToProviderMutation when it has an error', async () => { + const error = new Error('Associate failed'); + mockIdentityProviderDomainsCreate.mockRejectedValueOnce(error); + + const { result } = await renderUseSsoDomainTab('idp-1', { + provider: mockProvider, + }); + + await act(async () => { + await expect(result.current.handleToggleSwitch(mockDomain, true)).rejects.toThrow(); + }); + + await waitFor(() => expect(mockHandleError).toHaveBeenCalledWith(error)); + + mockIdentityProviderDomainsCreate.mockResolvedValue({}); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(mockIdentityProviderDomainsCreate).toHaveBeenCalledTimes(2); + }); + }); + + it('should retry deleteFromProviderMutation when it has an error', async () => { + const error = new Error('Delete from provider failed'); + mockIdentityProviderDomainsDelete.mockRejectedValueOnce(error); + + const { result } = await renderUseSsoDomainTab('idp-1', { + provider: mockProvider, + }); + + await act(async () => { + await expect(result.current.handleToggleSwitch(mockDomain, false)).rejects.toThrow(); + }); + + await waitFor(() => expect(mockHandleError).toHaveBeenCalledWith(error)); + + mockIdentityProviderDomainsDelete.mockResolvedValue({}); + + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(mockIdentityProviderDomainsDelete).toHaveBeenCalledTimes(2); + }); + }); + }); }); From 90f8ea447bd6284ce6cd29ad5ebb72b16dbcac87 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Wed, 4 Mar 2026 11:31:00 +0530 Subject: [PATCH 21/27] fix(react): fix step up forms ui --- .../step-up-authenticator-list.tsx | 13 ++++- .../mfa-step-up/step-up-challenge-form.tsx | 13 +---- .../step-up-contact-input-form.tsx | 5 +- .../step-up-enrollment-setup-form.tsx | 58 ++++--------------- .../step-up-qr-code-enrollment-form.tsx | 5 +- .../src/hooks/shared/use-step-up-challenge.ts | 2 +- 6 files changed, 30 insertions(+), 66 deletions(-) diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx index b098a9d16..1407eac62 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx @@ -15,6 +15,13 @@ interface StepUpAuthenticatorListProps { challengingAuthenticatorId: string | null; } +/** Maps API authenticator `type` values to translation keys. */ +const typeToTranslationKey: Record = { + 'push-notification': 'push', + phone: 'sms', + totp: 'otp', +}; + /** * Returns the translated display name for an authenticator. * @param auth - The authenticator. @@ -25,9 +32,9 @@ function getAuthenticatorDisplayName( auth: StepUpAuthenticator, t: (key: string) => string, ): string { - const key = auth.type ?? auth.authenticatorType; - const typeKey = `error.mfa.authenticator_type.${key}`; - return t(typeKey); + const rawKey = auth.type ?? auth.authenticatorType; + const key = typeToTranslationKey[rawKey] ?? rawKey; + return t(`error.mfa.authenticator_type.${key}`); } /** diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx index 660ed259a..353375f0a 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx @@ -31,14 +31,9 @@ type OtpForm = { }; /** - * StepUpChallengeForm - * - * Displayed during the VERIFY phase of the step-up challenge flow. - * A copy of OTPVerificationForm adapted to call verify() on the step-up service - * rather than confirmEnrollment() on the MFA management service. - * - * Handles both OTP (TOTP) and OOB (email/SMS/push) challenge types. - * @param root0 - Component props. + * Challenge form displayed during the VERIFY phase of the step-up flow. + * Handles OTP (TOTP), OOB (email/SMS/push), and recovery-code challenge types. + * @param props - Component props. * @returns Challenge form element. */ export function StepUpChallengeForm({ @@ -66,8 +61,6 @@ export function StepUpChallengeForm({ const handleSubmit = async (data: OtpForm) => { await onVerify(data.userOtp); - // Always reset so the user gets a blank field to re-enter on failure. - // On success the dialog closes, so the reset is a no-op. form.reset(); }; diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx index 045058eaa..08db9ac30 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx @@ -60,13 +60,12 @@ interface StepUpContactInputFormProps { } /** - * StepUpContactInputForm + * Contact input form for the step-up MFA enrollment flow. * - * Copy of ContactInputForm for the step-up enrollment flow. * Receives `enrollMfa` and `confirmEnrollment` adapters from the parent * (`StepUpEnrollmentSetupForm`) that call the step-up API service methods * (`enroll()` and `verify()`) instead of the My Account API. - * @param root0 - Component props. + * @param props - Component props. * @returns Contact input form element. */ export function StepUpContactInputForm({ diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx index 461630103..312275300 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx @@ -22,7 +22,6 @@ import { Separator } from '@/components/ui/separator'; import { Spinner } from '@/components/ui/spinner'; import { useRecoveryCodeGeneration } from '@/hooks/my-account/use-recovery-code'; import { useTranslator } from '@/hooks/shared/use-translator'; -import type { ENROLL, CONFIRM } from '@/lib/constants/my-account/mfa/mfa-constants'; import { ENTER_QR, ENTER_CONTACT, @@ -71,6 +70,9 @@ function mapMFATypeToStepUpFactorType( return map[mfaType] ?? 'otp'; } +/** No-op: sub-forms handle their own error display. */ +const handleEnrollError = () => {}; + interface StepUpEnrollmentSetupFormProps { mfaToken: string; enrollmentFactors: EnrollmentFactor[]; @@ -80,18 +82,8 @@ interface StepUpEnrollmentSetupFormProps { } /** - * StepUpEnrollmentSetupForm - * - * Copy + adapted version of UserMFASetupForm for the step-up enrollment flow. - * - * Key differences: - * - No Dialog wrapper (rendered inside GateKeeper's dialog) - * - Adds a PICK phase: shows the list of available enrollment factors first - * - Builds `enrollMfa` and `confirmEnrollment` adapters that translate - * the My Account API interface expected by sub-forms into step-up - * service calls (stepUpService.enroll() / stepUpService.verify()) - * - Passes selected MFAType → StepUpContactInputForm or StepUpQRCodeEnrollmentForm - * @param root0 - Component props. + * Enrollment setup form for the step-up MFA flow. + * @param props - Component props. * @returns Enrollment setup form element. */ export function StepUpEnrollmentSetupForm({ @@ -107,19 +99,7 @@ export function StepUpEnrollmentSetupForm({ const [phase, setPhase] = React.useState('PICK'); const [selectedFactor, setSelectedFactor] = React.useState(null); - // We repurpose `auth_session` to carry the oobCode back from enroll → to verify. - // This ref stores the oobCode between the enroll and verify calls. - const oobCodeRef = React.useRef(null); - - /* - * enrollMfa adapter - * Translates (MFAType, options) → stepUpService.enroll() - * Returns a normalized object that matches what sub-component hooks expect: - * auth_session → carries oobCode (for OOB verify call) - * barcode_uri → barcodeUri from step-up enroll response - * id → authenticator id - * manual_input_code → secret for TOTP manual entry - */ + /** Adapts step-up `enroll()` to the `CreateAuthenticationMethodResponseContent` shape expected by the shared enrollment sub-forms. */ const enrollMfa = React.useCallback( async ( factorType: MFAType, @@ -146,17 +126,13 @@ export function StepUpEnrollmentSetupForm({ const response = await stepUpService.enroll(params); - // For OOB factors the oobCode must reach verify(); store it in auth_session. const oobCode = 'oobCode' in response ? (response.oobCode ?? '') : ''; - oobCodeRef.current = oobCode || null; - const barcodeUri = 'barcodeUri' in response ? (response.barcodeUri ?? '') : ''; const secret = 'secret' in response ? (response.secret ?? '') : ''; - // Return shape compatible with CreateAuthenticationMethodResponseContent return { id: response.id ?? '', - auth_session: oobCode, // repurposed: carries oobCode for OOB verify + auth_session: oobCode, barcode_uri: barcodeUri, manual_input_code: secret, } as unknown as CreateAuthenticationMethodResponseContent; @@ -164,13 +140,12 @@ export function StepUpEnrollmentSetupForm({ [mfaToken, stepUpService], ); - /* - * confirmEnrollment adapter - * Translates (factorType, authSession, authId, { userOtpCode }) → stepUpService.verify() + /** + * Adapts the shared sub-form `confirmEnrollment` signature to step-up `verify()`. * - * For OTP/TOTP: verify({ mfaToken, otp: userOtpCode }) - * For OOB: verify({ mfaToken, oobCode: authSession, bindingCode: userOtpCode }) - * (authSession carries the oobCode from the enroll response via enrollMfa adapter above) + * - OTP/TOTP: `verify({ mfaToken, otp })` + * - OOB (email/SMS/push): `verify({ mfaToken, oobCode, bindingCode })` + * where `authSession` carries the `oobCode` from the `enrollMfa` adapter. */ const confirmEnrollment = React.useCallback( async ( @@ -196,15 +171,6 @@ export function StepUpEnrollmentSetupForm({ [mfaToken, stepUpService], ); - const handleEnrollError = React.useCallback( - (_error: Error, _stage: typeof ENROLL | typeof CONFIRM) => { - // Errors are displayed inside the sub-forms; no top-level toast needed here - }, - [], - ); - - // Fake dummy hook call: useRecoveryCodeGeneration is only invoked if SHOW_RECOVERY_CODE is needed. - // Keep the hook call unconditional (rules of hooks) but we skip RECOVERY_CODE in step-up for now. const { fetchRecoveryCode, loading: recoveryLoading } = useRecoveryCodeGeneration({ factorType: selectedFactor ?? FACTOR_TYPE_RECOVERY_CODE, enrollMfa, diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx index 2d95193d9..a71375294 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx @@ -44,13 +44,12 @@ interface StepUpQRCodeEnrollmentFormProps { } /** - * StepUpQRCodeEnrollmentForm + * QR code enrollment form for the step-up MFA flow. * - * Copy of QRCodeEnrollmentForm for the step-up enrollment flow. * Receives `enrollMfa` and `confirmEnrollment` adapters from the parent * (`StepUpEnrollmentSetupForm`) that call the step-up API service methods * (`enroll()` and `verify()`) instead of the My Account API. - * @param root0 - Component props. + * @param props - Component props. * @returns QR code enrollment form element. */ export function StepUpQRCodeEnrollmentForm({ diff --git a/packages/react/src/hooks/shared/use-step-up-challenge.ts b/packages/react/src/hooks/shared/use-step-up-challenge.ts index 598d2ad12..33dd2458f 100644 --- a/packages/react/src/hooks/shared/use-step-up-challenge.ts +++ b/packages/react/src/hooks/shared/use-step-up-challenge.ts @@ -43,7 +43,7 @@ const INITIAL_STATE: StepUpState = { /** * Manages the List → Challenge → Verify state machine for MFA step-up authentication. - * @param root0 - Hook options. + * @param options - Hook options. * @returns Step-up challenge state and action handlers. */ export function useStepUpChallenge({ From 1775f3f2dd2bdd0223f6a0439e231f45d2a3d122 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Wed, 4 Mar 2026 11:53:46 +0530 Subject: [PATCH 22/27] fix(react): fix enrollment flow ui --- .../step-up-enrollment-setup-form.tsx | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx index 312275300..656e99cd8 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx @@ -16,7 +16,7 @@ import GoogleLogo from '@/assets/icons/google-logo'; import { StepUpContactInputForm } from '@/components/auth0/shared/mfa-step-up/step-up-contact-input-form'; import { StepUpQRCodeEnrollmentForm } from '@/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form'; import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; +import { Card, CardAction, CardHeader, CardTitle } from '@/components/ui/card'; import { List, ListItem } from '@/components/ui/list'; import { Separator } from '@/components/ui/separator'; import { Spinner } from '@/components/ui/spinner'; @@ -45,8 +45,10 @@ type EnrollmentFormPhase = function mapEnrollmentFactorTypeToMFAType(type: string): MFAType | null { const map: Record = { otp: FACTOR_TYPE_TOTP, - push: FACTOR_TYPE_PUSH_NOTIFICATION, + totp: FACTOR_TYPE_TOTP, + 'push-notification': FACTOR_TYPE_PUSH_NOTIFICATION, sms: FACTOR_TYPE_PHONE, + phone: FACTOR_TYPE_PHONE, email: FACTOR_TYPE_EMAIL, 'recovery-code': FACTOR_TYPE_RECOVERY_CODE, }; @@ -73,6 +75,13 @@ function mapMFATypeToStepUpFactorType( /** No-op: sub-forms handle their own error display. */ const handleEnrollError = () => {}; +/** Maps API type values to translation keys. */ +const typeToTranslationKey: Record = { + 'push-notification': 'push', + phone: 'sms', + totp: 'otp', +}; + interface StepUpEnrollmentSetupFormProps { mfaToken: string; enrollmentFactors: EnrollmentFactor[]; @@ -207,30 +216,31 @@ export function StepUpEnrollmentSetupForm({ {t('error.mfa.enrollment_required')}

    - + {enrollmentFactors.map((factor) => { const mfaType = mapEnrollmentFactorTypeToMFAType(factor.type); - const displayKey = `error.mfa.authenticator_type.${factor.type}`; - const displayName = t(displayKey); + const translationKey = typeToTranslationKey[factor.type] ?? factor.type; + const displayName = t(`error.mfa.authenticator_type.${translationKey}`); return ( - - {displayName} - + + + + {displayName} + + + + + ); })} @@ -241,9 +251,8 @@ export function StepUpEnrollmentSetupForm({
    From f190b7938d622f2d1701b31dda661190ef9e185b Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Mon, 9 Mar 2026 13:20:07 +0530 Subject: [PATCH 25/27] feat(react): add recovery code flow --- .../core/src/i18n/translations/en-US.json | 2 + packages/core/src/i18n/translations/ja.json | 2 + .../shared/mfa/otp-verification-form.tsx | 10 ++- .../components/auth0/shared/gatekeeper.tsx | 8 +- .../step-up-enrollment-setup-form.tsx | 37 +-------- .../step-up-qr-code-enrollment-form.tsx | 83 +++++++++++++++++-- .../hooks/my-account/use-otp-enrollment.ts | 7 ++ .../src/types/my-account/mfa/mfa-types.ts | 2 + 8 files changed, 107 insertions(+), 44 deletions(-) diff --git a/packages/core/src/i18n/translations/en-US.json b/packages/core/src/i18n/translations/en-US.json index 2ab1d7353..dbb23e9cf 100644 --- a/packages/core/src/i18n/translations/en-US.json +++ b/packages/core/src/i18n/translations/en-US.json @@ -1022,6 +1022,8 @@ "enroll_sms_description": "Enter your phone number to receive a verification code", "show_auth0_guardian_title": "Scan this QR code with your Auth0 Guardian App to register this Authentication method or copy the url.", "recovery_code_description": "Copy this recovery code and keep it somewhere safe. You'll need it if you ever need to log in without your device.", + "recovery_code_title": "Generated recovery codes", + "recovery_code_acknowledged": "I have safely recorded this code", "show_otp": { "title": "Scan this QR code with your Authenticator App to register this Authentication method or copy the code.", "save_recovery": "Save these recovery codes!", diff --git a/packages/core/src/i18n/translations/ja.json b/packages/core/src/i18n/translations/ja.json index 4ee3637cd..5f5600d55 100644 --- a/packages/core/src/i18n/translations/ja.json +++ b/packages/core/src/i18n/translations/ja.json @@ -1024,6 +1024,8 @@ "enroll_sms_description": "認証コードを受信するためにあなたの電話番号を入力してください", "show_auth0_guardian_title": "このQRコードをAuth0 Guardianアプリでスキャンするか、URLをコピーして、この認証方法を登録してください", "recovery_code_description": "このリカバリーコードをコピーして、安全な場所に保管してください。デバイスなしでログインする必要がある場合に使用します。", + "recovery_code_title": "リカバリーコードの生成", + "recovery_code_acknowledged": "このコードを安全に記録しました", "show_otp": { "title": "この QR コードを Authenticator アプリでスキャンして、この認証方法を登録するか、コードをコピーしてください。", "save_recovery": "これらのリカバリーコードを保存してください!", diff --git a/packages/react/src/components/auth0/my-account/shared/mfa/otp-verification-form.tsx b/packages/react/src/components/auth0/my-account/shared/mfa/otp-verification-form.tsx index f284f3088..aff68f4ac 100644 --- a/packages/react/src/components/auth0/my-account/shared/mfa/otp-verification-form.tsx +++ b/packages/react/src/components/auth0/my-account/shared/mfa/otp-verification-form.tsx @@ -99,6 +99,8 @@ export function OTPVerificationForm({ authSession, authenticationMethodId, onBack, + buttonSize = 'default', + buttonAlignment = 'justify-end', styling = { variables: { common: {}, light: {}, dark: {} }, classes: {}, @@ -194,12 +196,12 @@ export function OTPVerificationForm({ )} /> -
    +
    ); - const renderRecoveryCodePhase = () => { - if (recoveryLoading) { - return ( -
    - -
    - ); - } - return null; - }; - if (!selectedFactor || phase === 'PICK') { return renderPickPhase(); } @@ -349,9 +323,6 @@ export function StepUpEnrollmentSetupForm({ /> ); - case SHOW_RECOVERY_CODE: - return renderRecoveryCodePhase(); - default: return null; } diff --git a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx index 557ebfab9..f98ced5e1 100644 --- a/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx @@ -10,6 +10,8 @@ import * as React from 'react'; import { OTPVerificationForm } from '@/components/auth0/my-account/shared/mfa/otp-verification-form'; import { CopyableTextField } from '@/components/auth0/shared/copyable-text-field'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; import { QRCodeDisplayer } from '@/components/ui/qr-code'; import { Spinner } from '@/components/ui/spinner'; import { useOtpEnrollment } from '@/hooks/my-account/use-otp-enrollment'; @@ -19,9 +21,12 @@ import { QR_PHASE_ENTER_OTP, QR_PHASE_SCAN } from '@/lib/constants/my-account/mf import type { ENROLL, CONFIRM } from '@/lib/constants/my-account/mfa/mfa-constants'; import { cn } from '@/lib/utils'; +const QR_PHASE_RECOVERY_CODE = 'RECOVERY_CODE' as const; + const PHASES = { SCAN: QR_PHASE_SCAN, ENTER_OTP: QR_PHASE_ENTER_OTP, + RECOVERY_CODE: QR_PHASE_RECOVERY_CODE, } as const; type Phase = (typeof PHASES)[keyof typeof PHASES]; @@ -72,6 +77,8 @@ export function StepUpQRCodeEnrollmentForm({ [isDarkMode], ); + const [recoveryAcknowledged, setRecoveryAcknowledged] = React.useState(false); + const { fetchOtpEnrollment, otpData, resetOtpData, loading } = useOtpEnrollment({ factorType, enrollMfa, @@ -85,6 +92,25 @@ export function StepUpQRCodeEnrollmentForm({ } }, [otpData?.barcodeUri]); + const hasRecoveryCodes = otpData.recoveryCodes && otpData.recoveryCodes.length > 0; + + const handlePostConfirm = React.useCallback(() => { + if (hasRecoveryCodes) { + setPhase(QR_PHASE_RECOVERY_CODE); + } else { + onSuccess(); + resetOtpData(); + onClose(); + } + }, [hasRecoveryCodes, onSuccess, resetOtpData, onClose]); + + const handleRecoveryContinue = React.useCallback(() => { + onSuccess(); + resetOtpData(); + onClose(); + }, [onSuccess, resetOtpData, onClose]); + + /** QR scan Continue: Push → confirm directly, TOTP → go to OTP entry. */ const handleContinue = React.useCallback(async () => { if (factorType === FACTOR_TYPE_PUSH_NOTIFICATION) { try { @@ -94,16 +120,14 @@ export function StepUpQRCodeEnrollmentForm({ otpData.authenticationMethodId, {}, ); - onSuccess(); - resetOtpData(); - onClose(); + handlePostConfirm(); } catch (error) { onError(error instanceof Error ? error : new Error('Unknown error'), 'confirm'); } } else { setPhase(QR_PHASE_ENTER_OTP); } - }, [factorType, otpData, confirmEnrollment, onSuccess, onError, resetOtpData, onClose]); + }, [factorType, otpData, confirmEnrollment, handlePostConfirm, onError]); const handleBack = React.useCallback(() => { setPhase(QR_PHASE_SCAN); @@ -163,18 +187,65 @@ export function StepUpQRCodeEnrollmentForm({
    ); + const renderRecoveryCodeScreen = () => ( +
    +
    +
    +

    + {t('enrollment_form.recovery_code_description')} +

    + +
    + +
    + setRecoveryAcknowledged(checked === true)} + /> + +
    + +
    + +
    +
    +
    + ); + const renderOtpScreen = () => ( ); - return phase === QR_PHASE_SCAN ? renderQrScreen() : renderOtpScreen(); + switch (phase) { + case QR_PHASE_SCAN: + return renderQrScreen(); + case QR_PHASE_RECOVERY_CODE: + return renderRecoveryCodeScreen(); + case QR_PHASE_ENTER_OTP: + return renderOtpScreen(); + default: + return null; + } } diff --git a/packages/react/src/hooks/my-account/use-otp-enrollment.ts b/packages/react/src/hooks/my-account/use-otp-enrollment.ts index 435b62620..d7a40260a 100644 --- a/packages/react/src/hooks/my-account/use-otp-enrollment.ts +++ b/packages/react/src/hooks/my-account/use-otp-enrollment.ts @@ -47,6 +47,7 @@ export function useOtpEnrollment({ barcodeUri: string; authenticationMethodId: string; manualInputCode?: string; + recoveryCodes?: string[]; }>({ authSession: '', barcodeUri: '', @@ -58,11 +59,17 @@ export function useOtpEnrollment({ setLoading(true); try { const response = await enrollMfa(factorType, {}); + const recoveryCodes = + 'recovery_codes' in response + ? (response as unknown as { recovery_codes?: string[] }).recovery_codes + : undefined; + setOtpData({ authSession: 'auth_session' in response ? response.auth_session : '', barcodeUri: 'barcode_uri' in response ? response.barcode_uri : '', authenticationMethodId: 'id' in response ? response.id : '', manualInputCode: 'manual_input_code' in response ? response.manual_input_code : '', + recoveryCodes, }); } catch (error) { const normalizedError = normalizeError(error, { diff --git a/packages/react/src/types/my-account/mfa/mfa-types.ts b/packages/react/src/types/my-account/mfa/mfa-types.ts index 9ad96e6b0..1856cf09e 100644 --- a/packages/react/src/types/my-account/mfa/mfa-types.ts +++ b/packages/react/src/types/my-account/mfa/mfa-types.ts @@ -177,6 +177,8 @@ export interface OTPVerificationFormProps contact?: string; recoveryCode?: string; onBack?: () => void; + buttonSize?: 'default' | 'sm' | 'lg' | 'icon'; + buttonAlignment?: 'justify-start' | 'justify-center' | 'justify-end'; } export interface QRCodeEnrollmentFormProps From 45e46616704e3ede195bbd8a454fbd9ae20bf1f9 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Mon, 9 Mar 2026 14:24:59 +0530 Subject: [PATCH 26/27] fix(core,react): review comments update --- packages/core/src/api/index.ts | 1 + packages/core/src/api/proxy-http-client.ts | 58 +++++++++++++++++++ .../__tests__/spa-token-retriever.test.ts | 15 ++--- packages/core/src/auth/core-client.ts | 5 +- packages/core/src/auth/spa-token-retriever.ts | 1 - .../services/step-up/step-up-api-service.ts | 55 ++++-------------- .../shared/__tests__/gatekeeper.test.tsx | 21 +++---- .../hooks/my-organization/use-domain-table.ts | 10 ++-- .../hooks/my-organization/use-scim-tokens.ts | 10 ++-- .../my-organization/use-sso-provider-edit.ts | 10 ++-- .../my-organization/use-sso-provisioning.ts | 10 ++-- .../utils/my-organization/action-cancelled.ts | 15 +++++ 12 files changed, 119 insertions(+), 92 deletions(-) create mode 100644 packages/core/src/api/proxy-http-client.ts create mode 100644 packages/react/src/lib/utils/my-organization/action-cancelled.ts diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index f79ab2743..e36348ba7 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -6,3 +6,4 @@ export * from './api-error'; export * from './business-error'; +export * from './proxy-http-client'; diff --git a/packages/core/src/api/proxy-http-client.ts b/packages/core/src/api/proxy-http-client.ts new file mode 100644 index 000000000..127fd55fa --- /dev/null +++ b/packages/core/src/api/proxy-http-client.ts @@ -0,0 +1,58 @@ +/** + * Shared HTTP client for proxy-mode API calls. + * @module proxy-http-client + * @internal + */ + +/** + * A lightweight HTTP client scoped to a base URL. + * Provides `get` and `post` helpers with standardized error handling. + */ +export interface ProxyHttpClient { + /** Sends a GET request with optional query parameters. */ + get: (path: string, query?: Record) => Promise; + /** Sends a POST request with a JSON body. */ + post: (path: string, body: unknown) => Promise; +} + +/** + * Creates a proxy HTTP client scoped to the given base URL. + * + * @param baseUrl - The base URL for all requests (trailing slash is stripped). + * @returns A {@link ProxyHttpClient} with `get` and `post` methods. + */ +export function createProxyHttpClient(baseUrl: string): ProxyHttpClient { + const normalizedBase = baseUrl.replace(/\/$/, ''); + + const handleResponse = async (response: Response): Promise => { + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + throw Object.assign(new Error(errorBody.error_description || `HTTP ${response.status}`), { + status: response.status, + body: errorBody, + ...errorBody, + }); + } + return response.json(); + }; + + const get = async (path: string, query?: Record): Promise => { + const url = new URL(`${normalizedBase}${path}`); + if (query) { + Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v)); + } + const response = await fetch(url.toString()); + return handleResponse(response); + }; + + const post = async (path: string, body: unknown): Promise => { + const response = await fetch(`${normalizedBase}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return handleResponse(response); + }; + + return { get, post }; +} diff --git a/packages/core/src/auth/__tests__/spa-token-retriever.test.ts b/packages/core/src/auth/__tests__/spa-token-retriever.test.ts index fbe5cd1ae..bf1200a0b 100644 --- a/packages/core/src/auth/__tests__/spa-token-retriever.test.ts +++ b/packages/core/src/auth/__tests__/spa-token-retriever.test.ts @@ -105,22 +105,15 @@ describe('spa-token-retriever', () => { }); describe('proxy mode', () => { - it('should return undefined when in proxy mode', async () => { - const proxyAuth = createAuthConfig({ authProxyUrl: 'https://proxy.example.com' }); - const tokenManager = createSpaTokenRetriever(proxyAuth); - const token = await tokenManager.getToken('read:users', 'management'); - expect(token).toBeUndefined(); - expect(mockContextInterface.getAccessTokenSilently).not.toHaveBeenCalled(); - }); - - it('should not validate contextInterface when in proxy mode', async () => { + it('should throw when contextInterface is not initialized in proxy mode', async () => { const proxyAuth = createAuthConfig({ authProxyUrl: 'https://proxy.example.com', contextInterface: undefined, }); const tokenManager = createSpaTokenRetriever(proxyAuth); - const token = await tokenManager.getToken('read:users', 'management'); - expect(token).toBeUndefined(); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( + 'SpaTokenRetriever: contextInterface is not initialized.', + ); }); }); diff --git a/packages/core/src/auth/core-client.ts b/packages/core/src/auth/core-client.ts index be48405f6..74ca6435a 100644 --- a/packages/core/src/auth/core-client.ts +++ b/packages/core/src/auth/core-client.ts @@ -78,7 +78,10 @@ export async function createCoreClient( myOrganizationApiClient, stepUpApiService, - getToken: (scope, aud, ignoreCache) => tokenManagerService.getToken(scope, aud, ignoreCache), + getToken: (scope, aud, ignoreCache) => + authDetails.authProxyUrl + ? Promise.resolve(undefined) + : tokenManagerService.getToken(scope, aud, ignoreCache), isProxyMode: () => !!authDetails.authProxyUrl, getDomain: () => authDetails.domain ?? authDetails.contextInterface?.getConfiguration()?.domain, diff --git a/packages/core/src/auth/spa-token-retriever.ts b/packages/core/src/auth/spa-token-retriever.ts index 50f5cf502..8ef2543da 100644 --- a/packages/core/src/auth/spa-token-retriever.ts +++ b/packages/core/src/auth/spa-token-retriever.ts @@ -38,7 +38,6 @@ export function createSpaTokenRetriever(auth: AuthDetails) { audiencePath: string, ignoreCache = false, ): Promise { - if (auth.authProxyUrl) return undefined; if (!auth.contextInterface) { throw new Error('SpaTokenRetriever: contextInterface is not initialized.'); } diff --git a/packages/core/src/services/step-up/step-up-api-service.ts b/packages/core/src/services/step-up/step-up-api-service.ts index 4181276a7..0c9ff4ff0 100644 --- a/packages/core/src/services/step-up/step-up-api-service.ts +++ b/packages/core/src/services/step-up/step-up-api-service.ts @@ -1,3 +1,4 @@ +import { createProxyHttpClient } from '../../api/proxy-http-client'; import type { AuthDetails, Authenticator, @@ -44,25 +45,11 @@ export function initializeStepUpApiService(auth: AuthDetails): StepUpApiService * @returns Proxy-based MFA client. */ function createProxyMfaClient(authProxyUrl: string): Omit { - const baseUrl = authProxyUrl.replace(/\/$/, ''); - - const handleResponse = async (response: Response): Promise => { - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - throw Object.assign(new Error(errorBody.error_description || `HTTP ${response.status}`), { - status: response.status, - body: errorBody, - ...errorBody, - }); - } - return response.json(); - }; + const { get, post } = createProxyHttpClient(authProxyUrl); return { - getAuthenticators: async (mfaToken: string) => { - const response = await fetch(`${baseUrl}/auth/mfa/authenticators?mfa_token=${mfaToken}`); - return handleResponse(response); - }, + getAuthenticators: async (mfaToken: string) => + get('/auth/mfa/authenticators', { mfa_token: mfaToken }), enroll: async (params: EnrollParams) => { const body: Record = { @@ -76,34 +63,16 @@ function createProxyMfaClient(authProxyUrl: string): Omit(response); + return post('/auth/mfa/enroll', body); }, - challenge: async (params: ChallengeAuthenticatorParams) => { - const response = await fetch(`${baseUrl}/auth/mfa/challenge`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - mfaToken: params.mfaToken, - challengeType: params.challengeType, - authenticatorId: params.authenticatorId, - }), - }); - return handleResponse(response); - }, + challenge: async (params: ChallengeAuthenticatorParams) => + post('/auth/mfa/challenge', { + mfaToken: params.mfaToken, + challengeType: params.challengeType, + authenticatorId: params.authenticatorId, + }), - verify: async (params: VerifyParams) => { - const response = await fetch(`${baseUrl}/auth/mfa/verify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params), - }); - return handleResponse(response); - }, + verify: async (params: VerifyParams) => post('/auth/mfa/verify', params), }; } diff --git a/packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx b/packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx index 93448f04f..1f8c72b56 100644 --- a/packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx +++ b/packages/react/src/components/auth0/shared/__tests__/gatekeeper.test.tsx @@ -144,7 +144,7 @@ describe('GateKeeper', () => { }); await waitFor(() => { - expect(screen.getByText('Google Authenticator')).toBeInTheDocument(); + expect(screen.getByText('error.mfa.authenticator_type.otp')).toBeInTheDocument(); }); expect(screen.queryByTestId('children')).not.toBeInTheDocument(); @@ -159,14 +159,12 @@ describe('GateKeeper', () => { renderGateKeeper({ error: mfaError }); - await waitFor(() => { - expect(screen.getByText('error.mfa.enrollment_required')).toBeInTheDocument(); - }); - await waitFor(() => { expect(screen.getByText('error.mfa.authenticator_type.otp')).toBeInTheDocument(); expect(screen.getByText('error.mfa.authenticator_type.sms')).toBeInTheDocument(); }); + + expect(screen.getAllByText('error.mfa.enroll_button')).toHaveLength(2); }); it('should show authenticators in SPA mode when no enrollment needed', async () => { @@ -184,7 +182,7 @@ describe('GateKeeper', () => { renderGateKeeper({ error: mfaError }); await waitFor(() => { - expect(screen.getByText('SMS Auth')).toBeInTheDocument(); + expect(screen.getByText('error.mfa.authenticator_type.sms')).toBeInTheDocument(); }); }); @@ -290,14 +288,13 @@ describe('GateKeeper', () => { renderGateKeeper({ error: mfaError }); await waitFor(() => { - expect(screen.getByText('Test Authenticator')).toBeInTheDocument(); + expect(screen.getByText('error.mfa.authenticator_type.otp')).toBeInTheDocument(); }); - await waitFor(() => { - expect( - screen.getByText('error.mfa.authenticator_type.webauthn-roaming'), - ).toBeInTheDocument(); - }); + // The second authenticator (active: false) is filtered out by the query select + expect( + screen.queryByText('error.mfa.authenticator_type.webauthn-roaming'), + ).not.toBeInTheDocument(); }); }); diff --git a/packages/react/src/hooks/my-organization/use-domain-table.ts b/packages/react/src/hooks/my-organization/use-domain-table.ts index 49d244d8c..85b58d68d 100644 --- a/packages/react/src/hooks/my-organization/use-domain-table.ts +++ b/packages/react/src/hooks/my-organization/use-domain-table.ts @@ -17,6 +17,10 @@ import { showToast } from '@/components/auth0/shared/toast'; import { useCoreClient } from '@/hooks/shared/use-core-client'; import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; +import { + ACTION_CANCELLED_ERROR, + isActionCancelledError, +} from '@/lib/utils/my-organization/action-cancelled'; import type { UseDomainTableOptions, UseDomainTableResult, @@ -24,12 +28,6 @@ import type { type ModalType = 'create' | 'configure' | 'verify' | 'delete'; -const ACTION_CANCELLED_ERROR = 'ACTION_CANCELLED'; - -const isActionCancelledError = (error: unknown): boolean => { - return error instanceof Error && error.message === ACTION_CANCELLED_ERROR; -}; - const domainQueryKeys = { all: ['domains'] as const, list: () => [...domainQueryKeys.all, 'list'] as const, diff --git a/packages/react/src/hooks/my-organization/use-scim-tokens.ts b/packages/react/src/hooks/my-organization/use-scim-tokens.ts index 4371199d3..9722c6311 100644 --- a/packages/react/src/hooks/my-organization/use-scim-tokens.ts +++ b/packages/react/src/hooks/my-organization/use-scim-tokens.ts @@ -18,14 +18,12 @@ import { showToast } from '@/components/auth0/shared/toast'; import { useCoreClient } from '@/hooks/shared/use-core-client'; import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; +import { + ACTION_CANCELLED_ERROR, + isActionCancelledError, +} from '@/lib/utils/my-organization/action-cancelled'; import type { SsoProvisioningTabEditProps } from '@/types/my-organization/idp-management/sso-provisioning/sso-provisioning-tab-types'; -const ACTION_CANCELLED_ERROR = 'ACTION_CANCELLED'; - -const isActionCancelledError = (error: unknown): boolean => { - return error instanceof Error && error.message === ACTION_CANCELLED_ERROR; -}; - export interface UseScimTokensOptions { provisioning?: SsoProvisioningTabEditProps; customMessages?: Record; diff --git a/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts b/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts index 373391285..0342d1e1d 100644 --- a/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts +++ b/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts @@ -25,17 +25,15 @@ import { useSsoProvisioning } from '@/hooks/my-organization/use-sso-provisioning import { useCoreClient } from '@/hooks/shared/use-core-client'; import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; +import { + ACTION_CANCELLED_ERROR, + isActionCancelledError, +} from '@/lib/utils/my-organization/action-cancelled'; import type { UseSsoProviderEditOptions, UseSsoProviderEditReturn, } from '@/types/my-organization/idp-management/sso-provider/sso-provider-edit-types'; -const ACTION_CANCELLED_ERROR = 'ACTION_CANCELLED'; - -const isActionCancelledError = (error: unknown): boolean => { - return error instanceof Error && error.message === ACTION_CANCELLED_ERROR; -}; - export const ssoProviderEditQueryKeys = { all: ['sso-providers'] as const, detail: (idpId: IdpId) => [...ssoProviderEditQueryKeys.all, 'detail', idpId] as const, diff --git a/packages/react/src/hooks/my-organization/use-sso-provisioning.ts b/packages/react/src/hooks/my-organization/use-sso-provisioning.ts index 2a36a3f46..ffe9c5f5b 100644 --- a/packages/react/src/hooks/my-organization/use-sso-provisioning.ts +++ b/packages/react/src/hooks/my-organization/use-sso-provisioning.ts @@ -18,14 +18,12 @@ import { ssoProviderEditQueryKeys } from '@/hooks/my-organization/use-sso-provid import { useCoreClient } from '@/hooks/shared/use-core-client'; import { useErrorHandler } from '@/hooks/shared/use-error-handler'; import { useTranslator } from '@/hooks/shared/use-translator'; +import { + ACTION_CANCELLED_ERROR, + isActionCancelledError, +} from '@/lib/utils/my-organization/action-cancelled'; import type { SsoProvisioningTabEditProps } from '@/types/my-organization/idp-management/sso-provisioning/sso-provisioning-tab-types'; -const ACTION_CANCELLED_ERROR = 'ACTION_CANCELLED'; - -const isActionCancelledError = (error: unknown): boolean => { - return error instanceof Error && error.message === ACTION_CANCELLED_ERROR; -}; - export interface UseSsoProvisioningOptions { provisioning?: SsoProvisioningTabEditProps; customMessages?: Record; diff --git a/packages/react/src/lib/utils/my-organization/action-cancelled.ts b/packages/react/src/lib/utils/my-organization/action-cancelled.ts new file mode 100644 index 000000000..94a09f912 --- /dev/null +++ b/packages/react/src/lib/utils/my-organization/action-cancelled.ts @@ -0,0 +1,15 @@ +/** + * Shared utilities for action cancellation via onBefore hooks. + * @module action-cancelled + */ + +export const ACTION_CANCELLED_ERROR = 'ACTION_CANCELLED'; + +/** + * Checks whether an error was thrown due to an action being cancelled by an onBefore hook. + * @param error - The error to check. + * @returns True if the error represents a cancelled action. + */ +export const isActionCancelledError = (error: unknown): boolean => { + return error instanceof Error && error.message === ACTION_CANCELLED_ERROR; +}; From f96aa37d18384b09298308faadd5ab8b60166bb1 Mon Sep 17 00:00:00 2001 From: naveen-s-chand_atko Date: Tue, 10 Mar 2026 18:30:41 +0530 Subject: [PATCH 27/27] fix(core): test case update --- .../__tests__/step-up-api-service.test.ts | 72 ++----------------- 1 file changed, 4 insertions(+), 68 deletions(-) diff --git a/packages/core/src/services/step-up/__tests__/step-up-api-service.test.ts b/packages/core/src/services/step-up/__tests__/step-up-api-service.test.ts index 29c142db3..59e35e676 100644 --- a/packages/core/src/services/step-up/__tests__/step-up-api-service.test.ts +++ b/packages/core/src/services/step-up/__tests__/step-up-api-service.test.ts @@ -57,11 +57,11 @@ describe('step-up-api-service', () => { vi.restoreAllMocks(); }); - it('should return proxy MFA client when authProxyUrl is provided', () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; + const auth: AuthDetails = { + authProxyUrl: 'https://proxy.example.com', + }; + it('should return proxy MFA client when authProxyUrl is provided', () => { const result = initializeStepUpApiService(auth); expect(result).toBeDefined(); @@ -91,10 +91,6 @@ describe('step-up-api-service', () => { describe('getAuthenticators', () => { it('should fetch authenticators with mfa_token', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const mockAuthenticators = [ { id: 'auth_1', type: 'otp' }, { id: 'auth_2', type: 'oob' }, @@ -115,10 +111,6 @@ describe('step-up-api-service', () => { }); it('should throw error when response is not ok', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const errorBody = { error: 'invalid_token', error_description: 'Invalid MFA token', @@ -138,10 +130,6 @@ describe('step-up-api-service', () => { }); it('should handle error when json parsing fails', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - fetchSpy.mockResolvedValue({ ok: false, status: 500, @@ -156,10 +144,6 @@ describe('step-up-api-service', () => { describe('enroll', () => { it('should enroll OTP authenticator', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const mockResponse = { authenticatorType: 'otp', secret: 'secret_123', @@ -189,10 +173,6 @@ describe('step-up-api-service', () => { }); it('should enroll SMS authenticator with phone number', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const mockResponse = { authenticatorType: 'sms', id: 'auth_123', @@ -223,10 +203,6 @@ describe('step-up-api-service', () => { }); it('should enroll voice authenticator with phone number', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const mockResponse = { authenticatorType: 'voice', id: 'auth_123', @@ -257,10 +233,6 @@ describe('step-up-api-service', () => { }); it('should enroll email authenticator with email', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const mockResponse = { authenticatorType: 'email', id: 'auth_123', @@ -291,10 +263,6 @@ describe('step-up-api-service', () => { }); it('should enroll email authenticator without email field', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const mockResponse = { authenticatorType: 'email', id: 'auth_123', @@ -323,10 +291,6 @@ describe('step-up-api-service', () => { }); it('should throw error when enrollment fails', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const errorBody = { error: 'enrollment_failed', error_description: 'Failed to enroll authenticator', @@ -351,10 +315,6 @@ describe('step-up-api-service', () => { describe('challenge', () => { it('should challenge authenticator', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const mockResponse = { challengeType: 'oob', oobCode: 'oob_code_123', @@ -385,10 +345,6 @@ describe('step-up-api-service', () => { }); it('should throw error when challenge fails', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const errorBody = { error: 'challenge_failed', error_description: 'Failed to challenge authenticator', @@ -414,10 +370,6 @@ describe('step-up-api-service', () => { describe('verify', () => { it('should verify OTP code', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const mockResponse = { access_token: 'access_token_123', id_token: 'id_token_123', @@ -447,10 +399,6 @@ describe('step-up-api-service', () => { }); it('should verify OOB code', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const mockResponse = { access_token: 'access_token_123', id_token: 'id_token_123', @@ -482,10 +430,6 @@ describe('step-up-api-service', () => { }); it('should verify recovery code', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const mockResponse = { access_token: 'access_token_123', id_token: 'id_token_123', @@ -515,10 +459,6 @@ describe('step-up-api-service', () => { }); it('should throw error when verification fails', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const errorBody = { error: 'invalid_code', error_description: 'Invalid OTP code', @@ -541,10 +481,6 @@ describe('step-up-api-service', () => { }); it('should handle error with error properties spread', async () => { - const auth: AuthDetails = { - authProxyUrl: 'https://proxy.example.com', - }; - const errorBody = { error: 'invalid_code', error_description: 'Invalid code',