diff --git a/web/cypress/e2e/account-settings.cy.ts b/web/cypress/e2e/account-settings.cy.ts index d1ad54721..21882bf5b 100644 --- a/web/cypress/e2e/account-settings.cy.ts +++ b/web/cypress/e2e/account-settings.cy.ts @@ -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', () => { diff --git a/web/src/components/modals/CreateApplicationTokenModal.tsx b/web/src/components/modals/CreateApplicationTokenModal.tsx index e8e36c529..e0d4814d1 100644 --- a/web/src/components/modals/CreateApplicationTokenModal.tsx +++ b/web/src/components/modals/CreateApplicationTokenModal.tsx @@ -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 ( - ); diff --git a/web/src/components/modals/ApplicationTokenCredentials.tsx b/web/src/components/modals/CredentialsModal.tsx similarity index 73% rename from web/src/components/modals/ApplicationTokenCredentials.tsx rename to web/src/components/modals/CredentialsModal.tsx index 4fc917ce0..d9bad1f90 100644 --- a/web/src/components/modals/ApplicationTokenCredentials.tsx +++ b/web/src/components/modals/CredentialsModal.tsx @@ -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(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 ( Done , ]} > - {isNewlyCreated ? ( + {isNewlyCreated && isToken && ( - ) : ( + )} + {!isNewlyCreated && isToken && ( )} + {!isToken && ( + + This encrypted password can be used for docker login and + other CLI commands. It is recommended to use this instead of your + plaintext password. + + )} setActiveTabKey(tabIndex)} > - Application Token} - > + {firstTabTitle}}>
- $app + {credentials.username} - + - {token.token_code} + {credentials.password} @@ -183,7 +221,7 @@ type: kubernetes.io/dockerconfigjson`; className="pf-v5-u-mb-md" > - {`kubectl create -f ${token?.title}-pull-secret.yaml --namespace=NAMESPACE`} + {`kubectl create -f ${credentials.title}-pull-secret.yaml --namespace=NAMESPACE`}
@@ -192,7 +230,7 @@ type: kubernetes.io/dockerconfigjson`; - {`imagePullSecrets:\n - name: ${token?.title}-pull-secret`} + {`imagePullSecrets:\n - name: ${credentials.title}-pull-secret`} diff --git a/web/src/components/modals/GenerateEncryptedPasswordModal.tsx b/web/src/components/modals/GenerateEncryptedPasswordModal.tsx index 36430132a..42de8cbbb 100644 --- a/web/src/components/modals/GenerateEncryptedPasswordModal.tsx +++ b/web/src/components/modals/GenerateEncryptedPasswordModal.tsx @@ -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(); + 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 ( + + ); + } + + // Step 1: Password input return ( - {props.buttonText} - , - , - ] - : [ - , - ] - } + onClose={handleClose} + actions={[ + , + , + ]} > - {step == 1 && ( - <> - - setPassword(value)} - aria-label="text input example" - label="Password" - /> - Please enter your password in order to generate - - )} - {step == 2 && ( - <> - Your encrypted password is:
{clientKey} - - )} + + setPassword(value)} + aria-label="text input example" + label="Password" + /> + Please enter your password in order to generate
); } diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/CLIConfiguration.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/CLIConfiguration.tsx index ad31e7850..8059cf6f0 100644 --- a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/CLIConfiguration.tsx +++ b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/CLIConfiguration.tsx @@ -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 && ( - )}