1
0
mirror of https://github.com/quay/quay.git synced 2026-01-26 06:21:37 +03:00

[redhat-3.16] fix(ui): consolidate credential modals and fix state management issues (PROJQUAY-9630) (#4477)

fix(ui): consolidate credential modals and fix state management issues (PROJQUAY-9630)

- Rename ApplicationTokenCredentials to CredentialsModal for reusability
- Add support for both application tokens and encrypted passwords
- Fix memory leak by moving state cleanup to useEffect
- Fix error handling to clear errors on successful responses
- Add null checks for user loading state
- Update data-testid naming for better specificity
- Mock encrypted password API in Cypress tests
- Simplify Cypress selectors for better reliability

Co-authored-by: Brady Pratt <bpratt@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
OpenShift Cherrypick Robot
2025-11-06 18:36:47 +01:00
committed by GitHub
parent 821baa4d5c
commit f1ff178e3b
5 changed files with 262 additions and 100 deletions

View File

@@ -3,8 +3,11 @@
import {humanizeTimeForExpiry, parseTimeDuration} from 'src/libs/utils';
describe('Account Settings Page', () => {
beforeEach(() => {
before(() => {
cy.exec('npm run quay:seed');
});
beforeEach(() => {
cy.visit('/signin');
cy.request('GET', `${Cypress.env('REACT_QUAY_APP_API_URL')}/csrf_token`)
.then((response) => response.body.csrf_token)
@@ -196,7 +199,27 @@ describe('Account Settings Page', () => {
cy.get('#checkbox').should('be.checked');
});
it('CLI Token', () => {
it('CLI Token - Generate Encrypted Password with All Credential Formats', () => {
// Mock the encrypted password API call - wrong password
cy.intercept('POST', '/api/v1/user/clientkey', (req) => {
if (req.body.password === 'wrongpassword') {
req.reply({
statusCode: 400,
body: {
error_message: 'Invalid Username or Password',
error_type: 'invalid_auth',
},
});
} else if (req.body.password === 'password') {
req.reply({
statusCode: 200,
body: {
key: 'fake-encrypted-password-12345',
},
});
}
}).as('createClientKey');
cy.visit('/organization/user1?tab=Settings');
// navigate to CLI Tab
@@ -208,13 +231,75 @@ describe('Account Settings Page', () => {
// Wrong password
cy.get('#delete-confirmation-input').type('wrongpassword');
cy.get('#submit').click();
cy.wait('@createClientKey');
cy.contains('Invalid Username or Password');
// Correct password
cy.get('#delete-confirmation-input').clear();
cy.get('#delete-confirmation-input').type('password');
cy.get('#submit').click();
cy.contains('Your encrypted password is');
cy.wait('@createClientKey');
// Should show credentials modal with all tabs
cy.get('[data-testid="credentials-modal"]').should('be.visible');
cy.contains('Credentials for user1').should('exist');
// Verify alert message for encrypted password
cy.contains('Encrypted Password').should('exist');
cy.contains('This encrypted password can be used for').should('exist');
cy.contains('docker login').should('exist');
// Verify all tabs exist
cy.contains('Encrypted Password').should('exist');
cy.contains('Kubernetes Secret').should('exist');
cy.contains('rkt Configuration').should('exist');
cy.contains('Podman Login').should('exist');
cy.contains('Docker Login').should('exist');
cy.contains('Docker Configuration').should('exist');
// Verify Encrypted Password tab (default) shows username and password
cy.get('[data-testid="credentials-modal-copy-username"]').should('exist');
cy.get('[data-testid="credentials-modal-copy-username"]')
.find('input[readonly]')
.should('have.value', 'user1'); // Encrypted password uses actual username
cy.get('[data-testid="credentials-modal-copy-password"]').should('exist');
// Test Kubernetes Secret tab
cy.contains('Kubernetes Secret').click();
cy.contains('Step 1: Create secret YAML file').should('exist');
cy.contains('apiVersion: v1').should('exist');
cy.contains('kind: Secret').should('exist');
cy.contains('user1-pull-secret').should('exist');
// Test Docker Login tab
cy.contains('Docker Login').click();
cy.contains('Run docker login command').should('exist');
cy.contains('docker login').should('exist');
// Verify username is user1 (not $app like application tokens)
cy.contains('user1').should('exist'); // Username in command
cy.contains('localhost').should('exist'); // Server hostname in command
// Test Podman Login tab
cy.contains('Podman Login').click();
cy.contains('Run podman login command').should('exist');
cy.contains('podman login').should('exist');
// Verify username is user1
cy.contains('user1').should('exist'); // Username in command
// Test rkt Configuration tab
cy.contains('rkt Configuration').click();
cy.contains('Step 1: Create rkt configuration file').should('exist');
cy.contains('rktKind').should('exist');
// Test Docker Configuration tab
cy.contains('Docker Configuration').click();
cy.contains('Step 1: Create Docker config file').should('exist');
cy.contains('This will').should('exist');
cy.contains('overwrite').should('exist');
// Close modal
cy.get('[data-testid="credentials-modal-close"]').click();
cy.get('[data-testid="credentials-modal"]').should('not.exist');
});
it('Avatar Display', () => {
@@ -351,7 +436,9 @@ describe('Account Settings Page', () => {
const mockNotification = function () {
// Empty constructor for mocking purposes
} as unknown as typeof Notification;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mockNotification as any).permission = 'default';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mockNotification as any).requestPermission = cy
.stub()
.resolves('granted');
@@ -489,7 +576,8 @@ describe('Account Settings Page', () => {
// Check section title
cy.contains('Docker CLI and other Application Tokens').should('exist');
// Check tokens table
// Check tokens table - wait for table to be populated
cy.get('table').last().should('be.visible');
cy.get('table')
.last()
.within(() => {
@@ -555,13 +643,13 @@ describe('Account Settings Page', () => {
cy.wait('@createToken');
// Should show success step with token
cy.get('[data-testid="token-credentials-modal"]').within(() => {
cy.get('[data-testid="credentials-modal"]').within(() => {
cy.contains('Token Created Successfully').should('exist');
cy.get('[data-testid="copy-token-button"]').should('exist');
cy.get('[data-testid="credentials-modal-copy-password"]').should('exist');
});
cy.get('[data-testid="token-credentials-close"]').click();
cy.get('[data-testid="token-credentials-modal"]').should('not.exist');
cy.get('[data-testid="credentials-modal-close"]').click();
cy.get('[data-testid="credentials-modal"]').should('not.exist');
cy.wait('@getTokensAfterCreate');
// Verify new token appears in table
@@ -724,7 +812,7 @@ describe('Account Settings Page', () => {
cy.wait('@createToken');
// Should show success with tabs
cy.get('[data-testid="token-credentials-modal"]').within(() => {
cy.get('[data-testid="credentials-modal"]').within(() => {
cy.contains('Token Created Successfully').should('exist');
// Check all tabs exist
@@ -736,9 +824,9 @@ describe('Account Settings Page', () => {
cy.contains('Docker Configuration').should('exist');
// Verify default tab (Application Token) shows username and token
cy.get('[data-testid="copy-username"]').should('exist');
cy.get('[data-testid="copy-token-button"]').should('exist');
cy.get('[data-testid="copy-token-button"]')
cy.get('[data-testid="credentials-modal-copy-username"]').should('exist');
cy.get('[data-testid="credentials-modal-copy-password"]').should('exist');
cy.get('[data-testid="credentials-modal-copy-password"]')
.find('input')
.should('have.value', 'fake-token-code-12345');
@@ -757,11 +845,17 @@ describe('Account Settings Page', () => {
cy.contains('Podman Login').click();
cy.contains('Run podman login command').should('exist');
cy.contains('podman login').should('exist');
// Verify command contains username $app
cy.contains('$app').should('exist'); // Username in command
cy.contains('localhost').should('exist'); // Server hostname in command
// Test Docker Login tab
cy.contains('Docker Login').click();
cy.contains('Run docker login command').should('exist');
cy.contains('docker login').should('exist');
// Verify command contains username $app
cy.contains('$app').should('exist'); // Username in command
cy.contains('localhost').should('exist'); // Server hostname in command
// Test Docker Configuration tab
cy.contains('Docker Configuration').click();
@@ -770,8 +864,8 @@ describe('Account Settings Page', () => {
cy.contains('overwrite').should('exist');
});
cy.get('[data-testid="token-credentials-close"]').click();
cy.get('[data-testid="token-credentials-modal"]').should('not.exist');
cy.get('[data-testid="credentials-modal-close"]').click();
cy.get('[data-testid="credentials-modal"]').should('not.exist');
});
it('Clickable Token Titles - View Token Details', () => {
@@ -825,12 +919,12 @@ describe('Account Settings Page', () => {
// Verify modal shows token details with tabs
cy.contains('Application Token').should('exist');
cy.get('[data-testid="copy-username"]').should('exist');
cy.get('[data-testid="copy-token-button"]').should('exist');
cy.get('[data-testid="credentials-modal-copy-username"]').should('exist');
cy.get('[data-testid="credentials-modal-copy-password"]').should('exist');
// Close modal
cy.contains('button', 'Done').click();
cy.get('[data-testid="token-credentials-modal"]').should('not.exist');
cy.get('[data-testid="credentials-modal"]').should('not.exist');
});
it('View Token Modal - Token Never Accessed', () => {
@@ -876,13 +970,13 @@ describe('Account Settings Page', () => {
cy.contains('Credentials for Unused Token').should('be.visible');
// Verify modal shows token credentials
cy.get('[data-testid="token-credentials-modal"]').should('be.visible');
cy.get('[data-testid="copy-username"]').should('exist');
cy.get('[data-testid="copy-token-button"]').should('exist');
cy.get('[data-testid="credentials-modal"]').should('be.visible');
cy.get('[data-testid="credentials-modal-copy-username"]').should('exist');
cy.get('[data-testid="credentials-modal-copy-password"]').should('exist');
// Close modal
cy.contains('button', 'Done').click();
cy.get('[data-testid="token-credentials-modal"]').should('not.exist');
cy.get('[data-testid="credentials-modal"]').should('not.exist');
});
it('Settings Tab Hidden in Read-Only Mode', () => {

View File

@@ -11,7 +11,7 @@ import {
} from '@patternfly/react-core';
import {useCreateApplicationToken} from 'src/hooks/UseApplicationTokens';
import {IApplicationToken} from 'src/resources/UserResource';
import ApplicationTokenCredentials from './ApplicationTokenCredentials';
import CredentialsModal from './CredentialsModal';
interface CreateApplicationTokenModalProps {
isOpen: boolean;
@@ -64,10 +64,15 @@ export default function CreateApplicationTokenModal({
// If token was created successfully, show credentials
if (createdToken) {
return (
<ApplicationTokenCredentials
<CredentialsModal
isOpen={isOpen}
onClose={handleClose}
token={createdToken}
credentials={{
username: '$app',
password: createdToken.token_code,
title: createdToken.title,
}}
type="token"
isNewlyCreated={true}
/>
);

View File

@@ -14,22 +14,31 @@ import {
CodeBlockCode,
Text,
} from '@patternfly/react-core';
import {IApplicationToken} from 'src/resources/UserResource';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
interface ApplicationTokenCredentialsProps {
export interface Credentials {
username: string;
password: string;
title: string;
}
export type CredentialsType = 'token' | 'encrypted-password';
interface CredentialsModalProps {
isOpen: boolean;
onClose: () => void;
token: IApplicationToken;
credentials: Credentials;
type: CredentialsType;
isNewlyCreated?: boolean;
}
export default function ApplicationTokenCredentials({
export default function CredentialsModal({
isOpen,
onClose,
token,
credentials,
type,
isNewlyCreated = false,
}: ApplicationTokenCredentialsProps) {
}: CredentialsModalProps) {
const [activeTabKey, setActiveTabKey] = useState<string | number>(0);
const quayConfig = useQuayConfig();
@@ -38,7 +47,9 @@ export default function ApplicationTokenCredentials({
};
const getContainerLoginCommand = (runtime: 'docker' | 'podman') => {
return `${runtime} login -u='$app' -p='${token?.token_code}' ${getServerHostname()}`;
return `${runtime} login -u='${credentials.username}' -p='${
credentials.password
}' ${getServerHostname()}`;
};
const kubernetesYaml = useMemo(() => {
@@ -46,7 +57,7 @@ export default function ApplicationTokenCredentials({
const dockerConfigJson = {
auths: {
[hostname]: {
auth: btoa(`$app:${token?.token_code}`),
auth: btoa(`${credentials.username}:${credentials.password}`),
email: '',
},
},
@@ -55,11 +66,16 @@ export default function ApplicationTokenCredentials({
return `apiVersion: v1
kind: Secret
metadata:
name: ${token?.title}-pull-secret
name: ${credentials.title}-pull-secret
data:
.dockerconfigjson: ${btoa(JSON.stringify(dockerConfigJson))}
type: kubernetes.io/dockerconfigjson`;
}, [token?.title, token?.token_code, quayConfig?.config?.SERVER_HOSTNAME]);
}, [
credentials.title,
credentials.username,
credentials.password,
quayConfig?.config?.SERVER_HOSTNAME,
]);
const rktConfig = useMemo(() => {
const hostname = getServerHostname();
@@ -69,43 +85,55 @@ type: kubernetes.io/dockerconfigjson`;
"domains": ["${hostname}"],
"type": "basic",
"credentials": {
"user": "$app",
"password": "${token?.token_code}"
"user": "${credentials.username}",
"password": "${credentials.password}"
}
}`;
}, [token?.token_code, quayConfig?.config?.SERVER_HOSTNAME]);
}, [
credentials.username,
credentials.password,
quayConfig?.config?.SERVER_HOSTNAME,
]);
const dockerConfig = useMemo(() => {
const hostname = getServerHostname();
return `{
"auths": {
"${hostname}": {
"auth": "${btoa(`$app:${token?.token_code}`)}",
"auth": "${btoa(`${credentials.username}:${credentials.password}`)}",
"email": ""
}
}
}`;
}, [token?.token_code, quayConfig?.config?.SERVER_HOSTNAME]);
}, [
credentials.username,
credentials.password,
quayConfig?.config?.SERVER_HOSTNAME,
]);
const isToken = type === 'token';
const firstTabTitle = isToken ? 'Application Token' : 'Encrypted Password';
const passwordFieldLabel = isToken ? 'Token' : 'Encrypted Password';
return (
<Modal
variant={ModalVariant.large}
title={`Credentials for ${token.title}`}
title={`Credentials for ${credentials.title}`}
isOpen={isOpen}
onClose={onClose}
data-testid="token-credentials-modal"
data-testid="credentials-modal"
actions={[
<Button
key="done"
variant="primary"
onClick={onClose}
data-testid="token-credentials-close"
data-testid="credentials-modal-close"
>
Done
</Button>,
]}
>
{isNewlyCreated ? (
{isNewlyCreated && isToken && (
<Alert
variant="success"
isInline
@@ -116,7 +144,8 @@ type: kubernetes.io/dockerconfigjson`;
your password for Docker and other CLI commands. Make sure to copy and
save it securely.
</Alert>
) : (
)}
{!isNewlyCreated && isToken && (
<Alert
variant="info"
isInline
@@ -127,15 +156,24 @@ type: kubernetes.io/dockerconfigjson`;
CLI commands. Keep it secure and do not share it.
</Alert>
)}
{!isToken && (
<Alert
variant="info"
isInline
title="Encrypted Password"
className="pf-v5-u-mb-md"
>
This encrypted password can be used for <code>docker login</code> and
other CLI commands. It is recommended to use this instead of your
plaintext password.
</Alert>
)}
<Tabs
activeKey={activeTabKey}
onSelect={(_event, tabIndex) => setActiveTabKey(tabIndex)}
>
<Tab
eventKey={0}
title={<TabTitleText>Application Token</TabTitleText>}
>
<Tab eventKey={0} title={<TabTitleText>{firstTabTitle}</TabTitleText>}>
<Form className="pf-v5-u-p-md">
<FormGroup
label="Username"
@@ -146,20 +184,20 @@ type: kubernetes.io/dockerconfigjson`;
hoverTip="Copy"
clickTip="Copied"
isReadOnly
data-testid="copy-username"
data-testid="credentials-modal-copy-username"
>
$app
{credentials.username}
</ClipboardCopy>
</FormGroup>
<FormGroup label="Token" fieldId="token-code">
<FormGroup label={passwordFieldLabel} fieldId="password">
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="expansion"
isReadOnly
data-testid="copy-token-button"
data-testid="credentials-modal-copy-password"
>
{token.token_code}
{credentials.password}
</ClipboardCopy>
</FormGroup>
</Form>
@@ -183,7 +221,7 @@ type: kubernetes.io/dockerconfigjson`;
className="pf-v5-u-mb-md"
>
<ClipboardCopy hoverTip="Copy" clickTip="Copied" isReadOnly>
{`kubectl create -f ${token?.title}-pull-secret.yaml --namespace=NAMESPACE`}
{`kubectl create -f ${credentials.title}-pull-secret.yaml --namespace=NAMESPACE`}
</ClipboardCopy>
</FormGroup>
<FormGroup label="Step 3: Reference in pod spec">
@@ -192,7 +230,7 @@ type: kubernetes.io/dockerconfigjson`;
</Text>
<CodeBlock>
<CodeBlockCode>
{`imagePullSecrets:\n - name: ${token?.title}-pull-secret`}
{`imagePullSecrets:\n - name: ${credentials.title}-pull-secret`}
</CodeBlockCode>
</CodeBlock>
</FormGroup>

View File

@@ -1,11 +1,14 @@
import {Modal, ModalVariant, Button, TextInput} from '@patternfly/react-core';
import {useState} from 'react';
import {useState, useEffect} from 'react';
import FormError from 'src/components/errors/FormError';
import {addDisplayError} from 'src/resources/ErrorHandling';
import {useCreateClientKey} from 'src/hooks/UseCreateClientKey';
import {useCurrentUser} from 'src/hooks/UseCurrentUser';
import CredentialsModal, {Credentials} from './CredentialsModal';
export function GenerateEncryptedPassword(props: ConfirmationModalProps) {
const [err, setErr] = useState<string>();
const {user} = useCurrentUser();
const [password, setPassword] = useState('');
const [step, setStep] = useState(1);
@@ -15,6 +18,7 @@ export function GenerateEncryptedPassword(props: ConfirmationModalProps) {
setErr(addDisplayError('Error', error));
},
onSuccess: () => {
setErr(undefined); // Clear any previous errors
setStep(step + 1);
},
});
@@ -23,53 +27,69 @@ export function GenerateEncryptedPassword(props: ConfirmationModalProps) {
createClientKey(password);
};
const handleClose = () => {
props.toggleModal();
};
// Cleanup state when modal closes
useEffect(() => {
if (!props.modalOpen) {
setStep(1);
setPassword('');
setErr(undefined);
}
}, [props.modalOpen]);
// Step 2: Show credentials modal with all credential formats
// Only transition to step 2 if we have clientKey, user, and no error
if (step === 2 && clientKey && user && !err) {
const credentials: Credentials = {
username: user.username,
password: clientKey,
title: user.username,
};
return (
<CredentialsModal
isOpen={props.modalOpen}
onClose={handleClose}
credentials={credentials}
type="encrypted-password"
/>
);
}
// Step 1: Password input
return (
<Modal
variant={ModalVariant.small}
title={props.title}
isOpen={props.modalOpen}
onClose={props.toggleModal}
actions={
step == 1
? [
<Button
key="confirm"
variant="primary"
onClick={handleModalConfirm}
id="submit"
>
{props.buttonText}
</Button>,
<Button key="cancel" variant="link" onClick={props.toggleModal}>
Cancel
</Button>,
]
: [
<Button key="cancel" variant="link" onClick={props.toggleModal}>
Done
</Button>,
]
}
onClose={handleClose}
actions={[
<Button
key="confirm"
variant="primary"
onClick={handleModalConfirm}
id="submit"
>
{props.buttonText}
</Button>,
<Button key="cancel" variant="link" onClick={handleClose}>
Cancel
</Button>,
]}
>
{step == 1 && (
<>
<FormError message={err} setErr={setErr} />
<TextInput
id="delete-confirmation-input"
value={password}
type="password"
onChange={(_, value) => setPassword(value)}
aria-label="text input example"
label="Password"
/>
Please enter your password in order to generate
</>
)}
{step == 2 && (
<>
Your encrypted password is: <br /> {clientKey}
</>
)}
<FormError message={err} setErr={setErr} />
<TextInput
id="delete-confirmation-input"
value={password}
type="password"
onChange={(_, value) => setPassword(value)}
aria-label="text input example"
label="Password"
/>
Please enter your password in order to generate
</Modal>
);
}

View File

@@ -22,7 +22,7 @@ import {EllipsisVIcon, KeyIcon} from '@patternfly/react-icons';
import {GenerateEncryptedPassword} from 'src/components/modals/GenerateEncryptedPasswordModal';
import CreateApplicationTokenModal from 'src/components/modals/CreateApplicationTokenModal';
import RevokeTokenModal from 'src/components/modals/RevokeTokenModal';
import ApplicationTokenCredentials from 'src/components/modals/ApplicationTokenCredentials';
import CredentialsModal from 'src/components/modals/CredentialsModal';
import {
useApplicationTokens,
useApplicationToken,
@@ -353,10 +353,15 @@ export const CliConfiguration = () => {
)}
{fetchedToken && !isFetchingToken && (
<ApplicationTokenCredentials
<CredentialsModal
isOpen={viewTokenModalOpen}
onClose={handleCloseViewModal}
token={fetchedToken}
credentials={{
username: '$app',
password: fetchedToken.token_code,
title: fetchedToken.title,
}}
type="token"
/>
)}
</>