Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions docs/accessibility-audit.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions docs/internationalization.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
27 changes: 27 additions & 0 deletions frontend/scripts/accessibility-check.mjs
Original file line number Diff line number Diff line change
@@ -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, /<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(/<img\b[^>]*>/g)) {
assert.match(image[0], /\balt=/, `${file} contains an image without alt text`);
}
}

console.log('accessibility static checks passed');
11 changes: 7 additions & 4 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,8 +26,9 @@ export default function App() {

return (
<DarkModeProvider>
<BrowserRouter>
<Routes>
<I18nProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/monitoring" replace />} />
<Route path="monitoring" element={<Monitoring />} />
Expand All @@ -37,8 +39,9 @@ export default function App() {
<Route path="drift" element={<Drift />} />
<Route path="ai" element={<AILayer />} />
</Route>
</Routes>
</BrowserRouter>
</Routes>
</BrowserRouter>
</I18nProvider>
</DarkModeProvider>
);
}
59 changes: 35 additions & 24 deletions frontend/src/components/layout/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,31 @@ 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 (
<>
<div className="fixed inset-0 bg-black/30 z-[90]" onClick={onClose} />
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[100] w-full max-w-sm px-4">
<button type="button" aria-label="Close connection error" className="fixed inset-0 bg-black/30 z-[90]" onClick={onClose} />
<div role="dialog" aria-modal="true" aria-labelledby="connection-error-title" className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[100] w-full max-w-sm px-4">
<div className="rounded-2xl border border-border-light dark:border-border-dark bg-bg-primary dark:bg-bg-dark-secondary shadow-soft-lg overflow-hidden">
<div className="px-5 py-4 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-100 dark:border-amber-900/40 flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-amber-100 dark:bg-amber-900/40 flex items-center justify-center flex-shrink-0">
<FiAlertTriangle size={18} className="text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm font-bold text-text-primary dark:text-text-dark-primary">Unable to connect</p>
<p id="connection-error-title" className="text-sm font-bold text-text-primary dark:text-text-dark-primary">Unable to connect</p>
<p className="text-xs text-text-secondary dark:text-text-dark-tertiary mt-0.5">Backend API unreachable</p>
</div>
</div>
<button onClick={onClose} className="p-1 rounded-lg text-text-tertiary hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors">
<button aria-label="Close connection error" onClick={onClose} className="p-1 rounded-lg text-text-tertiary hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors">
<FiX size={16} />
</button>
</div>
Expand Down Expand Up @@ -68,7 +64,7 @@ function ScanToast({ result, error, onClose }) {
const isSuccess = !!result;

return (
<div className="fixed bottom-6 right-6 z-[100] w-full max-w-xs">
<div role="status" aria-live="polite" className="fixed bottom-6 right-6 z-[100] w-full max-w-xs">
<div className={`rounded-2xl border shadow-soft-lg overflow-hidden ${
isSuccess
? 'bg-bg-primary dark:bg-bg-dark-secondary border-green-200 dark:border-green-900/50'
Expand All @@ -86,7 +82,7 @@ function ScanToast({ result, error, onClose }) {
{isSuccess ? 'Scan complete' : 'Scan failed'}
</p>
</div>
<button onClick={onClose} className="text-text-tertiary hover:text-text-primary transition-colors">
<button aria-label="Dismiss scan status" onClick={onClose} className="text-text-tertiary hover:text-text-primary transition-colors">
<FiX size={14} />
</button>
</div>
Expand Down Expand Up @@ -126,10 +122,10 @@ function ScanInputPopover({ onConfirm, onCancel }) {

return (
<>
<div className="fixed inset-0 z-[80]" onClick={onCancel} />
<div className="absolute top-full right-0 mt-2 z-[90] w-72 rounded-2xl border border-border-light dark:border-border-dark bg-bg-primary dark:bg-bg-dark-secondary shadow-soft-lg overflow-hidden">
<button type="button" aria-label="Cancel scan" className="fixed inset-0 z-[80]" onClick={onCancel} />
<div role="dialog" aria-modal="true" aria-labelledby="scan-dialog-title" className="absolute top-full right-0 mt-2 z-[90] w-72 rounded-2xl border border-border-light dark:border-border-dark bg-bg-primary dark:bg-bg-dark-secondary shadow-soft-lg overflow-hidden">
<div className="px-4 py-3 border-b border-border-light dark:border-border-dark">
<p className="text-sm font-semibold text-text-primary dark:text-text-dark-primary">Run Azure Scan</p>
<p id="scan-dialog-title" className="text-sm font-semibold text-text-primary dark:text-text-dark-primary">Run Azure Scan</p>
<p className="text-xs text-text-secondary dark:text-text-dark-tertiary mt-0.5">
Leave blank to use the subscription configured on the backend.
</p>
Expand Down Expand Up @@ -172,7 +168,11 @@ function ScanInputPopover({ onConfirm, onCancel }) {
// ── Main Header ────────────────────────────────────────────────────────────
export default function Header({ onMenuToggle }) {
const { pathname } = useLocation();
const page = PAGE_TITLES[pathname] || { title: 'OpenShield', subtitle: '' };
const { locale, locales, setLocale, t, formatDate } = useI18n();
const pageKey = PAGE_KEYS[pathname];
const page = pageKey
? { title: t(`page.${pageKey}.title`), subtitle: t(`page.${pageKey}.subtitle`) }
: { title: 'OpenShield', subtitle: '' };

const [showConnErr, setConnErr] = useState(false);
const [scanning, setScanning] = useState(false);
Expand All @@ -194,13 +194,13 @@ export default function Header({ onMenuToggle }) {
const latest = data?.scans?.[0];
const raw = latest?.started_at || latest?.startedAt;
if (raw) {
setLastScanAt(new Date(raw).toLocaleString(undefined, {
setLastScanAt(formatDate(raw, {
month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit',
}));
}
}).catch(() => {});
}, [isLive]);
}, [formatDate, isLive]);

const closeConnErr = () => setConnErr(false);

Expand Down Expand Up @@ -250,7 +250,7 @@ export default function Header({ onMenuToggle }) {
<button
onClick={onMenuToggle}
className="lg:hidden w-8 h-8 rounded-lg flex items-center justify-center text-text-secondary dark:text-text-dark-tertiary hover:bg-bg-secondary dark:hover:bg-bg-dark-tertiary transition-all flex-shrink-0"
aria-label="Open menu"
aria-label={t('menu.open')}
>
<FiMenu size={18} />
</button>
Expand All @@ -268,6 +268,15 @@ export default function Header({ onMenuToggle }) {

{/* Right: controls */}
<div className="flex items-center gap-2 md:gap-2.5 flex-shrink-0">
<label className="sr-only" htmlFor="locale-select">{t('language.label')}</label>
<select
id="locale-select"
value={locale}
onChange={(event) => setLocale(event.target.value)}
className="rounded-lg border border-border-light dark:border-border-dark bg-bg-primary dark:bg-bg-dark-tertiary px-2 py-1.5 text-xs text-text-primary dark:text-text-dark-primary"
>
{locales.map((item) => <option key={item} value={item}>{t(`language.${item}`)}</option>)}
</select>

{/* Run Scan button + popover wrapper */}
<div className="relative" ref={scanBtnRef}>
Expand All @@ -282,7 +291,7 @@ export default function Header({ onMenuToggle }) {
: <FiZap size={12} />
}
<span className="hidden sm:inline">
{scanning ? `Scanning… ${elapsed}s` : 'Run Scan'}
{scanning ? t('scan.scanning', { seconds: elapsed }) : t('scan.run')}
</span>
</button>

Expand All @@ -298,12 +307,14 @@ export default function Header({ onMenuToggle }) {
{lastScanAt && isLive && (
<div className="hidden sm:flex items-center gap-1.5 text-xs text-text-tertiary dark:text-text-dark-tertiary">
<FiClock size={13} />
<span>Last scanned: {lastScanAt}</span>
<span>{t('scan.last', { date: lastScanAt })}</span>
</div>
)}

{/* Live / Reconnecting status dot */}
<div
role="status"
aria-live="polite"
title={isLive ? 'Connected to live API' : 'Reconnecting to backend…'}
className="hidden sm:flex items-center gap-1.5 cursor-default select-none"
>
Expand All @@ -316,7 +327,7 @@ export default function Header({ onMenuToggle }) {
<span className="h-2 w-2 rounded-full bg-amber-400" />
)}
<span className={`text-xs font-medium ${isLive ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}`}>
{isLive ? 'Live' : 'Reconnecting'}
{isLive ? t('status.live') : t('status.reconnecting')}
</span>
</div>
</div>
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/components/layout/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="min-h-screen bg-bg-secondary dark:bg-bg-dark-primary transition-colors duration-300">
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[200] focus:bg-bg-primary focus:text-text-primary focus:px-4 focus:py-2 focus:rounded-lg">
{t('skip.content')}
</a>
{/* Mobile overlay */}
{sidebarOpen && (
<div
<button
type="button"
aria-label={t('menu.close')}
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
Expand All @@ -20,7 +27,7 @@ export default function Layout() {

<div className="lg:ml-20 flex flex-col min-h-screen">
<Header onMenuToggle={() => setSidebarOpen((v) => !v)} />
<main className="flex-1 p-4 md:p-6 relative">
<main id="main-content" tabIndex="-1" className="flex-1 p-4 md:p-6 relative">
<div className="absolute inset-0 dot-pattern pointer-events-none" />
<div className="relative z-10">
<Outlet />
Expand Down
Loading
Loading