1
0
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:
Harish Govindarajulu
2025-11-18 05:23:35 +05:30
committed by GitHub
parent 92eddf2dbc
commit a7d01d052d
32 changed files with 1165 additions and 593 deletions

View File

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

View File

@@ -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/', {

View 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}
/>
</>
);
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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);
}
},
},

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View 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,
};
}

View File

@@ -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) => {

View File

@@ -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,
},
);

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 = (

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -21,6 +21,7 @@ export function ConfigureQuotaModal(props: ConfigureQuotaModalProps) {
organizationName={props.organizationName}
isUser={props.isUser}
view="super-user"
onOperationSubmit={props.onClose}
/>
</Modal>
);

View File

@@ -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>
</>
);
}

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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}
/>
</>
);
}

View File

@@ -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,
);
}
});
}
}
};

View File

@@ -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 */}

View File

@@ -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}
/>
</>
);
}

View File

@@ -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,
);
},
);

View File

@@ -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

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