diff --git a/web/cypress/e2e/superuser-org-actions.cy.ts b/web/cypress/e2e/superuser-org-actions.cy.ts index cf0381dd4..31996e439 100644 --- a/web/cypress/e2e/superuser-org-actions.cy.ts +++ b/web/cypress/e2e/superuser-org-actions.cy.ts @@ -241,6 +241,73 @@ describe('Superuser Organization Actions', () => { cy.get('#new-organization-name').clear(); cy.get('button').contains('OK').should('be.disabled'); }); + + it('should show password verification when fresh login is required', () => { + cy.visit('/organization'); + cy.wait('@getConfig'); + cy.wait('@getSuperUser'); + cy.wait('@getSuperuserOrganizations'); + cy.wait('@getSuperuserUsers'); + + // First attempt returns fresh_login_required error + cy.intercept('PUT', '/api/v1/superuser/organizations/testorg', { + statusCode: 401, + body: { + title: 'fresh_login_required', + error_message: 'Fresh login required', + }, + }).as('renameRequiresFresh'); + + // Click action menu for testorg + cy.get('[data-testid="testorg-options-toggle"]').click(); + + // Click Rename Organization + cy.contains('Rename Organization').click(); + + // Should open rename modal + cy.get('[role="dialog"]').should('exist'); + cy.contains('Rename Organization').should('exist'); + + // Fill in new name + cy.get('#new-organization-name').type('testorg-renamed'); + + // Submit form + cy.get('button').contains('OK').click(); + + // Wait for the fresh login required response + cy.wait('@renameRequiresFresh'); + + // Should show fresh login modal + cy.contains('Please Verify').should('exist'); + cy.contains( + 'It has been more than a few minutes since you last logged in', + ).should('exist'); + cy.get('#fresh-password').should('exist'); + + // Mock successful password verification + cy.intercept('POST', '/api/v1/signin/verify', { + statusCode: 200, + body: {success: true}, + }).as('verifyPassword'); + + // Mock successful rename after verification + cy.intercept('PUT', '/api/v1/superuser/organizations/testorg', { + statusCode: 200, + }).as('renameSuccess'); + + // Enter password and verify + cy.get('#fresh-password').type('password'); + cy.get('button').contains('Verify').click(); + + // Wait for verification + cy.wait('@verifyPassword'); + + // Should retry rename and succeed + cy.wait('@renameSuccess'); + + // Fresh login modal should close + cy.contains('Please Verify').should('not.exist'); + }); }); describe('Delete Organization', () => { @@ -341,6 +408,70 @@ describe('Superuser Organization Actions', () => { // Modal should close cy.get('[role="dialog"]').should('not.exist'); }); + + it('should show password verification when fresh login is required', () => { + cy.visit('/organization'); + cy.wait('@getConfig'); + cy.wait('@getSuperUser'); + cy.wait('@getSuperuserOrganizations'); + cy.wait('@getSuperuserUsers'); + + // First attempt returns fresh_login_required error + cy.intercept('DELETE', '/api/v1/superuser/organizations/testorg', { + statusCode: 401, + body: { + title: 'fresh_login_required', + error_message: 'Fresh login required', + }, + }).as('deleteRequiresFresh'); + + // Click action menu for testorg + cy.get('[data-testid="testorg-options-toggle"]').click(); + + // Click Delete Organization + cy.contains('Delete Organization').click(); + + // Should open delete confirmation modal + cy.get('[role="dialog"]').should('exist'); + cy.contains('Delete Organization').should('exist'); + + // Confirm deletion + cy.get('button').contains('OK').click(); + + // Wait for the fresh login required response + cy.wait('@deleteRequiresFresh'); + + // Should show fresh login modal + cy.contains('Please Verify').should('exist'); + cy.contains( + 'It has been more than a few minutes since you last logged in', + ).should('exist'); + cy.get('#fresh-password').should('exist'); + + // Mock successful password verification + cy.intercept('POST', '/api/v1/signin/verify', { + statusCode: 200, + body: {success: true}, + }).as('verifyPassword'); + + // Mock successful delete after verification + cy.intercept('DELETE', '/api/v1/superuser/organizations/testorg', { + statusCode: 204, + }).as('deleteSuccess'); + + // Enter password and verify + cy.get('#fresh-password').type('password'); + cy.get('button').contains('Verify').click(); + + // Wait for verification + cy.wait('@verifyPassword'); + + // Should retry delete and succeed + cy.wait('@deleteSuccess'); + + // Fresh login modal should close + cy.contains('Please Verify').should('not.exist'); + }); }); describe('Take Ownership', () => { @@ -439,6 +570,68 @@ describe('Superuser Organization Actions', () => { // Wait for API call cy.wait('@takeOwnership'); }); + + it('should show password verification when fresh login is required', () => { + cy.visit('/organization'); + cy.wait('@getConfig'); + cy.wait('@getSuperUser'); + + // First attempt returns fresh_login_required error + cy.intercept('POST', '/api/v1/superuser/takeownership/testorg', { + statusCode: 401, + body: { + title: 'fresh_login_required', + error_message: 'Fresh login required', + }, + }).as('takeOwnershipRequiresFresh'); + + // Click action menu for testorg + cy.get('[data-testid="testorg-options-toggle"]').click(); + + // Click Take Ownership + cy.contains('Take Ownership').click(); + + // Should open take ownership modal + cy.get('[role="dialog"]').should('exist'); + cy.contains('Take Ownership').should('exist'); + + // Confirm take ownership + cy.get('button').contains('Take Ownership').click(); + + // Wait for the fresh login required response + cy.wait('@takeOwnershipRequiresFresh'); + + // Should show fresh login modal instead of error + cy.contains('Please Verify').should('exist'); + cy.contains( + 'It has been more than a few minutes since you last logged in', + ).should('exist'); + cy.get('#fresh-password').should('exist'); + + // Mock successful password verification + cy.intercept('POST', '/api/v1/signin/verify', { + statusCode: 200, + body: {success: true}, + }).as('verifyPassword'); + + // Mock successful take ownership after verification + cy.intercept('POST', '/api/v1/superuser/takeownership/testorg', { + statusCode: 200, + }).as('takeOwnershipSuccess'); + + // Enter password and verify + cy.get('#fresh-password').type('password'); + cy.get('button').contains('Verify').click(); + + // Wait for verification + cy.wait('@verifyPassword'); + + // Should retry take ownership and succeed + cy.wait('@takeOwnershipSuccess'); + + // Fresh login modal should close + cy.contains('Please Verify').should('not.exist'); + }); }); describe('Configure Quota - Phase 3', () => { @@ -544,5 +737,76 @@ describe('Superuser Organization Actions', () => { // Configure Quota should NOT appear cy.contains('Configure Quota').should('not.exist'); }); + + it('should show password verification when fresh login is required', () => { + cy.visit('/organization'); + cy.wait('@getConfig'); + cy.wait('@getSuperUser'); + cy.wait('@getSuperuserOrganizations'); + cy.wait('@getSuperuserUsers'); + + // Click action menu + cy.get('[data-testid="testorg-options-toggle"]').click(); + + // Click Configure Quota + cy.contains('Configure Quota').click(); + + // Modal should open + cy.get('[data-testid="configure-quota-modal"]').should('be.visible'); + + // Enter quota value + cy.get('[data-testid="quota-value-input"]').clear().type('100'); + + // First attempt returns fresh_login_required error + cy.intercept('POST', '/api/v1/organization/testorg/quota', { + statusCode: 401, + body: { + title: 'fresh_login_required', + error_message: 'Fresh login required', + }, + }).as('createQuotaRequiresFresh'); + + // Submit quota + cy.get('[data-testid="apply-quota-button"]').click(); + + // Wait for the fresh login required response + cy.wait('@createQuotaRequiresFresh'); + + // Should show fresh login modal + cy.contains('Please Verify').should('exist'); + cy.contains( + 'It has been more than a few minutes since you last logged in', + ).should('exist'); + cy.get('#fresh-password').should('exist'); + + // Mock successful password verification + cy.intercept('POST', '/api/v1/signin/verify', { + statusCode: 200, + body: {success: true}, + }).as('verifyPassword'); + + // Mock successful quota creation after verification + cy.intercept('POST', '/api/v1/organization/testorg/quota', { + statusCode: 201, + body: { + id: '123', + limit_bytes: 107374182400, + limits: [], + }, + }).as('createQuotaSuccess'); + + // Enter password and verify + cy.get('#fresh-password').type('password'); + cy.get('button').contains('Verify').click(); + + // Wait for verification + cy.wait('@verifyPassword'); + + // Should retry quota creation and succeed + cy.wait('@createQuotaSuccess'); + + // Fresh login modal should close + cy.contains('Please Verify').should('not.exist'); + }); }); }); diff --git a/web/cypress/e2e/superuser-user-management.cy.ts b/web/cypress/e2e/superuser-user-management.cy.ts index 58c71acef..2d6ad6ed4 100644 --- a/web/cypress/e2e/superuser-user-management.cy.ts +++ b/web/cypress/e2e/superuser-user-management.cy.ts @@ -335,6 +335,85 @@ describe('Superuser User Management', () => { cy.wait('@updateEmail'); }); + + it('should show password verification when fresh login is required', () => { + cy.intercept('GET', '/api/v1/superuser/users/', { + fixture: 'superuser-users.json', + }).as('getUsers'); + + cy.intercept('GET', '/api/v1/superuser/organizations/', { + body: {organizations: []}, + }).as('getOrgs'); + + // Use call counter to handle sequential requests deterministically + let changeEmailCallCount = 0; + cy.intercept('PUT', '/api/v1/superuser/users/tom', (req) => { + changeEmailCallCount += 1; + if (changeEmailCallCount === 1) { + // First attempt returns fresh_login_required error + req.reply({ + statusCode: 401, + body: { + title: 'fresh_login_required', + error_message: 'Fresh login required', + }, + }); + } else if (req.body.email) { + // Successful email change after verification + req.reply({statusCode: 200, body: {}}); + } + }).as('changeEmail'); + + cy.visit('/organization'); + cy.wait('@getConfig'); + cy.wait('@getSuperUser'); + cy.wait('@getUsers'); + cy.wait('@getOrgs'); + + // Wait for loading spinner to disappear + cy.get('.pf-v5-l-bullseye').should('not.exist'); + + cy.get('[data-testid="tom-options-toggle"]').click(); + cy.contains('Change E-mail Address').click(); + + // Modal should open + cy.contains('Change Email for tom').should('be.visible'); + + // Enter new email and submit + cy.get('[role="dialog"]').within(() => { + cy.get('input[type="email"]').clear().type('newemail@example.com'); + cy.contains('button', 'Change Email').click(); + }); + + // Wait for the fresh login required response + cy.wait('@changeEmail'); + + // Should show fresh login modal + cy.contains('Please Verify').should('exist'); + cy.contains( + 'It has been more than a few minutes since you last logged in', + ).should('exist'); + cy.get('#fresh-password').should('exist'); + + // Mock successful password verification + cy.intercept('POST', '/api/v1/signin/verify', { + statusCode: 200, + body: {success: true}, + }).as('verifyPassword'); + + // Enter password and verify + cy.get('#fresh-password').type('password'); + cy.get('button').contains('Verify').click(); + + // Wait for verification + cy.wait('@verifyPassword'); + + // Should retry email change and succeed + cy.wait('@changeEmail'); + + // Fresh login modal should close + cy.contains('Please Verify').should('not.exist'); + }); }); describe('Change Password', () => { @@ -371,6 +450,81 @@ describe('Superuser User Management', () => { cy.wait('@updatePassword'); }); + + it('should show password verification when fresh login is required', () => { + // Use call counter to handle sequential requests deterministically + let changePasswordCallCount = 0; + cy.intercept('PUT', '/api/v1/superuser/users/tom', (req) => { + changePasswordCallCount += 1; + if (changePasswordCallCount === 1) { + // First attempt returns fresh_login_required error + req.reply({ + statusCode: 401, + body: { + title: 'fresh_login_required', + error_message: 'Fresh login required', + }, + }); + } else if (req.body.password) { + // Successful password change after verification + req.reply({statusCode: 200, body: {}}); + } + }).as('changePassword'); + + cy.intercept('GET', '/api/v1/superuser/users/', { + fixture: 'superuser-users.json', + }).as('getUsers'); + + cy.intercept('GET', '/api/v1/superuser/organizations/', { + body: {organizations: []}, + }).as('getOrgs'); + + cy.visit('/organization'); + cy.wait('@getConfig'); + cy.wait('@getSuperUser'); + cy.wait(['@getUsers', '@getOrgs']); + + cy.get('[data-testid="tom-options-toggle"]').click(); + cy.contains('Change Password').click(); + + // Modal should open + cy.contains('Change Password for tom').should('be.visible'); + + // Enter new password and submit + cy.get('[role="dialog"]').within(() => { + cy.get('input[type="password"]').clear().type('newpassword123'); + cy.contains('button', 'Change Password').click(); + }); + + // Wait for the fresh login required response + cy.wait('@changePassword'); + + // Should show fresh login modal + cy.contains('Please Verify').should('exist'); + cy.contains( + 'It has been more than a few minutes since you last logged in', + ).should('exist'); + cy.get('#fresh-password').should('exist'); + + // Mock successful password verification + cy.intercept('POST', '/api/v1/signin/verify', { + statusCode: 200, + body: {success: true}, + }).as('verifyPassword'); + + // Enter password and verify + cy.get('#fresh-password').type('password'); + cy.get('button').contains('Verify').click(); + + // Wait for verification + cy.wait('@verifyPassword'); + + // Should retry password change and succeed + cy.wait('@changePassword'); + + // Fresh login modal should close + cy.contains('Please Verify').should('not.exist'); + }); }); describe('Toggle User Status', () => { @@ -492,6 +646,78 @@ describe('Superuser User Management', () => { cy.wait('@deleteUser'); }); + + it('should show password verification when fresh login is required', () => { + // Use call counter to handle sequential requests deterministically + let deleteUserCallCount = 0; + cy.intercept('DELETE', '/api/v1/superuser/users/tom', (req) => { + deleteUserCallCount += 1; + if (deleteUserCallCount === 1) { + // First attempt returns fresh_login_required error + req.reply({ + statusCode: 401, + body: { + title: 'fresh_login_required', + error_message: 'Fresh login required', + }, + }); + } else { + // Successful delete after verification + req.reply({statusCode: 204}); + } + }).as('deleteUser'); + + cy.intercept('GET', '/api/v1/superuser/users/', { + fixture: 'superuser-users.json', + }).as('getUsers'); + + cy.intercept('GET', '/api/v1/superuser/organizations/', { + body: {organizations: []}, + }).as('getOrgs'); + + cy.visit('/organization'); + cy.wait('@getConfig'); + cy.wait('@getSuperUser'); + cy.wait(['@getUsers', '@getOrgs']); + + cy.get('[data-testid="tom-options-toggle"]').click(); + cy.contains('Delete User').click(); + + // Modal should show warning + cy.contains('Delete User').should('be.visible'); + + // Confirm deletion + cy.contains('button', 'Delete User').click(); + + // Wait for the fresh login required response + cy.wait('@deleteUser'); + + // Should show fresh login modal + cy.contains('Please Verify').should('exist'); + cy.contains( + 'It has been more than a few minutes since you last logged in', + ).should('exist'); + cy.get('#fresh-password').should('exist'); + + // Mock successful password verification + cy.intercept('POST', '/api/v1/signin/verify', { + statusCode: 200, + body: {success: true}, + }).as('verifyPassword'); + + // Enter password and verify + cy.get('#fresh-password').type('password'); + cy.get('button').contains('Verify').click(); + + // Wait for verification + cy.wait('@verifyPassword'); + + // Should retry delete and succeed + cy.wait('@deleteUser'); + + // Fresh login modal should close + cy.contains('Please Verify').should('not.exist'); + }); }); describe('Take Ownership', () => { @@ -531,6 +757,210 @@ describe('Superuser User Management', () => { }); }); + describe('Fresh Login Verification - Wrong Password', () => { + it('should show error alert when wrong password is entered', () => { + cy.intercept('GET', '/api/v1/superuser/users/', { + fixture: 'superuser-users.json', + }).as('getUsers'); + + cy.intercept('GET', '/api/v1/superuser/organizations/', { + body: {organizations: []}, + }).as('getOrgs'); + + // Mock fresh login required for change email + let changeEmailCallCount = 0; + cy.intercept('PUT', '/api/v1/superuser/users/tom', (req) => { + changeEmailCallCount += 1; + if (changeEmailCallCount === 1) { + // First attempt returns fresh_login_required error + req.reply({ + statusCode: 401, + body: { + title: 'fresh_login_required', + error_message: 'Fresh login required', + }, + }); + } else if (req.body.email) { + // Successful email change after verification + req.reply({statusCode: 200, body: {}}); + } + }).as('changeEmail'); + + // Mock failed password verification (403 Forbidden) + cy.intercept('POST', '/api/v1/signin/verify', { + statusCode: 403, + body: { + message: 'Invalid Username or Password', + invalidCredentials: true, + }, + }).as('verifyPasswordFailed'); + + cy.visit('/organization'); + cy.wait('@getConfig'); + cy.wait('@getSuperUser'); + cy.wait('@getUsers'); + cy.wait('@getOrgs'); + + // Wait for loading spinner to disappear + cy.get('.pf-v5-l-bullseye').should('not.exist'); + + cy.get('[data-testid="tom-options-toggle"]').click(); + cy.contains('Change E-mail Address').click(); + + // Modal should open + cy.contains('Change Email for tom').should('be.visible'); + + // Enter new email and submit + cy.get('[role="dialog"]').within(() => { + cy.get('input[type="email"]').clear().type('newemail@example.com'); + cy.contains('button', 'Change Email').click(); + }); + + // Wait for the fresh login required response + cy.wait('@changeEmail'); + + // Should show fresh login modal + cy.contains('Please Verify').should('exist'); + cy.contains( + 'It has been more than a few minutes since you last logged in', + ).should('exist'); + cy.get('#fresh-password').should('exist'); + + // Enter WRONG password and verify + cy.get('#fresh-password').type('wrongpassword'); + cy.get('button').contains('Verify').click(); + + // Wait for failed verification + cy.wait('@verifyPasswordFailed'); + + // Fresh login modal should close + cy.contains('Please Verify').should('not.exist'); + + // Should show error alert + cy.contains('Invalid verification credentials').should('be.visible'); + + // Click to expand alert details + cy.get('button[aria-label="Danger alert details"]').click(); + + // Should show detailed error message + cy.contains('Invalid Username or Password').should('be.visible'); + }); + + it('should allow retry after wrong password error', () => { + cy.intercept('GET', '/api/v1/superuser/users/', { + fixture: 'superuser-users.json', + }).as('getUsers'); + + cy.intercept('GET', '/api/v1/superuser/organizations/', { + body: {organizations: []}, + }).as('getOrgs'); + + // Mock fresh login required for delete user + let deleteUserCallCount = 0; + cy.intercept('DELETE', '/api/v1/superuser/users/tom', (req) => { + deleteUserCallCount += 1; + if (deleteUserCallCount === 1 || deleteUserCallCount === 2) { + // First and second attempts return fresh_login_required error + req.reply({ + statusCode: 401, + body: { + title: 'fresh_login_required', + error_message: 'Fresh login required', + }, + }); + } else { + // Third attempt: successful delete after correct verification + req.reply({statusCode: 204}); + } + }).as('deleteUser'); + + // Mock password verification - first wrong, then correct + let verifyCallCount = 0; + cy.intercept('POST', '/api/v1/signin/verify', (req) => { + verifyCallCount += 1; + if (verifyCallCount === 1) { + // First attempt: wrong password + req.reply({ + statusCode: 403, + body: { + message: 'Invalid Username or Password', + invalidCredentials: true, + }, + }); + } else { + // Second attempt: correct password + req.reply({ + statusCode: 200, + body: {success: true}, + }); + } + }).as('verifyPassword'); + + cy.visit('/organization'); + cy.wait('@getConfig'); + cy.wait('@getSuperUser'); + cy.wait(['@getUsers', '@getOrgs']); + + cy.get('[data-testid="tom-options-toggle"]').click(); + cy.contains('Delete User').click(); + + // Modal should show warning + cy.contains('Delete User').should('be.visible'); + + // Confirm deletion + cy.contains('button', 'Delete User').click(); + + // Wait for the fresh login required response + cy.wait('@deleteUser'); + + // Should show fresh login modal + cy.contains('Please Verify').should('exist'); + cy.get('#fresh-password').should('exist'); + + // Enter WRONG password and verify + cy.get('#fresh-password').type('wrongpassword'); + cy.get('button').contains('Verify').click(); + + // Wait for failed verification + cy.wait('@verifyPassword'); + + // Fresh login modal should close and show error + cy.contains('Please Verify').should('not.exist'); + cy.contains('Invalid verification credentials').should('be.visible'); + + // Close the error alert + cy.get('button[aria-label*="Close Danger alert"]').click(); + + // Trigger action again to show fresh login modal + cy.get('[data-testid="tom-options-toggle"]').click(); + cy.contains('Delete User').click(); + cy.contains('button', 'Delete User').click(); + + // Wait for second fresh login required + cy.wait('@deleteUser'); + + // Fresh login modal should appear again + cy.contains('Please Verify').should('exist'); + cy.get('#fresh-password').should('exist'); + + // Enter CORRECT password this time + cy.get('#fresh-password').type('password'); + cy.get('button').contains('Verify').click(); + + // Wait for successful verification + cy.wait('@verifyPassword'); + + // Should retry delete and succeed + cy.wait('@deleteUser'); + + // Fresh login modal should close + cy.contains('Please Verify').should('not.exist'); + + // Should NOT show error alert (success case) + cy.contains('Invalid verification credentials').should('not.exist'); + }); + }); + describe('Configure Quota Option Visibility', () => { beforeEach(() => { cy.intercept('GET', '/api/v1/superuser/organizations/', { diff --git a/web/src/AppWithFreshLogin.tsx b/web/src/AppWithFreshLogin.tsx new file mode 100644 index 000000000..df3dd6d21 --- /dev/null +++ b/web/src/AppWithFreshLogin.tsx @@ -0,0 +1,61 @@ +import {ReactNode, useEffect, useState} from 'react'; +import {FreshLoginModal} from 'src/components/modals/FreshLoginModal'; +import {useGlobalFreshLogin} from 'src/hooks/UseGlobalFreshLogin'; +import {AlertVariant, useUI} from 'src/contexts/UIContext'; + +interface AppWithFreshLoginProps { + children: ReactNode; +} + +export function AppWithFreshLogin({children}: AppWithFreshLoginProps) { + const {isLoading, handleVerify, handleCancel} = useGlobalFreshLogin(); + const [isModalOpen, setIsModalOpen] = useState(false); + const {addAlert} = useUI(); + + useEffect(() => { + const handleFreshLoginRequired = () => { + setIsModalOpen(true); + }; + + window.addEventListener('freshLoginRequired', handleFreshLoginRequired); + return () => { + window.removeEventListener( + 'freshLoginRequired', + handleFreshLoginRequired, + ); + }; + }, []); + + const handleVerifyWrapper = async (password: string) => { + try { + await handleVerify(password); + setIsModalOpen(false); + } catch (err) { + // On verification failure, close modal and show toast alert + setIsModalOpen(false); + const errorMessage = (err as Error).message; + addAlert({ + variant: AlertVariant.Failure, + title: 'Invalid verification credentials', + message: errorMessage, + }); + } + }; + + const handleCancelWrapper = () => { + handleCancel(); + setIsModalOpen(false); + }; + + return ( + <> + {children} + + + ); +} diff --git a/web/src/components/modals/FreshLoginModal.tsx b/web/src/components/modals/FreshLoginModal.tsx index f50e14c9d..4decc4177 100644 --- a/web/src/components/modals/FreshLoginModal.tsx +++ b/web/src/components/modals/FreshLoginModal.tsx @@ -7,7 +7,6 @@ import { FormGroup, TextInput, Text, - Alert, } from '@patternfly/react-core'; interface FreshLoginModalProps { @@ -15,7 +14,6 @@ interface FreshLoginModalProps { onVerify: (password: string) => Promise; onCancel: () => void; isLoading?: boolean; - error?: string; } export function FreshLoginModal({ @@ -23,7 +21,6 @@ export function FreshLoginModal({ onVerify, onCancel, isLoading = false, - error, }: FreshLoginModalProps) { const [password, setPassword] = useState(''); @@ -33,9 +30,10 @@ export function FreshLoginModal({ try { await onVerify(password); - setPassword(''); // Clear password on success - } catch (err) { - // Error handling is done by parent component + setPassword(''); + } catch { + // Parent component handles error display, just clear the password field + setPassword(''); } }; @@ -70,12 +68,6 @@ export function FreshLoginModal({ verify your password to perform this sensitive operation: - {error && ( - - {error} - - )} -
Promise) => void; - isFreshLoginRequired: (error: unknown) => boolean; -} - -export function useChangeLog(freshLogin?: UseChangeLogWithFreshLogin) { - const queryClient = useQueryClient(); - +export function useChangeLog() { const result = useQuery({ queryKey: ['changeLog'], queryFn: async () => { - try { - return await fetchChangeLog(); - } catch (error: unknown) { - // Check if this is a fresh login required error - if (freshLogin?.isFreshLoginRequired(error)) { - // Show fresh login modal with retry operation - freshLogin.showFreshLoginModal(async () => { - // Retry the query after successful verification - queryClient.invalidateQueries({queryKey: ['changeLog']}); - }); - - // Don't throw the error - the modal will handle retry - throw new Error('Fresh login required'); - } - throw error; - } + return await fetchChangeLog(); }, staleTime: 5 * 60 * 1000, // 5 minutes - change log doesn't update frequently - retry: false, // Don't auto-retry, let fresh login handle it }); return { diff --git a/web/src/hooks/UseCreateUser.ts b/web/src/hooks/UseCreateUser.ts index c5932d5fb..a8f184cc6 100644 --- a/web/src/hooks/UseCreateUser.ts +++ b/web/src/hooks/UseCreateUser.ts @@ -6,7 +6,7 @@ import { interface UseCreateUserOptions { onSuccess?: (username: string) => void; - onError?: (error: string) => void; + onError?: (error: any) => void; } export function useCreateUser(options?: UseCreateUserOptions) { @@ -32,14 +32,8 @@ export function useCreateUser(options?: UseCreateUserOptions) { } }, onError: (error: any) => { - const errorMessage = - error?.response?.data?.error_message || - error?.response?.data?.message || - error?.message || - 'Failed to create user'; - if (options?.onError) { - options.onError(errorMessage); + options.onError(error); } }, }, diff --git a/web/src/hooks/UseEmailVerification.ts b/web/src/hooks/UseEmailVerification.ts deleted file mode 100644 index a93fdced1..000000000 --- a/web/src/hooks/UseEmailVerification.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {useState} from 'react'; -import axios from 'src/libs/axios'; -import {addDisplayError} from 'src/resources/ErrorHandling'; - -interface VerificationData { - code: string; - username?: string; -} - -export function useEmailVerification() { - const [isVerifying, setIsVerifying] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - - const verifyUser = async (data: VerificationData) => { - setIsVerifying(true); - setError(null); - setSuccess(false); - - try { - const response = await axios.post('/api/v1/signin/verify', data); - setSuccess(true); - return response.data; - } catch (err) { - const errorMsg = addDisplayError('Email verification failed', err); - setError(errorMsg); - throw new Error(errorMsg); - } finally { - setIsVerifying(false); - } - }; - - const resetVerification = () => { - setError(null); - setSuccess(false); - setIsVerifying(false); - }; - - return { - verifyUser, - isVerifying, - error, - success, - resetVerification, - }; -} diff --git a/web/src/hooks/UseFreshLogin.ts b/web/src/hooks/UseFreshLogin.ts deleted file mode 100644 index d97627024..000000000 --- a/web/src/hooks/UseFreshLogin.ts +++ /dev/null @@ -1,125 +0,0 @@ -import {useState, useCallback} from 'react'; -import axios from 'src/libs/axios'; -import {AxiosResponse, AxiosError} from 'axios'; -import {assertHttpCode} from 'src/resources/ErrorHandling'; - -interface VerifyUserRequest { - password: string; -} - -interface VerifyUserResponse { - success: boolean; -} - -interface FreshLoginState { - isModalOpen: boolean; - isLoading: boolean; - error: string | null; - pendingOperations: (() => Promise)[]; -} - -async function verifyUser(password: string): Promise { - const response: AxiosResponse = await axios.post( - '/api/v1/signin/verify', - {password} as VerifyUserRequest, - ); - assertHttpCode(response.status, 200); -} - -export function useFreshLogin() { - const [state, setState] = useState({ - isModalOpen: false, - isLoading: false, - error: null, - pendingOperations: [], - }); - - const showFreshLoginModal = useCallback( - (retryOperation: () => Promise) => { - setState((prevState) => ({ - ...prevState, - isModalOpen: true, - error: null, - pendingOperations: [...prevState.pendingOperations, retryOperation], - })); - }, - [], - ); - - const handleVerify = useCallback( - async (password: string) => { - setState((prevState) => ({ - ...prevState, - isLoading: true, - error: null, - })); - - try { - await verifyUser(password); - - // On success, retry all pending operations - const {pendingOperations} = state; - setState({ - isModalOpen: false, - isLoading: false, - error: null, - pendingOperations: [], - }); - - // Execute all pending operations - for (const operation of pendingOperations) { - try { - await operation(); - } catch (error) { - console.error( - 'Failed to retry operation after fresh login:', - error, - ); - } - } - } catch (error: unknown) { - const axiosError = error as AxiosError; - const errorMessage = - ((axiosError?.response?.data as Record) - ?.message as string) || 'Invalid verification credentials'; - setState((prevState) => ({ - ...prevState, - isLoading: false, - error: errorMessage, - })); - throw error; // Re-throw so the modal can handle it - } - }, - [state.pendingOperations], - ); - - const handleCancel = useCallback(() => { - setState({ - isModalOpen: false, - isLoading: false, - error: null, - pendingOperations: [], - }); - }, []); - - const isFreshLoginRequired = useCallback((error: unknown): boolean => { - const axiosError = error as AxiosError; - if (axiosError?.response?.status !== 401) return false; - - const data = axiosError.response?.data as Record; - return ( - data?.title === 'fresh_login_required' || - data?.error_type === 'fresh_login_required' - ); - }, []); - - return { - isModalOpen: state.isModalOpen, - isLoading: state.isLoading, - error: state.error, - showFreshLoginModal, - handleVerify, - handleCancel, - isFreshLoginRequired, - }; -} diff --git a/web/src/hooks/UseGlobalFreshLogin.tsx b/web/src/hooks/UseGlobalFreshLogin.tsx new file mode 100644 index 000000000..2f1cb0fba --- /dev/null +++ b/web/src/hooks/UseGlobalFreshLogin.tsx @@ -0,0 +1,54 @@ +import {useState, useCallback} from 'react'; +import {AxiosError} from 'axios'; +import { + getCsrfToken, + retryPendingFreshLoginRequests, + clearPendingFreshLoginRequests, +} from 'src/libs/axios'; +import {verifyUser} from 'src/resources/AuthResource'; + +export function useGlobalFreshLogin() { + const [isLoading, setIsLoading] = useState(false); + + const handleVerify = useCallback(async (password: string) => { + setIsLoading(true); + + try { + // Verify password with backend + await verifyUser(password); + + // Fetch new CSRF token after password verification + await getCsrfToken(); + + // Retry all queued requests that failed due to fresh login requirement + retryPendingFreshLoginRequests(); + + // Reset state + setIsLoading(false); + } catch (err: unknown) { + const axiosError = err as AxiosError; + const errorMessage = + ((axiosError?.response?.data as Record) + ?.message as string) || 'Invalid verification credentials'; + setIsLoading(false); + + // Reject all queued requests with error + clearPendingFreshLoginRequests(errorMessage); + + // Throw error to allow parent component to handle verification failure + throw new Error(errorMessage); + } + }, []); + + const handleCancel = useCallback(() => { + // Reject all queued requests + clearPendingFreshLoginRequests('Verification canceled'); + setIsLoading(false); + }, []); + + return { + isLoading, + handleVerify, + handleCancel, + }; +} diff --git a/web/src/hooks/UseOrganizationActions.ts b/web/src/hooks/UseOrganizationActions.ts index a5b19a6bd..35bfe732c 100644 --- a/web/src/hooks/UseOrganizationActions.ts +++ b/web/src/hooks/UseOrganizationActions.ts @@ -88,8 +88,8 @@ export function useTakeOwnership({onSuccess, onError}) { ]); queryClient.invalidateQueries(['organization', 'superuser', 'users']); queryClient.invalidateQueries(['user']); - // Navigate to the organization page as Angular does - navigate(`/organization/${namespace}?tab=organizations`); + // Navigate to the organization page + navigate(`/organization/${namespace}`); onSuccess(); }, onError: (err) => { diff --git a/web/src/hooks/UseOrganizations.ts b/web/src/hooks/UseOrganizations.ts index 54111058c..661b68cb6 100644 --- a/web/src/hooks/UseOrganizations.ts +++ b/web/src/hooks/UseOrganizations.ts @@ -38,8 +38,8 @@ export function useOrganizations() { ['organization', 'superuser', 'organizations'], fetchOrgsAsSuperUser, { - enabled: isSuperUser, retry: false, + enabled: isSuperUser === true && !loading, }, ); @@ -48,8 +48,8 @@ export function useOrganizations() { ['organization', 'superuser', 'users'], fetchUsersAsSuperUser, { - enabled: isSuperUser, retry: false, + enabled: isSuperUser === true && !loading, }, ); diff --git a/web/src/hooks/UseRegistrySize.ts b/web/src/hooks/UseRegistrySize.ts index 648f1f050..4ef3b3767 100644 --- a/web/src/hooks/UseRegistrySize.ts +++ b/web/src/hooks/UseRegistrySize.ts @@ -8,7 +8,7 @@ import {AxiosError} from 'axios'; import {addDisplayError} from 'src/resources/ErrorHandling'; // Hook to fetch registry size data -export function useRegistrySize() { +export function useRegistrySize(enabled = true) { const { data: registrySize, isLoading, @@ -16,6 +16,7 @@ export function useRegistrySize() { refetch, } = useQuery(['registrysize'], fetchRegistrySize, { retry: false, + enabled: enabled, }); return { diff --git a/web/src/index.tsx b/web/src/index.tsx index bceac6d47..43a00919d 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -7,6 +7,7 @@ import {UIProvider} from './contexts/UIContext'; // Load App after patternfly so custom CSS that overrides patternfly doesn't require !important import App from './App'; +import {AppWithFreshLogin} from './AppWithFreshLogin'; const queryClient = new QueryClient({ defaultOptions: { @@ -25,7 +26,9 @@ root.render( - + + + diff --git a/web/src/libs/axios.ts b/web/src/libs/axios.ts index 0a872201a..218eba092 100644 --- a/web/src/libs/axios.ts +++ b/web/src/libs/axios.ts @@ -18,6 +18,16 @@ export async function getCsrfToken() { return response.data; } +// Queue for requests that failed due to fresh login requirement +let pendingFreshLoginRequests: Array<{ + config: any; + resolve: (value: any) => void; + reject: (reason: any) => void; +}> = []; + +// Tracks whether fresh login modal is currently displayed +let freshLoginModalShown = false; + const axiosIns = axios.create(); axiosIns.interceptors.request.use(async (config) => { if (!GlobalAuthState.csrfToken) { @@ -40,41 +50,76 @@ axiosIns.interceptors.request.use(async (config) => { return config; }); -// Catches errors thrown in axiosIns.interceptors.request.use axiosIns.interceptors.response.use( (response) => { - // Check for updated CSRF token in response headers (exactly like Angular) - // Backend sends 'X-Next-CSRF-Token' but axios normalizes to lowercase + // Update CSRF token from response headers when backend provides a new one const nextCsrfToken = response.headers['x-next-csrf-token']; if (nextCsrfToken) { - // Update global CSRF token state (like Angular updates window.__token) GlobalAuthState.csrfToken = nextCsrfToken; } return response; }, async (error) => { if (error.response?.status === 401) { - // Check if this is a fresh login required error const data = error.response?.data; const isFreshLoginRequired = data?.title === 'fresh_login_required' || data?.error_type === 'fresh_login_required'; + if (isFreshLoginRequired) { + // Queue this request to be retried after password verification + return new Promise((resolve, reject) => { + pendingFreshLoginRequests.push({ + config: error.config, + resolve, + reject, + }); + + // Only show modal for the first failed request to avoid duplicates + if (!freshLoginModalShown) { + freshLoginModalShown = true; + window.dispatchEvent(new CustomEvent('freshLoginRequired')); + } + }); + } + + // Handle regular session expiry if (!isFreshLoginRequired) { - // Only redirect for session expiry, not fresh login required if (window?.insights?.chrome?.auth) { - // refresh token for plugin + // Refresh token for plugin GlobalAuthState.bearerToken = await window.insights.chrome.auth.getToken(); } else { - // redirect to login page for standalone + // Redirect to login page for standalone window.location.href = '/signin'; } } - // For fresh login required, let the component handle it } - throw error; // Rethrow error to be handled in components + throw error; }, ); +// Retry all queued requests after successful password verification +export function retryPendingFreshLoginRequests() { + const requests = [...pendingFreshLoginRequests]; + pendingFreshLoginRequests = []; + freshLoginModalShown = false; + + for (const {config, resolve, reject} of requests) { + axiosIns.request(config).then(resolve).catch(reject); + } +} + +// Clear all pending requests when user cancels password verification +export function clearPendingFreshLoginRequests( + errorMessage = 'Fresh login verification cancelled', +) { + // Reject all pending requests with error + pendingFreshLoginRequests.forEach(({reject}) => + reject(new Error(errorMessage)), + ); + pendingFreshLoginRequests = []; + freshLoginModalShown = false; +} + export default axiosIns; diff --git a/web/src/resources/AuthResource.ts b/web/src/resources/AuthResource.ts index cbfbb2975..f4c90a61c 100644 --- a/web/src/resources/AuthResource.ts +++ b/web/src/resources/AuthResource.ts @@ -54,15 +54,6 @@ export async function getOrganization(orgName: string) { return response; } -interface EmailVerificationRequest { - code: string; - username?: string; -} - -interface ExternalLoginAuthRequest { - kind: string; -} - export async function getExternalLoginAuthUrl( serviceId: string, action = 'login', @@ -80,8 +71,19 @@ export async function detachExternalLogin(serviceId: string) { return response.data; } -export async function verifyEmailAddress(data: EmailVerificationRequest) { - const response = await axios.post('/api/v1/signin/verify', data); +interface VerifyUserRequest { + password: string; +} + +interface VerifyUserResponse { + success: boolean; +} + +export async function verifyUser(password: string) { + const response = await axios.post( + '/api/v1/signin/verify', + {password} as VerifyUserRequest, + ); assertHttpCode(response.status, 200); return response.data; } diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/QuotaManagement.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/QuotaManagement.tsx index e874000cf..ebe6b5a2d 100644 --- a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/QuotaManagement.tsx +++ b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/QuotaManagement.tsx @@ -46,6 +46,7 @@ type QuotaManagementProps = { organizationName: string; isUser: boolean; view?: 'organization-view' | 'super-user'; // Add view parameter + onOperationSubmit?: () => void; // Callback to close modal when operation is submitted }; const QUOTA_UNITS = ['KiB', 'MiB', 'GiB', 'TiB']; @@ -329,6 +330,10 @@ export const QuotaManagement = (props: QuotaManagementProps) => { } else { createQuotaMutation({limit_bytes}); } + + // Close modal immediately after submitting the request + // If fresh login is required, the request will be queued and retried after verification + props.onOperationSubmit?.(); }; const handleDeleteQuota = () => { @@ -339,6 +344,9 @@ export const QuotaManagement = (props: QuotaManagementProps) => { if (organizationQuota) { deleteQuotaMutation(organizationQuota.id); setIsDeleteModalOpen(false); + // Close parent modal immediately after submitting the request + // If fresh login is required, the request will be queued and retried after verification + props.onOperationSubmit?.(); } }; @@ -397,6 +405,10 @@ export const QuotaManagement = (props: QuotaManagementProps) => { threshold_percent: Number(newLimit.limit_percent), }, }); + + // Close modal immediately after submitting the request + // If fresh login is required, the request will be queued and retried after verification + props.onOperationSubmit?.(); }; const handleUpdateLimit = (limitId: string, updatedLimit: IQuotaLimit) => { @@ -419,6 +431,10 @@ export const QuotaManagement = (props: QuotaManagementProps) => { threshold_percent: updatedLimit.limit_percent, }, }); + + // Close modal immediately after submitting the request + // If fresh login is required, the request will be queued and retried after verification + props.onOperationSubmit?.(); }; const handleDeleteLimit = (limitId: string) => { @@ -428,6 +444,10 @@ export const QuotaManagement = (props: QuotaManagementProps) => { quotaId: organizationQuota.id, limitId: limitId, }); + + // Close modal immediately after submitting the request + // If fresh login is required, the request will be queued and retried after verification + props.onOperationSubmit?.(); }; const handleLimitChange = ( diff --git a/web/src/routes/OrganizationsList/OrganizationsList.tsx b/web/src/routes/OrganizationsList/OrganizationsList.tsx index 024d1c5b5..195b33182 100644 --- a/web/src/routes/OrganizationsList/OrganizationsList.tsx +++ b/web/src/routes/OrganizationsList/OrganizationsList.tsx @@ -130,7 +130,7 @@ export default function OrganizationsList() { const [isCalculateModalOpen, setCalculateModalOpen] = useState(false); // Get current user for superuser check - const {user} = useCurrentUser(); + const {user, isSuperUser} = useCurrentUser(); const quayConfig = useQuayConfig(); // Determine authentication type for external auth alert @@ -140,7 +140,9 @@ export default function OrganizationsList() { quayConfig.config.AUTHENTICATION_TYPE !== 'AppToken'; // Registry size data (only fetch if superuser) - const {registrySize, refetch: refetchRegistrySize} = useRegistrySize(); + const {registrySize, refetch: refetchRegistrySize} = useRegistrySize( + isSuperUser === true, + ); // Registry size calculation mutation const {queueCalculation, isQueuing} = useQueueRegistrySizeCalculation({ @@ -165,8 +167,6 @@ export default function OrganizationsList() { userEmailMap, } = useOrganizations(); - const {isSuperUser} = useCurrentUser(); - const searchFilter = useRecoilValue(searchOrgsFilterState); // Helper function to format the last updated time @@ -374,7 +374,7 @@ export default function OrganizationsList() { isQueuing={false} showRegistrySize={false} isExternalAuth={!!isExternalAuth} - isSuperUser={!!user?.super_user} + isSuperUser={!!isSuperUser} /> @@ -441,11 +441,9 @@ export default function OrganizationsList() { handleCalculateClick={handleCalculateClick} isQueuing={isQueuing} showRegistrySize={ - !!( - user?.super_user && - quayConfig?.features?.QUOTA_MANAGEMENT && - quayConfig?.features?.EDIT_QUOTA - ) + isSuperUser === true && + quayConfig?.features?.QUOTA_MANAGEMENT === true && + quayConfig?.features?.EDIT_QUOTA === true } isExternalAuth={!!isExternalAuth} isSuperUser={!!user?.super_user} diff --git a/web/src/routes/OrganizationsList/modals/ChangeEmailModal.tsx b/web/src/routes/OrganizationsList/modals/ChangeEmailModal.tsx index 72eff1b86..381874023 100644 --- a/web/src/routes/OrganizationsList/modals/ChangeEmailModal.tsx +++ b/web/src/routes/OrganizationsList/modals/ChangeEmailModal.tsx @@ -10,6 +10,7 @@ import { } from '@patternfly/react-core'; import {useChangeUserEmail} from 'src/hooks/UseUserActions'; import {AlertVariant, useUI} from 'src/contexts/UIContext'; +import {isFreshLoginError} from 'src/utils/freshLoginErrors'; interface ChangeEmailModalProps { isOpen: boolean; @@ -32,7 +33,13 @@ export default function ChangeEmailModal(props: ChangeEmailModalProps) { }, onError: (err) => { const errorMessage = - err?.response?.data?.error_message || 'Failed to change email'; + err?.response?.data?.error_message || + err?.message || + 'Failed to change email'; + // Filter out fresh login errors to prevent duplicate alerts + if (isFreshLoginError(errorMessage)) { + return; + } setError(errorMessage); addAlert({ variant: AlertVariant.Failure, @@ -61,6 +68,8 @@ export default function ChangeEmailModal(props: ChangeEmailModalProps) { } setError(null); changeEmail(props.username, newEmail.trim()); + // Close modal; request is queued if fresh login required + handleClose(); }; return ( diff --git a/web/src/routes/OrganizationsList/modals/ChangePasswordModal.tsx b/web/src/routes/OrganizationsList/modals/ChangePasswordModal.tsx index c39c1549e..fe361917f 100644 --- a/web/src/routes/OrganizationsList/modals/ChangePasswordModal.tsx +++ b/web/src/routes/OrganizationsList/modals/ChangePasswordModal.tsx @@ -10,6 +10,7 @@ import { } from '@patternfly/react-core'; import {useChangeUserPassword} from 'src/hooks/UseUserActions'; import {AlertVariant, useUI} from 'src/contexts/UIContext'; +import {isFreshLoginError} from 'src/utils/freshLoginErrors'; interface ChangePasswordModalProps { isOpen: boolean; @@ -32,7 +33,13 @@ export default function ChangePasswordModal(props: ChangePasswordModalProps) { }, onError: (err) => { const errorMessage = - err?.response?.data?.error_message || 'Failed to change password'; + err?.response?.data?.error_message || + err?.message || + 'Failed to change password'; + // Filter out fresh login errors to prevent duplicate alerts + if (isFreshLoginError(errorMessage)) { + return; + } setError(errorMessage); addAlert({ variant: AlertVariant.Failure, @@ -59,6 +66,8 @@ export default function ChangePasswordModal(props: ChangePasswordModalProps) { } setError(null); changePassword(props.username, newPassword); + // Close modal; request is queued if fresh login required + handleClose(); }; return ( diff --git a/web/src/routes/OrganizationsList/modals/ConfigureQuotaModal.tsx b/web/src/routes/OrganizationsList/modals/ConfigureQuotaModal.tsx index c6c2167fa..1671e06b7 100644 --- a/web/src/routes/OrganizationsList/modals/ConfigureQuotaModal.tsx +++ b/web/src/routes/OrganizationsList/modals/ConfigureQuotaModal.tsx @@ -21,6 +21,7 @@ export function ConfigureQuotaModal(props: ConfigureQuotaModalProps) { organizationName={props.organizationName} isUser={props.isUser} view="super-user" + onOperationSubmit={props.onClose} /> ); diff --git a/web/src/routes/OrganizationsList/modals/CreateUserModal.tsx b/web/src/routes/OrganizationsList/modals/CreateUserModal.tsx index f48175dd8..ac58b64f1 100644 --- a/web/src/routes/OrganizationsList/modals/CreateUserModal.tsx +++ b/web/src/routes/OrganizationsList/modals/CreateUserModal.tsx @@ -11,6 +11,7 @@ import { import {useForm} from 'react-hook-form'; import {useCreateUser} from 'src/hooks/UseCreateUser'; import {AlertVariant, useUI} from 'src/contexts/UIContext'; +import {isFreshLoginError} from 'src/utils/freshLoginErrors'; interface CreateUserModalProps { isOpen: boolean; @@ -55,12 +56,21 @@ export function CreateUserModal(props: CreateUserModalProps) { setErrorMessage(null); props.onSuccess(); }, - onError: (error: string) => { - setErrorMessage(error); + onError: (err: any) => { + const errorMsg = + err?.response?.data?.error_message || + err?.response?.data?.message || + err?.message || + 'Failed to create user'; + // Filter out fresh login errors to prevent duplicate alerts + if (isFreshLoginError(errorMsg)) { + return; + } + setErrorMessage(errorMsg); addAlert({ variant: AlertVariant.Failure, title: 'Failed to create user', - message: error, + message: errorMsg, }); }, }); @@ -74,6 +84,8 @@ export function CreateUserModal(props: CreateUserModalProps) { email: data.email, password: data.password, }); + // Close modal; request is queued if fresh login required + handleClose(); }; const handleClose = () => { @@ -83,145 +95,147 @@ export function CreateUserModal(props: CreateUserModalProps) { }; return ( - - Create User - , - , - ]} - > - - {errorMessage && ( - + - {errorMessage} - - )} + Create User + , + , + ]} + > + + {errorMessage && ( + + {errorMessage} + + )} - - - + > + + - - - + > + + - - - + > + + - - - value === password || 'Passwords do not match', - })} - /> - - - + > + + value === password || 'Passwords do not match', + })} + /> + + + + ); } diff --git a/web/src/routes/OrganizationsList/modals/DeleteOrganizationModal.tsx b/web/src/routes/OrganizationsList/modals/DeleteOrganizationModal.tsx index ed9bac7dd..150e8d444 100644 --- a/web/src/routes/OrganizationsList/modals/DeleteOrganizationModal.tsx +++ b/web/src/routes/OrganizationsList/modals/DeleteOrganizationModal.tsx @@ -2,6 +2,7 @@ import {useState} from 'react'; import {Modal, ModalVariant, Button, Text, Alert} from '@patternfly/react-core'; import {useDeleteSingleOrganization} from 'src/hooks/UseOrganizationActions'; import {AlertVariant, useUI} from 'src/contexts/UIContext'; +import {isFreshLoginError} from 'src/utils/freshLoginErrors'; interface DeleteOrganizationModalProps { isOpen: boolean; @@ -25,7 +26,13 @@ export default function DeleteOrganizationModal( }, onError: (err) => { const errorMessage = - err?.response?.data?.error_message || 'Failed to delete organization'; + err?.response?.data?.error_message || + err?.message || + 'Failed to delete organization'; + // Filter out fresh login errors to prevent duplicate alerts + if (isFreshLoginError(errorMessage)) { + return; + } setError(errorMessage); addAlert({ variant: AlertVariant.Failure, @@ -43,6 +50,8 @@ export default function DeleteOrganizationModal( const handleDelete = () => { setError(null); deleteOrganization(props.organizationName); + // Close modal; request is queued if fresh login required + handleClose(); }; return ( diff --git a/web/src/routes/OrganizationsList/modals/DeleteUserModal.tsx b/web/src/routes/OrganizationsList/modals/DeleteUserModal.tsx index cc10ee961..1da70c43f 100644 --- a/web/src/routes/OrganizationsList/modals/DeleteUserModal.tsx +++ b/web/src/routes/OrganizationsList/modals/DeleteUserModal.tsx @@ -2,6 +2,7 @@ import {useState} from 'react'; import {Modal, ModalVariant, Button, Text, Alert} from '@patternfly/react-core'; import {useDeleteUser} from 'src/hooks/UseUserActions'; import {AlertVariant, useUI} from 'src/contexts/UIContext'; +import {isFreshLoginError} from 'src/utils/freshLoginErrors'; interface DeleteUserModalProps { isOpen: boolean; @@ -23,7 +24,13 @@ export default function DeleteUserModal(props: DeleteUserModalProps) { }, onError: (err) => { const errorMessage = - err?.response?.data?.error_message || 'Failed to delete user'; + err?.response?.data?.error_message || + err?.message || + 'Failed to delete user'; + // Filter out fresh login errors to prevent duplicate alerts + if (isFreshLoginError(errorMessage)) { + return; + } setError(errorMessage); addAlert({ variant: AlertVariant.Failure, @@ -41,6 +48,8 @@ export default function DeleteUserModal(props: DeleteUserModalProps) { const handleDelete = () => { setError(null); deleteUser(props.username); + // Close modal; request is queued if fresh login required + handleClose(); }; return ( diff --git a/web/src/routes/OrganizationsList/modals/RenameOrganizationModal.tsx b/web/src/routes/OrganizationsList/modals/RenameOrganizationModal.tsx index 236dac82e..2b534fd99 100644 --- a/web/src/routes/OrganizationsList/modals/RenameOrganizationModal.tsx +++ b/web/src/routes/OrganizationsList/modals/RenameOrganizationModal.tsx @@ -10,6 +10,7 @@ import { } from '@patternfly/react-core'; import {useRenameOrganization} from 'src/hooks/UseOrganizationActions'; import {AlertVariant, useUI} from 'src/contexts/UIContext'; +import {isFreshLoginError} from 'src/utils/freshLoginErrors'; interface RenameOrganizationModalProps { isOpen: boolean; @@ -34,7 +35,13 @@ export default function RenameOrganizationModal( }, onError: (err) => { const errorMessage = - err?.response?.data?.error_message || 'Failed to rename organization'; + err?.response?.data?.error_message || + err?.message || + 'Failed to rename organization'; + // Filter out fresh login errors to prevent duplicate alerts + if (isFreshLoginError(errorMessage)) { + return; + } setError(errorMessage); addAlert({ variant: AlertVariant.Failure, @@ -57,6 +64,8 @@ export default function RenameOrganizationModal( } setError(null); renameOrganization(props.organizationName, newName.trim()); + // Close modal; request is queued if fresh login required + handleClose(); }; return ( diff --git a/web/src/routes/OrganizationsList/modals/TakeOwnershipModal.tsx b/web/src/routes/OrganizationsList/modals/TakeOwnershipModal.tsx index 66bac7723..ddb2915f8 100644 --- a/web/src/routes/OrganizationsList/modals/TakeOwnershipModal.tsx +++ b/web/src/routes/OrganizationsList/modals/TakeOwnershipModal.tsx @@ -2,6 +2,7 @@ import {useState} from 'react'; import {Modal, ModalVariant, Button, Text, Alert} from '@patternfly/react-core'; import {useTakeOwnership} from 'src/hooks/UseOrganizationActions'; import {AlertVariant, useUI} from 'src/contexts/UIContext'; +import {isFreshLoginError} from 'src/utils/freshLoginErrors'; interface TakeOwnershipModalProps { isOpen: boolean; @@ -25,7 +26,13 @@ export default function TakeOwnershipModal(props: TakeOwnershipModalProps) { }, onError: (err) => { const errorMessage = - err?.response?.data?.error_message || 'Failed to take ownership'; + err?.response?.data?.error_message || + err?.message || + 'Failed to take ownership'; + // Filter out fresh login errors to prevent duplicate alerts + if (isFreshLoginError(errorMessage)) { + return; + } setError(errorMessage); addAlert({ variant: AlertVariant.Failure, @@ -43,6 +50,8 @@ export default function TakeOwnershipModal(props: TakeOwnershipModalProps) { const handleTakeOwnership = () => { setError(null); takeOwnership(props.organizationName); + // Close modal; request is queued if fresh login required + handleClose(); }; return ( diff --git a/web/src/routes/Superuser/ChangeLog/ChangeLog.tsx b/web/src/routes/Superuser/ChangeLog/ChangeLog.tsx index baf26a3c3..c3edb2112 100644 --- a/web/src/routes/Superuser/ChangeLog/ChangeLog.tsx +++ b/web/src/routes/Superuser/ChangeLog/ChangeLog.tsx @@ -12,8 +12,6 @@ import {QuayBreadcrumb} from 'src/components/breadcrumb/Breadcrumb'; import Empty from 'src/components/empty/Empty'; import {useCurrentUser} from 'src/hooks/UseCurrentUser'; import {useChangeLog} from 'src/hooks/UseChangeLog'; -import {useFreshLogin} from 'src/hooks/UseFreshLogin'; -import {FreshLoginModal} from 'src/components/modals/FreshLoginModal'; import {Navigate} from 'react-router-dom'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -34,15 +32,7 @@ function ChangeLogHeader() { export default function ChangeLog() { const {isSuperUser, loading: userLoading} = useCurrentUser(); - const freshLogin = useFreshLogin(); - const { - changeLog, - isLoading: changeLogLoading, - error, - } = useChangeLog({ - showFreshLoginModal: freshLogin.showFreshLoginModal, - isFreshLoginRequired: freshLogin.isFreshLoginRequired, - }); + const {changeLog, isLoading: changeLogLoading, error} = useChangeLog(); if (userLoading) { return null; @@ -116,15 +106,6 @@ export default function ChangeLog() { /> )} - - {/* Fresh Login Modal */} - ); } diff --git a/web/src/routes/Superuser/Messages/CreateMessageForm.tsx b/web/src/routes/Superuser/Messages/CreateMessageForm.tsx index 35687a207..d04af81e6 100644 --- a/web/src/routes/Superuser/Messages/CreateMessageForm.tsx +++ b/web/src/routes/Superuser/Messages/CreateMessageForm.tsx @@ -40,17 +40,9 @@ interface CreateMessageFormData { interface CreateMessageFormProps { isOpen: boolean; onClose: () => void; - freshLogin?: { - showFreshLoginModal: (retryOperation: () => Promise) => void; - isFreshLoginRequired: (error: unknown) => boolean; - }; } -export function CreateMessageForm({ - isOpen, - onClose, - freshLogin, -}: CreateMessageFormProps) { +export function CreateMessageForm({isOpen, onClose}: CreateMessageFormProps) { const [activeTabKey, setActiveTabKey] = useState('write'); const createMessage = useCreateGlobalMessage(); @@ -107,28 +99,6 @@ export function CreateMessageForm({ formHook.reset(); } catch (error) { console.error('Failed to create message:', error); - // If fresh login is required, show fresh login modal - if (freshLogin?.isFreshLoginRequired(error)) { - freshLogin.showFreshLoginModal(async () => { - try { - // Retry the create operation after fresh login - await createMessage.mutateAsync({ - message: { - content: data.content, - media_type: 'text/markdown', - severity: data.severity, - }, - }); - onClose(); - formHook.reset(); - } catch (retryError) { - console.error( - 'Failed to create message after fresh login:', - retryError, - ); - } - }); - } } }; diff --git a/web/src/routes/Superuser/Messages/Messages.tsx b/web/src/routes/Superuser/Messages/Messages.tsx index d52d4782a..e85612a46 100644 --- a/web/src/routes/Superuser/Messages/Messages.tsx +++ b/web/src/routes/Superuser/Messages/Messages.tsx @@ -33,8 +33,6 @@ import { useGlobalMessages, useDeleteGlobalMessage, } from 'src/hooks/UseGlobalMessages'; -import {useFreshLogin} from 'src/hooks/UseFreshLogin'; -import {FreshLoginModal} from 'src/components/modals/FreshLoginModal'; import {Navigate} from 'react-router-dom'; import {IGlobalMessage} from 'src/resources/GlobalMessagesResource'; import {CreateMessageForm} from './CreateMessageForm'; @@ -130,7 +128,6 @@ function SeverityDisplay({severity}: {severity: IGlobalMessage['severity']}) { export default function Messages() { const {isSuperUser, loading: userLoading} = useCurrentUser(); - const freshLogin = useFreshLogin(); const { data: messages = [], isLoading: messagesLoading, @@ -170,25 +167,6 @@ export default function Messages() { setMessageToDelete(null); } catch (error) { console.error('Failed to delete message:', error); - // If fresh login is required, close the delete modal and show fresh login modal - if (freshLogin.isFreshLoginRequired(error)) { - setIsDeleteModalOpen(false); - freshLogin.showFreshLoginModal(async () => { - try { - // Retry the delete operation after fresh login - await deleteMessage.mutateAsync(messageToDelete.uuid); - setMessageToDelete(null); - } catch (retryError) { - console.error( - 'Failed to delete message after fresh login:', - retryError, - ); - setMessageToDelete(null); - } - }); - return; - } - // For other errors, reset the state setIsDeleteModalOpen(false); setMessageToDelete(null); } @@ -315,23 +293,10 @@ export default function Messages() { {renderContent()} - {/* Fresh Login Modal */} - - {/* Create Message Modal */} setIsCreateModalOpen(false)} - freshLogin={{ - showFreshLoginModal: freshLogin.showFreshLoginModal, - isFreshLoginRequired: freshLogin.isFreshLoginRequired, - }} /> {/* Delete Confirmation Modal */} diff --git a/web/src/routes/Superuser/UsageLogs/UsageLogs.tsx b/web/src/routes/Superuser/UsageLogs/UsageLogs.tsx index 9ebf87f80..31b0d51c4 100644 --- a/web/src/routes/Superuser/UsageLogs/UsageLogs.tsx +++ b/web/src/routes/Superuser/UsageLogs/UsageLogs.tsx @@ -12,8 +12,6 @@ import { import {QuayBreadcrumb} from 'src/components/breadcrumb/Breadcrumb'; import {useCurrentUser} from 'src/hooks/UseCurrentUser'; import {Navigate} from 'react-router-dom'; -import {useFreshLogin} from 'src/hooks/UseFreshLogin'; -import {FreshLoginModal} from 'src/components/modals/FreshLoginModal'; import UsageLogsGraph from '../../UsageLogs/UsageLogsGraph'; import {UsageLogsTable} from '../../UsageLogs/UsageLogsTable'; import React from 'react'; @@ -45,7 +43,6 @@ function formatDate(date: string) { export default function UsageLogs() { const {isSuperUser, loading} = useCurrentUser(); - const freshLogin = useFreshLogin(); // Date state and logic const maxDate = new Date(); @@ -156,10 +153,6 @@ export default function UsageLogs() { org="" type="superuser" isSuperuser={true} - freshLogin={{ - showFreshLoginModal: freshLogin.showFreshLoginModal, - isFreshLoginRequired: freshLogin.isFreshLoginRequired, - }} /> )} @@ -171,22 +164,10 @@ export default function UsageLogs() { org="" type="superuser" isSuperuser={true} - freshLogin={{ - showFreshLoginModal: freshLogin.showFreshLoginModal, - isFreshLoginRequired: freshLogin.isFreshLoginRequired, - }} /> - - ); } diff --git a/web/src/routes/UsageLogs/UsageLogsGraph.tsx b/web/src/routes/UsageLogs/UsageLogsGraph.tsx index 51768beef..c0d25e897 100644 --- a/web/src/routes/UsageLogs/UsageLogsGraph.tsx +++ b/web/src/routes/UsageLogs/UsageLogsGraph.tsx @@ -8,7 +8,7 @@ import { } from '@patternfly/react-charts'; import {getAggregateLogs} from 'src/hooks/UseUsageLogs'; -import {useQuery, useQueryClient} from '@tanstack/react-query'; +import {useQuery} from '@tanstack/react-query'; import RequestError from 'src/components/errors/RequestError'; import {Flex, FlexItem, Spinner} from '@patternfly/react-core'; import {logKinds} from './UsageLogs'; @@ -23,15 +23,9 @@ interface UsageLogsGraphProps { type: string; isSuperuser?: boolean; isHidden?: boolean; - freshLogin?: { - showFreshLoginModal: (retryOperation: () => Promise) => void; - isFreshLoginRequired: (error: unknown) => boolean; - }; } export default function UsageLogsGraph(props: UsageLogsGraphProps) { - const queryClient = useQueryClient(); - // D3 Category20 colors (same as Angular) const d3Category20Colors = [ '#1f77b4', @@ -73,46 +67,13 @@ export default function UsageLogsGraph(props: UsageLogsGraphProps) { }, ], async () => { - try { - return await getAggregateLogs( - props.org, - props.repo, - props.starttime, - props.endtime, - props.isSuperuser, - ); - } catch (error: unknown) { - // Check if this is a fresh login required error and we have fresh login integration - if ( - props.isSuperuser && - props.freshLogin?.isFreshLoginRequired(error) - ) { - // Show fresh login modal with retry operation - props.freshLogin.showFreshLoginModal(async () => { - // Retry the query after successful verification - queryClient.invalidateQueries({ - queryKey: [ - 'usageLogs', - props.starttime, - props.endtime, - { - org: props.org, - repo: props.repo ? props.repo : 'isOrg', - type: 'chart', - isSuperuser: props.isSuperuser, - }, - ], - }); - }); - - // Don't throw the error - the modal will handle retry - throw new Error('Fresh login required'); - } - throw error; - } - }, - { - retry: props.isSuperuser && props.freshLogin ? false : true, // Don't auto-retry when fresh login is available + return await getAggregateLogs( + props.org, + props.repo, + props.starttime, + props.endtime, + props.isSuperuser, + ); }, ); diff --git a/web/src/routes/UsageLogs/UsageLogsTable.tsx b/web/src/routes/UsageLogs/UsageLogsTable.tsx index 764147bc7..8266039c2 100644 --- a/web/src/routes/UsageLogs/UsageLogsTable.tsx +++ b/web/src/routes/UsageLogs/UsageLogsTable.tsx @@ -17,7 +17,7 @@ import { Thead, Tr, } from '@patternfly/react-table'; -import {useInfiniteQuery, useQueryClient} from '@tanstack/react-query'; +import {useInfiniteQuery} from '@tanstack/react-query'; import RequestError from 'src/components/errors/RequestError'; import {getLogs} from 'src/hooks/UseUsageLogs'; import {useLogDescriptions} from 'src/hooks/UseLogDescriptions'; @@ -57,10 +57,6 @@ interface UsageLogsTableProps { repo: string; type: string; isSuperuser?: boolean; - freshLogin?: { - showFreshLoginModal: (retryOperation: () => Promise) => void; - isFreshLoginRequired: (error: unknown) => boolean; - }; } interface LogEntry { @@ -87,8 +83,6 @@ export function UsageLogsTable(props: UsageLogsTableProps) { setFilterValue(value); }; - const queryClient = useQueryClient(); - const { data: logs, isLoading: loadingLogs, @@ -109,49 +103,18 @@ export function UsageLogsTable(props: UsageLogsTableProps) { }, ], queryFn: async ({pageParam = undefined}) => { - try { - const logResp = await getLogs( - props.org, - props.repo, - props.starttime, - props.endtime, - pageParam, - props.isSuperuser, - ); - return logResp; - } catch (error: unknown) { - // Check if this is a fresh login required error and we have fresh login integration - if ( - props.isSuperuser && - props.freshLogin?.isFreshLoginRequired(error) - ) { - // Show fresh login modal with retry operation - props.freshLogin.showFreshLoginModal(async () => { - // Retry the query after successful verification - queryClient.invalidateQueries({ - queryKey: [ - 'usageLogs', - props.starttime, - props.endtime, - { - org: props.org, - repo: props.repo ? props.repo : 'isOrg', - type: 'table', - isSuperuser: props.isSuperuser, - }, - ], - }); - }); - - // Don't throw the error - the modal will handle retry - throw new Error('Fresh login required'); - } - throw error; - } + const logResp = await getLogs( + props.org, + props.repo, + props.starttime, + props.endtime, + pageParam, + props.isSuperuser, + ); + return logResp; }, initialPageParam: undefined, getNextPageParam: (lastPage: LogPage) => lastPage.nextPage, - retry: props.isSuperuser && props.freshLogin ? false : true, // Don't auto-retry when fresh login is available }); // Flatten all log pages into a single array for our table hook diff --git a/web/src/utils/freshLoginErrors.ts b/web/src/utils/freshLoginErrors.ts new file mode 100644 index 000000000..4775ec02d --- /dev/null +++ b/web/src/utils/freshLoginErrors.ts @@ -0,0 +1,12 @@ +/** + * Checks if error message is from fresh login verification. + * Used to prevent duplicate error alerts in modals. + */ +export function isFreshLoginError(errorMessage: string): boolean { + return ( + errorMessage === 'Fresh login verification cancelled' || + errorMessage === 'Verification canceled' || + errorMessage === 'Invalid verification credentials' || + errorMessage === 'Invalid Username or Password' + ); +}