feat(web): close FE-M6 — migrate static inline-style attrs to Tailwind + correct CSP rationale comment

Closes frontend-design-audit finding FE-M6 (Med):

  CSP allows 'unsafe-inline' for `style-src` — necessary today
  because of inline SVG `style=` attrs (related to FE-H2)

═══════════════════════════ GROUND-TRUTH FINDINGS ═══════════════════

Ground-truth recon found 4 audit-framing errors:

(1) The "17 inline-style tsx files" count was stale — actual is 9
    (8 after excluding a Layout.tsx comment match the audit's grep
    counted).

(2) The CSP rationale comment at securityheaders.go:35 LIED about
    WHY 'unsafe-inline' is needed. It claimed "Tailwind (via Vite)
    injects per-component <style> blocks at build time." Verified
    against the post-build artifact: `grep -c '<style' dist/index.html`
    = 0; Vite's CSS output is a single .css file linked via
    `<link rel="stylesheet">`. The 'unsafe-inline' grant exists for
    React's `style={...}` attribute model, NOT for Vite or Tailwind.

(3) The 9 sites split cleanly into:
    LOAD-BEARING DYNAMIC (5 sites; can't be Tailwind utilities
    because values are computed at runtime):
      - Tooltip.tsx Floating-UI position (left/top px per-tick)
      - AgentFleetPage.tsx dynamic color+width chart bars
      - dashboard/charts.tsx Recharts color props
      - CertificatesPage.tsx progress-bar percent width
      - IssuerHierarchyPage.tsx depth-based marginLeft
    STATIC PIXEL VALUES (3 files, ~12 sites; clean Tailwind
    migration targets):
      - UsersPage.tsx — filter UI + table styling
      - DigestPage.tsx — iframe min-height
      - AuthProvider.tsx — demo-mode banner

(4) Fully eliminating 'unsafe-inline' would require either banning
    dynamic `style={...}` (CSS-in-JS rewrite of the 5 load-bearing
    sites) or adopting CSP nonces with React 18+'s style runtime.
    Neither fits the original FE-M6 phase budget.

═══════════════════════════ CHANGES ═══════════════════════════════

web/src/pages/auth/UsersPage.tsx:
  9 inline-style attrs → Tailwind utility classes. The filter UI
  (mb-4, mr-2, w-[280px] p-1), the table (w-full border-collapse),
  the thead row (border-b-2 border-gray-300 text-left), per-row
  borders (border-b border-gray-200 + opacity-50/100 conditional),
  buttons (px-3 py-1), the empty-state cell (p-3 text-center).
  Behavior-preserving.

web/src/pages/DigestPage.tsx:
  iframe `style={{ minHeight: '600px' }}` → className "min-h-[600px]"
  (composed into the existing className).

web/src/components/AuthProvider.tsx:
  Demo-mode banner: 6-prop `style={{ background, color, padding,
  fontSize, fontWeight, textAlign }}` → className "bg-red-700
  text-white px-4 py-2 text-[13px] font-semibold text-center".
  Same visual.

internal/api/middleware/securityheaders.go:
  CSP rationale comment rewritten to accurately describe WHY
  'unsafe-inline' is required. New comment:
    - Names the 5 load-bearing dynamic-style sites explicitly
    - Lists the 3 static sites that were migrated to Tailwind today
    - Documents that the OLD comment's "Tailwind/Vite injects
      <style> blocks" claim was factually wrong (verified against
      built dist/index.html — zero <style> tags emitted)
    - Records the future-tightening path (React style-runtime
      nonces OR CSS-in-JS rewrite of the 5 sites) and notes it
      doesn't fit the original FE-M6 phase budget

═══════════════════════════ AUDIT FRAMING ════════════════════════

The audit said FE-M6 was about "inline SVG style= attrs (related
to FE-H2)." Ground-truth: FE-H2 (Phase 3 Layout SVG → Lucide
icons) ALREADY happened; the remaining inline-style sites have
nothing to do with SVGs. The audit's bridge from FE-H2 → FE-M6
was a red herring.

The OPERATOR-VISIBLE win from this closure:
  • 3 production tsx files now use Tailwind utility classes for
    static styling — consistent with the rest of the codebase.
  • The CSP comment now tells the truth about why 'unsafe-inline'
    is needed, so the next operator who reads it doesn't waste
    time hunting for non-existent <style> blocks.
  • The inline-style attribute surface is reduced to ONLY
    load-bearing dynamic styling — making any future tightening
    work (nonces, CSS-in-JS migration) easier to scope.

The CSP header itself is UNCHANGED ("style-src 'self'
'unsafe-inline'"). True elimination of 'unsafe-inline' is a
separate workstream tracked in the corrected comment.

═══════════════════════════ VERIFICATION ═══════════════════════════

  • gofmt -l internal/api/middleware/securityheaders.go — clean
  • go vet ./internal/api/middleware/... — exit 0
  • go test -short -count=1 ./internal/api/middleware/... —
    ok 0.247s (existing securityheaders_test.go pins the
    Content-Security-Policy header value byte-string; unchanged
    by this commit so test stays green)
  • npx tsc --noEmit — exit 0
  • npx vitest run AuthProvider DigestPage UsersPage — 16/16 pass
  • npx vite build — built in 3.42s

Ground-truth: origin/master tip 9ba5ee4 (P-M2 just pushed)
verified via GitHub API BEFORE commit.

Falsifiable proof: a future engineer reading securityheaders.go:35
sees an accurate explanation of why 'unsafe-inline' is needed,
NOT the previous false "Tailwind/Vite" claim.
This commit is contained in:
shankar0123
2026-05-14 20:40:55 +00:00
parent 9ba5ee41be
commit 7268d12a17
4 changed files with 55 additions and 22 deletions
+4 -8
View File
@@ -142,17 +142,13 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
the bypass — but the GUI still surfaces the state plainly.
*/}
{authType === 'none' && !loading && (
// FE-M6 closure 2026-05-14: was a 6-prop style={...} attr;
// migrated to Tailwind utilities. Same visual: red banner,
// white text, 8px/16px padding, 13px semibold center.
<div
data-testid="demo-mode-banner"
role="alert"
style={{
background: '#b91c1c',
color: '#fff',
padding: '8px 16px',
fontSize: 13,
fontWeight: 600,
textAlign: 'center',
}}
className="bg-red-700 text-white px-4 py-2 text-[13px] font-semibold text-center"
>
Demo mode active (CERTCTL_AUTH_TYPE=none). Every caller is anonymous admin.
Production deployments MUST set CERTCTL_AUTH_TYPE=api-key or oidc.
+1 -2
View File
@@ -76,8 +76,7 @@ export default function DigestPage() {
<iframe
srcDoc={html}
title="Digest Preview"
className="w-full border-0"
style={{ minHeight: '600px' }}
className="w-full border-0 min-h-[600px]"
sandbox="allow-same-origin"
/>
</div>
+21 -9
View File
@@ -74,23 +74,29 @@ export default function UsersPage() {
return (
<div>
<PageHeader title="Federated Users" subtitle="One row per (oidc_provider_id, oidc_subject) tuple." />
<div style={{ marginBottom: 16 }}>
<label style={{ marginRight: 8 }}>Filter by provider:</label>
{/* FE-M6 closure 2026-05-14: migrated 9 inline-style attrs in this
page to Tailwind utility classes. Pre-closure these were the
single biggest concentration of style={...} in production tsx.
Closes the "static styles in inline-attr position" half of
FE-M6; load-bearing dynamic styles (Tooltip Floating-UI, chart
color props, computed widths) remain inline by necessity. */}
<div className="mb-4">
<label className="mr-2">Filter by provider:</label>
<input
type="text"
placeholder="op-keycloak (leave empty for all)"
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
style={{ width: 280, padding: 4 }}
className="w-[280px] p-1"
/>
</div>
{err && <ErrorState message={err} />}
{usersQuery.isLoading && <p>Loading users</p>}
{usersQuery.error && <ErrorState message={usersQuery.error.message} />}
{usersQuery.data && (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<table className="w-full border-collapse">
<thead>
<tr style={{ borderBottom: '2px solid #ccc', textAlign: 'left' }}>
<tr className="border-b-2 border-gray-300 text-left">
<th>ID</th>
<th>Email</th>
<th>Display Name</th>
@@ -104,7 +110,13 @@ export default function UsersPage() {
{usersQuery.data.map((u) => {
const deactivated = Boolean(u.deactivated_at);
return (
<tr key={u.id} style={{ borderBottom: '1px solid #eee', opacity: deactivated ? 0.5 : 1 }}>
<tr
key={u.id}
className={
'border-b border-gray-200 ' +
(deactivated ? 'opacity-50' : 'opacity-100')
}
>
<td><code>{u.id}</code></td>
<td>{u.email}</td>
<td>{u.display_name}</td>
@@ -116,7 +128,7 @@ export default function UsersPage() {
<button
onClick={() => deactivate(u)}
disabled={pending === u.id}
style={{ padding: '4px 12px' }}
className="px-3 py-1"
>
{pending === u.id ? 'Deactivating…' : 'Deactivate'}
</button>
@@ -125,7 +137,7 @@ export default function UsersPage() {
<button
onClick={() => reactivate(u)}
disabled={pending === u.id}
style={{ padding: '4px 12px' }}
className="px-3 py-1"
>
{pending === u.id ? 'Reactivating…' : 'Reactivate'}
</button>
@@ -135,7 +147,7 @@ export default function UsersPage() {
);
})}
{usersQuery.data.length === 0 && (
<tr><td colSpan={7} style={{ padding: 12, textAlign: 'center' }}>No users matching filter.</td></tr>
<tr><td colSpan={7} className="p-3 text-center">No users matching filter.</td></tr>
)}
</tbody>
</table>