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}
-
- )}
-
-
+ >
+
+ 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'
+ );
+}