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