mirror of
https://github.com/quay/quay.git
synced 2026-01-26 06:21:37 +03:00
fix(ui): Add "Requires fresh login" checks for superuser operations (PROJQUAY-9658) (#4451)
* fix(ui): show password verification for take ownership (PROJQUAY-9658) Co-authored-by: Claude <noreply@anthropic.com> * fix(ui): add fresh login verification to superuser operations (PROJQUAY-9658) Implemented password verification modal for sensitive superuser operations that require fresh login. Added fresh login handling to: - Delete organization - Rename organization - Delete user - Create user - Change user password - Change user email Also fixed UseCreateUser hook to pass error object instead of string to enable fresh login detection. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(ui): implement global fresh login with request queuing (PROJQUAY-9658) Replaced component-level fresh login handling with centralized axios interceptor approach. All failed requests due to fresh login requirement are now queued and automatically retried after password verification. - Add axios interceptor to catch 401 fresh_login_required globally - Implement request queuing mechanism for pending operations - Add global FreshLoginModal at app root level - Remove component-level useFreshLogin hook from all modals - Close operation modals immediately after submit (request queued if needed) - Add fresh login support for quota management operations * Add Cypress tests for fresh login flow (6 tests) * Address code rabbit reviews * fix(ui): handle fresh login password verification errors - Display error toast when wrong password entered in verification modal - Add centralized fresh login error filtering utility - Add Cypress tests for wrong password and retry scenarios --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
92eddf2dbc
commit
a7d01d052d
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/', {
|
||||
|
||||
61
web/src/AppWithFreshLogin.tsx
Normal file
61
web/src/AppWithFreshLogin.tsx
Normal file
@@ -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}
|
||||
<FreshLoginModal
|
||||
isOpen={isModalOpen}
|
||||
onVerify={handleVerifyWrapper}
|
||||
onCancel={handleCancelWrapper}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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:
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" title="Verification failed" isInline>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormGroup label="Current Password" fieldId="fresh-password" isRequired>
|
||||
<TextInput
|
||||
|
||||
@@ -1,36 +1,13 @@
|
||||
import {useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {fetchChangeLog} from 'src/resources/ChangeLogResource';
|
||||
|
||||
interface UseChangeLogWithFreshLogin {
|
||||
showFreshLoginModal: (retryOperation: () => Promise<void>) => 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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<string | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<void>)[];
|
||||
}
|
||||
|
||||
async function verifyUser(password: string): Promise<void> {
|
||||
const response: AxiosResponse<VerifyUserResponse> = await axios.post(
|
||||
'/api/v1/signin/verify',
|
||||
{password} as VerifyUserRequest,
|
||||
);
|
||||
assertHttpCode(response.status, 200);
|
||||
}
|
||||
|
||||
export function useFreshLogin() {
|
||||
const [state, setState] = useState<FreshLoginState>({
|
||||
isModalOpen: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
pendingOperations: [],
|
||||
});
|
||||
|
||||
const showFreshLoginModal = useCallback(
|
||||
(retryOperation: () => Promise<void>) => {
|
||||
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<string, unknown>)
|
||||
?.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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
54
web/src/hooks/UseGlobalFreshLogin.tsx
Normal file
54
web/src/hooks/UseGlobalFreshLogin.tsx
Normal file
@@ -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<string, unknown>)
|
||||
?.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,
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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<IRegistrySize>(['registrysize'], fetchRegistrySize, {
|
||||
retry: false,
|
||||
enabled: enabled,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -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(
|
||||
<RecoilRoot>
|
||||
<UIProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<AppWithFreshLogin>
|
||||
<App />
|
||||
</AppWithFreshLogin>
|
||||
</QueryClientProvider>
|
||||
</UIProvider>
|
||||
</RecoilRoot>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<VerifyUserResponse>(
|
||||
'/api/v1/signin/verify',
|
||||
{password} as VerifyUserRequest,
|
||||
);
|
||||
assertHttpCode(response.status, 200);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<LoadingPage />
|
||||
</>
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -21,6 +21,7 @@ export function ConfigureQuotaModal(props: ConfigureQuotaModalProps) {
|
||||
organizationName={props.organizationName}
|
||||
isUser={props.isUser}
|
||||
view="super-user"
|
||||
onOperationSubmit={props.onClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Modal
|
||||
variant={ModalVariant.medium}
|
||||
title="Create New User"
|
||||
isOpen={props.isOpen}
|
||||
onClose={handleClose}
|
||||
data-testid="create-user-modal"
|
||||
actions={[
|
||||
<Button
|
||||
key="submit"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={!isValid || isLoading}
|
||||
isLoading={isLoading}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
data-testid="create-user-submit"
|
||||
>
|
||||
Create User
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="link"
|
||||
onClick={handleClose}
|
||||
data-testid="create-user-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form onSubmit={handleSubmit(onSubmit)}>
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
title="Error creating user"
|
||||
isInline
|
||||
style={{marginBottom: '1em'}}
|
||||
<>
|
||||
<Modal
|
||||
variant={ModalVariant.medium}
|
||||
title="Create New User"
|
||||
isOpen={props.isOpen}
|
||||
onClose={handleClose}
|
||||
data-testid="create-user-modal"
|
||||
actions={[
|
||||
<Button
|
||||
key="submit"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={!isValid || isLoading}
|
||||
isLoading={isLoading}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
data-testid="create-user-submit"
|
||||
>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
Create User
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="link"
|
||||
onClick={handleClose}
|
||||
data-testid="create-user-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form onSubmit={handleSubmit(onSubmit)}>
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
title="Error creating user"
|
||||
isInline
|
||||
style={{marginBottom: '1em'}}
|
||||
>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormGroup
|
||||
label="Username"
|
||||
isRequired
|
||||
fieldId="username"
|
||||
helperTextInvalid={errors.username?.message}
|
||||
validated={errors.username ? 'error' : 'default'}
|
||||
>
|
||||
<TextInput
|
||||
id="username"
|
||||
type="text"
|
||||
data-testid="username-input"
|
||||
<FormGroup
|
||||
label="Username"
|
||||
isRequired
|
||||
fieldId="username"
|
||||
helperTextInvalid={errors.username?.message}
|
||||
validated={errors.username ? 'error' : 'default'}
|
||||
isDisabled={isLoading}
|
||||
{...register('username', {
|
||||
required: 'Username is required',
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: 'Username must be at least 2 characters',
|
||||
},
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: 'Username must be less than 255 characters',
|
||||
},
|
||||
pattern: {
|
||||
value: /^[a-z0-9_][a-z0-9_-]*$/,
|
||||
message:
|
||||
'Username must contain only lowercase letters, numbers, hyphens, and underscores',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormGroup>
|
||||
>
|
||||
<TextInput
|
||||
id="username"
|
||||
type="text"
|
||||
data-testid="username-input"
|
||||
validated={errors.username ? 'error' : 'default'}
|
||||
isDisabled={isLoading}
|
||||
{...register('username', {
|
||||
required: 'Username is required',
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: 'Username must be at least 2 characters',
|
||||
},
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: 'Username must be less than 255 characters',
|
||||
},
|
||||
pattern: {
|
||||
value: /^[a-z0-9_][a-z0-9_-]*$/,
|
||||
message:
|
||||
'Username must contain only lowercase letters, numbers, hyphens, and underscores',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Email"
|
||||
isRequired
|
||||
fieldId="email"
|
||||
helperTextInvalid={errors.email?.message}
|
||||
validated={errors.email ? 'error' : 'default'}
|
||||
>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
data-testid="email-input"
|
||||
<FormGroup
|
||||
label="Email"
|
||||
isRequired
|
||||
fieldId="email"
|
||||
helperTextInvalid={errors.email?.message}
|
||||
validated={errors.email ? 'error' : 'default'}
|
||||
isDisabled={isLoading}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormGroup>
|
||||
>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
data-testid="email-input"
|
||||
validated={errors.email ? 'error' : 'default'}
|
||||
isDisabled={isLoading}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Password"
|
||||
isRequired
|
||||
fieldId="password"
|
||||
helperTextInvalid={errors.password?.message}
|
||||
validated={errors.password ? 'error' : 'default'}
|
||||
>
|
||||
<TextInput
|
||||
id="password"
|
||||
type="password"
|
||||
data-testid="password-input"
|
||||
<FormGroup
|
||||
label="Password"
|
||||
isRequired
|
||||
fieldId="password"
|
||||
helperTextInvalid={errors.password?.message}
|
||||
validated={errors.password ? 'error' : 'default'}
|
||||
isDisabled={isLoading}
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Password must be at least 8 characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormGroup>
|
||||
>
|
||||
<TextInput
|
||||
id="password"
|
||||
type="password"
|
||||
data-testid="password-input"
|
||||
validated={errors.password ? 'error' : 'default'}
|
||||
isDisabled={isLoading}
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Password must be at least 8 characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Confirm Password"
|
||||
isRequired
|
||||
fieldId="confirmPassword"
|
||||
helperTextInvalid={errors.confirmPassword?.message}
|
||||
validated={errors.confirmPassword ? 'error' : 'default'}
|
||||
>
|
||||
<TextInput
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
data-testid="confirm-password-input"
|
||||
<FormGroup
|
||||
label="Confirm Password"
|
||||
isRequired
|
||||
fieldId="confirmPassword"
|
||||
helperTextInvalid={errors.confirmPassword?.message}
|
||||
validated={errors.confirmPassword ? 'error' : 'default'}
|
||||
isDisabled={isLoading}
|
||||
{...register('confirmPassword', {
|
||||
required: 'Please confirm your password',
|
||||
validate: (value) =>
|
||||
value === password || 'Passwords do not match',
|
||||
})}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</Modal>
|
||||
>
|
||||
<TextInput
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
data-testid="confirm-password-input"
|
||||
validated={errors.confirmPassword ? 'error' : 'default'}
|
||||
isDisabled={isLoading}
|
||||
{...register('confirmPassword', {
|
||||
required: 'Please confirm your password',
|
||||
validate: (value) =>
|
||||
value === password || 'Passwords do not match',
|
||||
})}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
)}
|
||||
</PageSection>
|
||||
|
||||
{/* Fresh Login Modal */}
|
||||
<FreshLoginModal
|
||||
isOpen={freshLogin.isModalOpen}
|
||||
onVerify={freshLogin.handleVerify}
|
||||
onCancel={freshLogin.handleCancel}
|
||||
isLoading={freshLogin.isLoading}
|
||||
error={freshLogin.error}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,17 +40,9 @@ interface CreateMessageFormData {
|
||||
interface CreateMessageFormProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
freshLogin?: {
|
||||
showFreshLoginModal: (retryOperation: () => Promise<void>) => void;
|
||||
isFreshLoginRequired: (error: unknown) => boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function CreateMessageForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
freshLogin,
|
||||
}: CreateMessageFormProps) {
|
||||
export function CreateMessageForm({isOpen, onClose}: CreateMessageFormProps) {
|
||||
const [activeTabKey, setActiveTabKey] = useState<string | number>('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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<MessagesHeader onCreateMessage={handleCreateMessage} />
|
||||
<PageSection>{renderContent()}</PageSection>
|
||||
|
||||
{/* Fresh Login Modal */}
|
||||
<FreshLoginModal
|
||||
isOpen={freshLogin.isModalOpen}
|
||||
onVerify={freshLogin.handleVerify}
|
||||
onCancel={freshLogin.handleCancel}
|
||||
isLoading={freshLogin.isLoading}
|
||||
error={freshLogin.error}
|
||||
/>
|
||||
|
||||
{/* Create Message Modal */}
|
||||
<CreateMessageForm
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
freshLogin={{
|
||||
showFreshLoginModal: freshLogin.showFreshLoginModal,
|
||||
isFreshLoginRequired: freshLogin.isFreshLoginRequired,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FlexItem>
|
||||
@@ -171,22 +164,10 @@ export default function UsageLogs() {
|
||||
org=""
|
||||
type="superuser"
|
||||
isSuperuser={true}
|
||||
freshLogin={{
|
||||
showFreshLoginModal: freshLogin.showFreshLoginModal,
|
||||
isFreshLoginRequired: freshLogin.isFreshLoginRequired,
|
||||
}}
|
||||
/>
|
||||
</FlexItem>
|
||||
</Flex>
|
||||
</PageSection>
|
||||
|
||||
<FreshLoginModal
|
||||
isOpen={freshLogin.isModalOpen}
|
||||
onCancel={freshLogin.handleCancel}
|
||||
onVerify={freshLogin.handleVerify}
|
||||
isLoading={freshLogin.isLoading}
|
||||
error={freshLogin.error}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>) => 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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>) => 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
|
||||
|
||||
12
web/src/utils/freshLoginErrors.ts
Normal file
12
web/src/utils/freshLoginErrors.ts
Normal file
@@ -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'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user