diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d47b1c..a492bb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -608,6 +608,9 @@ jobs: - name: Run aiSettings tests run: node src/utils/aiApi.test.mjs + - name: Run accessibility and internationalization checks + run: npm run test:a11y && npm run test:i18n + - name: Build run: npm run build diff --git a/docs/accessibility-audit.md b/docs/accessibility-audit.md new file mode 100644 index 0000000..026cfaa --- /dev/null +++ b/docs/accessibility-audit.md @@ -0,0 +1,38 @@ +# Accessibility Audit + +Assessment date: 16 July 2026. Scope: React dashboard and static project +website. Target: practical alignment with WCAG 2.2 AA; this is not a formal +conformance certification. + +## Controls added + +- A keyboard-visible skip link targets the dashboard's main content. +- Primary navigation and mobile navigation have accessible names. +- Decorative navigation icons are hidden from assistive technology. +- Icon-only close and status actions have accessible labels. +- Popovers and connection errors expose dialog semantics and names. +- Scan results and backend connectivity expose polite live status updates. +- The off-screen mobile navigation is inert while closed. +- A source-level CI check rejects positive tab order, non-semantic clickable + `div`/`span` elements, missing image alternative text, and missing document + language. + +## Keyboard review + +The expected keyboard path is: skip link, mobile menu when present, language +selector, scan control, primary navigation, then page content. Native buttons, +links, inputs and selects retain browser focus behavior. Escape handling remains +available in the scan input. A full screen-reader/browser matrix remains a +release-quality follow-up rather than a claim made by this audit. + +## Known limitations + +- Some data visualizations need separate screen-reader summaries as their + components evolve. +- Focus trapping and restoration for every future modal must be checked during + component review. +- Colour contrast should be rechecked whenever theme tokens change. +- The static website has its own editing surface and needs repeat manual review + when that interface changes. + +Run `npm run test:a11y` from `frontend/` for the automated source checks. diff --git a/docs/internationalization.md b/docs/internationalization.md new file mode 100644 index 0000000..f848083 --- /dev/null +++ b/docs/internationalization.md @@ -0,0 +1,22 @@ +# Internationalization + +The dashboard uses message catalogs through `I18nContext`. English is the +fallback language and Spanish demonstrates a second complete catalog for core +navigation, page titles, scan controls, status messages and theme controls. + +The selected locale is stored locally, applied to the document `lang` +attribute, and used with `Intl.DateTimeFormat` and `Intl.NumberFormat`. An +unsupported locale falls back to English. Security identifiers, Azure resource +names, findings and compliance control IDs are data and are never translated. + +## Adding a locale + +1. Add a catalog to `frontend/src/i18n/messages.js` using exactly the English + keys. +2. Translate meaning rather than word order; retain `{name}` placeholders. +3. Add the language's self-name to each catalog. +4. Run `npm run test:i18n`; missing keys fail the catalog test. +5. Review navigation at narrow and wide widths and verify date/number output. + +The website remains English-first. Additional website locales should reuse the +same terminology but must not duplicate security data or rule definitions. diff --git a/frontend/package.json b/frontend/package.json index 631146a..f54699c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,8 @@ "dev": "vite", "build": "vite build", "lint": "eslint . --max-warnings=0", + "test:i18n": "node src/i18n/messages.test.mjs", + "test:a11y": "node scripts/accessibility-check.mjs", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/scripts/accessibility-check.mjs b/frontend/scripts/accessibility-check.mjs new file mode 100644 index 0000000..35f8150 --- /dev/null +++ b/frontend/scripts/accessibility-check.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); +const sourceRoot = path.join(root, 'src'); + +function filesUnder(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(directory, entry.name); + return entry.isDirectory() ? filesUnder(full) : [full]; + }); +} + +const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8'); +assert.match(html, /]+lang="[a-z]{2}"/i, 'frontend HTML must declare a language'); + +for (const file of filesUnder(sourceRoot).filter((item) => /\.(jsx?|html)$/.test(item))) { + const source = fs.readFileSync(file, 'utf8'); + assert.doesNotMatch(source, /tabIndex=["'{]?[1-9]/, `${file} uses a positive tab order`); + assert.doesNotMatch(source, /<(div|span)\b[^>]*\bonClick=/, `${file} uses a non-semantic clickable element`); + for (const image of source.matchAll(/]*>/g)) { + assert.match(image[0], /\balt=/, `${file} contains an image without alt text`); + } +} + +console.log('accessibility static checks passed'); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 206e84f..6f9e3f0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { DarkModeProvider } from './contexts/DarkModeContext'; +import { I18nProvider } from './contexts/I18nContext'; import { api } from './utils/api'; import Layout from './components/layout/Layout'; import Discovery from './pages/Discovery'; @@ -25,8 +26,9 @@ export default function App() { return ( - - + + + }> } /> } /> @@ -37,8 +39,9 @@ export default function App() { } /> } /> - - + + + ); } diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx index feb635f..e6c52cc 100644 --- a/frontend/src/components/layout/Header.jsx +++ b/frontend/src/components/layout/Header.jsx @@ -5,23 +5,19 @@ import { FiLoader, FiZap, FiCheckCircle, FiAlertCircle, FiClock, } from 'react-icons/fi'; import { api } from '../../utils/api'; +import { useI18n } from '../../i18n/I18nState'; -const PAGE_TITLES = { - '/monitoring': { title: 'Security Monitoring', subtitle: 'Overall health score and trends' }, - '/discovery': { title: 'Resource Discovery', subtitle: 'All resources across your Azure environment' }, - '/prioritization': { title: 'Risk Prioritization', subtitle: 'What to fix first based on risk and effort' }, - '/scan': { title: 'Detailed Scan', subtitle: 'Findings with step-by-step remediation playbooks' }, - '/compliance': { title: 'Compliance', subtitle: 'Framework tracking and control status' }, - '/drift': { title: 'Configuration Drift', subtitle: 'Detect unexpected changes to your environment' }, - '/ai': { title: 'AI Assistant', subtitle: 'Ask questions about your security posture' }, +const PAGE_KEYS = { + '/monitoring': 'monitoring', '/discovery': 'discovery', '/prioritization': 'prioritization', + '/scan': 'scan', '/compliance': 'compliance', '/drift': 'drift', '/ai': 'ai', }; // ── Connection-error popup ───────────────────────────────────────────────── function ConnectionErrorPopup({ apiBase, onClose }) { return ( <> -
-
+
@@ -68,7 +64,7 @@ function ScanToast({ result, error, onClose }) { const isSuccess = !!result; return ( -
+
-
@@ -126,10 +122,10 @@ function ScanInputPopover({ onConfirm, onCancel }) { return ( <> -
-
+ @@ -268,6 +268,15 @@ export default function Header({ onMenuToggle }) { {/* Right: controls */}
+ + {/* Run Scan button + popover wrapper */}
@@ -282,7 +291,7 @@ export default function Header({ onMenuToggle }) { : } - {scanning ? `Scanning… ${elapsed}s` : 'Run Scan'} + {scanning ? t('scan.scanning', { seconds: elapsed }) : t('scan.run')} @@ -298,12 +307,14 @@ export default function Header({ onMenuToggle }) { {lastScanAt && isLive && (
- Last scanned: {lastScanAt} + {t('scan.last', { date: lastScanAt })}
)} {/* Live / Reconnecting status dot */}
@@ -316,7 +327,7 @@ export default function Header({ onMenuToggle }) { )} - {isLive ? 'Live' : 'Reconnecting'} + {isLive ? t('status.live') : t('status.reconnecting')}
diff --git a/frontend/src/components/layout/Layout.jsx b/frontend/src/components/layout/Layout.jsx index 01a23c4..c2c4681 100644 --- a/frontend/src/components/layout/Layout.jsx +++ b/frontend/src/components/layout/Layout.jsx @@ -2,15 +2,22 @@ import { useState } from 'react'; import { Outlet } from 'react-router-dom'; import Sidebar from './Sidebar'; import Header from './Header'; +import { useI18n } from '../../i18n/I18nState'; export default function Layout() { const [sidebarOpen, setSidebarOpen] = useState(false); + const { t } = useI18n(); return (
+ + {t('skip.content')} + {/* Mobile overlay */} {sidebarOpen && ( -
setSidebarOpen(false)} /> @@ -20,7 +27,7 @@ export default function Layout() {
setSidebarOpen((v) => !v)} /> -
+
diff --git a/frontend/src/components/layout/Sidebar.jsx b/frontend/src/components/layout/Sidebar.jsx index e79074a..39461ed 100644 --- a/frontend/src/components/layout/Sidebar.jsx +++ b/frontend/src/components/layout/Sidebar.jsx @@ -4,31 +4,33 @@ import { FiShield, FiGitBranch, FiCpu, FiSun, FiMoon, FiX, } from 'react-icons/fi'; import { useDarkMode } from '../../contexts/DarkModeContext'; +import { useI18n } from '../../i18n/I18nState'; import Logo from '../shared/Logo'; const navItems = [ - { path: '/monitoring', label: 'Monitor', Icon: FiActivity }, - { path: '/discovery', label: 'Discover', Icon: FiSearch }, - { path: '/prioritization', label: 'Prioritize', Icon: FiTarget }, - { path: '/scan', label: 'Scan', Icon: FiZap }, - { path: '/compliance', label: 'Comply', Icon: FiShield }, - { path: '/drift', label: 'Drift', Icon: FiGitBranch }, - { path: '/ai', label: 'AI', Icon: FiCpu }, + { path: '/monitoring', key: 'monitoring', Icon: FiActivity }, + { path: '/discovery', key: 'discovery', Icon: FiSearch }, + { path: '/prioritization', key: 'prioritization', Icon: FiTarget }, + { path: '/scan', key: 'scan', Icon: FiZap }, + { path: '/compliance', key: 'compliance', Icon: FiShield }, + { path: '/drift', key: 'drift', Icon: FiGitBranch }, + { path: '/ai', key: 'ai', Icon: FiCpu }, ]; export default function Sidebar({ isOpen, onClose }) { const { isDark, toggle } = useDarkMode(); + const { t } = useI18n(); return ( <> {/* ── Desktop sidebar (always visible on lg+) ── */} -
{/* Drawer nav */} -
diff --git a/frontend/src/components/shared/Card.jsx b/frontend/src/components/shared/Card.jsx index bafe013..363fb70 100644 --- a/frontend/src/components/shared/Card.jsx +++ b/frontend/src/components/shared/Card.jsx @@ -1,10 +1,15 @@ export default function Card({ children, className = '', onClick }) { + const classes = `rounded-2xl border border-border-light dark:border-border-dark bg-bg-primary dark:bg-bg-dark-secondary p-6 shadow-soft hover:shadow-soft-lg transition-all duration-200 ${onClick ? 'cursor-pointer' : ''} ${className}`; + if (onClick) { + return ( + + ); + } return ( -
+
{children}
); diff --git a/frontend/src/contexts/I18nContext.jsx b/frontend/src/contexts/I18nContext.jsx new file mode 100644 index 0000000..f56e34f --- /dev/null +++ b/frontend/src/contexts/I18nContext.jsx @@ -0,0 +1,36 @@ +import { useEffect, useMemo, useState } from 'react'; +import { DEFAULT_LOCALE, messages, translate } from '../i18n/messages'; +import { I18nState } from '../i18n/I18nState'; +const STORAGE_KEY = 'openshield.locale'; + +function initialLocale() { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored && messages[stored]) return stored; + const browserLocale = window.navigator.language?.split('-')[0]; + return messages[browserLocale] ? browserLocale : DEFAULT_LOCALE; +} + +export function I18nProvider({ children }) { + const [locale, setLocaleState] = useState(initialLocale); + + const setLocale = (nextLocale) => { + const supported = messages[nextLocale] ? nextLocale : DEFAULT_LOCALE; + window.localStorage.setItem(STORAGE_KEY, supported); + setLocaleState(supported); + }; + + useEffect(() => { + document.documentElement.lang = locale; + }, [locale]); + + const value = useMemo(() => ({ + locale, + locales: Object.keys(messages), + setLocale, + t: (key, values) => translate(locale, key, values), + formatDate: (value, options) => new Intl.DateTimeFormat(locale, options).format(new Date(value)), + formatNumber: (value, options) => new Intl.NumberFormat(locale, options).format(value), + }), [locale]); + + return {children}; +} diff --git a/frontend/src/i18n/I18nState.js b/frontend/src/i18n/I18nState.js new file mode 100644 index 0000000..5a4283f --- /dev/null +++ b/frontend/src/i18n/I18nState.js @@ -0,0 +1,9 @@ +import { createContext, useContext } from 'react'; + +export const I18nState = createContext(null); + +export function useI18n() { + const context = useContext(I18nState); + if (!context) throw new Error('useI18n must be used inside I18nProvider'); + return context; +} diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js new file mode 100644 index 0000000..dfe1ebe --- /dev/null +++ b/frontend/src/i18n/messages.js @@ -0,0 +1,41 @@ +export const DEFAULT_LOCALE = 'en'; + +export const messages = { + en: { + 'nav.monitoring': 'Monitor', 'nav.discovery': 'Discover', 'nav.prioritization': 'Prioritize', + 'nav.scan': 'Scan', 'nav.compliance': 'Comply', 'nav.drift': 'Drift', 'nav.ai': 'AI', + 'theme.dark': 'Dark mode', 'theme.light': 'Light mode', 'theme.toggle': 'Toggle colour theme', + 'menu.open': 'Open menu', 'menu.close': 'Close menu', 'nav.primary': 'Primary navigation', + 'language.label': 'Language', 'language.en': 'English', 'language.es': 'Español', + 'page.monitoring.title': 'Security Monitoring', 'page.monitoring.subtitle': 'Overall health score and trends', + 'page.discovery.title': 'Resource Discovery', 'page.discovery.subtitle': 'All resources across your Azure environment', + 'page.prioritization.title': 'Risk Prioritization', 'page.prioritization.subtitle': 'What to fix first based on risk and effort', + 'page.scan.title': 'Detailed Scan', 'page.scan.subtitle': 'Findings with step-by-step remediation playbooks', + 'page.compliance.title': 'Compliance', 'page.compliance.subtitle': 'Framework tracking and control status', + 'page.drift.title': 'Configuration Drift', 'page.drift.subtitle': 'Detect unexpected changes to your environment', + 'page.ai.title': 'AI Assistant', 'page.ai.subtitle': 'Ask questions about your security posture', + 'scan.run': 'Run Scan', 'scan.scanning': 'Scanning… {seconds}s', 'scan.last': 'Last scanned: {date}', + 'status.live': 'Live', 'status.reconnecting': 'Reconnecting', 'skip.content': 'Skip to main content', + }, + es: { + 'nav.monitoring': 'Monitorear', 'nav.discovery': 'Descubrir', 'nav.prioritization': 'Priorizar', + 'nav.scan': 'Escanear', 'nav.compliance': 'Cumplimiento', 'nav.drift': 'Cambios', 'nav.ai': 'IA', + 'theme.dark': 'Modo oscuro', 'theme.light': 'Modo claro', 'theme.toggle': 'Cambiar tema de color', + 'menu.open': 'Abrir menú', 'menu.close': 'Cerrar menú', 'nav.primary': 'Navegación principal', + 'language.label': 'Idioma', 'language.en': 'English', 'language.es': 'Español', + 'page.monitoring.title': 'Monitoreo de seguridad', 'page.monitoring.subtitle': 'Puntuación general y tendencias', + 'page.discovery.title': 'Descubrimiento de recursos', 'page.discovery.subtitle': 'Recursos del entorno de Azure', + 'page.prioritization.title': 'Priorización de riesgos', 'page.prioritization.subtitle': 'Qué corregir primero según riesgo y esfuerzo', + 'page.scan.title': 'Escaneo detallado', 'page.scan.subtitle': 'Hallazgos y guías de corrección', + 'page.compliance.title': 'Cumplimiento', 'page.compliance.subtitle': 'Controles y marcos de cumplimiento', + 'page.drift.title': 'Cambios de configuración', 'page.drift.subtitle': 'Cambios inesperados del entorno', + 'page.ai.title': 'Asistente de IA', 'page.ai.subtitle': 'Preguntas sobre la postura de seguridad', + 'scan.run': 'Ejecutar escaneo', 'scan.scanning': 'Escaneando… {seconds}s', 'scan.last': 'Último escaneo: {date}', + 'status.live': 'En línea', 'status.reconnecting': 'Reconectando', 'skip.content': 'Saltar al contenido principal', + }, +}; + +export function translate(locale, key, values = {}) { + const template = messages[locale]?.[key] ?? messages[DEFAULT_LOCALE][key] ?? key; + return Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), template); +} diff --git a/frontend/src/i18n/messages.test.mjs b/frontend/src/i18n/messages.test.mjs new file mode 100644 index 0000000..5d09033 --- /dev/null +++ b/frontend/src/i18n/messages.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { DEFAULT_LOCALE, messages, translate } from './messages.js'; + +const referenceKeys = Object.keys(messages[DEFAULT_LOCALE]).sort(); +for (const [locale, catalog] of Object.entries(messages)) { + assert.deepEqual(Object.keys(catalog).sort(), referenceKeys, `${locale} must contain the complete message catalog`); +} +assert.equal(translate('es', 'nav.monitoring'), 'Monitorear'); +assert.equal(translate('unknown', 'nav.monitoring'), 'Monitor'); +assert.equal(translate('en', 'scan.scanning', { seconds: 12 }), 'Scanning… 12s'); +console.log('i18n catalogs valid');