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/examples/next-rwa/package.json b/examples/next-rwa/package.json index 736f1734f..9e1c7ce9a 100644 --- a/examples/next-rwa/package.json +++ b/examples/next-rwa/package.json @@ -10,7 +10,7 @@ "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..a1037c1d0 100644 --- a/examples/next-rwa/src/lib/auth0.ts +++ b/examples/next-rwa/src/lib/auth0.ts @@ -25,7 +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/`, }), diff --git a/examples/react-spa-npm/package.json b/examples/react-spa-npm/package.json index 614cf728b..932365bd2 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/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/__mocks__/core-client.mocks.ts b/packages/core/src/auth/__mocks__/core-client.mocks.ts index fbe92730f..746329061 100644 --- a/packages/core/src/auth/__mocks__/core-client.mocks.ts +++ b/packages/core/src/auth/__mocks__/core-client.mocks.ts @@ -7,7 +7,6 @@ import type { BasicAuth0ContextInterface, CoreClientInterface, User, - Auth0ContextInterface, GetTokenSilentlyVerboseResponse, GetTokenSilentlyOptions, } from '../auth-types'; @@ -39,6 +38,29 @@ export const createMockVerboseTokenResponse = ( ...overrides, }); +/** + * Creates a mock MFA API client + */ +const createMockMfaClient = () => ({ + 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/__mocks__/index.ts b/packages/core/src/auth/__mocks__/index.ts index dc3db7cf4..a7b5d6e7c 100644 --- a/packages/core/src/auth/__mocks__/index.ts +++ b/packages/core/src/auth/__mocks__/index.ts @@ -1,2 +1,2 @@ export * from './core-client.mocks'; -export * from './token-manager.mocks'; +export * from './spa-token-retriever.mocks'; diff --git a/packages/core/src/auth/__mocks__/token-manager.mocks.ts b/packages/core/src/auth/__mocks__/spa-token-retriever.mocks.ts similarity index 62% rename from packages/core/src/auth/__mocks__/token-manager.mocks.ts rename to packages/core/src/auth/__mocks__/spa-token-retriever.mocks.ts index bf6bd48e6..b487fc141 100644 --- a/packages/core/src/auth/__mocks__/token-manager.mocks.ts +++ b/packages/core/src/auth/__mocks__/spa-token-retriever.mocks.ts @@ -1,19 +1,19 @@ import { vi } from 'vitest'; -import type { createTokenManager } from '../token-manager'; +import type { createSpaTokenRetriever } from '../spa-token-retriever'; /** - * Creates a mock token manager service + * Creates a mock SPA token retriever service */ -export const createMockTokenManager = ( +export const createMockSpaTokenRetriever = ( tokenValue: string | undefined = 'mock-access-token', -): ReturnType => ({ +): ReturnType => ({ getToken: vi.fn(async () => tokenValue), }); -export const createMockTokenManagerWithScopes = ( +export const createMockSpaTokenRetrieverWithScopes = ( tokenValue: string | undefined = 'mock-access-token', -): ReturnType & { +): ReturnType & { lastScope?: string; lastAudiencePath?: string; } => { @@ -29,9 +29,9 @@ export const createMockTokenManagerWithScopes = ( return mockManager; }; -export const createMockTokenManagerWithError = ( +export const createMockSpaTokenRetrieverWithError = ( error: Error = new Error('Token retrieval failed'), -): ReturnType => ({ +): ReturnType => ({ getToken: async () => { throw error; }, diff --git a/packages/core/src/auth/__tests__/core-client.test.ts b/packages/core/src/auth/__tests__/core-client.test.ts index ebee65e33..5d1da899d 100644 --- a/packages/core/src/auth/__tests__/core-client.test.ts +++ b/packages/core/src/auth/__tests__/core-client.test.ts @@ -1,7 +1,9 @@ -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 { 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'; @@ -12,29 +14,38 @@ import { } from '../../internals/__mocks__/shared/api-service.mocks'; import { createMockMyAccountClient } from '../../services/my-account/__tests__/__mocks__/my-account-api-service.mocks'; import { createMockMyOrganizationClient } from '../../services/my-organization/__tests__/__mocks__/my-organization-api-service.mocks'; -import { createMockTokenManager } from '../__mocks__/token-manager.mocks'; +import { createMockSpaTokenRetriever } from '../__mocks__/spa-token-retriever.mocks'; import type { AuthDetails } from '../auth-types'; import { createCoreClient } from '../core-client'; -import { createTokenManager } from '../token-manager'; +import { createSpaTokenRetriever } from '../spa-token-retriever'; // Mock the modules vi.mock('@core/i18n'); -vi.mock('@core/auth/token-manager'); +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 const mockI18nService = createMockI18nService(); - const mockTokenManager = createMockTokenManager(); + 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 createTokenManagerMock = vi.mocked(createTokenManager); + 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 { @@ -50,9 +61,10 @@ describe('createCoreClient', () => { // Setup default mock implementations createI18nServiceMock.mockResolvedValue(mockI18nService); - createTokenManagerMock.mockReturnValue(mockTokenManager); + 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'); @@ -138,175 +150,14 @@ 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 () => { const authDetails = createAuthDetails(); await createCoreClient(authDetails); - expect(createTokenManagerMock).toHaveBeenCalledWith(authDetails); + expect(createSpaTokenRetrieverMock).toHaveBeenCalledWith(authDetails); }); it('initializes MyOrg client with auth and token manager', async () => { @@ -332,35 +183,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 +221,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); @@ -408,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 () => { @@ -418,7 +310,6 @@ describe('createCoreClient', () => { expect(client.myAccountApiClient).toBeUndefined(); expect(client.myOrganizationApiClient).toBeUndefined(); expect(typeof client.getToken).toBe('function'); - expect(typeof client.ensureScopes).toBe('function'); expect(typeof client.isProxyMode).toBe('function'); }); @@ -451,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__/token-manager.test.ts b/packages/core/src/auth/__tests__/spa-token-retriever.test.ts similarity index 72% rename from packages/core/src/auth/__tests__/token-manager.test.ts rename to packages/core/src/auth/__tests__/spa-token-retriever.test.ts index 36a024fac..bf1200a0b 100644 --- a/packages/core/src/auth/__tests__/token-manager.test.ts +++ b/packages/core/src/auth/__tests__/spa-token-retriever.test.ts @@ -6,9 +6,29 @@ import type { BasicAuth0ContextInterface, GetTokenSilentlyVerboseResponse, } from '../auth-types'; -import { createTokenManager } from '../token-manager'; +import { createSpaTokenRetriever } from '../spa-token-retriever'; + +describe('spa-token-retriever', () => { + 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, + }), + }; -describe('token-manager', () => { 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 => ({ @@ -41,10 +62,10 @@ describe('token-manager', () => { vi.clearAllMocks(); }); - describe('createTokenManager', () => { - it('should create a token manager with getToken method', () => { + describe('createSpaTokenRetriever', () => { + it('should create a SPA token retriever with getToken method', () => { const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); expect(tokenManager).toBeDefined(); expect(tokenManager.getToken).toBeDefined(); expect(typeof tokenManager.getToken).toBe('function'); @@ -53,18 +74,17 @@ 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(() => createSpaTokenRetriever(null as unknown as AuthDetails)).toThrow( + 'SpaTokenRetriever: auth is not initialized.', ); }); it('should throw error when contextInterface is not initialized', async () => { const authWithoutContext = createAuthConfig({ contextInterface: undefined }); - const tokenManager = createTokenManager(authWithoutContext); + const tokenManager = createSpaTokenRetriever(authWithoutContext); await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'TokenUtils: contextInterface in CoreClient is not initialized.', + 'SpaTokenRetriever: contextInterface is not initialized.', ); }); @@ -77,37 +97,30 @@ describe('token-manager', () => { domain: undefined, contextInterface: contextWithoutDomain, }); - const tokenManager = createTokenManager(authWithoutDomain); + const tokenManager = createSpaTokenRetriever(authWithoutDomain); await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'TokenUtils: Auth0 domain is not configured', + 'SpaTokenRetriever: Auth0 domain is not configured', ); }); }); describe('proxy mode', () => { - it('should return undefined when in proxy mode', async () => { - const proxyAuth = createAuthConfig({ authProxyUrl: 'https://proxy.example.com' }); - const tokenManager = createTokenManager(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 = createTokenManager(proxyAuth); - const token = await tokenManager.getToken('read:users', 'management'); - expect(token).toBeUndefined(); + const tokenManager = createSpaTokenRetriever(proxyAuth); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( + 'SpaTokenRetriever: contextInterface is not initialized.', + ); }); }); describe('successful token retrieval', () => { it('should fetch token with correct audience and scope', async () => { const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); const token = await tokenManager.getToken('read:users', 'management'); expect(token).toBe(mockToken); @@ -122,7 +135,7 @@ describe('token-manager', () => { it('should build audience URL correctly for MFA', async () => { const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); await tokenManager.getToken('read:me:authentication_methods', 'mfa'); expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ @@ -136,7 +149,7 @@ describe('token-manager', () => { it('should handle domain with https protocol', async () => { const authWithHttps = createAuthConfig({ domain: `https://${TEST_DOMAIN}` }); - const tokenManager = createTokenManager(authWithHttps); + const tokenManager = createSpaTokenRetriever(authWithHttps); await tokenManager.getToken('read:users', 'management'); expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ @@ -152,7 +165,7 @@ describe('token-manager', () => { describe('cache management', () => { it('should not use cacheMode option when ignoreCache is false', async () => { const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); await tokenManager.getToken('read:users', 'management', false); expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ @@ -166,7 +179,7 @@ describe('token-manager', () => { it('should use cacheMode off when ignoreCache is true', async () => { const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); await tokenManager.getToken('read:users', 'management', true); expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ @@ -179,7 +192,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) => { @@ -191,7 +204,7 @@ describe('token-manager', () => { ); const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); // Start multiple concurrent requests for the same token const promise1 = tokenManager.getToken('read:users', 'management'); @@ -210,8 +223,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 () => { @@ -231,7 +244,7 @@ describe('token-manager', () => { }); const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); const [token1, token2] = await Promise.all([ tokenManager.getToken('read:users', 'management'), @@ -260,7 +273,7 @@ describe('token-manager', () => { }); const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); const [token1, token2] = await Promise.all([ tokenManager.getToken('read:users', 'management'), @@ -290,7 +303,7 @@ describe('token-manager', () => { }); const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); // Start first request const promise1 = tokenManager.getToken('read:users', 'management'); @@ -314,7 +327,7 @@ describe('token-manager', () => { it('should clean up pending request after completion', async () => { const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); // First request await tokenManager.getToken('read:users', 'management'); @@ -331,11 +344,11 @@ describe('token-manager', () => { ); const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); // First request fails await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'getAccessToken: failed', + 'Network error', ); // Reset mock for second call @@ -353,80 +366,49 @@ describe('token-manager', () => { }); describe('error handling with fallback', () => { - it('should use popup with consent prompt for consent_required error', async () => { - const mockToken = 'popup-token'; - vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({ - error: 'consent_required', - }); - vi.mocked(mockContextInterface.getAccessTokenWithPopup).mockResolvedValue(mockToken); + it('should throw error for consent_required error (handled by interactiveErrorHandler)', async () => { + const consentError = { error: 'consent_required' }; + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(consentError); const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); - const token = await tokenManager.getToken('read:users', 'management'); + const tokenManager = createSpaTokenRetriever(auth); - 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( + consentError, + ); + expect(mockContextInterface.getAccessTokenWithPopup).not.toHaveBeenCalled(); }); - it('should use popup with login prompt for login_required error', async () => { - const mockToken = 'popup-token'; - vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({ - error: 'login_required', - }); - vi.mocked(mockContextInterface.getAccessTokenWithPopup).mockResolvedValue(mockToken); + it('should throw error for login_required error (handled by interactiveErrorHandler)', async () => { + const loginError = { error: 'login_required' }; + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(loginError); const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); - const token = await tokenManager.getToken('read:users', 'management'); + const tokenManager = createSpaTokenRetriever(auth); - expect(token).toBe(mockToken); - expect(mockContextInterface.getAccessTokenWithPopup).toHaveBeenCalledWith({ - authorizationParams: { - audience: `https://${TEST_DOMAIN}/management/`, - scope: 'read:users', - prompt: 'login', - }, - }); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toEqual(loginError); + expect(mockContextInterface.getAccessTokenWithPopup).not.toHaveBeenCalled(); }); - 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'); + const tokenManager = createSpaTokenRetriever(auth); - 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', - ); + const tokenManager = createSpaTokenRetriever(auth); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toEqual(popupError); }); it('should throw error for non-fallback errors', async () => { @@ -434,39 +416,37 @@ describe('token-manager', () => { vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(originalError); const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(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); const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); try { 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', - ); + const tokenManager = createSpaTokenRetriever(auth); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toEqual(errorObj); expect(mockContextInterface.getAccessTokenWithPopup).not.toHaveBeenCalled(); }); @@ -474,10 +454,8 @@ describe('token-manager', () => { vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(null); const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); - await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'getAccessToken: failed', - ); + const tokenManager = createSpaTokenRetriever(auth); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toBe(null); }); it('should handle string errors', async () => { @@ -486,9 +464,9 @@ describe('token-manager', () => { ); const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); - await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( - 'getAccessToken: failed', + const tokenManager = createSpaTokenRetriever(auth); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toBe( + 'String error message', ); }); }); @@ -496,7 +474,7 @@ describe('token-manager', () => { describe('edge cases', () => { it('should handle empty scope', async () => { const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); await tokenManager.getToken('', 'management'); expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ @@ -510,7 +488,7 @@ describe('token-manager', () => { it('should handle empty audiencePath', async () => { const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); await tokenManager.getToken('read:users', ''); expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ @@ -524,7 +502,7 @@ describe('token-manager', () => { it('should handle special characters in scope', async () => { const auth = createAuthConfig(); - const tokenManager = createTokenManager(auth); + const tokenManager = createSpaTokenRetriever(auth); const scope = 'read:users write:users update:users:self'; await tokenManager.getToken(scope, 'management'); @@ -536,6 +514,27 @@ describe('token-manager', () => { 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/auth-types.ts b/packages/core/src/auth/auth-types.ts index b69bfb793..ed17c73a4 100644 --- a/packages/core/src/auth/auth-types.ts +++ b/packages/core/src/auth/auth-types.ts @@ -3,12 +3,12 @@ * @module auth-types * @internal */ - -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'; /** * Response structure from the token endpoint. @@ -119,9 +119,174 @@ export interface ClientConfiguration { } /** - * Basic Auth0 context interface for minimal authentication operations. - * @internal + * 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; @@ -135,6 +300,7 @@ export interface BasicAuth0ContextInterface { getAccessTokenWithPopup: (options?: unknown) => Promise; loginWithRedirect: (options?: unknown) => Promise; getConfiguration: () => Readonly; + mfa: MfaApiClient; } /** @@ -161,7 +327,6 @@ export interface BaseCoreClientInterface { ignoreCache?: boolean, ) => Promise; isProxyMode: () => boolean; - ensureScopes: (requiredScopes: string, audiencePath: string) => Promise; getDomain: () => string | undefined; } @@ -170,8 +335,10 @@ export interface BaseCoreClientInterface { * @internal */ 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 46bde1799..74ca6435a 100644 --- a/packages/core/src/auth/core-client.ts +++ b/packages/core/src/auth/core-client.ts @@ -6,12 +6,13 @@ 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'; import type { AuthDetails, CoreClientInterface } from './auth-types'; -import { createTokenManager } from './token-manager'; +import { createSpaTokenRetriever } from './spa-token-retriever'; /** * Creates and initializes the core client with all necessary services. @@ -40,7 +41,6 @@ export async function createCoreClient( isProxyMode() { return false; }, - ensureScopes: async () => {}, myAccountApiClient: undefined, myOrganizationApiClient: undefined, getMyAccountApiClient: function () { @@ -52,6 +52,10 @@ export async function createCoreClient( getDomain: function (): string | undefined { return undefined; }, + stepUpApiService: undefined, + getStepUpApiService: function () { + return undefined as unknown as ReturnType; + }, }; return { @@ -59,50 +63,29 @@ export async function createCoreClient( }; } - const tokenManagerService = createTokenManager(authDetails); + const tokenManagerService = createSpaTokenRetriever(authDetails); + + const myOrganizationApiClient = initializeMyOrganizationClient(authDetails, tokenManagerService); - const { client: myOrganizationApiClient, setLatestScopes: setOrgScopes } = - initializeMyOrganizationClient(authDetails, tokenManagerService); + const myAccountApiClient = initializeMyAccountClient(authDetails, tokenManagerService); - const { client: myAccountApiClient, setLatestScopes: setAccountScopes } = - initializeMyAccountClient(authDetails, tokenManagerService); + const stepUpApiService = initializeStepUpApiService(authDetails); return { auth: authDetails, i18nService, myAccountApiClient, 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, - 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( @@ -118,5 +101,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/spa-token-retriever.ts b/packages/core/src/auth/spa-token-retriever.ts new file mode 100644 index 000000000..8ef2543da --- /dev/null +++ b/packages/core/src/auth/spa-token-retriever.ts @@ -0,0 +1,58 @@ +import type { AuthDetails } from './auth-types'; +import { AuthUtils } from './auth-utils'; + +/** + * Builds the audience URL from a domain and audience path. + * @param domain - The Auth0 tenant domain. + * @param audiencePath - The API audience path segment. + * @returns The constructed audience URL string. + */ +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 SPA token retriever for retrieving access tokens. + * @param auth - Authentication configuration details. + * @returns Token retriever with a getToken method. + */ +export function createSpaTokenRetriever(auth: AuthDetails) { + if (!auth) throw new Error('SpaTokenRetriever: auth is not initialized.'); + + return { + /** + * Retrieves an access token for the specified scope and audience. + * @param scope - The OAuth scope to request. + * @param audiencePath - The API audience path segment. + * @param ignoreCache - Whether to bypass the token cache. + * @returns The access token, or undefined if using proxy mode. + */ + async getToken( + scope: string, + audiencePath: string, + ignoreCache = false, + ): Promise { + if (!auth.contextInterface) { + throw new Error('SpaTokenRetriever: contextInterface is not initialized.'); + } + + const domain = auth.domain ?? auth.contextInterface.getConfiguration()?.domain; + if (!domain) throw new Error('SpaTokenRetriever: Auth0 domain is not configured'); + + const audience = buildAudience(domain, audiencePath); + + const tokenResponse = await auth.contextInterface.getAccessTokenSilently({ + authorizationParams: { audience, scope }, + detailedResponse: true, + ...(ignoreCache && { cacheMode: 'off' }), + }); + return tokenResponse.access_token; + }, + }; +} diff --git a/packages/core/src/auth/token-manager.ts b/packages/core/src/auth/token-manager.ts deleted file mode 100644 index 36d22f472..000000000 --- a/packages/core/src/auth/token-manager.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Token management service for handling access token retrieval and caching. - * @module token-manager - * @internal - */ - -import type { AuthDetails, BasicAuth0ContextInterface } 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. - * @internal - */ -const pendingTokenRequests = new Map>(); - -/** - * Set of error types that trigger fallback authentication flows. - * @internal - */ -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. - * @internal - */ -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.'); - } - }, - - /** - * 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, - }, - }); - - if (!token) { - throw new Error('getAccessTokenWithPopup: Access token is not defined'); - } - return token; - } - throw new Error('getAccessToken: failed', { cause: error }); - } - }, -}; - -/** - * Creates a token manager service that handles access token retrieval with caching and deduplication. - * @internal - * - * The token manager provides intelligent caching to prevent duplicate requests for the same token - * and supports silent 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); - * ``` - */ -export function createTokenManager(auth: AuthDetails) { - 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 - */ - async getToken( - scope: string, - audiencePath: string, - ignoreCache: boolean = false, - ): Promise { - // Ensure core client auth is initialized - TokenUtils.isCoreClientAuthInitialized(auth); - - if (TokenUtils.isProxyMode(auth)) { - return Promise.resolve(undefined); - } - - // Ensure core client "contextInterface" is initialized before getting a token - TokenUtils.isCoreClientContextInterfaceInitialized(auth); - - const domain = auth.domain ?? auth.contextInterface!.getConfiguration()?.domain; - TokenUtils.validateDomain(domain); - - // 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); - - try { - const token = await tokenPromise; - return token; - } finally { - // Clean up the pending request after completion - pendingTokenRequests.delete(requestKey); - } - }, - }; -} 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 1762a8c9c..dbb23e9cf 100644 --- a/packages/core/src/i18n/translations/en-US.json +++ b/packages/core/src/i18n/translations/en-US.json @@ -1,7 +1,46 @@ { "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" + }, + "error": { + "generic": "There was an issue processing your request. Please try again or contact support if the issue persists.", + "mfa": { + "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.", + "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.", + "recovery_code_instruction": "Enter the recovery code you were provided during your initial enrollment.", + "enter_code_label": "One-time passcode", + "recovery_code_label": "Recovery code", + "registered_on": "Registered on ${date}", + "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" + } + } + } }, "domain_management": { "domain_table": { @@ -854,7 +893,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." @@ -983,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 cb161b784..5f5600d55 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": "認証方法が見つかりません。サポートにお問い合わせください。", + "factor_available": "設定可能", + "challenge_error": "確認の開始に失敗しました。再度お試しください。", + "verify_error": "確認に失敗しました。コードを確認して再度お試しください。", + "enroll_error": "認証方法の設定に失敗しました。再度お試しください。", + "otp_instruction": "認証アプリに表示されているワンタイムコードを入力してください。", + "oob_instruction": "デバイスに送信されたワンタイムコードを入力してください。", + "recovery_code_instruction": "初回登録時に提供されたリカバリーコードを入力してください。", + "enter_code_label": "ワンタイムパスコード", + "recovery_code_label": "リカバリーコード", + "registered_on": "${date}に登録", + "authenticator_type": { + "otp": "認証アプリ", + "oob": "Auth0 Guardianプッシュ通知", + "recovery-code": "リカバリーコード", + "email": "メールOTP", + "sms": "SMS", + "push": "Auth0 Guardianプッシュ通知", + "voice": "音声通話" + } + } + } }, "domain_management": { "domain_table": { @@ -855,7 +894,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": "プロビジョニングマッピングが更新されました。" @@ -985,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/core/src/index.ts b/packages/core/src/index.ts index 6b5e2f22c..bc91a5c4a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,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'; @@ -49,4 +60,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/internals/__mocks__/shared/api-service.mocks.ts b/packages/core/src/internals/__mocks__/shared/api-service.mocks.ts index d242be431..8dbc8c069 100644 --- a/packages/core/src/internals/__mocks__/shared/api-service.mocks.ts +++ b/packages/core/src/internals/__mocks__/shared/api-service.mocks.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest'; import type { AuthDetails, BasicAuth0ContextInterface } from '../../../auth/auth-types'; -import type { createTokenManager } from '../../../auth/token-manager'; +import type { createSpaTokenRetriever } from '../../../auth/spa-token-retriever'; // ============================================================================= // Test Constants @@ -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, + }), + }, }); // ============================================================================= @@ -68,13 +87,13 @@ export const mockAuthWithProxyUrlWhitespace: AuthDetails = { export const createMockTokenManager = ( tokenValue: string | undefined = 'mock-access-token', -): ReturnType => ({ +): ReturnType => ({ getToken: vi.fn(async () => tokenValue), }); export const createMockTokenManagerWithScopes = ( tokenValue: string | undefined = 'mock-access-token', -): ReturnType & { +): ReturnType & { lastScope?: string; lastAudiencePath?: string; } => { @@ -92,7 +111,7 @@ export const createMockTokenManagerWithScopes = ( export const createMockTokenManagerWithError = ( error: Error = new Error('Token retrieval failed'), -): ReturnType => ({ +): ReturnType => ({ getToken: async () => { throw error; }, diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index e98359a0b..9b72160b0 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -6,3 +6,4 @@ export * from './my-account'; export * from './my-organization'; +export * from './step-up'; 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..710c5118c 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 @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import type { createTokenManager } from '../../../auth/token-manager'; +import type { createSpaTokenRetriever } from '../../../auth/spa-token-retriever'; import { createMockFetch, getConfigFromMockCalls, @@ -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); @@ -452,7 +450,7 @@ describe('initializeMyAccountClient', () => { const mockFetch = createMockFetch(); vi.stubGlobal('fetch', mockFetch); - const tokenManager: ReturnType = { + const tokenManager: ReturnType = { getToken: vi.fn(async () => undefined), }; initializeMyAccountClient(mockAuthWithDomain, tokenManager); @@ -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 27fe9f186..e38ab72e8 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 @@ -7,29 +7,24 @@ import { MyAccountClient } from '@auth0/myaccount-js'; import type { AuthDetails } from '../../auth/auth-types'; -import type { createTokenManager } from '../../auth/token-manager'; +import type { createSpaTokenRetriever } from '../../auth/spa-token-retriever'; + +export interface MyAccountClientWithScopes extends MyAccountClient { + withScopes: (scopes: string) => MyAccountClientWithScopes; +} /** - * Initializes the My Account API client for MFA and user profile operations. - * @internal - * - * @param auth - Authentication configuration details - * @param tokenManagerService - Token manager for handling access tokens - * @returns Object containing the client and scope setter function + * Initializes the My Account API client. + * @param auth - Authentication configuration details. + * @param tokenManagerService - Token retriever for obtaining access tokens. + * @returns Initialized My Account client with scope management. */ export function initializeMyAccountClient( auth: AuthDetails, - tokenManagerService: ReturnType, -): { - client: MyAccountClient; - setLatestScopes: (scopes: string) => void; -} { + tokenManagerService: ReturnType, +): MyAccountClientWithScopes { let latestScopes = ''; - const setLatestScopes = (scopes: string) => { - latestScopes = scopes; - }; - if (auth.authProxyUrl) { const myAccountProxyPath = 'me'; const myAccountBaseUrl = `${auth.authProxyUrl.replace(/\/$/, '')}/${myAccountProxyPath}`; @@ -43,15 +38,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; @@ -72,13 +71,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..3b28d6dcd 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 @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { AuthDetails } from '../../../auth/auth-types'; -import type { createTokenManager } from '../../../auth/token-manager'; +import type { createSpaTokenRetriever } from '../../../auth/spa-token-retriever'; import { createMockFetch, getConfigFromMockCalls, @@ -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); @@ -424,7 +394,7 @@ describe('initializeMyOrganizationClient', () => { it('should not add Authorization header when token is undefined', async () => { // Create a fresh mock that actually returns undefined - const tokenManagerUndefined: ReturnType = { + const tokenManagerUndefined: ReturnType = { getToken: vi.fn(async () => undefined), }; initializeMyOrganizationClient(mockAuthWithDomain, tokenManagerUndefined); @@ -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 e61c0e986..2e5adc533 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 @@ -6,29 +6,24 @@ import { MyOrganizationClient } from '@auth0/myorganization-js'; import type { AuthDetails } from '@core/auth/auth-types'; -import type { createTokenManager } from '@core/auth/token-manager'; +import type { createSpaTokenRetriever } from '@core/auth/spa-token-retriever'; + +export interface MyOrganizationClientWithScopes extends MyOrganizationClient { + withScopes: (scopes: string) => MyOrganizationClientWithScopes; +} /** - * Initializes the My Organization API client for organization, SSO, and domain operations. - * @internal - * - * @param auth - Authentication configuration details - * @param tokenManagerService - Token manager for handling access tokens - * @returns Object containing the client and scope setter function + * Initializes the My Organization API client. + * @param auth - Authentication configuration details. + * @param tokenManagerService - Token retriever for obtaining access tokens. + * @returns Initialized My Organization client with scope management. */ export function initializeMyOrganizationClient( auth: AuthDetails, - tokenManagerService: ReturnType, -): { - client: MyOrganizationClient; - setLatestScopes: (scopes: string) => void; -} { + tokenManagerService: ReturnType, +): MyOrganizationClientWithScopes { let latestScopes = ''; - const setLatestScopes = (scopes: string) => { - latestScopes = scopes; - }; - if (auth.authProxyUrl) { const myOrganizationProxyPath = 'my-org'; const myOrganizationProxyBaseUrl = `${auth.authProxyUrl.replace(/\/$/, '')}/${myOrganizationProxyPath}`; @@ -42,15 +37,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; @@ -71,13 +70,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..59e35e676 --- /dev/null +++ b/packages/core/src/services/step-up/__tests__/step-up-api-service.test.ts @@ -0,0 +1,514 @@ +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(); + }); + + 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(); + 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 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 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 () => { + 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 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 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 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 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 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 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 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 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 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 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 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 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 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 new file mode 100644 index 000000000..91c7bb79e --- /dev/null +++ b/packages/core/src/services/step-up/index.ts @@ -0,0 +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..0c9ff4ff0 --- /dev/null +++ b/packages/core/src/services/step-up/step-up-api-service.ts @@ -0,0 +1,78 @@ +import { createProxyHttpClient } from '../../api/proxy-http-client'; +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. + * + * @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) { + 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. + * + * @param authProxyUrl - Base URL for the auth proxy. + * @returns Proxy-based MFA client. + */ +function createProxyMfaClient(authProxyUrl: string): Omit { + const { get, post } = createProxyHttpClient(authProxyUrl); + + return { + getAuthenticators: async (mfaToken: string) => + get('/auth/mfa/authenticators', { mfa_token: mfaToken }), + + 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; + } + + return post('/auth/mfa/enroll', body); + }, + + challenge: async (params: ChallengeAuthenticatorParams) => + post('/auth/mfa/challenge', { + mfaToken: params.mfaToken, + challengeType: params.challengeType, + authenticatorId: params.authenticatorId, + }), + + verify: async (params: VerifyParams) => post('/auth/mfa/verify', params), + }; +} 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..f8f7e7273 --- /dev/null +++ b/packages/core/src/services/step-up/step-up-types.ts @@ -0,0 +1,15 @@ +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: ChallengeType }>; +} + +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..c7a718285 --- /dev/null +++ b/packages/core/src/services/step-up/step-up-utils.ts @@ -0,0 +1,26 @@ +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; + + const err = error as Record; + + // 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 78b784002..543c50eba 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -88,7 +88,7 @@ "author": "Auth0", "license": "Apache-2.0", "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/__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/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({ )} /> -
    +
    + + + ); +} + +/** + * MFA step-up dialog. Fetches authenticators/enrollment factors, + * handles challenge + verify flow, and renders the dialog UI. + * + * @param props - Component props. + * @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. + */ +function MfaStepUpDialog({ + error, + onSuccess, + onClose, +}: { + error: unknown; + onSuccess: () => Promise; + onClose: () => void; +}): React.JSX.Element { + const { t } = useTranslator('common'); + const { coreClient } = useCoreClient(); + + const mfaToken = useMemo(() => extractMfaToken(error), [error]); + const isProxyMode = coreClient?.isProxyMode() ?? false; + const stepUpService = coreClient?.getStepUpApiService(); + + const { + data: enrollmentFactors, + isLoading: isFetchingEnrollmentFactors, + error: fetchEnrollmentFactorsError, + } = useQuery({ + queryKey: ['mfa-enrollment-factors', mfaToken], + queryFn: () => stepUpService!.getEnrollmentFactors(mfaToken!), + select: (factors) => factors.filter((f) => f.type !== FACTOR_TYPE_RECOVERY_CODE), + enabled: Boolean(!isProxyMode && mfaToken && stepUpService), + retry: false, + }); + + const needsEnrollment = enrollmentFactors && enrollmentFactors.length > 0; + + const { + data: authenticators, + isLoading: isFetchingAuthenticators, + error: fetchAuthenticatorsError, + } = useQuery({ + queryKey: ['mfa-authenticators', mfaToken], + queryFn: () => stepUpService!.getAuthenticators(mfaToken!), + select: (items) => items.filter((a) => a.active), + enabled: Boolean( + mfaToken && + stepUpService && + (isProxyMode || (!needsEnrollment && enrollmentFactors !== undefined)), + ), + retry: false, + }); + + const { + state: challengeState, + selectedAuthenticator, + challengeResponse, + isChallenging, + isVerifying, + error: challengeError, + handleSelectAuthenticator, + handleVerify, + handleBack: handleChallengeBack, + } = useStepUpChallenge({ + mfaToken: mfaToken ?? '', + 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 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') { + return ( + + ); + } + + if (fetchState === 'AUTHENTICATORS' && authenticators) { + return ( + + ); + } + + return null; + }; + + const isListScreen = fetchState === 'AUTHENTICATORS' && challengeState !== 'VERIFY'; + + return ( + !open && onClose()}> + + + {t('error.mfa.title')} + {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(); + setIsMfaDismissed(false); + } finally { + setIsRetrying(false); + } + }; + + if (isLoading) { + return ( +
    + +
    + ); + } + + if (error && isMfaRequiredError(error) && !isMfaDismissed) { + return ( + setIsMfaDismissed(true)} + /> + ); + } + + const statusCode = getStatusCode(error); + const shouldShowErrorFallback = + error && ((statusCode && statusCode >= 500) || isMfaRequiredError(error)); + + if (shouldShowErrorFallback) { + return ( + + ); + } + + return <>{children}; +} 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..1407eac62 --- /dev/null +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-authenticator-list.tsx @@ -0,0 +1,134 @@ +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'; + +interface StepUpAuthenticatorListProps { + authenticators: StepUpAuthenticator[]; + onSelectAuthenticator: (auth: StepUpAuthenticator) => void; + onCancel: () => void; + isChallenging: boolean; + 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. + * @param t - Translation function. + * @returns Display name string. + */ +function getAuthenticatorDisplayName( + auth: StepUpAuthenticator, + t: (key: string) => string, +): string { + const rawKey = auth.type ?? auth.authenticatorType; + const key = typeToTranslationKey[rawKey] ?? rawKey; + return t(`error.mfa.authenticator_type.${key}`); +} + +/** + * 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; + try { + return new Date(isoDate).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return undefined; + } +} + +/** + * 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. + * + * @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({ + authenticators, + onSelectAuthenticator, + onCancel, + isChallenging, + challengingAuthenticatorId, +}: StepUpAuthenticatorListProps) { + const { t } = useTranslator('common'); + + return ( +
    + + {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..353375f0a --- /dev/null +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-challenge-form.tsx @@ -0,0 +1,170 @@ +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 { Separator } from '@/components/ui/separator'; +import { TextField } from '@/components/ui/text-field'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { cn } from '@/lib/utils'; + +interface StepUpChallengeFormProps { + challengeResponse: ChallengeResponse | null; + authenticatorType: string | null; + onVerify: (code: string) => Promise; + onBack: () => void; + isVerifying: boolean; + error: string | null; +} + +type OtpForm = { + userOtp: string; +}; + +/** + * 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({ + challengeResponse, + authenticatorType, + onVerify, + onBack, + isVerifying, + error, +}: StepUpChallengeFormProps) { + const { t } = useTranslator('common'); + const form = useForm({ mode: 'onChange' }); + const userOtp = form.watch('userOtp'); + const otpInputRef = React.useRef(null); + const recoveryCodeInputRef = React.useRef(null); + const isRecoveryCode = authenticatorType === 'recovery-code'; + + React.useEffect(() => { + if (isRecoveryCode) { + recoveryCodeInputRef.current?.focus(); + } else { + otpInputRef.current?.focus(); + } + }, [isRecoveryCode]); + + const handleSubmit = async (data: OtpForm) => { + await onVerify(data.userOtp); + form.reset(); + }; + + const isOtp = challengeResponse?.challengeType === 'otp'; + const instruction = isRecoveryCode + ? t('error.mfa.recovery_code_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} +

    + + ( + + + {isRecoveryCode + ? t('error.mfa.recovery_code_label') + : t('error.mfa.enter_code_label')} + + + {isRecoveryCode ? ( + field.onChange(e.target.value)} + value={field.value || ''} + aria-invalid={!!form.formState.errors.userOtp || !!error} + autoComplete="off" + /> + ) : ( + + )} + + + + )} + /> + + {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..08db9ac30 --- /dev/null +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-contact-input-form.tsx @@ -0,0 +1,246 @@ +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 }; +} + +/** + * Contact input form for the step-up MFA 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 props - Component props. + * @returns Contact input form element. + */ +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..c4922918d --- /dev/null +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-enrollment-setup-form.tsx @@ -0,0 +1,329 @@ +import { + type MFAType, + type EnrollmentFactor, + type EnrollParams, + 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 * 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, CardAction, CardHeader, CardTitle } from '@/components/ui/card'; +import { List, ListItem } from '@/components/ui/list'; +import { Separator } from '@/components/ui/separator'; +import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { + ENTER_QR, + ENTER_CONTACT, + QR_PHASE_INSTALLATION, +} 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; + +/** + * 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 = { + otp: FACTOR_TYPE_TOTP, + 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, + }; + return map[type] ?? 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, +): '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'; +} + +/** 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[]; + onSuccess: () => void; + onClose: () => void; +} + +/** + * Enrollment setup form for the step-up MFA flow. + * @param props - Component props. + * @returns Enrollment setup form element. + */ +export function StepUpEnrollmentSetupForm({ + mfaToken, + enrollmentFactors, + onSuccess, + onClose, +}: StepUpEnrollmentSetupFormProps) { + const { coreClient } = useCoreClient(); + const stepUpService = coreClient!.getStepUpApiService()!; + + const { t } = useTranslator('common'); + const tMfa = useTranslator('mfa').t; + + const [phase, setPhase] = React.useState('PICK'); + const [selectedFactor, setSelectedFactor] = React.useState(null); + + /** Adapts step-up `enroll()` to the `CreateAuthenticationMethodResponseContent` shape expected by the shared enrollment sub-forms. */ + const enrollMfa = React.useCallback( + async ( + factorType: MFAType, + options: Record, + ): Promise => { + const stepUpFactorType = mapMFATypeToStepUpFactorType(factorType); + + let params: EnrollParams; + + 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); + + const oobCode = 'oobCode' in response ? (response.oobCode ?? '') : ''; + const barcodeUri = 'barcodeUri' in response ? (response.barcodeUri ?? '') : ''; + const secret = 'secret' in response ? (response.secret ?? '') : ''; + + const recoveryCodes = 'recoveryCodes' in response ? (response.recoveryCodes ?? []) : []; + + return { + id: response.id ?? '', + auth_session: oobCode, + barcode_uri: barcodeUri, + manual_input_code: secret, + recovery_codes: recoveryCodes, + } as unknown as CreateAuthenticationMethodResponseContent; + }, + [mfaToken, stepUpService], + ); + + /** + * Adapts the shared sub-form `confirmEnrollment` signature to step-up `verify()`. + * + * - 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 ( + 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 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, + }; + + setPhase(phaseMap[mfaType] ?? ENTER_CONTACT); + }; + + const renderPickPhase = () => ( +
    +

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

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

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

    + +
    + +
    + + +
    +
    +
    +
    + ); + + if (!selectedFactor || phase === 'PICK') { + return renderPickPhase(); + } + + switch (phase) { + case QR_PHASE_INSTALLATION: + return renderInstallationPhase(); + + case ENTER_CONTACT: + return ( + + ); + + case ENTER_QR: + return ( + + ); + + 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..f98ced5e1 --- /dev/null +++ b/packages/react/src/components/auth0/shared/mfa-step-up/step-up-qr-code-enrollment-form.tsx @@ -0,0 +1,251 @@ +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 { 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'; +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 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]; + +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; +} + +/** + * QR code enrollment form for the step-up MFA 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 props - Component props. + * @returns QR code enrollment form element. + */ +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 [recoveryAcknowledged, setRecoveryAcknowledged] = React.useState(false); + + const { fetchOtpEnrollment, otpData, resetOtpData, loading } = useOtpEnrollment({ + factorType, + enrollMfa, + onError, + onClose, + }); + + React.useEffect(() => { + if (!otpData?.barcodeUri) { + fetchOtpEnrollment(); + } + }, [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 { + await confirmEnrollment( + factorType, + otpData.authSession, + otpData.authenticationMethodId, + {}, + ); + handlePostConfirm(); + } catch (error) { + onError(error instanceof Error ? error : new Error('Unknown error'), 'confirm'); + } + } else { + setPhase(QR_PHASE_ENTER_OTP); + } + }, [factorType, otpData, confirmEnrollment, handlePostConfirm, onError]); + + 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 renderRecoveryCodeScreen = () => ( +
    +
    +
    +

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

    + +
    + +
    + setRecoveryAcknowledged(checked === true)} + /> + +
    + +
    + +
    +
    +
    + ); + + const 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/hoc/with-services.tsx b/packages/react/src/hoc/with-services.tsx deleted file mode 100644 index 3c9b11534..000000000 --- a/packages/react/src/hoc/with-services.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/** - * HOC for OAuth scope registration and authorization. - * @module with-services - * @internal - */ - -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'; - -/** Required API scopes configuration. */ -export interface ServiceRequirements { - myAccountApiScopes?: string; - myOrganizationApiScopes?: string; -} - -/** - * Checks if required scopes are satisfied by ensured scopes. - * @param required - Required scopes array - * @param ensured - Ensured scopes array - * @returns Whether all required scopes are ensured - * @internal - */ -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)); -} - -/** - * Normalizes scope string (sorts, dedupes, trims). - * @param scopes - OAuth scopes array - * @returns The normalized scope string - * @internal - */ -function normalizeScopes(scopes?: string) { - return scopes - ? scopes - .split(' ') - .map((s) => s.trim()) - .filter(Boolean) - .sort() - .join(' ') - : ''; -} - -/** - * HOC that registers OAuth scopes and shows loader until authorized. - * @param WrappedComponent - Component to wrap. - * @param requirements - Required API scopes. - * @returns Wrapped component with scope handling. - * @internal - */ -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; -} - -/** - * HOC for my-organization API scope authorization. - * @param WrappedComponent - Component to wrap. - * @param scopes - Required my-organization API scopes. - * @returns Wrapped component. - * @internal - */ -export function withMyOrganizationService

    ( - WrappedComponent: React.ComponentType

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

    { - return withServices(WrappedComponent, { myOrganizationApiScopes: scopes }); -} - -/** - * HOC for my-account API scope authorization. - * @param WrappedComponent - Component to wrap. - * @param scopes - Required my-account API scopes. - * @returns Wrapped component. - * @internal - */ -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 9ef2774a2..bdec3fcdb 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -8,7 +8,6 @@ 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 { useErrorHandler } from './shared/use-error-handler'; // My Account hooks @@ -23,7 +22,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-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/hooks/my-organization/__tests__/use-config.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-config.test.ts index a7409a03e..0c66c63b2 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', () => { @@ -150,4 +198,16 @@ describe('useConfig', () => { expect(mockGet).not.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 99ab51104..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 @@ -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,120 +118,69 @@ 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 read from cache without refetching 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 read from cache without triggering refetch - await result.current.fetchDomains(); - - // Should not trigger additional API calls - expect( - vi.mocked(mockCoreClient.getMyOrganizationApiClient().organization.domains.list).mock.calls - .length, - ).toBe(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' }); + + mockCoreClient.getMyOrganizationApiClient().organization.domains.list = vi + .fn() + .mockResolvedValue({ organization_domains: [mockDomain] }); - // Mock all providers response mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi .fn() - .mockResolvedValue({ - identity_providers: [provider1, provider2, provider3], - }); + .mockResolvedValue({ identity_providers: [mockProvider] }); - // Mock associated providers response - only provider1 and provider3 are associated mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get = vi .fn() - .mockResolvedValue({ - identity_providers: [{ id: 'provider-1' }, { id: 'provider-3' }], - }); + .mockResolvedValue({ identity_providers: [] }); const { result } = renderUseDomainTable(mockOptions); - await result.current.fetchProviders(mockDomain); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); + + act(() => { + result.current.handleConfigureClick(mockDomain); + }); + expect(result.current.showConfigureModal).toBe(true); + expect(result.current.selectedDomain).toEqual(mockDomain); + + // Should fetch providers await waitFor(() => { expect(result.current.isLoadingProviders).toBe(false); }); @@ -249,619 +188,534 @@ describe('useDomainTable', () => { 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'); + expect(result.current.providers).toHaveLength(1); }); - 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], - }); - - // Mock empty associated providers response - 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: [], - }); + .mockResolvedValue({ organization_domains: [mockDomain] }); const { result } = renderUseDomainTable(mockOptions); - await result.current.fetchProviders(mockDomain); - await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); + expect(result.current.isFetching).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); + act(() => { + result.current.handleConfigureClick(mockDomain); }); + + expect(result.current.showVerifyModal).toBe(true); + expect(result.current.showConfigureModal).toBe(false); }); - it('should handle all providers being associated', async () => { - const mockDomain = createMockDomain(); - const provider1 = createMockIdentityProvider({ - id: 'provider-1', - display_name: 'Provider 1', + it('should close all modals and clear verifyError', () => { + const { result } = renderUseDomainTable(mockOptions); + + act(() => { + result.current.handleCreateClick(); }); - const provider2 = createMockIdentityProvider({ - id: 'provider-2', - display_name: 'Provider 2', + + expect(result.current.showCreateModal).toBe(true); + + act(() => { + result.current.closeModal(); }); - // Mock all providers response - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi + expect(result.current.showCreateModal).toBe(false); + expect(result.current.verifyError).toBeUndefined(); + }); + }); + + describe('handleCreate', () => { + it('should create domain and open verify modal', async () => { + const mockDomain = createMockDomain(); + const domainUrl = mockDomain.domain; + const expectedPayload: CreateOrganizationDomainRequestContent = { domain: domainUrl }; + + mockCoreClient.getMyOrganizationApiClient().organization.domains.create = vi .fn() - .mockResolvedValue({ - identity_providers: [provider1, provider2], - }); + .mockResolvedValue(mockDomain); - // Mock all providers as associated - mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get = vi + mockCoreClient.getMyOrganizationApiClient().organization.domains.list = vi .fn() - .mockResolvedValue({ - identity_providers: [{ id: 'provider-1' }, { id: 'provider-2' }], - }); + .mockResolvedValue({ organization_domains: [mockDomain] }); const { result } = renderUseDomainTable(mockOptions); - await result.current.fetchProviders(mockDomain); + await act(async () => { + await result.current.handleCreate(domainUrl); + }); await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); + expect(result.current.isCreating).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); - }); + expect(mockOptions.createAction!.onBefore).toHaveBeenCalled(); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.domains.create, + ).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); }); + }); - it('should handle fetchProviders error and reset loading state', async () => { + describe('handleDelete', () => { + it('should delete domain and close modal', 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 act(async () => { + await result.current.handleDelete(mockDomain); + }); await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); + 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).toHaveBeenCalled(); + + // Should close modal and clear selection + expect(result.current.showDeleteModal).toBe(false); + expect(result.current.selectedDomain).toBeNull(); }); + }); - it('should handle null/undefined responses gracefully', async () => { + describe('handleVerify', () => { + it('should verify domain successfully and close modal', 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 - .fn() - .mockResolvedValue({ - identity_providers: null, - }); - const { result } = renderUseDomainTable(mockOptions); - await result.current.fetchProviders(mockDomain); + await act(async () => { + await result.current.handleVerify(mockDomain); + }); await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); + expect(result.current.isVerifying).toBe(false); }); - // Should handle null gracefully and return empty array - expect(result.current.providers).toEqual([]); + expect(mockOptions.verifyAction!.onBefore).toHaveBeenCalledWith(mockDomain); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create, + ).toHaveBeenCalledWith(mockDomain.id); + expect(mockOptions.verifyAction!.onAfter).toHaveBeenCalled(); }); - it('should use ensureQueryData to fetch providers', async () => { + it('should set verifyError when verification fails', 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 + mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create = vi .fn() - .mockResolvedValue({ - identity_providers: [{ id: 'provider-1' }], - }); + .mockResolvedValue({ status: 'pending' }); const { result } = renderUseDomainTable(mockOptions); - await result.current.fetchProviders(mockDomain); + await act(async () => { + await result.current.handleVerify(mockDomain); + }); await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); + expect(result.current.verifyError).toBeDefined(); }); - expect(result.current.providers).toHaveLength(1); - const firstProvider = result.current.providers[0]; - expect(firstProvider).toBeDefined(); - expect(firstProvider!.is_associated).toBe(true); + expect(result.current.verifyError).toBeTruthy(); }); + }); - it('should fetch providers from cache via ensureQueryData', async () => { + describe('handleToggleSwitch', () => { + it('should associate domain to provider when checked', 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' }], - }); + const mockProvider = createMockIdentityProvider(); const { result } = renderUseDomainTable(mockOptions); - // First fetch - await result.current.fetchProviders(mockDomain); - - await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); - }); - - 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); - - await waitFor(() => { - expect(result.current.isLoadingProviders).toBe(false); + await act(async () => { + await result.current.handleToggleSwitch(mockDomain, mockProvider, true); }); - // 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(mockOptions.associateToProviderAction!.onBefore).toHaveBeenCalledWith( + mockDomain, + mockProvider, + ); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.create, + ).toHaveBeenCalledWith(mockProvider.id, { domain: mockDomain.domain }); }); - }); - describe('onCreateDomain', () => { - it('should create domain successfully with callbacks', async () => { + it('should delete domain from provider when unchecked', async () => { const mockDomain = createMockDomain(); - const createData: CreateOrganizationDomainRequestContent = { domain: mockDomain.domain }; + const mockProvider = createMockIdentityProvider(); const { result } = renderUseDomainTable(mockOptions); - await result.current.onCreateDomain(createData); - - await waitFor(() => { - expect(result.current.isCreating).toBe(false); + await act(async () => { + await result.current.handleToggleSwitch(mockDomain, mockProvider, false); }); - expect(mockOptions.createAction!.onBefore).toHaveBeenCalledWith(createData); + expect(mockOptions.deleteFromProviderAction!.onBefore).toHaveBeenCalledWith( + mockDomain, + mockProvider, + ); expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.create, - ).toHaveBeenCalledWith(createData); + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete, + ).toHaveBeenCalledWith(mockProvider.id, mockDomain.domain); }); - it('should handle onBefore callback returning false', async () => { - const createData: CreateOrganizationDomainRequestContent = { domain: 'test.com' }; - const mockOptionsWithFalseBefore = createMockOptions({ - createAction: { + 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(mockOptionsWithFalseBefore); + const { result } = renderUseDomainTable(options); - await expect(result.current.onCreateDomain(createData)).rejects.toThrow(BusinessError); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await expect( + result.current.handleToggleSwitch(mockDomain, mockProvider, true), + ).rejects.toThrow(); expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.create, + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.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); - - const { result } = renderUseDomainTable(mockOptions); - - await expect(result.current.onCreateDomain(createData)).rejects.toThrow('API error'); - - await waitFor(() => { - expect(result.current.isCreating).toBe(false); - }); - - expect(result.current.isCreating).toBe(false); - }); - - it('should work without onBefore and onAfter callbacks', async () => { + it('should abort delete from provider when onBefore returns false', async () => { const mockDomain = createMockDomain(); - const createData: CreateOrganizationDomainRequestContent = { domain: mockDomain.domain }; - const mockOptionsWithoutCallbacks = createMockOptions({ - createAction: undefined, + const mockProvider = createMockIdentityProvider(); + + const options = createMockOptions({ + deleteFromProviderAction: { + onBefore: vi.fn().mockReturnValue(false), + onAfter: vi.fn(), + }, }); - const { result } = renderUseDomainTable(mockOptionsWithoutCallbacks); + const { result } = renderUseDomainTable(options); - await result.current.onCreateDomain(createData); + await waitFor(() => expect(result.current.isFetching).toBe(false)); - await waitFor(() => { - expect(result.current.isCreating).toBe(false); - }); + await expect( + result.current.handleToggleSwitch(mockDomain, mockProvider, false), + ).rejects.toThrow(); expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.create, - ).toHaveBeenCalledWith(createData); + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete, + ).not.toHaveBeenCalled(); }); }); - describe('onVerifyDomain', () => { - it('should verify domain successfully and return true', async () => { - const mockDomain = createMockDomain(); - const { result } = renderUseDomainTable(mockOptions); + describe('Error Handling', () => { + it('should expose error from domains query', async () => { + const error = new Error('Network error'); + const mockList = vi.fn().mockRejectedValue(error); - const isVerified = await result.current.onVerifyDomain(mockDomain); + // Set up the mock before creating the core client reference + const apiService = mockCoreClient.getMyOrganizationApiClient(); + apiService.organization.domains.list = mockList; + + const { result } = renderUseDomainTable(mockOptions); await waitFor(() => { - expect(result.current.isVerifying).toBe(false); + expect(result.current.isFetching).toBe(false); }); - expect(mockOptions.verifyAction!.onBefore).toHaveBeenCalledWith(mockDomain); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create, - ).toHaveBeenCalledWith(mockDomain.id); - expect(isVerified).toBe(true); + // Error should be exposed even though handleError processes it + expect(result.current.error).toBeTruthy(); + expect(mockList).toHaveBeenCalled(); }); - it('should verify domain and return false when status is not verified', async () => { - const mockDomain = createMockDomain(); - mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create = vi + it('should retry on error', async () => { + const error = new Error('Network error'); + const mockList = vi .fn() - .mockResolvedValue({ - status: 'pending', - }); + .mockRejectedValueOnce(error) + .mockResolvedValue({ organization_domains: [] }); - const { result } = renderUseDomainTable(mockOptions); + // Set up error scenario + const apiService = mockCoreClient.getMyOrganizationApiClient(); + apiService.organization.domains.list = mockList; - const isVerified = await result.current.onVerifyDomain(mockDomain); + const { result } = renderUseDomainTable(mockOptions); await waitFor(() => { - expect(result.current.isVerifying).toBe(false); + expect(result.current.isFetching).toBe(false); }); - expect(isVerified).toBe(false); - }); + // Should have error after initial failed fetch + expect(result.current.error).toBeTruthy(); + expect(mockList).toHaveBeenCalledTimes(1); - it('should handle onBefore callback returning false', async () => { - const mockDomain = createMockDomain(); - const mockOptionsWithFalseBefore = createMockOptions({ - verifyAction: { - onBefore: vi.fn().mockReturnValue(false), - onAfter: vi.fn(), - }, + // Retry should trigger another fetch + await act(async () => { + await result.current.retry(); }); - const { result } = renderUseDomainTable(mockOptionsWithFalseBefore); - - await expect(result.current.onVerifyDomain(mockDomain)).rejects.toThrow(BusinessError); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create, - ).not.toHaveBeenCalled(); - }); - - it('should work without onBefore and onAfter callbacks', async () => { - const mockDomain = createMockDomain(); - const mockOptionsWithoutCallbacks = createMockOptions({ - verifyAction: undefined, + await waitFor(() => { + expect(mockList).toHaveBeenCalledTimes(2); }); - const { result } = renderUseDomainTable(mockOptionsWithoutCallbacks); - - const isVerified = await result.current.onVerifyDomain(mockDomain); - + // Error should be cleared after successful retry await waitFor(() => { - expect(result.current.isVerifying).toBe(false); + expect(result.current.error).toBeNull(); }); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create, - ).toHaveBeenCalledWith(mockDomain.id); - expect(isVerified).toBe(true); }); - }); - describe('onDeleteDomain', () => { - it('should delete domain successfully with callbacks', async () => { + 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 result.current.onDeleteDomain(mockDomain); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await expect(result.current.handleCreate(mockDomain.domain)).rejects.toThrow('Create failed'); await waitFor(() => { - expect(result.current.isDeleting).toBe(false); + expect(result.current.error).toBe(error); }); - expect(mockOptions.deleteAction!.onBefore).toHaveBeenCalledWith(mockDomain); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.delete, - ).toHaveBeenCalledWith(mockDomain.id); - expect(mockOptions.deleteAction!.onAfter).toHaveBeenCalledWith(mockDomain); + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); }); - it('should handle onBefore callback returning false', async () => { + it('should retry failed verify mutation', async () => { + const error = new Error('Verify failed'); const mockDomain = createMockDomain(); - const mockOptionsWithFalseBefore = createMockOptions({ - deleteAction: { - onBefore: vi.fn().mockReturnValue(false), - onAfter: vi.fn(), - }, - }); - const { result } = renderUseDomainTable(mockOptionsWithFalseBefore); + mockCoreClient.getMyOrganizationApiClient().organization.domains.verify.create = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue({ status: 'verified' }); - await expect(result.current.onDeleteDomain(mockDomain)).rejects.toThrow(BusinessError); + const { result } = renderUseDomainTable(mockOptions); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.delete, - ).not.toHaveBeenCalled(); - }); + await waitFor(() => expect(result.current.isFetching).toBe(false)); - it('should work without onBefore and onAfter callbacks', async () => { - const mockDomain = createMockDomain(); - const mockOptionsWithoutCallbacks = createMockOptions({ - deleteAction: undefined, - }); + await expect(result.current.handleVerify(mockDomain)).rejects.toThrow('Verify failed'); - const { result } = renderUseDomainTable(mockOptionsWithoutCallbacks); + await waitFor(() => { + expect(result.current.error).toBe(error); + }); - await result.current.onDeleteDomain(mockDomain); + await act(async () => { + await result.current.retry(); + }); await waitFor(() => { - expect(result.current.isDeleting).toBe(false); + expect(result.current.error).toBeNull(); }); - - expect( - mockCoreClient.getMyOrganizationApiClient().organization.domains.delete, - ).toHaveBeenCalledWith(mockDomain.id); }); - }); - describe('onAssociateToProvider', () => { - it('should associate domain to provider successfully', async () => { + it('should retry failed delete mutation', async () => { + const error = new Error('Delete failed'); const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider(); + + mockCoreClient.getMyOrganizationApiClient().organization.domains.delete = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue(undefined); const { result } = renderUseDomainTable(mockOptions); - await result.current.onAssociateToProvider(mockDomain, mockProvider); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await expect(result.current.handleDelete(mockDomain)).rejects.toThrow('Delete failed'); await waitFor(() => { - expect(result.current.isCreating).toBe(false); + expect(result.current.error).toBe(error); }); - expect(mockOptions.associateToProviderAction!.onBefore).toHaveBeenCalledWith( - mockDomain, - mockProvider, - ); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.create, - ).toHaveBeenCalledWith(mockProvider.id, { domain: mockDomain.domain }); + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); }); - it('should handle onBefore callback returning false', async () => { + it('should retry failed associate to provider mutation', async () => { + const error = new Error('Associate failed'); const mockDomain = createMockDomain(); const mockProvider = createMockIdentityProvider(); - const mockOptionsWithFalseBefore = createMockOptions({ - associateToProviderAction: { - onBefore: vi.fn().mockReturnValue(false), - onAfter: vi.fn(), - }, - }); - const { result } = renderUseDomainTable(mockOptionsWithFalseBefore); + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.create = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue(undefined); - await expect(result.current.onAssociateToProvider(mockDomain, mockProvider)).rejects.toThrow( - BusinessError, - ); + const { result } = renderUseDomainTable(mockOptions); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.create, - ).not.toHaveBeenCalled(); - }); + await waitFor(() => expect(result.current.isFetching).toBe(false)); - it('should work without onBefore and onAfter callbacks', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider(); - const mockOptionsWithoutCallbacks = createMockOptions({ - associateToProviderAction: undefined, - }); + await expect( + result.current.handleToggleSwitch(mockDomain, mockProvider, true), + ).rejects.toThrow('Associate failed'); - const { result } = renderUseDomainTable(mockOptionsWithoutCallbacks); + await waitFor(() => { + expect(result.current.error).toBe(error); + }); - await result.current.onAssociateToProvider(mockDomain, mockProvider); + await act(async () => { + await result.current.retry(); + }); await waitFor(() => { - expect(result.current.isCreating).toBe(false); + expect(result.current.error).toBeNull(); }); - - 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 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 result.current.onDeleteFromProvider(mockDomain, mockProvider); + 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.isDeleting).toBe(false); + expect(result.current.error).toBe(error); }); - expect(mockOptions.deleteFromProviderAction!.onBefore).toHaveBeenCalledWith( - mockDomain, - mockProvider, - ); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete, - ).toHaveBeenCalledWith(mockProvider.id, mockDomain.domain); - }); + await act(async () => { + await result.current.retry(); + }); - 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(), - }, + await waitFor(() => { + expect(result.current.error).toBeNull(); }); + }); - const { result } = renderUseDomainTable(mockOptionsWithFalseBefore); + it('should retry providers query error', async () => { + const error = new Error('Providers fetch failed'); + const mockDomain = createMockDomain({ status: 'verified', id: 'domain-1' }); - await expect(result.current.onDeleteFromProvider(mockDomain, mockProvider)).rejects.toThrow( - BusinessError, - ); + mockCoreClient.getMyOrganizationApiClient().organization.domains.list = vi + .fn() + .mockResolvedValue({ organization_domains: [mockDomain] }); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete, - ).not.toHaveBeenCalled(); - }); + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue({ identity_providers: [] }); - it('should work without onBefore and onAfter callbacks', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider(); - const mockOptionsWithoutCallbacks = createMockOptions({ - deleteFromProviderAction: undefined, - }); + mockCoreClient.getMyOrganizationApiClient().organization.domains.identityProviders.get = vi + .fn() + .mockResolvedValue({ identity_providers: [] }); - const { result } = renderUseDomainTable(mockOptionsWithoutCallbacks); + const { result } = renderUseDomainTable(mockOptions); - await result.current.onDeleteFromProvider(mockDomain, mockProvider); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + act(() => { + result.current.handleConfigureClick(mockDomain); + }); await waitFor(() => { - expect(result.current.isDeleting).toBe(false); + expect(result.current.error).toBe(error); }); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.delete, - ).toHaveBeenCalledWith(mockProvider.id, mockDomain.domain); + await act(async () => { + await result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); }); }); - describe('Edge Cases and Integration', () => { - it('should handle provider with undefined id in onAssociateToProvider', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider({ id: undefined }); + 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 result.current.onAssociateToProvider(mockDomain, mockProvider); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + await act(async () => { + await result.current.handleVerifyClick(mockDomain); + }); await waitFor(() => { - expect(result.current.isCreating).toBe(false); + expect(result.current.isVerifying).toBe(false); }); - expect( - mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.domains.create, - ).toHaveBeenCalledWith(undefined, { domain: mockDomain.domain }); + expect(result.current.showConfigureModal).toBe(true); }); - it('should handle provider with undefined id in onDeleteFromProvider', async () => { - const mockDomain = createMockDomain(); - const mockProvider = createMockIdentityProvider({ id: undefined }); + 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 result.current.onDeleteFromProvider(mockDomain, mockProvider); + await waitFor(() => expect(result.current.isFetching).toBe(false)); - await waitFor(() => { - expect(result.current.isDeleting).toBe(false); + await act(async () => { + await result.current.handleVerifyClick(mockDomain); }); - 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); + await waitFor(() => { + expect(result.current.verifyError).toBeDefined(); + }); - expect(useTranslatorSpy).toHaveBeenCalledWith( - 'domain_management.domain_table.notifications', - {}, - ); + expect(result.current.showConfigureModal).toBe(false); }); }); }); 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 a92c4d38f..d5eac7c92 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); @@ -169,10 +203,12 @@ describe('useIdpConfig', () => { }); describe('fetchIdpConfig', () => { - it('returns cached data without refetching', async () => { + it('triggers refetch', async () => { const mockConfig = createMockIdpConfig(); mockGet.mockResolvedValue(mockConfig); - const { result } = await renderUseIdpConfig(); + const { result } = renderUseIdpConfig(); + + await waitFor(() => expect(result.current.isLoadingIdpConfig).toBe(false)); mockGet.mockClear(); const cachedData = await result.current.fetchIdpConfig(); @@ -181,4 +217,60 @@ describe('useIdpConfig', () => { expect(mockGet).not.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 fa6a8708f..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,16 +64,73 @@ describe('useOrganizationDetailsEdit', () => { expect(result.current.organization).toEqual(mockOrganization); }); - it('should read cached organization data without refetching', 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).not.toHaveBeenCalled(); + 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 () => { @@ -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-scim-tokens.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts new file mode 100644 index 000000000..2c9676d55 --- /dev/null +++ b/packages/react/src/hooks/my-organization/__tests__/use-scim-tokens.test.ts @@ -0,0 +1,358 @@ +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 propagate 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); + + await expect(result.current.listScimTokens()).rejects.toThrow('List failed'); + + 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 call onBefore callback and abort when it returns false', async () => { + const onBefore = vi.fn().mockReturnValue(false); + + const { result } = renderUseScimTokens(mockIdpId, mockProvider, { + provisioning: { + createScimTokenAction: { onBefore }, + }, + }); + + await expect(result.current.createScimToken({})).rejects.toThrow('ACTION_CANCELLED'); + + 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_success', + }); + }); + }); + + 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 expect(result.current.listScimTokens()).rejects.toThrow('List error'); + + 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); + + await expect(result.current.createScimToken({})).rejects.toThrow('Create error'); + + 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); + + 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-domain-tab.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-domain-tab.test.ts index 9d7d7b5b2..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 @@ -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,11 @@ describe('useSsoDomainTab', () => { const { result } = await renderUseSsoDomainTab('idp-1'); await act(async () => { - await result.current.handleCreate('newdomain.com'); + await expect(result.current.handleCreate('newdomain.com')).rejects.toThrow(); }); await waitFor(() => { - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_create.error', - }); + expect(mockHandleError).toHaveBeenCalledWith(error); expect(result.current.isCreating).toBe(false); }); }); @@ -272,12 +270,12 @@ describe('useSsoDomainTab', () => { }); await act(async () => { - await result.current.handleCreate('newdomain.com'); + await expect(result.current.handleCreate('newdomain.com')).rejects.toThrow(); }); 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 +318,11 @@ describe('useSsoDomainTab', () => { const { result } = await renderUseSsoDomainTab('idp-1'); await act(async () => { - await result.current.handleVerify(mockDomain); + await expect(result.current.handleVerify(mockDomain)).rejects.toThrow(); }); await waitFor(() => { - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_verify.verification_failed', - }); + expect(mockHandleError).toHaveBeenCalledWith(error); expect(result.current.isVerifying).toBe(false); }); }); @@ -379,7 +375,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 +424,11 @@ describe('useSsoDomainTab', () => { const { result } = await renderUseSsoDomainTab('idp-1'); await act(async () => { - await result.current.handleDelete(mockDomain); + await expect(result.current.handleDelete(mockDomain)).rejects.toThrow(); }); await waitFor(() => { - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'domain_delete.error', - }); + expect(mockHandleError).toHaveBeenCalledWith(error); expect(result.current.isDeleting).toBe(false); }); }); @@ -527,13 +521,11 @@ describe('useSsoDomainTab', () => { }); await act(async () => { - await result.current.handleToggleSwitch(mockDomain, true); + await expect(result.current.handleToggleSwitch(mockDomain, true)).rejects.toThrow(); }); await waitFor(() => { - expect(mockHandleError).toHaveBeenCalledWith(error, { - fallbackMessage: 'general_error', - }); + expect(mockHandleError).toHaveBeenCalledWith(error); expect(result.current.isUpdating).toBe(false); }); }); @@ -559,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', () => { @@ -672,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); + }); + }); + }); }); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-create-logic.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-create-logic.test.ts deleted file mode 100644 index 0dfee80f8..000000000 --- a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-create-logic.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { useSsoProviderCreateLogic } from '../use-sso-provider-create-logic'; - -// Mock useConfig and useIdpConfig to avoid hitting queryClient or network -vi.mock('@/hooks/my-organization/use-config', () => ({ - useConfig: () => ({ - isLoadingConfig: false, - filteredStrategies: ['samlp', 'oidc'], - }), -})); -vi.mock('@/hooks/my-organization/use-idp-config', () => ({ - useIdpConfig: () => ({ - isLoadingIdpConfig: false, - idpConfig: {}, - }), -})); - -// Minimal local mocks -const mockCreateProvider = vi.fn(); -const mockOnNext = vi.fn(); -const mockOnPrevious = vi.fn(); - -describe('useSsoProviderCreateLogic', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should initialize formData and refs', () => { - const { result } = renderHook(() => - useSsoProviderCreateLogic({ - onNext: mockOnNext, - onPrevious: mockOnPrevious, - createProvider: mockCreateProvider, - }), - ); - expect(result.current.formData).toEqual({}); - expect(result.current.detailsRef.current).toBeNull(); - expect(result.current.configureRef.current).toBeNull(); - }); - - it('should update formData via setFormData', () => { - const { result } = renderHook(() => - useSsoProviderCreateLogic({ - onNext: mockOnNext, - onPrevious: mockOnPrevious, - createProvider: mockCreateProvider, - }), - ); - act(() => { - result.current.setFormData({ - strategy: 'samlp', - details: { name: 'test', display_name: 'test provider' }, - }); - }); - expect(result.current.formData.strategy).toBe('samlp'); - expect(result.current.formData.details).toEqual({ - name: 'test', - display_name: 'test provider', - }); - }); - - it('should call createProvider with merged data on handleCreate', async () => { - const { result } = renderHook(() => - useSsoProviderCreateLogic({ - onNext: mockOnNext, - onPrevious: mockOnPrevious, - createProvider: mockCreateProvider, - }), - ); - act(() => { - result.current.setFormData({ - strategy: 'oidc', - details: { name: 'test', display_name: 'test provider' }, - }); - }); - // Mock configureRef.current.getData - result.current.configureRef.current = { - validate: vi.fn().mockResolvedValue(true), - getData: vi - .fn() - .mockReturnValue({ name: 'test', display_name: 'test provider', strategy: 'oidc' }), - }; - await act(async () => { - await result.current.handleCreate(); - }); - expect(mockCreateProvider).toHaveBeenCalledWith({ - strategy: 'oidc', - display_name: 'test provider', - name: 'test', - }); - }); - - it('createStepActions calls onNext and onPrevious handlers', async () => { - const { result } = renderHook(() => - useSsoProviderCreateLogic({ - onNext: mockOnNext, - onPrevious: mockOnPrevious, - createProvider: mockCreateProvider, - }), - ); - // Mock ref with validate and getData - const ref = { - current: { - validate: vi.fn().mockResolvedValue(true), - getData: vi.fn().mockReturnValue({ name: 'test' }), - }, - }; - const actions = result.current.createStepActions('provider_details', ref); - await act(async () => { - await actions.onNextAction(); - await actions.onPreviousAction(); - }); - expect(ref.current.validate).toHaveBeenCalled(); - expect(ref.current.getData).toHaveBeenCalled(); - expect(mockOnNext).toHaveBeenCalledWith( - 'provider_details', - expect.objectContaining({ details: { name: 'test' } }), - ); - expect(mockOnPrevious).toHaveBeenCalledWith( - 'provider_details', - expect.objectContaining({ details: { name: 'test' } }), - ); - }); - - it('createStepActions returns false if validation fails', async () => { - const { result } = renderHook(() => - useSsoProviderCreateLogic({ - onNext: mockOnNext, - onPrevious: mockOnPrevious, - createProvider: mockCreateProvider, - }), - ); - const ref = { - current: { - validate: vi.fn().mockResolvedValue(false), - getData: vi.fn(), - }, - }; - const actions = result.current.createStepActions('provider_details', ref); - let nextResult; - await act(async () => { - nextResult = await actions.onNextAction(); - }); - expect(nextResult).toBe(false); - expect(ref.current.validate).toHaveBeenCalled(); - expect(mockOnNext).not.toHaveBeenCalled(); - }); -}); 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-logic.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-edit-logic.test.ts deleted file mode 100644 index 4154bf3ab..000000000 --- a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-edit-logic.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { useSsoProviderEditLogic } from '../use-sso-provider-edit-logic'; - -// Mock useConfig and useIdpConfig to avoid network/queryClient -vi.mock('@/hooks/my-organization/use-config', () => ({ - useConfig: () => ({ - shouldAllowDeletion: true, - isLoadingConfig: false, - }), -})); -vi.mock('@/hooks/my-organization/use-idp-config', () => ({ - useIdpConfig: () => ({ - idpConfig: {}, - isLoadingIdpConfig: false, - isProvisioningEnabled: vi.fn(() => true), - isProvisioningMethodEnabled: vi.fn(() => true), - }), -})); - -describe('useSsoProviderEditLogic', () => { - const mockUpdateProvider = { updateProvider: vi.fn() }; - const mockHandlers = { - listScimTokens: vi.fn(), - syncSsoAttributes: vi.fn(), - onDeleteConfirm: vi.fn(), - onRemoveConfirm: vi.fn(), - createProvisioning: vi.fn(), - deleteProvisioning: vi.fn(), - createScimToken: vi.fn(), - deleteScimToken: vi.fn(), - syncProvisioningAttributes: vi.fn(), - fetchProvider: vi.fn(), - fetchOrganizationDetails: vi.fn(), - fetchProvisioning: vi.fn(), - }; - - const mockLogic = { - isLoading: false, - isUpdating: false, - isDeleting: false, - isRemoving: false, - idpConfig: {}, - customMessages: {}, - backButton: undefined, - shouldAllowDeletion: true, - isLoadingConfig: false, - isLoadingIdpConfig: false, - showProvisioningTab: true, - isProvisioningUpdating: false, - isProvisioningDeleting: false, - isScimTokensLoading: false, - isScimTokenCreating: false, - isScimTokenDeleting: false, - isSsoAttributesSyncing: false, - isProvisioningAttributesSyncing: false, - hasSsoAttributeSyncWarning: false, - hasProvisioningAttributeSyncWarning: false, - provisioningConfig: null, - isProvisioningLoading: false, - }; - const mockProvider = { - provider: { - id: 'test-provider-id', - name: 'Test Provider', - is_enabled: true, - strategy: 'waad' as const, - options: {}, - }, - }; - - const mockOrganization = { - organization: { - name: 'Org', - branding: { - colors: { - primary: '', - page_background: '', - }, - logo_url: undefined, - }, - }, - }; - const ssoProviderEdit = { - ...mockHandlers, - ...mockLogic, - ...mockProvider, - ...mockOrganization, - ...mockUpdateProvider, - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should return correct logic state', () => { - const { result } = renderHook(() => useSsoProviderEditLogic(ssoProviderEdit)); - expect(result.current.shouldAllowDeletion).toBe(true); - expect(result.current.isLoadingConfig).toBe(false); - expect(result.current.idpConfig).toEqual({}); - expect(result.current.isLoadingIdpConfig).toBe(false); - expect(result.current.showProvisioningTab).toBe(true); - expect(typeof result.current.handleToggleProvider).toBe('function'); - }); - - it('should call updateProvider with correct params on handleToggleProvider', async () => { - const { result } = renderHook(() => useSsoProviderEditLogic(ssoProviderEdit)); - await act(async () => { - await result.current.handleToggleProvider(false); - }); - expect(mockUpdateProvider.updateProvider).toHaveBeenCalledWith({ - strategy: ssoProviderEdit.provider.strategy, - is_enabled: false, - }); - }); - - it('should not call updateProvider if provider.strategy is missing', async () => { - const ssoProviderEditNoStrategy = { - ...ssoProviderEdit, - provider: null, - }; - const { result } = renderHook(() => useSsoProviderEditLogic(ssoProviderEditNoStrategy)); - await act(async () => { - await result.current.handleToggleProvider(true); - }); - expect(mockUpdateProvider.updateProvider).not.toHaveBeenCalled(); - }); -}); 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..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 @@ -3,80 +3,26 @@ 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 * 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 { 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 { 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'; -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 +52,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 +124,762 @@ 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(); + await expect( + result.current.updateProvider({ strategy: 'samlp', is_enabled: true }), + ).rejects.toThrow('Update failed'); 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); + 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 } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - const tokens = await result.current.listScimTokens(); + const tokenData: CreateIdpProvisioningScimTokenRequestContent = {}; + const token = await result.current.createScimToken(tokenData); + expect(token).toEqual(mockToken); await waitFor(() => { - expect(mockScimTokensList).toHaveBeenCalledWith(mockIdpId); - expect(tokens).toEqual(mockTokens); - expect(result.current.isScimTokensLoading).toBe(false); + expect(result.current.isScimTokenCreating).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); + it('should delete SCIM token successfully', async () => { + ( + 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)); - const token = await result.current.createScimToken(tokenData); + await result.current.deleteScimToken('token_123'); 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); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.delete, + ).toHaveBeenCalledWith(mockIdpId, 'token_123'); + expect(result.current.isScimTokenDeleting).toBe(false); }); }); - it('should call onBefore callback for SCIM token create and abort when it returns false', async () => { - const tokenData = {} as CreateIdpProvisioningScimTokenRequestContent; + 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 onBefore = vi.fn().mockReturnValue(false); + const { result } = renderUseSsoProviderEdit(mockIdpId); - const { result } = renderUseSsoProviderEdit(mockIdpId, { - provisioning: { - createScimTokenAction: { onBefore }, - }, - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); + + const tokens = await result.current.listScimTokens(); + expect(tokens).toEqual(mockTokens); await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.provisioning + .scimTokens.list, + ).toHaveBeenCalledWith(mockIdpId); + expect(result.current.isScimTokensLoading).toBe(false); }); - - 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); + 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.deleteScimToken(tokenId); + await expect(result.current.listScimTokens()).rejects.toThrow('List failed'); await waitFor(() => { - expect(mockScimTokensDelete).toHaveBeenCalledWith(mockIdpId, tokenId); - expect(showToast).toHaveBeenCalledWith({ - type: 'success', - message: 'SCIM token deleted successfully', - }); - expect(result.current.isScimTokenDeleting).toBe(false); + expect(mockHandleError).toHaveBeenCalledWith(error); }); }); - it('should return early if coreClient is not available', async () => { - (useCoreClient as Mock).mockReturnValue({ coreClient: null }); - + it('should expose granular SCIM token loading states', async () => { const { result } = renderUseSsoProviderEdit(mockIdpId); - const provider = await result.current.fetchProvider(); - - expect(provider).toBe(null); - expect(mockGet).not.toHaveBeenCalled(); - }); - - it('should return early if idpId is not provided', async () => { - const { result } = renderUseSsoProviderEdit(''); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - const provider = await result.current.fetchProvider(); - - expect(provider).toBe(null); - expect(mockGet).not.toHaveBeenCalled(); + expect(result.current.isScimTokensLoading).toBe(false); + expect(result.current.isScimTokenCreating).toBe(false); + expect(result.current.isScimTokenDeleting).toBe(false); }); - it('should handle fetch provider error', async () => { - mockGet.mockRejectedValue(new Error('Fetch 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(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', - }; + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - renderUseSsoProviderEdit(mockIdpId, { customMessages }); + await result.current.syncSsoAttributes(); await waitFor(() => { - expect(useTranslator).toHaveBeenCalledWith('idp_management.notifications', customMessages); + expect(result.current.isSsoAttributesSyncing).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 sync provisioning attributes successfully', async () => { 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); + await result.current.syncProvisioningAttributes(); 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(result.current.isProvisioningAttributesSyncing).toBe(false); }); }); - 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 return early if coreClient is not available', () => { + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: null }); - 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); - - await result.current.syncSsoAttributes(); + const { result } = renderUseSsoProviderEdit(mockIdpId); - expect(mockUpdateAttributes).not.toHaveBeenCalled(); - }); + expect(result.current.provider).toBe(null); + expect(result.current.isLoading).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); + it('should return early if idpId is not provided', () => { + const { result } = renderUseSsoProviderEdit(''); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + expect(result.current.provider).toBe(null); + expect(result.current.isLoading).toBe(false); + }); - await result.current.syncProvisioningAttributes(); + it('should handle 404 when fetching provisioning config', async () => { + const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(mockProvisioningUpdateAttributes).toHaveBeenCalledWith(mockIdpId, {}); - expect(showToast).toHaveBeenCalledWith({ - type: 'success', - message: 'provisioning_attributes_sync_success', - }); - expect(result.current.isProvisioningAttributesSyncing).toBe(false); - }); + await waitFor(() => { + expect(result.current.provisioningConfig).toBe(null); }); + }); - it('should handle error when syncing provisioning attributes', async () => { - mockProvisioningUpdateAttributes.mockRejectedValue(new Error('Sync failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await expect(result.current.syncProvisioningAttributes()).rejects.toThrow(); + it('should call onBefore callback and abort when it returns false', async () => { + const onBefore = vi.fn().mockReturnValue(false); - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); + const { result } = renderUseSsoProviderEdit(mockIdpId, { + sso: { + updateAction: { onBefore }, + deleteAction: {}, + deleteFromOrganizationAction: {}, + }, }); - it('should return early if coreClient is not available', async () => { - (useCoreClient as Mock).mockReturnValue({ coreClient: null }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await result.current.syncProvisioningAttributes(); - - expect(mockProvisioningUpdateAttributes).not.toHaveBeenCalled(); + await act(async () => { + await result.current.updateProvider({ strategy: 'samlp', is_enabled: true }); }); - it('should return early if idpId is not provided', async () => { - const { result } = renderUseSsoProviderEdit(''); - - await result.current.syncProvisioningAttributes(); - - expect(mockProvisioningUpdateAttributes).not.toHaveBeenCalled(); + await waitFor(() => { + expect(onBefore).toHaveBeenCalled(); + expect(mockHandleError).not.toHaveBeenCalled(); // Should NOT call handleError for cancelled actions }); }); - 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); + it('should call onAfter callback after successful update', async () => { + const onAfter = vi.fn(); - 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); - }); + const { result } = renderUseSsoProviderEdit(mockIdpId, { + sso: { + updateAction: { onAfter }, + deleteAction: {}, + deleteFromOrganizationAction: {}, + }, }); - it('should return false when provider has no attributes property', async () => { - mockGet.mockResolvedValue(mockProvider); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - const { result } = renderUseSsoProviderEdit(mockIdpId); + await result.current.updateProvider({ strategy: 'samlp', is_enabled: true }); - await waitFor(() => { - expect(result.current.hasSsoAttributeSyncWarning).toBe(false); - }); + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider, mockProvider); }); + }); - it('should return false when provider is null', async () => { - mockGet.mockResolvedValue(null); + 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); - const { result } = renderUseSsoProviderEdit(mockIdpId); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.hasSsoAttributeSyncWarning).toBe(false); - }); + await waitFor(() => { + expect(result.current.error).toBe(error); }); }); - describe('hasProvisioningAttributeSyncWarning', () => { - it('should return true when provisioning config has extra attributes', async () => { - mockProvisioningGet.mockResolvedValue({ - enabled: true, - attributes: [{ is_extra: true, is_missing: false }], - }); + 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); - 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(true); - }); + await waitFor(() => { + expect(result.current.error).toBe(error); }); - it('should return true when provisioning config has missing attributes', async () => { - mockProvisioningGet.mockResolvedValue({ - enabled: true, - attributes: [{ is_extra: false, is_missing: true }], - }); + await result.current.retry(); - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await result.current.fetchProvisioning(); - - await waitFor(() => { - expect(result.current.hasProvisioningAttributeSyncWarning).toBe(true); - }); + await waitFor(() => { + expect(result.current.error).toBeNull(); + expect(result.current.provider).toEqual(mockProvider); }); + }); - 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); - }); + it('should fetch provider imperatively', async () => { + const { result } = renderUseSsoProviderEdit(mockIdpId); - await result.current.fetchProvisioning(); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - await waitFor(() => { - expect(result.current.hasProvisioningAttributeSyncWarning).toBe(false); - }); - }); - - it('should return false when provisioning config is null', async () => { - mockProvisioningGet.mockRejectedValue({ body: { status: 404 } }); + const provider = await result.current.fetchProvider(); - const { result } = renderUseSsoProviderEdit(mockIdpId); + expect(provider).toEqual(mockProvider); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.get, + ).toHaveBeenCalledWith(mockIdpId); + }); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + it('should compute hasSsoAttributeSyncWarning as false when no attributes', async () => { + const { result } = renderUseSsoProviderEdit(mockIdpId); - await result.current.fetchProvisioning(); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - await waitFor(() => { - expect(result.current.hasProvisioningAttributeSyncWarning).toBe(false); - }); - }); + expect(result.current.hasSsoAttributeSyncWarning).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); - }); + 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); - await result.current.updateProvider({ display_name: 'Test', strategy: 'samlp' }); + const { result } = renderUseSsoProviderEdit(mockIdpId); - expect(onBefore).toHaveBeenCalledWith(mockProvider); - expect(mockUpdate).not.toHaveBeenCalled(); - expect(showToast).not.toHaveBeenCalled(); + await waitFor(() => { + expect(result.current.hasSsoAttributeSyncWarning).toBe(true); }); + }); - 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 }, - }, - }); + it('should toggle provider enabled state', async () => { + 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 result.current.handleToggleProvider(false); - expect(onBefore).toHaveBeenCalledWith(mockProvider); - expect(mockProvisioningDelete).not.toHaveBeenCalled(); - expect(showToast).not.toHaveBeenCalled(); + await waitFor(() => { + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.update, + ).toHaveBeenCalledWith(mockIdpId, expect.objectContaining({ is_enabled: false })); }); + }); - 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 }, - }, - }); + 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); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await result.current.deleteScimToken('token_123'); + await waitFor(() => expect(result.current.provider).toEqual(providerNoStrategy)); - expect(onBefore).toHaveBeenCalledWith(mockProvider); - expect(mockScimTokensDelete).not.toHaveBeenCalled(); - expect(showToast).not.toHaveBeenCalled(); - }); + await result.current.handleToggleProvider(true); - it('should call onBefore callback for remove from org and abort when it returns false', async () => { - const onBefore = vi.fn().mockReturnValue(false); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.update, + ).not.toHaveBeenCalled(); + }); - const { result } = renderUseSsoProviderEdit(mockIdpId, { - sso: { - deleteAction: {}, - deleteFromOrganizationAction: { onBefore }, - }, - }); + it('should show provisioning tab when provisioning is enabled', async () => { + vi.spyOn(useIdpConfigModule, 'useIdpConfig').mockReturnValue( + createMockUseIdpConfig({ + isProvisioningEnabled: vi.fn(() => true), + isProvisioningMethodEnabled: vi.fn(() => true), + }), + ); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await result.current.onRemoveConfirm(); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - expect(onBefore).toHaveBeenCalledWith(mockProvider); - expect(mockDetach).not.toHaveBeenCalled(); - }); + expect(result.current.showProvisioningTab).toBe(true); }); - describe('organization query errors', () => { - it('should show toast when organization query fails on mount', async () => { - mockGetOrgDetails.mockRejectedValue(new Error('Organization fetch failed')); + 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), + }), + ); - renderUseSsoProviderEdit(mockIdpId); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); - }); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - 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); - }); - }); + expect(result.current.showProvisioningTab).toBe(false); + }); - it('should call onAfter callback after successful provisioning create', async () => { - const provisioningResult = { enabled: true }; - mockProvisioningCreate.mockResolvedValue(provisioningResult); - const onAfter = vi.fn(); + 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, { - provisioning: { - createAction: { onAfter }, - }, - }); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.error).toBe(configError)); - await result.current.createProvisioning(); + await result.current.retry(); - await waitFor(() => { - expect(onAfter).toHaveBeenCalledWith(mockProvider, provisioningResult); - }); - }); + expect(mockConfigRetry).toHaveBeenCalled(); + }); - it('should call onAfter callback after successful provisioning delete', async () => { - mockProvisioningDelete.mockResolvedValue(undefined); - const onAfter = vi.fn(); + 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, { - provisioning: { - deleteAction: { onAfter }, - }, - }); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.error).toBe(idpConfigError)); - await result.current.deleteProvisioning(); + await result.current.retry(); - await waitFor(() => { - expect(onAfter).toHaveBeenCalledWith(mockProvider); - }); - }); + expect(mockIdpConfigRetry).toHaveBeenCalled(); + }); - 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(); + 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, { - provisioning: { - createScimTokenAction: { onAfter }, - }, - }); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - await result.current.createScimToken({}); + await expect( + result.current.updateProvider({ strategy: 'samlp', is_enabled: true }), + ).rejects.toThrow('Update failed'); - await waitFor(() => { - expect(onAfter).toHaveBeenCalledWith(mockProvider, newToken); - }); + await waitFor(() => { + expect(result.current.error).toBe(error); }); - 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 }, - }, - }); + // Reset the mock to succeed on retry + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .update as ReturnType + ).mockResolvedValue(mockProvider); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await result.current.retry(); - await result.current.deleteScimToken('token_123'); - - await waitFor(() => { - expect(onAfter).toHaveBeenCalledWith(mockProvider); - }); + await waitFor(() => { + expect(result.current.error).toBeNull(); }); }); - describe('error handling', () => { - it('should handle update provider error', async () => { - mockUpdate.mockRejectedValue(new Error('Update failed')); + 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); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - await expect( - result.current.updateProvider({ display_name: 'Test', strategy: 'samlp' }), - ).rejects.toThrow(); + await expect(result.current.onDeleteConfirm()).rejects.toThrow('Delete failed'); - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - expect(result.current.isUpdating).toBe(false); - }); + await waitFor(() => { + expect(result.current.error).toBe(error); }); - it('should handle create provisioning error', async () => { - mockProvisioningCreate.mockRejectedValue(new Error('Create failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .delete as ReturnType + ).mockResolvedValue(undefined); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await result.current.retry(); - await expect(result.current.createProvisioning()).rejects.toThrow(); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); + await waitFor(() => { + expect(result.current.error).toBeNull(); }); + }); - it('should handle delete provisioning error', async () => { - mockProvisioningDelete.mockRejectedValue(new Error('Delete failed')); + 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); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - await expect(result.current.deleteProvisioning()).rejects.toThrow(); + await expect(result.current.onRemoveConfirm()).rejects.toThrow('Detach failed'); - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); + await waitFor(() => { + expect(result.current.error).toBe(error); }); - 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); - }); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .detach as ReturnType + ).mockResolvedValue(undefined); - const tokens = await result.current.listScimTokens(); + await result.current.retry(); - expect(tokens).toBe(null); - - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); + await waitFor(() => { + expect(result.current.error).toBeNull(); }); + }); - it('should handle create SCIM token error', async () => { - mockScimTokensCreate.mockRejectedValue(new Error('Create failed')); + 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); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - await expect(result.current.createScimToken({})).rejects.toThrow(); + await expect(result.current.syncSsoAttributes()).rejects.toThrow('Sync failed'); - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); + await waitFor(() => { + expect(result.current.error).toBe(error); }); - it('should handle delete SCIM token error', async () => { - mockScimTokensDelete.mockRejectedValue(new Error('Delete failed')); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders + .updateAttributes as ReturnType + ).mockResolvedValue(undefined); - const { result } = renderUseSsoProviderEdit(mockIdpId); + await result.current.retry(); - 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', - }); - }); + await waitFor(() => { + expect(result.current.error).toBeNull(); }); + }); - 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(); + it('should call onAfter callback after successful delete', async () => { + const onAfter = vi.fn(); - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); + const { result } = renderUseSsoProviderEdit(mockIdpId, { + sso: { + updateAction: {}, + deleteAction: { onAfter }, + deleteFromOrganizationAction: {}, + }, }); - 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 waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); - - await expect(result.current.onRemoveConfirm()).rejects.toThrow(); + await result.current.onDeleteConfirm(); - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider); }); + }); - it('should handle fetch organization details error', async () => { - mockGetOrgDetails.mockRejectedValue(new Error('Fetch failed')); - - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await result.current.fetchOrganizationDetails(); + it('should call onBefore and abort detach when it returns false', async () => { + const onBefore = vi.fn().mockReturnValue(false); - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); + const { result } = renderUseSsoProviderEdit(mockIdpId, { + sso: { + updateAction: {}, + deleteAction: {}, + deleteFromOrganizationAction: { onBefore }, + }, }); - it('should handle non-404 error when fetching provisioning config', async () => { - mockProvisioningGet.mockRejectedValue({ - body: { status: 500 }, - }); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await waitFor(() => { - expect(result.current.provider).toEqual(mockProvider); - }); + await result.current.onRemoveConfirm(); - await result.current.fetchProvisioning(); - await waitFor(() => { - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An error occurred', - }); - }); - }); + expect(onBefore).toHaveBeenCalled(); + expect( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.detach, + ).not.toHaveBeenCalled(); }); - 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' }); + it('should call onAfter callback after successful detach', async () => { + const onAfter = vi.fn(); - 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(); + const { result } = renderUseSsoProviderEdit(mockIdpId, { + sso: { + updateAction: {}, + deleteAction: {}, + deleteFromOrganizationAction: { onAfter }, + }, }); - it('should return early from deleteProvisioning if provider is null', async () => { - mockGet.mockResolvedValue(null); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - const { result } = renderUseSsoProviderEdit(mockIdpId); - - await result.current.deleteProvisioning(); + await result.current.onRemoveConfirm(); - expect(mockProvisioningDelete).not.toHaveBeenCalled(); + await waitFor(() => { + expect(onAfter).toHaveBeenCalledWith(mockProvider); }); + }); - it('should return early from listScimTokens if coreClient is null', async () => { - (useCoreClient as Mock).mockReturnValue({ coreClient: null }); - - const { result } = renderUseSsoProviderEdit(mockIdpId); + 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); - const tokens = await result.current.listScimTokens(); + renderUseSsoProviderEdit(mockIdpId); - expect(tokens).toBe(null); - expect(mockScimTokensList).not.toHaveBeenCalled(); + await waitFor(() => { + expect(mockHandleError).toHaveBeenCalledWith(error); }); + }); - 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 compute hasSsoAttributeSyncWarning as false when attributes key exists but is null', async () => { + const providerWithNullAttributes = { + ...mockProvider, + attributes: null, + } as unknown as IdentityProvider; - it('should return early from deleteScimToken if coreClient is null', async () => { - (useCoreClient as Mock).mockReturnValue({ coreClient: null }); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.get as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(providerWithNullAttributes); - const { result } = renderUseSsoProviderEdit(mockIdpId); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await result.current.deleteScimToken('token_123'); + await waitFor(() => expect(result.current.provider).toEqual(providerWithNullAttributes)); - expect(mockScimTokensDelete).not.toHaveBeenCalled(); - }); + expect(result.current.hasSsoAttributeSyncWarning).toBe(false); + }); - it('should return early from onDeleteConfirm if provider is null', async () => { - mockGet.mockResolvedValue(null); + 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; - const { result } = renderUseSsoProviderEdit(mockIdpId); + ( + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.get as ReturnType< + typeof vi.fn + > + ).mockResolvedValue(providerWithMissingAttributes); - await result.current.onDeleteConfirm(); + const { result } = renderUseSsoProviderEdit(mockIdpId); - expect(mockDelete).not.toHaveBeenCalled(); + await waitFor(() => { + expect(result.current.hasSsoAttributeSyncWarning).toBe(true); }); + }); - it('should return early from onRemoveConfirm if provider is null', async () => { - mockGet.mockResolvedValue(null); + 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); + const { result } = renderUseSsoProviderEdit(mockIdpId); - await result.current.onRemoveConfirm(); + await waitFor(() => expect(result.current.provider).toEqual(mockProvider)); - expect(mockDetach).not.toHaveBeenCalled(); - }); + expect(result.current.organization).toBeDefined(); + expect(result.current.organization.id).toBeDefined(); }); }); diff --git a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-table-logic.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-table-logic.test.ts deleted file mode 100644 index bc81b099b..000000000 --- a/packages/react/src/hooks/my-organization/__tests__/use-sso-provider-table-logic.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { useSsoProviderTableLogic } from '../use-sso-provider-table-logic'; - -// Mock useConfig and useIdpConfig to avoid network/queryClient -vi.mock('@/hooks/my-organization/use-config', () => ({ - useConfig: () => ({ - isLoadingConfig: false, - shouldAllowDeletion: true, - isConfigValid: true, - }), -})); -vi.mock('@/hooks/my-organization/use-idp-config', () => ({ - useIdpConfig: () => ({ - isLoadingIdpConfig: false, - isIdpConfigValid: true, - }), -})); - -describe('useSsoProviderTableLogic', () => { - const mockOnEnableProvider = vi.fn(); - const mockOnDeleteConfirm = vi.fn(); - const mockOnRemoveConfirm = vi.fn(); - const mockCreateAction = { onAfter: vi.fn() }; - const mockEditAction = { onAfter: vi.fn() }; - const mockDeleteAction = { onBefore: vi.fn(() => true) }; - const mockDeleteFromOrgAction = { onBefore: vi.fn(() => true) }; - - const idp = { id: 'idp1', name: 'Test IDP', options: {}, strategy: 'waad' as const }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should return correct logic state', () => { - const { result } = renderHook(() => - useSsoProviderTableLogic({ - readOnly: false, - isLoading: false, - createAction: mockCreateAction, - editAction: mockEditAction, - deleteAction: mockDeleteAction, - deleteFromOrganizationAction: mockDeleteFromOrgAction, - onEnableProvider: mockOnEnableProvider, - onDeleteConfirm: mockOnDeleteConfirm, - onRemoveConfirm: mockOnRemoveConfirm, - }), - ); - expect(result.current.shouldAllowDeletion).toBe(true); - expect(result.current.isViewLoading).toBe(false); - expect(result.current.shouldHideCreate).toBe(false); - expect(result.current.showDeleteModal).toBe(false); - expect(result.current.showRemoveModal).toBe(false); - expect(result.current.selectedIdp).toBeNull(); - }); - - it('should call createAction.onAfter on handleCreate', () => { - const { result } = renderHook(() => - useSsoProviderTableLogic({ - readOnly: false, - isLoading: false, - createAction: mockCreateAction, - editAction: mockEditAction, - deleteAction: mockDeleteAction, - deleteFromOrganizationAction: mockDeleteFromOrgAction, - onEnableProvider: mockOnEnableProvider, - onDeleteConfirm: mockOnDeleteConfirm, - onRemoveConfirm: mockOnRemoveConfirm, - }), - ); - act(() => { - result.current.handleCreate(); - }); - expect(mockCreateAction.onAfter).toHaveBeenCalled(); - }); - - it('should call editAction.onAfter on handleEdit', () => { - const { result } = renderHook(() => - useSsoProviderTableLogic({ - readOnly: false, - isLoading: false, - createAction: mockCreateAction, - editAction: mockEditAction, - deleteAction: mockDeleteAction, - deleteFromOrganizationAction: mockDeleteFromOrgAction, - onEnableProvider: mockOnEnableProvider, - onDeleteConfirm: mockOnDeleteConfirm, - onRemoveConfirm: mockOnRemoveConfirm, - }), - ); - act(() => { - result.current.handleEdit(idp); - }); - expect(mockEditAction.onAfter).toHaveBeenCalledWith(idp); - }); - - it('should set selectedIdp and showDeleteModal on handleDelete', () => { - const { result } = renderHook(() => - useSsoProviderTableLogic({ - readOnly: false, - isLoading: false, - createAction: mockCreateAction, - editAction: mockEditAction, - deleteAction: mockDeleteAction, - deleteFromOrganizationAction: mockDeleteFromOrgAction, - onEnableProvider: mockOnEnableProvider, - onDeleteConfirm: mockOnDeleteConfirm, - onRemoveConfirm: mockOnRemoveConfirm, - }), - ); - act(() => { - result.current.handleDelete(idp); - }); - expect(result.current.selectedIdp).toEqual(idp); - expect(result.current.showDeleteModal).toBe(true); - }); - - it('should set selectedIdp and showRemoveModal on handleDeleteFromOrganization', () => { - const { result } = renderHook(() => - useSsoProviderTableLogic({ - readOnly: false, - isLoading: false, - createAction: mockCreateAction, - editAction: mockEditAction, - deleteAction: mockDeleteAction, - deleteFromOrganizationAction: mockDeleteFromOrgAction, - onEnableProvider: mockOnEnableProvider, - onDeleteConfirm: mockOnDeleteConfirm, - onRemoveConfirm: mockOnRemoveConfirm, - }), - ); - act(() => { - result.current.handleDeleteFromOrganization(idp); - }); - expect(result.current.selectedIdp).toEqual(idp); - expect(result.current.showRemoveModal).toBe(true); - }); - - it('should call onEnableProvider if not readOnly', async () => { - const { result } = renderHook(() => - useSsoProviderTableLogic({ - readOnly: false, - isLoading: false, - createAction: mockCreateAction, - editAction: mockEditAction, - deleteAction: mockDeleteAction, - deleteFromOrganizationAction: mockDeleteFromOrgAction, - onEnableProvider: mockOnEnableProvider, - onDeleteConfirm: mockOnDeleteConfirm, - onRemoveConfirm: mockOnRemoveConfirm, - }), - ); - await act(async () => { - await result.current.handleToggleEnabled(idp, true); - }); - expect(mockOnEnableProvider).toHaveBeenCalledWith(idp, true); - }); - - it('should not call onEnableProvider if readOnly', async () => { - const { result } = renderHook(() => - useSsoProviderTableLogic({ - readOnly: true, - isLoading: false, - createAction: mockCreateAction, - editAction: mockEditAction, - deleteAction: mockDeleteAction, - deleteFromOrganizationAction: mockDeleteFromOrgAction, - onEnableProvider: mockOnEnableProvider, - onDeleteConfirm: mockOnDeleteConfirm, - onRemoveConfirm: mockOnRemoveConfirm, - }), - ); - await act(async () => { - await result.current.handleToggleEnabled(idp, false); - }); - expect(mockOnEnableProvider).not.toHaveBeenCalled(); - }); - - it('should call onDeleteConfirm and close modal on handleDeleteConfirm', async () => { - const { result } = renderHook(() => - useSsoProviderTableLogic({ - readOnly: false, - isLoading: false, - createAction: mockCreateAction, - editAction: mockEditAction, - deleteAction: mockDeleteAction, - deleteFromOrganizationAction: mockDeleteFromOrgAction, - onEnableProvider: mockOnEnableProvider, - onDeleteConfirm: mockOnDeleteConfirm, - onRemoveConfirm: mockOnRemoveConfirm, - }), - ); - act(() => { - result.current.setShowDeleteModal(true); - result.current.setSelectedIdp(idp); - }); - await act(async () => { - await result.current.handleDeleteConfirm(idp); - }); - expect(mockOnDeleteConfirm).toHaveBeenCalledWith(idp); - expect(result.current.showDeleteModal).toBe(false); - expect(result.current.selectedIdp).toBeNull(); - }); - - it('should call onRemoveConfirm and close modal on handleRemoveConfirm', async () => { - const { result } = renderHook(() => - useSsoProviderTableLogic({ - readOnly: false, - isLoading: false, - createAction: mockCreateAction, - editAction: mockEditAction, - deleteAction: mockDeleteAction, - deleteFromOrganizationAction: mockDeleteFromOrgAction, - onEnableProvider: mockOnEnableProvider, - onDeleteConfirm: mockOnDeleteConfirm, - onRemoveConfirm: mockOnRemoveConfirm, - }), - ); - act(() => { - result.current.setShowRemoveModal(true); - result.current.setSelectedIdp(idp); - }); - await act(async () => { - await result.current.handleRemoveConfirm(idp); - }); - expect(mockOnRemoveConfirm).toHaveBeenCalledWith(idp); - expect(result.current.showRemoveModal).toBe(false); - expect(result.current.selectedIdp).toBeNull(); - }); -}); 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 df278f199..9238773a7 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,693 +1,378 @@ -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; - } = {}, + 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 defaultOptions: Parameters[0] = { + createAction: { onBefore: vi.fn(() => true), onAfter: vi.fn() }, + editAction: { onBefore: vi.fn(() => true), onAfter: vi.fn() }, + }; + + const renderUseSsoProviderTable = ( + overrides: Partial[0]> = {}, ) => { - 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; + const { wrapper } = createTestQueryClientWrapper(); + return renderHook(() => useSsoProviderTable({ ...defaultOptions, ...overrides }), { 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(); + const apiService = mockCoreClient.getMyOrganizationApiClient(); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - 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')); - - setupMockMyOrgClient({ list: mockList }); + const { mockHandleError: setupMockHandleError } = setupAllCommonMocks({ + coreClient: mockCoreClient, + useCoreClientModule, + useTranslatorModule, + useErrorHandlerModule, + }); - const { result } = renderUseSsoProviderTable(); + mockHandleError = setupMockHandleError; + vi.spyOn(useErrorHandlerModule, 'useErrorHandler').mockReturnValue(mockHandleError); + }); - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); + it('should fetch and set providers successfully', async () => { + const { result } = renderUseSsoProviderTable(); - 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 read from cache without invalidating when fetchProviders is called', 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).not.toHaveBeenCalled(); + 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); - - setupMockMyOrgClient({ - list: vi.fn().mockResolvedValue({ identity_providers: [] }), - organizationGet: mockGet, - }); - - const { result } = renderUseSsoProviderTable(); + it('should delete provider successfully', async () => { + 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({ enableProviderAction: { 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({ deleteAction: { 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({ deleteFromOrganizationAction: { 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/__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..1a847554a --- /dev/null +++ b/packages/react/src/hooks/my-organization/__tests__/use-sso-provisioning.test.ts @@ -0,0 +1,535 @@ +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 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 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)); + + await expect(result.current.syncProvisioningAttributes()).rejects.toThrow('Sync failed'); + + 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 propagate non-404 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)); + + await expect(result.current.fetchProvisioning()).rejects.toThrow('Fetch failed'); + }); + + 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)); + + await expect(result.current.createProvisioning()).rejects.toThrow('Create error'); + + 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)); + + await expect(result.current.deleteProvisioning()).rejects.toThrow('Delete error'); + + 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)); + + await expect(result.current.syncProvisioningAttributes()).rejects.toThrow('Sync error'); + + await waitFor(() => { + expect(result.current.provisioningError).toEqual(error); + }); + }); + }); +}); diff --git a/packages/react/src/hooks/my-organization/use-config.ts b/packages/react/src/hooks/my-organization/use-config.ts index f3db4455f..33ab289c1 100644 --- a/packages/react/src/hooks/my-organization/use-config.ts +++ b/packages/react/src/hooks/my-organization/use-config.ts @@ -6,11 +6,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 = { @@ -25,10 +28,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) { @@ -38,6 +46,12 @@ export function useConfig(): UseConfigResult { }, }); + useEffect(() => { + if (configQuery.error) { + handleError(configQuery.error); + } + }, [configQuery.error, handleError]); + const config = configQuery.data; const allowedStrategies = config?.allowed_strategies; @@ -51,6 +65,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, @@ -58,5 +76,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 c48ab252f..000000000 --- a/packages/react/src/hooks/my-organization/use-domain-table-logic.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Domain table UI logic hook. - * @module use-domain-table-logic - * @internal - */ - -import { type Domain, type IdentityProvider } from '@auth0/universal-components-core'; -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, -} from '@/types/my-organization/domain-management/domain-table-types'; - -/** - * Hook for domain table modal state and action handlers. - * @param props - Component props. - * @param props.t - Translation function - * @param props.onCreateDomain - The on create domain - * @param props.onVerifyDomain - The on verify domain - * @param props.onDeleteDomain - The on delete domain - * @param props.onAssociateToProvider - The on associate to provider - * @param props.onDeleteFromProvider - The on delete from provider - * @param props.fetchProviders - The fetch providers - * @param props.fetchDomains - The fetch domains - * @internal - * @returns Hook state and methods - */ -export function useDomainTableLogic({ - t, - onCreateDomain, - onVerifyDomain, - onDeleteDomain, - onAssociateToProvider, - onDeleteFromProvider, - fetchProviders, - fetchDomains, -}: UseDomainTableLogicOptions): UseDomainTableLogicResult { - const { handleError } = useErrorHandler(); - 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) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_create.error'), - }); - } - }, - [onCreateDomain, t, handleError], - ); - - 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) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_verify.error'), - }); - } - }, - [onVerifyDomain, t, handleError], - ); - - 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) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_delete.error'), - }); - } - }, - [onDeleteDomain, t, handleError], - ); - - 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) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_associate_provider.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) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_delete_provider.error'), - }); - } - } - }, - [onAssociateToProvider, onDeleteFromProvider, t, handleError], - ); - - 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) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.fetch_providers_error'), - }); - } - } - }, - [fetchProviders, t, handleError], - ); - - 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) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.domain_verify.error'), - }); - } - }, - [onVerifyDomain, t, handleError], - ); - - const handleDeleteClick = useCallback((domain: Domain) => { - setSelectedDomain(domain); - setShowVerifyModal(false); - setShowDeleteModal(true); - }, []); - - // Initialization - useEffect(() => { - try { - fetchDomains(); - } catch (error) { - handleError(error, { - fallbackMessage: t('domain_table.notifications.fetch_domains_error'), - }); - } - }, []); - - return { - // 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 7237bac6e..85b58d68d 100644 --- a/packages/react/src/hooks/my-organization/use-domain-table.ts +++ b/packages/react/src/hooks/my-organization/use-domain-table.ts @@ -8,24 +8,43 @@ 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 { + ACTION_CANCELLED_ERROR, + isActionCancelledError, +} from '@/lib/utils/my-organization/action-cancelled'; 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. /** * Hook for domain table data fetching and CRUD operations. * @param props - Component props. @@ -45,87 +64,135 @@ 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(ACTION_CANCELLED_ERROR); } - 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) => { + if (!isActionCancelledError(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(ACTION_CANCELLED_ERROR); } - 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) => { + if (!isActionCancelledError(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(ACTION_CANCELLED_ERROR); } - 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) => { + if (!isActionCancelledError(error)) handleError(error); }, }); @@ -135,16 +202,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({ @@ -153,42 +223,185 @@ 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) => { + 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], + ); + + const handleCreate = useCallback( + async (domainUrl: string) => { + try { + await createDomainMutation.mutateAsync({ domain: domainUrl }); + } catch (error) { + if (!isActionCancelledError(error)) throw error; + } + }, + [createDomainMutation], + ); + + const handleVerify = useCallback( + async (domain: Domain) => verifyAndTransition(domain, null), + [verifyAndTransition], + ); + + const handleDelete = useCallback( + async (domain: Domain) => { + try { + await deleteDomainMutation.mutateAsync(domain); + } catch (error) { + if (!isActionCancelledError(error)) throw error; + } + }, + [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.getQueryData(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 b60e1725c..26c25bed1 100644 --- a/packages/react/src/hooks/my-organization/use-idp-config.ts +++ b/packages/react/src/hooks/my-organization/use-idp-config.ts @@ -1,12 +1,13 @@ -/** - * Identity provider configuration hook. - * @module use-idp-config - */ - -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, @@ -24,6 +25,7 @@ export const idpConfigQueryKeys = { export function useIdpConfig(): UseConfigIdpResult { const { coreClient } = useCoreClient(); const queryClient = useQueryClient(); + const handleError = useErrorHandler(); const idpConfigQuery = useQuery({ queryKey: idpConfigQueryKeys.config(), @@ -31,6 +33,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) { @@ -47,6 +50,12 @@ export function useIdpConfig(): UseConfigIdpResult { }, }); + useEffect(() => { + if (idpConfigQuery.error) { + handleError(idpConfigQuery.error); + } + }, [idpConfigQuery.error, handleError]); + const idpConfig = idpConfigQuery.data ?? null; const strategies = idpConfig?.strategies; @@ -60,6 +69,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, @@ -67,5 +80,7 @@ export function useIdpConfig(): UseConfigIdpResult { fetchIdpConfig: async () => await queryClient.getQueryData(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 ca913f7c3..479f90363 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 @@ -6,6 +6,7 @@ import { OrganizationDetailsFactory, OrganizationDetailsMappers, + MY_ORGANIZATION_DETAILS_EDIT_SCOPES, type OrganizationPrivate, } from '@auth0/universal-components-core'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -13,6 +14,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 { UseOrganizationDetailsEditOptions, @@ -45,11 +47,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'), @@ -59,28 +60,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); @@ -97,26 +98,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.getQueryData(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); @@ -141,23 +132,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-scim-tokens.ts b/packages/react/src/hooks/my-organization/use-scim-tokens.ts new file mode 100644 index 000000000..9722c6311 --- /dev/null +++ b/packages/react/src/hooks/my-organization/use-scim-tokens.ts @@ -0,0 +1,164 @@ +/** + * 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 { + 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'; + +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_success') }); + if (provisioning?.deleteScimTokenAction?.onAfter && provider) { + await provisioning.deleteScimTokenAction.onAfter(provider); + } + }, + onError: (error) => { + if (!isActionCancelledError(error)) handleError(error); + }, + }); + + const listScimTokens = useCallback(async () => { + return await listScimTokensMutation.mutateAsync(); + }, [listScimTokensMutation]); + + const createScimToken = useCallback( + async (data: CreateIdpProvisioningScimTokenRequestContent) => { + return await createScimTokenMutation.mutateAsync(data); + }, + [createScimTokenMutation], + ); + + 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-domain-tab.ts b/packages/react/src/hooks/my-organization/use-sso-domain-tab.ts index ec474143e..89241b383 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 @@ -4,7 +4,11 @@ */ import type { CreateOrganizationDomainRequestContent } from '@auth0/universal-components-core'; -import { BusinessError, 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, useEffect } from 'react'; @@ -41,8 +45,8 @@ export function useSsoDomainTab( ): UseSsoDomainTabReturn { const { coreClient } = useCoreClient(); const { t } = useTranslator('idp_management.notifications', customMessages); - const { handleError } = useErrorHandler(); const queryClient = useQueryClient(); + const handleError = useErrorHandler(); const [selectedDomain, setSelectedDomain] = useState(null); const [showVerifyModal, setShowVerifyModal] = useState(false); @@ -56,23 +60,23 @@ 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, }); - const domainsList = domainsQuery.data ?? []; - const isLoading = domainsQuery.isLoading; - - // Handle errors from domains query useEffect(() => { if (domainsQuery.error) { - handleError(domainsQuery.error, { - fallbackMessage: t('general_error'), - }); + handleError(domainsQuery.error); } - }, [domainsQuery.error, handleError, t]); + }, [domainsQuery.error, handleError]); + + const domainsList = domainsQuery.data ?? []; + const isLoading = domainsQuery.isLoading; // Fetch IDP associations for each domain using useQueries const idpAssociationQueries = useQueries({ @@ -81,13 +85,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 })), }); @@ -106,12 +110,13 @@ 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')); } } const result: Domain = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.domains.create(data); domains?.createAction?.onAfter?.(result); @@ -125,6 +130,7 @@ export function useSsoDomainTab( queryKey: domainQueryKeys.idpAssociation(newDomain.id, idpId), }); }, + onError: (error) => handleError(error), }); const verifyDomainMutation = useMutation({ @@ -132,12 +138,13 @@ 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')); } } const updatedDomain = await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.domains.verify.create(domain.id); if (domains?.verifyAction?.onAfter) { @@ -154,6 +161,7 @@ export function useSsoDomainTab( }); } }, + onError: (error) => handleError(error), }); const deleteDomainMutation = useMutation({ @@ -165,11 +173,14 @@ 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')); } } - 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); @@ -184,6 +195,7 @@ export function useSsoDomainTab( queryKey: domainQueryKeys.idpAssociation(domain.id, idpId), }); }, + onError: (error) => handleError(error), }); const associateToProviderMutation = useMutation({ @@ -191,12 +203,13 @@ 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')); } } await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.identityProviders.domains.create(idpId, { domain: domain.domain, }); @@ -213,6 +226,7 @@ export function useSsoDomainTab( queryKey: domainQueryKeys.idpAssociation(domain.id, idpId), }); }, + onError: (error) => handleError(error), }); const deleteFromProviderMutation = useMutation({ @@ -224,12 +238,13 @@ 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')); } } await coreClient! .getMyOrganizationApiClient() + .withScopes(MY_ORGANIZATION_DOMAIN_SCOPES) .organization.identityProviders.domains.delete(provider.id!, domain.domain); if (domains?.deleteFromProviderAction?.onAfter) { @@ -244,30 +259,25 @@ export function useSsoDomainTab( queryKey: domainQueryKeys.idpAssociation(domain.id, idpId), }); }, + onError: (error) => handleError(error), }); 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(() => { @@ -277,29 +287,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) => { @@ -310,25 +314,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( @@ -355,16 +353,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( @@ -372,8 +366,8 @@ export function useSsoDomainTab( setIsUpdating(true); setIsUpdatingId(domain.id); - if (newCheckedValue) { - try { + try { + if (newCheckedValue) { await associateToProviderMutation.mutateAsync(domain); showToast({ @@ -383,16 +377,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({ @@ -402,21 +387,74 @@ 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; + + // 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, + retry, domainsList, isCreating: createDomainMutation.isPending, selectedDomain, diff --git a/packages/react/src/hooks/my-organization/use-sso-provider-create-logic.ts b/packages/react/src/hooks/my-organization/use-sso-provider-create-logic.ts deleted file mode 100644 index c1f389b07..000000000 --- a/packages/react/src/hooks/my-organization/use-sso-provider-create-logic.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * SSO provider create logic hook. - * @module use-sso-provider-create-logic - * @internal - */ - -import { useCallback, useRef, useState } from 'react'; - -import { useConfig } from '@/hooks/my-organization/use-config'; -import { useIdpConfig } from '@/hooks/my-organization/use-idp-config'; -import type { - FormState, - ProviderConfigureHandle, - ProviderDetailsFormHandle, - UseSsoProviderCreateLogicOptions, - UseSsoProviderCreateLogicResult, -} from '@/types/my-organization/idp-management/sso-provider/sso-provider-create-types'; - -/** - * Hook for SSO provider create logic (form state, step actions, create handler). - * @param params - SsoProviderCreateLogicParams - * @returns formData, setFormData, createStepActions, handleCreate, detailsRef, configureRef - */ -export function useSsoProviderCreateLogic({ - onNext, - onPrevious, - createProvider, -}: UseSsoProviderCreateLogicOptions): UseSsoProviderCreateLogicResult { - const [formData, setFormData] = useState({}); - const { strategy, details, configure } = formData; - const detailsRef = useRef(null); - const configureRef = useRef(null); - const { isLoadingConfig, filteredStrategies } = useConfig(); - const { isLoadingIdpConfig, idpConfig } = useIdpConfig(); - - const createStepActions = useCallback( - ( - stepId: 'provider_details' | 'provider_configure', - ref: React.RefObject, - ) => { - const dataKey = stepId === 'provider_details' ? 'details' : 'configure'; - const handleAction = async ( - handler: typeof onNext | typeof onPrevious | undefined, - shouldValidate = false, - ): Promise => { - if (shouldValidate) { - const isValid = await ref.current?.validate(); - if (!isValid) return false; - } - const currentData = ref.current?.getData() ?? null; - setFormData((prev) => ({ ...prev, [dataKey]: currentData })); - if (!handler) return true; - const fullPayload = { ...formData, [dataKey]: currentData }; - return handler(stepId, fullPayload); - }; - return { - onNextAction: () => handleAction(onNext, true), - onPreviousAction: () => handleAction(onPrevious, false), - }; - }, - [formData, onNext, onPrevious], - ); - - const handleCreate = useCallback(async () => { - const finalConfigureData = configureRef.current?.getData(); - await createProvider({ - strategy: strategy!, - ...details!, - ...finalConfigureData, - }); - }, [strategy, details, configure, createProvider]); - - return { - formData, - setFormData, - createStepActions, - handleCreate, - detailsRef, - configureRef, - isLoadingConfig, - filteredStrategies, - isLoadingIdpConfig, - idpConfig, - }; -} 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 5722d2fff..aed8ebbe0 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,13 +11,23 @@ import { type IdentityProvider, } from '@auth0/universal-components-core'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useCallback } from 'react'; +import type React from 'react'; +import { useCallback, useRef, useState } 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 { 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'; +import type { + FormState, + ProviderConfigureHandle, + ProviderDetailsFormHandle, + UseSsoProviderCreateOptions, + UseSsoProviderCreateReturn, +} from '@/types/my-organization/idp-management/sso-provider/sso-provider-create-types'; /** * Extracts domain from discovery error detail. @@ -31,34 +41,52 @@ function extractDomainFromDiscoveryError(detail?: string): string | null { return match?.[1]?.trim() ?? null; } -export interface UseSsoProviderCreateReturn { - createProvider: (data: CreateIdentityProviderRequestContentPrivate) => Promise; - isCreating: boolean; -} - /** * Hook for creating SSO identity providers. + * Combines API mutation logic with form state management, config loading, + * and step navigation. * @param options - Hook options. * @param options.createAction - Callback after successful creation. * @param options.customMessages - Custom translation messages. + * @param options.onNext - Callback for wizard next step. + * @param options.onPrevious - Callback for wizard previous step. * @returns Hook state and methods */ export function useSsoProviderCreate({ createAction, customMessages = {}, + onNext, + onPrevious, }: UseSsoProviderCreateOptions = {}): UseSsoProviderCreateReturn { const { coreClient } = useCoreClient(); const { t } = useTranslator('idp_management.create_sso_provider', customMessages); const queryClient = useQueryClient(); + const handleError = useErrorHandler(); + + // Config & IDP config + const { + isLoadingConfig, + filteredStrategies, + error: configError, + retry: retryConfig, + } = useConfig(); + const { + isLoadingIdpConfig, + idpConfig, + error: idpConfigError, + retry: retryIdpConfig, + } = useIdpConfig(); + + // Form state & refs + const [formData, setFormData] = useState({}); + const { strategy, details, configure } = formData; + const detailsRef = useRef(null); + const configureRef = useRef(null); 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 = { @@ -71,7 +99,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); @@ -89,6 +117,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 && @@ -102,6 +131,7 @@ export function useSsoProviderCreate({ }); return; } + // Handle discovery failure error for domain if (hasApiErrorBody(error)) { const domainFromError = extractDomainFromDiscoveryError(error.body?.detail); @@ -115,11 +145,7 @@ export function useSsoProviderCreate({ return; } } - - showToast({ - type: 'error', - message: t('notifications.general_error'), - }); + handleError(error); }, }); @@ -142,11 +168,74 @@ export function useSsoProviderCreate({ await createProviderMutation.mutateAsync(data); }, - [coreClient, createAction, createProviderMutation], + [coreClient, t, createAction, createProviderMutation], + ); + + const error = createProviderMutation.error || configError || idpConfigError; + + const retry = useCallback(async () => { + if (configError) { + await retryConfig(); + } else if (idpConfigError) { + await retryIdpConfig(); + } else if (createProviderMutation.variables) { + await createProviderMutation.mutateAsync(createProviderMutation.variables); + } else { + createProviderMutation.reset(); + } + }, [configError, idpConfigError, createProviderMutation, retryConfig, retryIdpConfig]); + + const createStepActions = useCallback( + ( + stepId: 'provider_details' | 'provider_configure', + ref: React.RefObject, + ) => { + const dataKey = stepId === 'provider_details' ? 'details' : 'configure'; + const handleAction = async ( + handler: typeof onNext | typeof onPrevious | undefined, + shouldValidate = false, + ): Promise => { + if (shouldValidate) { + const isValid = await ref.current?.validate(); + if (!isValid) return false; + } + const currentData = ref.current?.getData() ?? null; + setFormData((prev: FormState) => ({ ...prev, [dataKey]: currentData })); + if (!handler) return true; + const fullPayload = { ...formData, [dataKey]: currentData }; + return handler(stepId, fullPayload); + }; + return { + onNextAction: () => handleAction(onNext, true), + onPreviousAction: () => handleAction(onPrevious, false), + }; + }, + [formData, onNext, onPrevious], ); + const handleCreate = useCallback(async () => { + const finalConfigureData = configureRef.current?.getData(); + await createProvider({ + strategy: strategy!, + ...details!, + ...finalConfigureData, + }); + }, [strategy, details, configure, createProvider]); + return { createProvider, isCreating: createProviderMutation.isPending, + error, + retry, + formData, + setFormData, + createStepActions, + handleCreate, + detailsRef, + configureRef, + isLoadingConfig, + filteredStrategies, + isLoadingIdpConfig, + idpConfig, }; } diff --git a/packages/react/src/hooks/my-organization/use-sso-provider-edit-logic.ts b/packages/react/src/hooks/my-organization/use-sso-provider-edit-logic.ts deleted file mode 100644 index 195353b99..000000000 --- a/packages/react/src/hooks/my-organization/use-sso-provider-edit-logic.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * SSO provider edit logic hook. - * @module use-sso-provider-edit-logic - * @internal - */ - -import { useCallback } from 'react'; - -import { useConfig } from '@/hooks/my-organization/use-config'; -import { useIdpConfig } from '@/hooks/my-organization/use-idp-config'; -import type { - UseSsoProviderEditLogicResult, - UseSsoProviderEditReturn, -} from '@/types/my-organization/idp-management/sso-provider/sso-provider-edit-types'; - -/** - * Hook for SSO provider edit logic (e.g., handleToggleProvider). - * @param ssoProviderEdit - SSO Provider Edit Prop - * @internal - * @returns Hook state and methods - */ -export function useSsoProviderEditLogic( - ssoProviderEdit: UseSsoProviderEditReturn, -): UseSsoProviderEditLogicResult { - const { shouldAllowDeletion, isLoadingConfig } = useConfig(); - const { idpConfig, isLoadingIdpConfig, isProvisioningEnabled, isProvisioningMethodEnabled } = - useIdpConfig(); - - const showProvisioningTab = - isProvisioningEnabled(ssoProviderEdit.provider?.strategy) && - isProvisioningMethodEnabled(ssoProviderEdit.provider?.strategy); - - const handleToggleProvider = useCallback( - async (enabled: boolean) => { - if (!ssoProviderEdit.provider?.strategy) return; - await ssoProviderEdit.updateProvider({ - strategy: ssoProviderEdit.provider.strategy, - is_enabled: enabled, - }); - }, - [ssoProviderEdit.provider?.strategy, ssoProviderEdit.updateProvider], - ); - - return { - shouldAllowDeletion, - isLoadingConfig, - idpConfig, - isLoadingIdpConfig, - showProvisioningTab, - handleToggleProvider, - }; -} 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 d9ca39259..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 @@ -7,31 +7,33 @@ import { OrganizationDetailsFactory, OrganizationDetailsMappers, SsoProviderMappers, + MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES, type IdentityProvider, type IdpId, type OrganizationPrivate, type UpdateIdentityProviderRequestContent, - type CreateIdpProvisioningScimTokenRequestContent, - type GetIdPProvisioningConfigResponseContent, - getStatusCode, + type UpdateIdentityProviderRequestContentPrivate, } 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 { 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'; +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, @@ -56,9 +58,21 @@ 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(); + const { + shouldAllowDeletion, + isLoadingConfig, + error: configError, + retry: configRetry, + } = useConfig(); + const { + idpConfig, + isLoadingIdpConfig, + isProvisioningEnabled, + isProvisioningMethodEnabled, + error: idpConfigError, + retry: idpConfigRetry, + } = useIdpConfig(); /** * Provider query - fetches the identity provider details. @@ -69,776 +83,334 @@ export function useSsoProviderEdit( 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() - .organization.identityProviders.provisioning.get(idpId); - return result; - } catch (error) { - const status = getStatusCode(error); - if (status === 404) { - return null; - } - throw error; - } - }, - enabled: !!coreClient && !!idpId, - }); - 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.error) handleError(organizationQuery.error); + }, [organizationQuery.error, handleError]); - if (!organizationQuery.isError) { - hasShownOrganizationError.current = false; - } - }, [organizationQuery.error, organizationQuery.isError, t]); + const provider = providerQuery.data ?? null; - useEffect(() => { - if (provisioningQuery.isError && !hasShownProvisioningError.current) { - showToast({ - type: 'error', - message: t('general_error'), - }); - hasShownProvisioningError.current = true; - } + const { + provisioningConfig, + isProvisioningLoading, + isProvisioningUpdating, + isProvisioningDeleting, + isProvisioningAttributesSyncing, + hasProvisioningAttributeSyncWarning, + provisioningError, + fetchProvisioning, + createProvisioning, + deleteProvisioning, + syncProvisioningAttributes, + } = useSsoProvisioning(idpId, provider, { provisioning, customMessages }); - if (!provisioningQuery.isError) { - hasShownProvisioningError.current = false; - } - }, [provisioningQuery.isError, t]); + const { + listScimTokens, + createScimToken, + deleteScimToken, + isScimTokensLoading, + isScimTokenCreating, + isScimTokenDeleting, + scimTokensError, + } = useScimTokens(idpId, provider, { provisioning, customMessages }); - /** - * 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'); - } + const currentProvider = providerQuery.data; + if (!currentProvider) 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(currentProvider)) { + throw new Error(ACTION_CANCELLED_ERROR); } - const apiRequestData: UpdateIdentityProviderRequestContent = SsoProviderMappers.updateToAPI({ - strategy: provider.strategy, + const apiRequestData = SsoProviderMappers.updateToAPI({ + strategy: currentProvider.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) => { - 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; - } - showToast({ - type: 'error', - message: t('general_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 (provisioning?.createAction?.onBefore) { - const canProceed = provisioning.createAction.onBefore(provider); - if (!canProceed) { - throw new Error(ACTION_CANCELLED_ERROR); - } - } - - const result = await coreClient! - .getMyOrganizationApiClient() - .organization.identityProviders.provisioning.create(idpId); - - 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 }), }); - - // Invalidate queries to refetch fresh data - 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'), - }); - }, - }); - - /** - * 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 (provisioning?.deleteAction?.onBefore) { - const canProceed = provisioning.deleteAction.onBefore(provider); - if (!canProceed) { - throw new Error(ACTION_CANCELLED_ERROR); - } - } - - await coreClient! - .getMyOrganizationApiClient() - .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), - }); - - if (provisioning?.deleteAction?.onAfter && provider) { - await provisioning.deleteAction.onAfter(provider); - } - }, - onError: (error) => { - if (isActionCancelledError(error)) { - return; - } - showToast({ - type: 'error', - message: t('general_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 (provisioning?.createScimTokenAction?.onBefore) { - const canProceed = provisioning.createScimTokenAction.onBefore(provider); - if (!canProceed) { - throw new Error(ACTION_CANCELLED_ERROR); - } - } - - const result = await coreClient! - .getMyOrganizationApiClient() - .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), - }); - - if (provisioning?.createScimTokenAction?.onAfter && provider) { - await provisioning.createScimTokenAction.onAfter(provider, result); - } - }, - onError: (error) => { - if (isActionCancelledError(error)) { - return; - } - showToast({ - type: 'error', - message: t('general_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 (provisioning?.deleteScimTokenAction?.onBefore) { - const canProceed = provisioning.deleteScimTokenAction.onBefore(provider); - if (!canProceed) { - throw new Error(ACTION_CANCELLED_ERROR); - } - } - - await coreClient! - .getMyOrganizationApiClient() - .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), - }); - - if (provisioning?.deleteScimTokenAction?.onAfter && provider) { - await provisioning.deleteScimTokenAction.onAfter(provider); + queryClient.setQueryData(ssoProviderEditQueryKeys.detail(idpId), result); + if (sso?.updateAction?.onAfter && currentProvider) { + await sso.updateAction.onAfter(currentProvider, result); } }, 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. - */ 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() - .organization.identityProviders.delete(provider.id); + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .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 }), }); - - // Remove all related queries from cache - queryClient.removeQueries({ - queryKey: ssoProviderEditQueryKeys.detail(idpId), - }); - queryClient.removeQueries({ - queryKey: ssoProviderEditQueryKeys.provisioning(idpId), - }); - queryClient.removeQueries({ - queryKey: ssoProviderEditQueryKeys.scimTokens(idpId), - }); - - if (sso?.deleteAction?.onAfter && provider) { - await sso.deleteAction.onAfter(provider); + queryClient.removeQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); + queryClient.removeQueries({ queryKey: ssoProviderEditQueryKeys.provisioning(idpId) }); + if (sso?.deleteAction?.onAfter && currentProvider) { + await sso.deleteAction.onAfter(currentProvider); } }, - onError: () => { - showToast({ - type: 'error', - message: t('general_error'), - }); - }, + 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'); - } + const currentProvider = providerQuery.data; + if (!currentProvider?.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); - } + if ( + sso?.deleteFromOrganizationAction?.onBefore && + !sso.deleteFromOrganizationAction.onBefore(currentProvider) + ) { + throw new Error(ACTION_CANCELLED_ERROR); } - // Ensure organization data is fresh before detaching - await queryClient.ensureQueryData({ - queryKey: ssoProviderEditQueryKeys.organization(), - }); - await coreClient! .getMyOrganizationApiClient() - .organization.identityProviders.detach(provider.id); + .withScopes(MY_ORGANIZATION_SSO_PROVIDER_EDIT_SCOPES) + .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, }), }); - - // Remove provider from cache - queryClient.removeQueries({ - queryKey: ssoProviderEditQueryKeys.detail(idpId), - }); - - if (sso?.deleteFromOrganizationAction?.onAfter && provider) { - await sso.deleteFromOrganizationAction.onAfter(provider); + queryClient.removeQueries({ queryKey: ssoProviderEditQueryKeys.detail(idpId) }); + if (sso?.deleteFromOrganizationAction?.onAfter && currentProvider) { + await sso.deleteFromOrganizationAction.onAfter(currentProvider); } }, onError: (error) => { - if (isActionCancelledError(error)) { - return; - } - showToast({ - type: 'error', - message: t('general_error'), - }); + if (!isActionCancelledError(error)) handleError(error); }, }); - 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.getQueryData(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; - } - - try { - await updateProviderMutation.mutateAsync(data); - } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } - } - }, - [coreClient, idpId, providerQuery.data, updateProviderMutation], - ); - - const createProvisioning = useCallback(async (): Promise => { - const provider = providerQuery.data; - if (!coreClient || !idpId || !provider) { - return; - } - - try { - await createProvisioningMutation.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; - } - - try { - await deleteProvisioningMutation.mutateAsync(); - } catch (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({ + const syncSsoAttributesMutation = useMutation({ mutationFn: async () => { - if (!coreClient || !idpId) { - return null; - } - - const result = await coreClient + await coreClient! .getMyOrganizationApiClient() - .organization.identityProviders.provisioning.scimTokens.list(idpId); - return result; + .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), }); - const listScimTokens = useCallback(async () => { - try { - return await listScimTokensMutation.mutateAsync(); - } catch (error) { - return null; - } - }, [listScimTokensMutation]); - - const createScimToken = useCallback( - async (data: CreateIdpProvisioningScimTokenRequestContent) => { - const provider = providerQuery.data; - if (!coreClient || !idpId || !provider) { - return undefined; - } - - try { - return await createScimTokenMutation.mutateAsync(data); - } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } - return undefined; - } - }, - [coreClient, createScimTokenMutation, idpId, providerQuery.data], - ); - - const deleteScimToken = useCallback( - async (idpScimTokenId: string): Promise => { - const provider = providerQuery.data; - if (!coreClient || !idpId || !provider) { - return; - } - + const updateProvider = useCallback( + async (data: UpdateIdentityProviderRequestContentPrivate) => { + if (!coreClient || !providerQuery.data) return; try { - await deleteScimTokenMutation.mutateAsync(idpScimTokenId); + await updateProviderMutation.mutateAsync( + data as unknown as UpdateIdentityProviderRequestContent, + ); } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } + if (!isActionCancelledError(error)) throw error; } }, - [coreClient, deleteScimTokenMutation, idpId, providerQuery.data], + [coreClient, providerQuery.data, updateProviderMutation], ); - 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 onDeleteConfirm = useCallback(async () => { + if (!coreClient || !providerQuery.data?.id) return; + await deleteProviderMutation.mutateAsync(); + }, [coreClient, deleteProviderMutation, providerQuery.data?.id]); - const syncSsoAttributes = useCallback(async (): Promise => { - if (!coreClient || !idpId) { - return; + const onRemoveConfirm = useCallback(async () => { + if (!coreClient || !providerQuery.data?.id) return; + try { + await detachProviderMutation.mutateAsync(); + } catch (error) { + if (!isActionCancelledError(error)) throw error; } + }, [coreClient, detachProviderMutation, providerQuery.data?.id]); + const syncSsoAttributes = useCallback(async () => { + if (!coreClient) return; await syncSsoAttributesMutation.mutateAsync(); - }, [coreClient, idpId, syncSsoAttributesMutation]); + }, [coreClient, 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 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 hasSsoAttributeSyncWarning = useMemo(() => { + 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 showProvisioningTab = + isProvisioningEnabled(providerQuery.data?.strategy) && + isProvisioningMethodEnabled(providerQuery.data?.strategy); + + const handleToggleProvider = useCallback( + async (enabled: boolean) => { + if (!providerQuery.data?.strategy) return; + await updateProvider({ + strategy: providerQuery.data.strategy, + is_enabled: enabled, }); }, - }); + [providerQuery.data?.strategy, updateProvider], + ); - const syncProvisioningAttributes = useCallback(async (): Promise => { - if (!coreClient || !idpId) { + const error = + providerQuery.error || + organizationQuery.error || + configError || + idpConfigError || + provisioningError || + scimTokensError || + updateProviderMutation.error || + deleteProviderMutation.error || + detachProviderMutation.error || + syncSsoAttributesMutation.error; + + const retry = async () => { + if (configError) { + await configRetry(); return; } - - await syncProvisioningAttributesMutation.mutateAsync(); - }, [coreClient, idpId, syncProvisioningAttributesMutation]); - - const onDeleteConfirm = useCallback(async (): Promise => { - const provider = providerQuery.data; - if (!coreClient || !provider?.id) { + if (idpConfigError) { + await idpConfigRetry(); return; } - try { - await deleteProviderMutation.mutateAsync(); - } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } - } - }, [coreClient, deleteProviderMutation, providerQuery.data]); + const queries = [ + { error: providerQuery.error, key: ssoProviderEditQueryKeys.detail(idpId) }, + { error: organizationQuery.error, key: ssoProviderEditQueryKeys.organization() }, + { error: provisioningError, key: ssoProviderEditQueryKeys.provisioning(idpId) }, + ]; - const onRemoveConfirm = useCallback(async (): Promise => { - const provider = providerQuery.data; - if (!coreClient || !provider?.id) { + const failedQuery = queries.find((q) => q.error); + if (failedQuery) { + await queryClient.invalidateQueries({ queryKey: failedQuery.key }); return; } - try { - await detachProviderMutation.mutateAsync(); - } catch (error) { - if (!isActionCancelledError(error)) { - throw error; - } + 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: syncSsoAttributesMutation.error, + retry: () => syncSsoAttributesMutation.mutateAsync(), + }, + ]; + + const failedMutation = mutations.find((m) => m.error); + if (failedMutation) { + await failedMutation.retry(); } - }, [coreClient, detachProviderMutation, providerQuery.data]); - - const hasSsoAttributeSyncWarning = useMemo(() => { - const provider = providerQuery.data; - const attributes = provider && 'attributes' in provider ? (provider.attributes ?? []) : []; - return attributes.some((attr) => attr.is_extra || attr.is_missing); - }, [providerQuery.data]); - - const hasProvisioningAttributeSyncWarning = useMemo(() => { - const provisioningConfig = provisioningQuery.data; - const attributes = provisioningConfig?.attributes ?? []; - return attributes.some((attr) => attr.is_extra || attr.is_missing); - }, [provisioningQuery.data]); + }; return { - // Data from TanStack Query - single source of truth - provider: providerQuery.data ?? null, + provider, organization: organizationQuery.data ?? OrganizationDetailsFactory.create(), - provisioningConfig: provisioningQuery.data ?? null, - - // Loading states - all derived from TanStack Query + 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, - - // Warning states + isProvisioningAttributesSyncing, hasSsoAttributeSyncWarning, hasProvisioningAttributeSyncWarning, - - // Actions + shouldAllowDeletion, + isLoadingConfig, + idpConfig, + isLoadingIdpConfig, + showProvisioningTab, + error, + retry, fetchProvider, - fetchOrganizationDetails, fetchProvisioning, updateProvider, + handleToggleProvider, + onDeleteConfirm, + onRemoveConfirm, createProvisioning, deleteProvisioning, listScimTokens, @@ -846,7 +418,5 @@ export function useSsoProviderEdit( deleteScimToken, syncSsoAttributes, syncProvisioningAttributes, - onDeleteConfirm, - onRemoveConfirm, }; } diff --git a/packages/react/src/hooks/my-organization/use-sso-provider-table-logic.ts b/packages/react/src/hooks/my-organization/use-sso-provider-table-logic.ts deleted file mode 100644 index dce7fdc95..000000000 --- a/packages/react/src/hooks/my-organization/use-sso-provider-table-logic.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * SSO provider table logic hook. - * @module use-sso-provider-table-logic - * @internal - */ - -import type { IdentityProvider } from '@auth0/universal-components-core'; -import { useCallback, useState } from 'react'; - -import { useConfig } from '@/hooks/my-organization/use-config'; -import { useIdpConfig } from '@/hooks/my-organization/use-idp-config'; -import type { - UseSsoProviderTableLogicOptions, - UseSsoProviderTableLogicResult, -} from '@/types/my-organization/idp-management/sso-provider/sso-provider-table-types'; - -/** - * Logic hook for SSO provider table UI. - * @param params - Hook options - * @param params.readOnly - Whether the table is in read-only mode - * @param params.isLoading - Loading state from parent/data hook - * @param params.createAction - Action config for create - * @param params.editAction - Action config for edit - * @param params.deleteAction - Action config for delete - * @param params.deleteFromOrganizationAction - Action config for remove from org - * @param params.onEnableProvider - Handler for enabling/disabling provider - * @param params.onDeleteConfirm - Handler for confirming delete - * @param params.onRemoveConfirm - Handler for confirming remove from org - * @returns Hook state and methods - * @internal - */ -export function useSsoProviderTableLogic({ - readOnly, - isLoading, - createAction, - editAction, - deleteAction, - deleteFromOrganizationAction, - onEnableProvider, - onDeleteConfirm, - onRemoveConfirm, -}: UseSsoProviderTableLogicOptions): UseSsoProviderTableLogicResult { - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [showRemoveModal, setShowRemoveModal] = useState(false); - const [selectedIdp, setSelectedIdp] = useState(null); - const { isLoadingConfig, shouldAllowDeletion, isConfigValid } = useConfig(); - const { isLoadingIdpConfig, isIdpConfigValid } = useIdpConfig(); - const shouldHideCreate = !isConfigValid || !isIdpConfigValid; - const isViewLoading = isLoading || isLoadingConfig || isLoadingIdpConfig; - - const handleCreate = useCallback(() => { - if (createAction?.onAfter) { - createAction.onAfter(); - } - }, [createAction]); - - const handleEdit = useCallback( - (idp: IdentityProvider) => { - if (editAction?.onAfter) { - editAction.onAfter(idp); - } - }, - [editAction], - ); - - const handleDelete = useCallback( - (idp: IdentityProvider) => { - setSelectedIdp(idp); - - if (deleteAction?.onBefore) { - const shouldProceed = deleteAction.onBefore(idp); - if (!shouldProceed) return; - } - - setShowDeleteModal(true); - }, - [deleteAction], - ); - - const handleDeleteFromOrganization = useCallback( - (idp: IdentityProvider) => { - setSelectedIdp(idp); - - if (deleteFromOrganizationAction?.onBefore) { - const shouldProceed = deleteFromOrganizationAction.onBefore(idp); - if (!shouldProceed) return; - } - - setShowRemoveModal(true); - }, - [deleteFromOrganizationAction], - ); - - const handleToggleEnabled = useCallback( - async (idp: IdentityProvider, enabled: boolean) => { - if (readOnly || !onEnableProvider) return; - await onEnableProvider(idp, enabled); - }, - [readOnly, onEnableProvider], - ); - - const handleDeleteConfirm = useCallback( - async (provider: IdentityProvider) => { - await onDeleteConfirm(provider); - setShowDeleteModal(false); - setSelectedIdp(null); - }, - [onDeleteConfirm], - ); - - const handleRemoveConfirm = useCallback( - async (provider: IdentityProvider) => { - await onRemoveConfirm(provider); - setShowRemoveModal(false); - setSelectedIdp(null); - }, - [onRemoveConfirm], - ); - - return { - isViewLoading, - shouldAllowDeletion, - showDeleteModal, - shouldHideCreate, - showRemoveModal, - selectedIdp, - setShowDeleteModal, - setShowRemoveModal, - setSelectedIdp, - handleCreate, - handleEdit, - handleDelete, - handleDeleteFromOrganization, - handleToggleEnabled, - handleDeleteConfirm, - handleRemoveConfirm, - }; -} 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 663c8c7f2..2792c8927 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 @@ -6,19 +6,25 @@ import { OrganizationDetailsMappers, SsoProviderMappers, + MY_ORGANIZATION_SSO_PROVIDER_TABLE_SCOPES, type UpdateIdentityProviderRequestContent, - type ComponentAction, type IdentityProvider, type OrganizationPrivate, BusinessError, } from '@auth0/universal-components-core'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useState } 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 { 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'; +import type { + UseSsoProviderTableOptions, + UseSsoProviderTableReturn, +} from '@/types/my-organization/idp-management/sso-provider/sso-provider-table-types'; export const ssoProviderQueryKeys = { all: ['sso-providers'] as const, @@ -27,72 +33,68 @@ export const ssoProviderQueryKeys = { }; /** - * Hook for SSO provider table data and CRUD operations. - * @param deleteAction - Delete action handler. - * @param removeFromOrg - Remove from org handler. - * @param enableAction - Enable/disable handler. - * @param customMessages - Translation overrides. - * @returns Provider data, mutations, and actions. + * Hook for SSO provider table data, CRUD operations, and UI logic. + * @param options - Hook options. + * @param options.readOnly - Whether the table is in read-only mode. + * @param options.createAction - Action config for create. + * @param options.editAction - Action config for edit. + * @param options.deleteAction - Delete action handler. + * @param options.deleteFromOrganizationAction - Remove from org handler. + * @param options.enableProviderAction - Enable/disable handler. + * @param options.customMessages - Translation overrides. + * @returns Provider data, mutations, UI state, and actions. */ -export function useSsoProviderTable( - deleteAction?: ComponentAction, - removeFromOrg?: ComponentAction, - enableAction?: ComponentAction, +export function useSsoProviderTable({ + readOnly = false, + createAction, + editAction, + deleteAction, + deleteFromOrganizationAction, + enableProviderAction, customMessages = {}, -): UseSsoProviderTableReturn { +}: UseSsoProviderTableOptions): UseSsoProviderTableReturn { const { t } = useTranslator('idp_management.notifications', customMessages); const { coreClient } = useCoreClient(); const queryClient = useQueryClient(); - const hasShownProvidersError = useRef(false); - const hasShownOrganizationError = useRef(false); + const handleError = useErrorHandler(); + const { + isLoadingConfig, + shouldAllowDeletion, + isConfigValid, + error: configError, + retry: retryConfig, + } = useConfig(); + const { + isLoadingIdpConfig, + isIdpConfigValid, + error: idpConfigError, + retry: retryIdpConfig, + } = useIdpConfig(); + + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showRemoveModal, setShowRemoveModal] = useState(false); + const [selectedIdp, setSelectedIdp] = useState(null); + + const shouldHideCreate = !isConfigValid || !isIdpConfigValid; 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, - }); - - const organizationQuery = useQuery({ - queryKey: ssoProviderQueryKeys.organization, - queryFn: async () => { - const response = await coreClient!.getMyOrganizationApiClient().organizationDetails.get(); - return OrganizationDetailsMappers.fromAPI(response); - }, - enabled: !!coreClient, + retry: false, }); useEffect(() => { - if (providersQuery.isError && !hasShownProvidersError.current) { - showToast({ - type: 'error', - message: t('general_error'), - }); - hasShownProvidersError.current = true; + if (providersQuery.error) { + handleError(providersQuery.error); } - - 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 (!organizationQuery.isError) { - hasShownOrganizationError.current = false; - } - }, [organizationQuery.isError, t]); + }, [providersQuery.error, handleError]); const enableProviderMutation = useMutation({ mutationFn: async ({ @@ -102,12 +104,8 @@ 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 (enableProviderAction?.onBefore) { + const shouldProceed = enableProviderAction.onBefore(selectedIdp); if (!shouldProceed) { throw new BusinessError({ message: t('general_error') }); } @@ -120,13 +118,14 @@ 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; }, onSuccess: async (updatedProvider, { selectedIdp }) => { - if (enableAction?.onAfter) { - await enableAction.onAfter(selectedIdp); + if (enableProviderAction?.onAfter) { + await enableProviderAction.onAfter(selectedIdp); } showToast({ @@ -142,23 +141,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) { @@ -172,27 +163,19 @@ 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) { - await removeFromOrg.onAfter(selectedIdp); + if (deleteFromOrganizationAction?.onAfter) { + await deleteFromOrganizationAction.onAfter(selectedIdp); } const organizationData = queryClient.getQueryData( @@ -209,18 +192,12 @@ export function useSsoProviderTable( queryClient.invalidateQueries({ queryKey: ssoProviderQueryKeys.list() }); }, - onError: () => { - showToast({ - type: 'error', - message: t('general_error'), - }); - return; - }, + onError: (error) => handleError(error), }); const onEnableProvider = useCallback( async (selectedIdp: IdentityProvider, enabled: boolean): Promise => { - if (!selectedIdp || !coreClient || !selectedIdp.id) { + if (!selectedIdp || !selectedIdp.id) { return false; } @@ -236,7 +213,7 @@ export function useSsoProviderTable( const onDeleteConfirm = useCallback( async (selectedIdp: IdentityProvider): Promise => { - if (!selectedIdp || !coreClient || !selectedIdp.id) { + if (!selectedIdp || !selectedIdp.id) { return; } @@ -247,7 +224,7 @@ export function useSsoProviderTable( const onRemoveConfirm = useCallback( async (selectedIdp: IdentityProvider): Promise => { - if (!selectedIdp || !coreClient || !selectedIdp.id) { + if (!selectedIdp || !selectedIdp.id) { return; } @@ -260,48 +237,176 @@ export function useSsoProviderTable( await queryClient.getQueryData(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, handleError]); + + const isViewLoading = providersQuery.isLoading || isLoadingConfig || isLoadingIdpConfig; + + const handleCreate = useCallback(() => { + if (createAction?.onAfter) { + createAction.onAfter(); + } + }, [createAction]); + + const handleEdit = useCallback( + (idp: IdentityProvider) => { + if (editAction?.onAfter) { + editAction.onAfter(idp); + } + }, + [editAction], + ); + + const handleDelete = useCallback( + (idp: IdentityProvider) => { + setSelectedIdp(idp); + + if (deleteAction?.onBefore) { + const shouldProceed = deleteAction.onBefore(idp); + if (!shouldProceed) return; + } + + setShowDeleteModal(true); + }, + [deleteAction], + ); + + const handleDeleteFromOrganization = useCallback( + (idp: IdentityProvider) => { + setSelectedIdp(idp); + + if (deleteFromOrganizationAction?.onBefore) { + const shouldProceed = deleteFromOrganizationAction.onBefore(idp); + if (!shouldProceed) return; + } + + setShowRemoveModal(true); + }, + [deleteFromOrganizationAction], + ); + + const handleToggleEnabled = useCallback( + async (idp: IdentityProvider, enabled: boolean) => { + if (readOnly || !onEnableProvider) return; + await onEnableProvider(idp, enabled); + }, + [readOnly, onEnableProvider], + ); + + const handleDeleteConfirm = useCallback( + async (provider: IdentityProvider) => { + await onDeleteConfirm(provider); + setShowDeleteModal(false); + setSelectedIdp(null); + }, + [onDeleteConfirm], + ); + + const handleRemoveConfirm = useCallback( + async (provider: IdentityProvider) => { + await onRemoveConfirm(provider); + setShowRemoveModal(false); + setSelectedIdp(null); + }, + [onRemoveConfirm], + ); + + const error = + providersQuery.error || + configError || + idpConfigError || + enableProviderMutation.error || + deleteProviderMutation.error || + removeProviderMutation.error; + + const retry = async () => { + if (configError) { + await retryConfig(); + return; + } + if (idpConfigError) { + await retryIdpConfig(); + return; + } + if (providersQuery.error) { + await queryClient.invalidateQueries({ queryKey: ssoProviderQueryKeys.list() }); + return; } - }, [coreClient, queryClient, t]); + + 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, + isViewLoading, isDeleting: deleteProviderMutation.isPending, isRemoving: removeProviderMutation.isPending, isUpdating: enableProviderMutation.isPending, isUpdatingId: enableProviderMutation.isPending ? (enableProviderMutation.variables?.selectedIdp?.id ?? null) : null, - - // Actions + shouldAllowDeletion, + shouldHideCreate, + showDeleteModal, + showRemoveModal, + selectedIdp, + error, + retry, fetchProviders, - fetchOrganizationDetails, + getOrganizationName, onDeleteConfirm, onRemoveConfirm, onEnableProvider, + setShowDeleteModal, + setShowRemoveModal, + setSelectedIdp, + handleCreate, + handleEdit, + handleDelete, + handleDeleteFromOrganization, + handleToggleEnabled, + handleDeleteConfirm, + handleRemoveConfirm, }; } 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..ffe9c5f5b --- /dev/null +++ b/packages/react/src/hooks/my-organization/use-sso-provisioning.ts @@ -0,0 +1,221 @@ +/** + * 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 { + 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'; + +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 () => { + 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 () => { + try { + await createProvisioningMutation.mutateAsync(); + } catch (error) { + if (!isActionCancelledError(error)) throw error; + } + }, [createProvisioningMutation]); + + const deleteProvisioning = useCallback(async () => { + try { + await deleteProvisioningMutation.mutateAsync(); + } catch (error) { + if (!isActionCancelledError(error)) throw error; + } + }, [deleteProvisioningMutation]); + + 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, + }; +} 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..8ea17f7c9 --- /dev/null +++ b/packages/react/src/hooks/shared/__tests__/use-error-handler.test.ts @@ -0,0 +1,182 @@ +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 not show toast for null/undefined errors', () => { + const { result } = renderHook(() => useErrorHandler()); + + result.current(null); + result.current(undefined); + expect(mockedShowToast).not.toHaveBeenCalled(); + }); + + it('should not show toast for MFA errors', () => { + const { result } = renderHook(() => useErrorHandler()); + const mfaError = { + body: { + error: 'mfa_required', + }, + }; + + result.current(mfaError); + expect(mockedShowToast).not.toHaveBeenCalled(); + }); + + it('should not show toast for 500+ errors', () => { + const { result } = renderHook(() => useErrorHandler()); + const serverError = { + body: { + status: 500, + }, + }; + + result.current(serverError); + 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', + }, + }; + + result.current(apiError); + + 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'); + + result.current(error); + + expect(mockedShowToast).toHaveBeenCalledWith({ + type: 'error', + message: 'Something went wrong', + }); + }); + + it('should handle string errors', () => { + const { result } = renderHook(() => useErrorHandler()); + const error = 'Network error occurred'; + + result.current(error); + + 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' }; + + result.current(error); + + 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'); + + result.current(error, { showToast: false }); + + 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'); + + result.current(error, { getErrorMessage }); + + expect(getErrorMessage).toHaveBeenCalledWith(error); + 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, + }, + }; + + result.current(apiError); + + 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', + }, + }; + + result.current(notFoundError); + + 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', + }, + }; + + result.current(unauthorizedError); + + 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 100979bb1..7061183e5 100644 --- a/packages/react/src/hooks/shared/use-error-handler.ts +++ b/packages/react/src/hooks/shared/use-error-handler.ts @@ -1,50 +1,81 @@ -/** - * Error handling hook with toast notifications. - * @module use-error-handler - */ - -import { hasApiErrorBody, isBusinessError } from '@auth0/universal-components-core'; +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 ErrorHandlerOptions { - fallbackMessage?: string; - showToastNotification?: boolean; +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 handling errors with optional toast notifications. + * 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(); + * + * // With custom message + * onError: (error) => handleError(error, { + * getErrorMessage: (err) => t('my_error', { message: err.message }) + * }); + * + * // With defaults + * onError: handleError; */ -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 }; -}; +export function useErrorHandler() { + const { t } = useTranslator('common'); + + return useCallback( + (error: unknown, options: ErrorHandlerCallOptions = {}): void => { + if (!shouldHandleError(error)) return; + + const { getErrorMessage, showToast: shouldShowToast = true } = options; + + const errorMessage = + getErrorMessage?.(error) ?? extractErrorMessage(error, t('error.generic')); + + if (shouldShowToast) { + showToast({ + type: 'error', + message: 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 ebe393a01..000000000 --- a/packages/react/src/hooks/shared/use-scope-manager.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * OAuth scope manager context and hook. - * @module use-scope-manager - */ - -import { createContext, useContext } from 'react'; - -/** API audience type. */ -export type Audience = 'me' | 'my-org'; - -/** Scope manager context value. */ -export interface ScopeManagerContextValue { - registerScopes: (audience: Audience, scopes: string) => void; - isReady: boolean; - ensured: Record; -} - -/** @internal */ -export const ScopeManagerContext = createContext({ - registerScopes: () => {}, - isReady: false, - ensured: { me: '', 'my-org': '' }, -}); - -/** - * Hook to access scope manager context. - * @returns Scope manager context value. - */ -export const useScopeManager = () => useContext(ScopeManagerContext); diff --git a/packages/react/src/hooks/shared/use-step-up-challenge.ts b/packages/react/src/hooks/shared/use-step-up-challenge.ts new file mode 100644 index 000000000..33dd2458f --- /dev/null +++ b/packages/react/src/hooks/shared/use-step-up-challenge.ts @@ -0,0 +1,153 @@ +import type { StepUpAuthenticator, ChallengeResponse } from '@auth0/universal-components-core'; +import { useCallback, useState } from 'react'; + +import { useCoreClient } from '@/hooks/shared/use-core-client'; + +export type StepUpChallengeState = 'LIST' | 'CHALLENGING' | 'VERIFY'; + +interface UseStepUpChallengeProps { + mfaToken: string; + 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; +} + +interface StepUpState { + step: StepUpChallengeState; + selectedAuthenticator: StepUpAuthenticator | null; + challengeResponse: ChallengeResponse | null; + isChallenging: boolean; + isVerifying: boolean; + error: string | null; +} + +const INITIAL_STATE: StepUpState = { + step: 'LIST', + selectedAuthenticator: null, + challengeResponse: null, + isChallenging: false, + isVerifying: false, + error: null, +}; + +/** + * Manages the List → Challenge → Verify state machine for MFA step-up authentication. + * @param options - Hook options. + * @returns Step-up challenge state and action handlers. + */ +export function useStepUpChallenge({ + mfaToken, + onSuccess, +}: UseStepUpChallengeProps): UseStepUpChallengeResult { + const { coreClient } = useCoreClient(); + const stepUpService = coreClient?.getStepUpApiService(); + const [challengeState, setChallengeState] = useState(INITIAL_STATE); + + const handleSelectAuthenticator = useCallback( + async (auth: StepUpAuthenticator) => { + if (!stepUpService) return; + + // 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({ + mfaToken, + challengeType, + authenticatorId: auth.id, + }); + setChallengeState((prev) => ({ + ...prev, + step: 'VERIFY', + selectedAuthenticator: auth, + challengeResponse: response, + isChallenging: false, + })); + } catch (err) { + setChallengeState((prev) => ({ + ...prev, + isChallenging: false, + error: err instanceof Error ? err.message : 'Failed to start challenge', + })); + } + }, + [mfaToken, stepUpService], + ); + + const handleVerify = useCallback( + async (code: string) => { + 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 = + 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) { + setChallengeState((prev) => ({ + ...prev, + isVerifying: false, + error: err instanceof Error ? err.message : 'Verification failed', + })); + } + }, + [ + challengeState.selectedAuthenticator, + challengeState.challengeResponse, + mfaToken, + stepUpService, + onSuccess, + ], + ); + + const handleBack = useCallback(() => setChallengeState(INITIAL_STATE), []); + const clearError = useCallback(() => setChallengeState((prev) => ({ ...prev, error: null })), []); + + return { + state: challengeState.step, + selectedAuthenticator: challengeState.selectedAuthenticator, + challengeResponse: challengeState.challengeResponse, + isChallenging: challengeState.isChallenging, + isVerifying: challengeState.isVerifying, + error: challengeState.error, + handleSelectAuthenticator, + handleVerify, + handleBack, + clearError, + }; +} 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 dd682ce97..25b006621 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 @@ -20,5 +20,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/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; +}; diff --git a/packages/react/src/providers/__tests__/proxy-provider.test.tsx b/packages/react/src/providers/__tests__/proxy-provider.test.tsx index cdc3e258a..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: () =>

    , })); @@ -19,12 +22,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 +64,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( @@ -120,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__/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/__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(); + }); + }); }); diff --git a/packages/react/src/providers/proxy-provider.tsx b/packages/react/src/providers/proxy-provider.tsx index f81238a97..a1022114c 100644 --- a/packages/react/src/providers/proxy-provider.tsx +++ b/packages/react/src/providers/proxy-provider.tsx @@ -13,7 +13,6 @@ 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 { 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'; @@ -61,6 +60,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 eebd78fc4..000000000 --- a/packages/react/src/providers/scope-manager-provider.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/** - * OAuth scope management provider. - * @module scope-manager-provider - * @internal - */ - -import React, { useState, useCallback, useEffect, type ReactNode } from 'react'; - -import { useCoreClient } from '@/hooks/shared/use-core-client'; -import { ScopeManagerContext, type Audience } from '@/hooks/shared/use-scope-manager'; - -/** - * Provides scope registration and authorization for components. - * @param props - Component props. - * @param props.children - Child components. - * @returns The context provider component - * @internal - */ -export const ScopeManagerProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const { coreClient } = useCoreClient(); - - const [scopeRegistry, setScopeRegistry] = useState>>(() => ({ - me: new Set(), - 'my-org': new Set(), - })); - - const [ensured, setEnsured] = useState>({ - me: '', - 'my-org': '', - }); - - const [isReady, setIsReady] = useState(false); - - 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((currentRegistry) => { - const audienceSet = currentRegistry[audience]; - let changed = false; - const nextAudienceSet = new Set(audienceSet); - - newScopes.forEach((scope) => { - if (!nextAudienceSet.has(scope)) { - nextAudienceSet.add(scope); - changed = true; - } - }); - - if (changed) { - return { - ...currentRegistry, - [audience]: nextAudienceSet, - }; - } - return currentRegistry; - }); - }, []); - - useEffect(() => { - if (!coreClient) return; - - const ensureAllScopesSequential = async () => { - let hasScopes = false; - let anyUpdated = false; - - for (const audience of ['me', 'my-org'] as const) { - const scopes = Array.from(scopeRegistry[audience]).sort(); - const scopeString = scopes.join(' '); - - if (scopes.length > 0 && scopeString.trim()) { - hasScopes = true; - - if (scopeString !== ensured[audience]) { - try { - await coreClient.ensureScopes(scopeString, audience); - anyUpdated = true; - } catch (error) { - console.error(`Failed to ensure scopes for ${audience}: ${scopeString}`, 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(' '), - }); - } - - setIsReady(hasScopes); - }; - - ensureAllScopesSequential(); - }, [coreClient, scopeRegistry, ensured]); - - 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 5ef33e83b..0b104a664 100644 --- a/packages/react/src/providers/spa-provider.tsx +++ b/packages/react/src/providers/spa-provider.tsx @@ -15,7 +15,6 @@ 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 { 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'; @@ -47,9 +46,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) { @@ -81,6 +78,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 ba29fe9bb..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', @@ -37,29 +34,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 => @@ -67,7 +59,6 @@ export const createMockWebAuthnAuthenticator = (): Authenticator => id: 'auth_webauthn_123', type: 'webauthn-roaming', name: 'YubiKey 5', - enrolled: true, }); export const createMockAuthenticationMethodsResponse = ( @@ -88,8 +79,6 @@ export const createMockUnconfirmedAuthenticator = (): Authenticator => createMockAuthenticator({ id: 'auth_unconfirmed_123', type: 'email', - email: 'user@example.com', - enrolled: true, confirmed: false, }); @@ -122,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/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/domain-management/domain.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/domain-management/domain.mocks.ts index 1511650fd..61ce16186 100644 --- a/packages/react/src/tests/utils/__mocks__/my-organization/domain-management/domain.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-organization/domain-management/domain.mocks.ts @@ -8,8 +8,7 @@ import { vi } from 'vitest'; import type { DomainTableProps, - UseDomainTableLogicOptions, - UseDomainTableResult, + DomainTableViewProps, } from '@/types/my-organization/domain-management/domain-table-types'; export const createMockDomain = (overrides?: Partial): Domain => ({ @@ -119,53 +118,51 @@ export const createMockDeleteAction = (): ComponentAction => ({ onAfter: vi.fn(), }); -export const createMockLogic = ( - overrides: Partial = {}, -) => ({ - domains: [createMockDomain(), createMockVerifiedDomain()], - providers: [], - isCreating: false, - isVerifying: false, - isFetching: false, - isLoadingProviders: false, - isDeleting: false, - schema: undefined, - styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} }, - hideHeader: false, - readOnly: false, - customMessages: {}, - createAction: undefined, - onOpenProvider: undefined, - onCreateProvider: undefined, - fetchProviders: vi.fn(), - fetchDomains: vi.fn(), - onCreateDomain: vi.fn(), - onVerifyDomain: vi.fn(), - onDeleteDomain: vi.fn(), - onAssociateToProvider: vi.fn(), - onDeleteFromProvider: vi.fn(), - ...overrides, -}); - -export const createMockApi = (overrides: Partial = {}) => ({ - showCreateModal: false, - showConfigureModal: false, - showVerifyModal: false, - showDeleteModal: false, - verifyError: undefined, - selectedDomain: null, - setShowCreateModal: vi.fn(), - setShowConfigureModal: vi.fn(), - setShowDeleteModal: vi.fn(), - setShowVerifyModal: vi.fn(), - handleCreate: vi.fn(), - handleVerify: vi.fn(), - handleDelete: vi.fn(), - handleToggleSwitch: vi.fn(), - handleCloseVerifyModal: vi.fn(), - handleCreateClick: vi.fn(), - handleConfigureClick: vi.fn(), - handleVerifyClick: vi.fn(), - handleDeleteClick: vi.fn(), - ...overrides, -}); +export const createMockDomainTableViewProps = ( + overrides: Partial = {}, +): DomainTableViewProps => + ({ + domains: [createMockDomain(), createMockVerifiedDomain()], + providers: [], + isCreating: false, + isVerifying: false, + isFetching: false, + isLoadingProviders: false, + isDeleting: false, + schema: undefined, + styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + hideHeader: false, + readOnly: false, + customMessages: {}, + createAction: undefined, + onOpenProvider: undefined, + onCreateProvider: undefined, + fetchProviders: vi.fn(), + fetchDomains: vi.fn(), + onCreateDomain: vi.fn(), + onVerifyDomain: vi.fn(), + onDeleteDomain: vi.fn(), + onAssociateToProvider: vi.fn(), + onDeleteFromProvider: vi.fn(), + closeModal: vi.fn(), + showCreateModal: false, + showConfigureModal: false, + showVerifyModal: false, + showDeleteModal: false, + verifyError: undefined, + selectedDomain: null, + setShowCreateModal: vi.fn(), + setShowConfigureModal: vi.fn(), + setShowDeleteModal: vi.fn(), + setShowVerifyModal: vi.fn(), + handleCreate: vi.fn(), + handleVerify: vi.fn(), + handleDelete: vi.fn(), + handleToggleSwitch: vi.fn(), + handleCloseVerifyModal: vi.fn(), + handleCreateClick: vi.fn(), + handleConfigureClick: vi.fn(), + handleVerifyClick: vi.fn(), + handleDeleteClick: vi.fn(), + ...overrides, + }) as DomainTableViewProps; 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/__mocks__/my-organization/idp-management/sso-domain.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/sso-domain.mocks.ts index d5653a4fa..705009ba4 100644 --- a/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/sso-domain.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/sso-domain.mocks.ts @@ -1,10 +1,7 @@ import type { Domain, IdentityProvider } from '@auth0/universal-components-core'; import { vi } from 'vitest'; -import type { - SsoProviderCreateHandlerProps, - SsoProviderCreateLogicProps, -} from '@/types/my-organization/idp-management/sso-provider/sso-provider-create-types'; +import type { SsoProviderCreateViewProps } from '@/types/my-organization/idp-management/sso-provider/sso-provider-create-types'; export const createMockSsoDomain = (overrides?: Partial): Domain => ({ id: 'domain-1', @@ -36,14 +33,15 @@ export const createMockSsoProvider = (overrides?: Partial): Id ...overrides, }) as IdentityProvider; -export function createMockSsoProviderCreateLogic( - overrides: Partial = {}, -): SsoProviderCreateLogicProps { +export function createMockSsoProviderCreateViewProps( + overrides: Partial = {}, +): SsoProviderCreateViewProps { return { styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} }, customMessages: {}, backButton: undefined, isCreating: false, + createProvider: vi.fn(), strategy: undefined, details: undefined, configure: undefined, @@ -52,14 +50,6 @@ export function createMockSsoProviderCreateLogic( isLoadingIdpConfig: false, idpConfig: undefined, formData: {}, - ...overrides, - }; -} - -export function createMockSsoProviderCreateHandler( - overrides: Partial = {}, -): SsoProviderCreateHandlerProps { - return { onNext: vi.fn(), onPrevious: vi.fn(), setFormData: vi.fn(), @@ -71,5 +61,5 @@ export function createMockSsoProviderCreateHandler( onPreviousAction: vi.fn(), }), ...overrides, - }; + } as SsoProviderCreateViewProps; } diff --git a/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/sso-provider-edit/sso-provider-edit.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/sso-provider-edit/sso-provider-edit.mocks.ts index 262fb24af..41e255ef9 100644 --- a/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/sso-provider-edit/sso-provider-edit.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/sso-provider-edit/sso-provider-edit.mocks.ts @@ -1,13 +1,10 @@ import { mockProvider } from './sso-provisioning/sso-provisioning-tab.mocks'; -import type { - SsoProviderEditHandlerProps, - SsoProviderEditLogicProps, -} from '@/types/my-organization/idp-management/sso-provider/sso-provider-edit-types'; +import type { SsoProviderEditViewProps } from '@/types/my-organization/idp-management/sso-provider/sso-provider-edit-types'; -export function createMockSsoProviderEditLogic( - overrides: Partial = {}, -): SsoProviderEditLogicProps { +export function createMockSsoProviderEditViewProps( + overrides: Partial = {}, +): SsoProviderEditViewProps { return { styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} }, schema: undefined, @@ -30,6 +27,8 @@ export function createMockSsoProviderEditLogic( isUpdating: false, isDeleting: false, isRemoving: false, + provisioningConfig: null, + isProvisioningLoading: false, idpConfig: { organization: { can_set_show_as_button: false, @@ -81,25 +80,19 @@ export function createMockSsoProviderEditLogic( isProvisioningAttributesSyncing: false, hasSsoAttributeSyncWarning: false, hasProvisioningAttributeSyncWarning: false, - ...overrides, - }; -} - -export function createMockSsoProviderEditHandler( - overrides: Partial = {}, -): SsoProviderEditHandlerProps { - return { + fetchProvider: () => Promise.resolve(null), + fetchProvisioning: () => Promise.resolve(null), updateProvider: () => Promise.resolve(), listScimTokens: () => Promise.resolve(null), syncSsoAttributes: () => Promise.resolve(), onDeleteConfirm: () => Promise.resolve(), onRemoveConfirm: () => Promise.resolve(), handleToggleProvider: () => Promise.resolve(), - createProvisioningAction: () => Promise.resolve(), - deleteProvisioningAction: () => Promise.resolve(), - createScimTokenAction: (_data) => Promise.resolve(undefined), - deleteScimTokenAction: () => Promise.resolve(), + createProvisioning: () => Promise.resolve(), + deleteProvisioning: () => Promise.resolve(), + createScimToken: (_data: unknown) => Promise.resolve(undefined), + deleteScimToken: () => Promise.resolve(), syncProvisioningAttributes: () => Promise.resolve(), ...overrides, - }; + } as SsoProviderEditViewProps; } diff --git a/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/sso-provider-table/sso-provider-table-mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/sso-provider-table/sso-provider-table-mocks.ts index 4bb2ecca6..d1609d7ee 100644 --- a/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/sso-provider-table/sso-provider-table-mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-organization/idp-management/sso-provider-table/sso-provider-table-mocks.ts @@ -1,19 +1,15 @@ import { vi } from 'vitest'; -import type { - SsoProviderTableHandlerProps, - SsoProviderTableLogicProps, -} from '@/types/my-organization/idp-management/sso-provider/sso-provider-table-types'; +import type { SsoProviderTableViewProps } from '@/types/my-organization/idp-management/sso-provider/sso-provider-table-types'; -export function createMockSsoProviderTableLogic( - overrides: Partial = {}, -): SsoProviderTableLogicProps { +export function createMockSsoProviderTableViewProps( + overrides: Partial = {}, +): SsoProviderTableViewProps { return { - data: [], + providers: [], isLoading: false, styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} }, customMessages: {}, - hideHeader: false, readOnly: false, shouldHideCreate: false, isViewLoading: false, @@ -21,11 +17,17 @@ export function createMockSsoProviderTableLogic( shouldAllowDeletion: false, showDeleteModal: false, showRemoveModal: false, - organization: null, isUpdating: false, - isUpdatingId: '', + isUpdatingId: null, isDeleting: false, isRemoving: false, + error: null as unknown, + retry: vi.fn(), + fetchProviders: vi.fn(), + getOrganizationName: vi.fn(), + onDeleteConfirm: vi.fn(), + onRemoveConfirm: vi.fn(), + onEnableProvider: vi.fn(), createAction: { disabled: false, onAfter: vi.fn(), @@ -36,14 +38,6 @@ export function createMockSsoProviderTableLogic( onAfter: vi.fn(), onBefore: vi.fn(), }, - ...overrides, - }; -} - -export function createMockSsoProviderTableHandler( - overrides: Partial = {}, -): SsoProviderTableHandlerProps { - return { handleCreate: vi.fn(), handleEdit: vi.fn(), handleDelete: vi.fn(), @@ -55,5 +49,5 @@ export function createMockSsoProviderTableHandler( setShowRemoveModal: vi.fn(), setSelectedIdp: vi.fn(), ...overrides, - }; + } as SsoProviderTableViewProps; } diff --git a/packages/react/src/tests/utils/__mocks__/my-organization/organization-management/organization-details.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/organization-management/organization-details.mocks.ts index fb2f0f2ed..ab02cacf7 100644 --- a/packages/react/src/tests/utils/__mocks__/my-organization/organization-management/organization-details.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-organization/organization-management/organization-details.mocks.ts @@ -1,9 +1,6 @@ import type { Organization } from '@auth0/universal-components-core'; -import type { - OrganizationDetailsEditLogicProps, - OrganizationDetailsEditHandlerProps, -} from '@/types/my-organization/organization-management/organization-details-edit-types'; +import type { OrganizationDetailsEditViewProps } from '@/types/my-organization/organization-management/organization-details-edit-types'; export const createMockOrganization = (): Organization => ({ id: 'organization_abc123xyz456', @@ -18,26 +15,21 @@ export const createMockOrganization = (): Organization => ({ }, }); -export function createMockOrganizationDetailsEditLogic( - overrides: Partial = {}, -): OrganizationDetailsEditLogicProps { +export function createMockOrganizationDetailsEditViewProps( + overrides: Partial = {}, +): OrganizationDetailsEditViewProps { return { organization: { ...createMockOrganization() }, + isLoading: false, isFetchLoading: false, + isSaveLoading: false, + updateOrgDetails: async () => true, schema: undefined, styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} }, customMessages: {}, readOnly: false, hideHeader: false, backButton: undefined, - ...overrides, - }; -} - -export function createMockOrganizationDetailsEditHandler( - overrides: Partial = {}, -): OrganizationDetailsEditHandlerProps { - return { formActions: { isLoading: false, nextAction: { @@ -46,5 +38,5 @@ export function createMockOrganizationDetailsEditHandler( }, }, ...overrides, - }; + } as OrganizationDetailsEditViewProps; } diff --git a/packages/react/src/tests/utils/test-provider.tsx b/packages/react/src/tests/utils/test-provider.tsx index 0b21584e3..7f1081c27 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 @@ -70,9 +69,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 1b2c34598..eb69308ce 100644 --- a/packages/react/src/tests/utils/test-utilities.ts +++ b/packages/react/src/tests/utils/test-utilities.ts @@ -26,9 +26,7 @@ export const createMockUseTranslator = (_customMessages?: object) => ({ fallbackLanguage: 'en', }); -export const createMockUseErrorHandler = (handleError: ReturnType) => ({ - handleError, -}); +export const createMockUseErrorHandler = (handleError: ReturnType) => handleError; /** * Sets up a mock for useCoreClient hook with a valid core client. 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..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 @@ -285,7 +287,7 @@ export type UseMFAResult = { ) => Promise; }; -export interface UserMFAMgmtLogicProps { +export interface UserMFAMgmtViewProps { error: string | null; schema: | Partial<{ @@ -311,9 +313,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 +320,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 +369,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/config/config-idp-types.ts b/packages/react/src/types/my-organization/config/config-idp-types.ts index b7c1e6f9b..e04fa16f0 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 @@ -30,4 +30,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 b305c8cd9..fd31de862 100644 --- a/packages/react/src/types/my-organization/config/config-types.ts +++ b/packages/react/src/types/my-organization/config/config-types.ts @@ -16,4 +16,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 d5d26a2dd..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 @@ -14,8 +14,6 @@ import type { DomainConfigureMessages, DomainVerifyMessages, DomainTableMessages, - CreateOrganizationDomainRequestContent, - EnhancedTranslationFunction, IdentityProviderAssociatedWithDomain, } from '@auth0/universal-components-core'; @@ -56,10 +54,41 @@ export interface DomainTableProps onCreateProvider?: () => void; } -// DomainTableView component props -export interface DomainTableViewProps { - logic: UseDomainTableResult & DomainTableProps; - handlers: UseDomainTableLogicResult; +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. */ @@ -85,52 +114,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 { - // 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 cafcdda75..bb68a2c02 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 @@ -70,6 +70,8 @@ export interface UseSsoDomainTabOptions extends SharedComponentProps { export interface UseSsoDomainTabReturn { domainsList: Domain[]; isLoading: boolean; + error: unknown; + retry: () => Promise; showCreateModal: boolean; isCreating: boolean; selectedDomain: Domain | null; 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 c10afe536..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 @@ -108,18 +108,24 @@ export interface SsoProviderCreateProps onNext?: (stepId: string, values: Partial) => boolean; } +export type FormState = { + strategy?: IdpStrategy; + details?: ProviderDetailsFormValues | null; + configure?: ProviderConfigureFormValues | null; +}; + export interface UseSsoProviderCreateOptions { createAction?: SsoProviderCreateProps['createAction']; customMessages?: SsoProviderCreateProps['customMessages']; -} - -export interface UseSsoProviderCreateLogicOptions { onNext?: SsoProviderCreateProps['onNext']; onPrevious?: SsoProviderCreateProps['onPrevious']; - createProvider: (data: CreateIdentityProviderRequestContentPrivate) => Promise; } -export interface UseSsoProviderCreateLogicResult { +export interface UseSsoProviderCreateReturn { + createProvider: (data: CreateIdentityProviderRequestContentPrivate) => Promise; + isCreating: boolean; + error: unknown; + retry: () => Promise; formData: FormState; setFormData: React.Dispatch>; detailsRef: React.RefObject; @@ -138,39 +144,18 @@ export interface UseSsoProviderCreateLogicResult { }; } -export type FormState = { - strategy?: IdpStrategy; - details?: ProviderDetailsFormValues | null; - configure?: ProviderConfigureFormValues | null; -}; - -export type SsoProviderCreateViewProps = { - logic: SsoProviderCreateLogicProps; - handlers: SsoProviderCreateHandlerProps; -}; - -export interface SsoProviderCreateLogicProps { - formData: FormState; - strategy?: IdpStrategy; - details?: ProviderDetailsFormValues | null; - configure?: ProviderConfigureFormValues | null; +export interface SsoProviderCreateViewProps { + createProvider: (data: CreateIdentityProviderRequestContentPrivate) => Promise; isCreating: boolean; - isLoadingConfig: boolean; - filteredStrategies: IdpStrategy[]; - isLoadingIdpConfig: boolean; - idpConfig?: IdpConfig | null; - styling?: SsoProviderCreateProps['styling']; - customMessages?: SsoProviderCreateProps['customMessages']; - backButton?: SsoProviderCreateProps['backButton']; -} - -export interface SsoProviderCreateHandlerProps { - onNext: ((stepId: string, values: Partial) => boolean) | undefined; - onPrevious: ((stepId: string, values: Partial) => boolean) | undefined; + 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, @@ -178,4 +163,9 @@ export interface SsoProviderCreateHandlerProps { 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 0b34f5c67..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 @@ -87,7 +87,7 @@ export interface UseSsoProviderEditOptions extends SharedComponentProps { export interface UseSsoProviderEditReturn { provider: IdentityProvider | null; - organization: OrganizationPrivate | null; + organization: OrganizationPrivate; provisioningConfig: GetIdPProvisioningConfigResponseContent | null; isLoading: boolean; isUpdating: boolean; @@ -103,10 +103,17 @@ export interface UseSsoProviderEditReturn { isProvisioningAttributesSyncing: boolean; hasSsoAttributeSyncWarning: boolean; hasProvisioningAttributeSyncWarning: boolean; + shouldAllowDeletion: boolean; + isLoadingConfig: boolean; + idpConfig: IdpConfig | null; + isLoadingIdpConfig: boolean; + showProvisioningTab: boolean; + error: unknown; + retry: () => Promise; fetchProvider: () => Promise; - fetchOrganizationDetails: () => Promise; fetchProvisioning: () => Promise; updateProvider: (data: UpdateIdentityProviderRequestContentPrivate) => Promise; + handleToggleProvider: (enabled: boolean) => Promise; createProvisioning: () => Promise; deleteProvisioning: () => Promise; listScimTokens: () => Promise; @@ -136,54 +143,50 @@ export interface SsoProviderAttributeSyncAlertProps { customMessages?: Partial; } -export type SsoProviderEditViewProps = { - logic: SsoProviderEditLogicProps; - handlers: SsoProviderEditHandlerProps; -}; - -export interface SsoProviderEditLogicProps - extends SsoProviderEditProps, - Omit, - Pick< - UseSsoProviderEditReturn, - | 'provider' - | 'organization' - | 'isLoading' - | 'isUpdating' - | 'isDeleting' - | 'isRemoving' - | 'isProvisioningUpdating' - | 'isProvisioningDeleting' - | 'isScimTokensLoading' - | 'isScimTokenCreating' - | 'isScimTokenDeleting' - | 'isSsoAttributesSyncing' - | 'isProvisioningAttributesSyncing' - | 'hasSsoAttributeSyncWarning' - | 'hasProvisioningAttributeSyncWarning' - > {} - -export interface SsoProviderEditHandlerProps { +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; - createProvisioningAction: () => Promise; - deleteProvisioningAction: () => Promise; + handleToggleProvider: (enabled: boolean) => Promise; + createProvisioning: () => Promise; + deleteProvisioning: () => Promise; listScimTokens: () => Promise; - createScimTokenAction: ( + createScimToken: ( data: CreateIdpProvisioningScimTokenRequestContent, ) => Promise; - deleteScimTokenAction: (idpScimTokenId: string) => Promise; + deleteScimToken: (idpScimTokenId: string) => Promise; syncSsoAttributes: () => Promise; syncProvisioningAttributes: () => Promise; onDeleteConfirm: () => Promise; onRemoveConfirm: () => Promise; - handleToggleProvider: (enabled: boolean) => Promise; -} - -export interface UseSsoProviderEditLogicResult { - shouldAllowDeletion: boolean; - isLoadingConfig: boolean; - idpConfig: IdpConfig | null; - isLoadingIdpConfig: boolean; - showProvisioningTab: boolean; - handleToggleProvider: (enabled: boolean) => 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 2b27af838..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 @@ -9,7 +9,6 @@ import type { SsoProviderDeleteSchema, SsoProviderTableMessages, IdentityProvider as CoreIdentityProvider, - OrganizationPrivate, } from '@auth0/universal-components-core'; export type IdentityProvider = CoreIdentityProvider; @@ -42,20 +41,47 @@ export interface SsoProviderTableProps enableProviderAction?: ComponentAction; } -/** useSsoProviderTable hook result. */ -export interface UseSsoProviderTableReturn extends SharedComponentProps { +/** useSsoProviderTable options. */ +export interface UseSsoProviderTableOptions { + readOnly?: boolean; + createAction: ComponentAction; + editAction: ComponentAction; + deleteAction?: ComponentAction; + deleteFromOrganizationAction?: ComponentAction; + enableProviderAction?: ComponentAction; + customMessages?: SsoProviderTableProps['customMessages']; +} + +export interface UseSsoProviderTableReturn { providers: IdentityProvider[]; - organization: OrganizationPrivate | null; 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; - fetchOrganizationDetails: () => 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; } /** Props for SsoProviderTable actions column. */ @@ -77,34 +103,31 @@ export interface SsoProviderTableActionsColumnProps onDelete: (provider: IdentityProvider) => void; onRemoveFromOrganization: (provider: IdentityProvider) => void; } -export interface UseSsoProviderTableLogicOptions { - readOnly: boolean; + +/** Props for the SsoProviderTableView component. */ +export interface SsoProviderTableViewProps { + providers: IdentityProvider[]; isLoading: boolean; - createAction: ComponentAction; - editAction: ComponentAction; - deleteAction?: ComponentAction; - deleteFromOrganizationAction?: ComponentAction; - onEnableProvider: (selectedIdp: IdentityProvider, enabled: boolean) => Promise; - onDeleteConfirm: (selectedIdp: IdentityProvider) => Promise; - onRemoveConfirm: (selectedIdp: IdentityProvider) => Promise; -} -/** - * Combined logic and handler result for SSO provider table. - * Used for hooks and view props. - */ -export interface UseSsoProviderTableLogicResult { - // Logic props isViewLoading: boolean; - showDeleteModal: boolean; + isDeleting: boolean; + isRemoving: boolean; + isUpdating: boolean; + isUpdatingId: string | null; shouldAllowDeletion: boolean; shouldHideCreate: boolean; + showDeleteModal: boolean; showRemoveModal: boolean; selectedIdp: IdentityProvider | null; - - // Handler props - setShowDeleteModal: React.Dispatch>; - setShowRemoveModal: React.Dispatch>; - setSelectedIdp: React.Dispatch>; + 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; @@ -112,44 +135,10 @@ export interface UseSsoProviderTableLogicResult { handleToggleEnabled: (idp: IdentityProvider, enabled: boolean) => Promise; handleDeleteConfirm: (provider: IdentityProvider) => Promise; handleRemoveConfirm: (provider: IdentityProvider) => Promise; -} - -export interface SsoProviderTableLogicProps { - data: IdentityProvider[]; - isLoading: boolean; styling: SsoProviderTableProps['styling']; customMessages: SsoProviderTableProps['customMessages']; - hideHeader: boolean; - readOnly: boolean; - shouldHideCreate: boolean; - isViewLoading: boolean; + readOnly: SsoProviderTableProps['readOnly']; createAction: SsoProviderTableProps['createAction']; editAction: SsoProviderTableProps['editAction']; - selectedIdp: IdentityProvider | null; - showDeleteModal: boolean; - showRemoveModal: boolean; - organization: OrganizationPrivate | null; - isUpdating: boolean; - isUpdatingId: string | null; - isDeleting: boolean; - isRemoving: boolean; - shouldAllowDeletion: boolean; -} - -export interface SsoProviderTableHandlerProps { - handleCreate: () => void; - handleEdit: (idp: IdentityProvider) => void; - handleDelete: (idp: IdentityProvider) => void; - handleDeleteFromOrganization: (idp: IdentityProvider) => void; - handleToggleEnabled: (idp: IdentityProvider, enabled: boolean) => void; - handleDeleteConfirm: (provider: IdentityProvider) => Promise; - handleRemoveConfirm: (provider: IdentityProvider) => Promise; - setShowDeleteModal: React.Dispatch>; - setShowRemoveModal: React.Dispatch>; - setSelectedIdp: React.Dispatch>; + hideHeader?: boolean; } - -export type SsoProviderTableViewProps = { - logic: SsoProviderTableLogicProps; - handlers: SsoProviderTableHandlerProps; -}; 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 97274f863..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 @@ -10,7 +10,6 @@ import type { BackButton, OrganizationPrivate, OrganizationDetailsEditMessages, - ComponentStyling, } from '@auth0/universal-components-core'; import type { LucideIcon } from 'lucide-react'; import type React from 'react'; @@ -57,30 +56,26 @@ 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; } -export interface OrganizationDetailsEditLogicProps { +export interface OrganizationDetailsEditViewProps { organization: OrganizationPrivate; + isLoading: boolean; isFetchLoading: boolean; - schema: Partial | undefined; - styling: ComponentStyling; + isSaveLoading: boolean; + formActions: OrganizationDetailsFormActions; + updateOrgDetails: (data: OrganizationPrivate) => Promise; + schema?: OrganizationDetailsEditProps['schema']; customMessages: OrganizationDetailsEditProps['customMessages']; + styling: OrganizationDetailsEditProps['styling']; readOnly: OrganizationDetailsEditProps['readOnly']; hideHeader: boolean; - backButton?: OrganizationEditBackButton; -} - -export interface OrganizationDetailsEditHandlerProps { - formActions: OrganizationDetailsFormActions; -} - -export interface OrganizationDetailsEditViewProps { - logic: OrganizationDetailsEditLogicProps; - handlers: OrganizationDetailsEditHandlerProps; + backButton?: OrganizationDetailsEditProps['backButton']; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed2ee2266..14a96a529 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,8 +184,8 @@ importers: examples/next-rwa: dependencies: '@auth0/nextjs-auth0': - specifier: ^4.13.2 - version: 4.13.2(next@16.1.5(@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 @@ -233,8 +233,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 @@ -621,8 +621,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 @@ -686,8 +686,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 @@ -698,8 +698,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==} @@ -712,10 +712,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 @@ -7108,9 +7108,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) @@ -7120,7 +7120,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 @@ -7139,7 +7139,7 @@ snapshots: dependencies: '@auth0/auth0-auth-js': 1.2.0 - '@auth0/nextjs-auth0@4.13.2(next@16.1.5(@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