diff --git a/web/cypress/e2e/system-status-banner.cy.ts b/web/cypress/e2e/system-status-banner.cy.ts new file mode 100644 index 000000000..e074d1124 --- /dev/null +++ b/web/cypress/e2e/system-status-banner.cy.ts @@ -0,0 +1,176 @@ +describe('System Status Banner', () => { + beforeEach(() => { + cy.request('GET', `${Cypress.env('REACT_QUAY_APP_API_URL')}/csrf_token`) + .then((response) => response.body.csrf_token) + .then((token) => { + cy.loginByCSRF(token); + }); + }); + + describe('Read-only mode banner', () => { + it('displays read-only banner when registry_state is readonly', () => { + // Mock config with read-only mode enabled + cy.intercept('GET', '/config', (req) => + req.reply((res) => { + res.body.registry_state = 'readonly'; + return res; + }), + ).as('getConfig'); + + cy.visit('/organization'); + cy.wait('@getConfig'); + + // Verify banner is visible + cy.get('[data-testid="readonly-mode-banner"]').should('be.visible'); + + // Verify banner contains expected text + cy.get('[data-testid="readonly-mode-banner"]').should( + 'contain', + 'is currently in read-only mode', + ); + cy.get('[data-testid="readonly-mode-banner"]').should( + 'contain', + 'Pulls and other read-only operations will succeed', + ); + cy.get('[data-testid="readonly-mode-banner"]').should( + 'contain', + 'all other operations are currently suspended', + ); + }); + + it('does not display read-only banner in normal mode', () => { + cy.visit('/organization'); + + // Verify banner is not present + cy.get('[data-testid="readonly-mode-banner"]').should('not.exist'); + }); + + it('displays registry name in the banner', () => { + cy.intercept('GET', '/config', (req) => + req.reply((res) => { + res.body.registry_state = 'readonly'; + return res; + }), + ).as('getConfig'); + + cy.visit('/organization'); + cy.wait('@getConfig'); + + // Verify registry name is displayed (from config) + cy.get('[data-testid="readonly-mode-banner"]') + .invoke('text') + .should('match', /\S+\s+is currently in read-only mode/); + }); + }); + + describe('Account recovery mode banner', () => { + it('displays account recovery banner when account_recovery_mode is true', () => { + // Mock config with account recovery mode enabled + cy.intercept('GET', '/config', (req) => + req.reply((res) => { + res.body.account_recovery_mode = true; + return res; + }), + ).as('getConfig'); + + cy.visit('/organization'); + cy.wait('@getConfig'); + + // Verify banner is visible + cy.get('[data-testid="account-recovery-mode-banner"]').should( + 'be.visible', + ); + + // Verify banner contains expected text + cy.get('[data-testid="account-recovery-mode-banner"]').should( + 'contain', + 'is currently in account recovery mode', + ); + cy.get('[data-testid="account-recovery-mode-banner"]').should( + 'contain', + 'This instance should only be used to link accounts', + ); + cy.get('[data-testid="account-recovery-mode-banner"]').should( + 'contain', + 'Registry operations such as pushes/pulls will not work', + ); + }); + + it('does not display account recovery banner in normal mode', () => { + // Config defaults to account_recovery_mode: false, no intercept needed + cy.visit('/organization'); + + // Verify banner is not present + cy.get('[data-testid="account-recovery-mode-banner"]').should( + 'not.exist', + ); + }); + }); + + describe('Banner positioning', () => { + it('appears after the feedback banner', () => { + cy.intercept('GET', '/config', (req) => + req.reply((res) => { + res.body.registry_state = 'readonly'; + return res; + }), + ).as('getConfig'); + + cy.visit('/organization'); + cy.wait('@getConfig'); + + // The read-only banner should exist after the feedback banner + // We can verify this by checking the DOM order + cy.get('[data-testid="readonly-mode-banner"]') + .should('be.visible') + .parent() + .should('exist'); + }); + }); + + describe('Both banners', () => { + it('displays both banners when both modes are active', () => { + // Create a config with both flags enabled + cy.intercept('GET', '/config', (req) => + req.reply((res) => { + res.body.registry_state = 'readonly'; + res.body.account_recovery_mode = true; + return res; + }), + ).as('getConfig'); + + cy.visit('/organization'); + cy.wait('@getConfig'); + + // Verify both banners are visible + cy.get('[data-testid="readonly-mode-banner"]').should('be.visible'); + cy.get('[data-testid="account-recovery-mode-banner"]').should( + 'be.visible', + ); + }); + }); + + describe('Multiple page navigation', () => { + it('displays banner across different pages', () => { + cy.intercept('GET', '/config', (req) => + req.reply((res) => { + res.body.registry_state = 'readonly'; + return res; + }), + ).as('getConfig'); + + // Visit organization page + cy.visit('/organization'); + cy.wait('@getConfig'); + cy.get('[data-testid="readonly-mode-banner"]').should('be.visible'); + + // Navigate to repositories page + cy.visit('/repository'); + cy.get('[data-testid="readonly-mode-banner"]').should('be.visible'); + + // Navigate to overview page + cy.visit('/overview'); + cy.get('[data-testid="readonly-mode-banner"]').should('be.visible'); + }); + }); +}); diff --git a/web/src/components/LoginPageLayout.tsx b/web/src/components/LoginPageLayout.tsx index 1142c34f3..cdf20f295 100644 --- a/web/src/components/LoginPageLayout.tsx +++ b/web/src/components/LoginPageLayout.tsx @@ -3,6 +3,7 @@ import {ListVariant, LoginPage} from '@patternfly/react-core'; import logo from 'src/assets/quay.svg'; import {useQuayConfig} from 'src/hooks/UseQuayConfig'; import {useLoginFooterItems} from 'src/components/LoginFooter'; +import SystemStatusBanner from 'src/components/SystemStatusBanner'; import './LoginPageLayout.css'; interface LoginPageLayoutProps { @@ -35,17 +36,20 @@ export function LoginPageLayout({ }, [quayConfig]); return ( - - {children} - + <> + + + {children} + + ); } diff --git a/web/src/components/SystemStatusBanner.tsx b/web/src/components/SystemStatusBanner.tsx new file mode 100644 index 000000000..d8bea01ee --- /dev/null +++ b/web/src/components/SystemStatusBanner.tsx @@ -0,0 +1,59 @@ +import {Banner, Flex, FlexItem} from '@patternfly/react-core'; +import {ExclamationTriangleIcon} from '@patternfly/react-icons'; +import {useQuayState} from 'src/hooks/UseQuayState'; +import {useQuayConfig} from 'src/hooks/UseQuayConfig'; +import PropTypes from 'prop-types'; + +const BannerContent = ({icon, children}) => ( + + {icon} + {children} + +); + +BannerContent.propTypes = { + icon: PropTypes.node.isRequired, + children: PropTypes.node.isRequired, +}; + +export default function SystemStatusBanner() { + const {inReadOnlyMode, inAccountRecoveryMode} = useQuayState(); + const config = useQuayConfig(); + const registryName = config?.config?.REGISTRY_TITLE || 'Quay'; + + return ( + <> + {inReadOnlyMode && ( + + }> + {registryName} is currently in read-only mode. + Pulls and other read-only operations will succeed but all other + operations are currently suspended. + + + )} + {inAccountRecoveryMode && ( + + }> + {registryName} is currently in account recovery + mode. This instance should only be used to link accounts to an + external login service, e.g., Red Hat. Registry operations such as + pushes/pulls will not work. + + + )} + + ); +} diff --git a/web/src/routes/PluginMain.tsx b/web/src/routes/PluginMain.tsx index 2e792806a..218e229b0 100644 --- a/web/src/routes/PluginMain.tsx +++ b/web/src/routes/PluginMain.tsx @@ -25,6 +25,7 @@ import axiosIns from 'src/libs/axios'; import ManageMembersList from './OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/ManageMembers/ManageMembersList'; import OverviewList from './OverviewList/OverviewList'; import {LoadingPage} from 'src/components/LoadingPage'; +import SystemStatusBanner from 'src/components/SystemStatusBanner'; const NavigationRoutes = [ { @@ -138,6 +139,7 @@ function PluginMain() { + {user && ( + Error loading registry status}>