mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:41:39 +00:00
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:
@@ -32,9 +32,35 @@ type SecurityHeadersConfig struct {
|
|||||||
// CSP: default-src 'self' confines fetches to the same origin.
|
// CSP: default-src 'self' confines fetches to the same origin.
|
||||||
// img-src 'self' data: allows inline base64 images (used by the
|
// img-src 'self' data: allows inline base64 images (used by the
|
||||||
// dashboard's certctl-logo and a few status icons).
|
// dashboard's certctl-logo and a few status icons).
|
||||||
// style-src 'self' 'unsafe-inline' is required because Tailwind
|
// style-src 'self' 'unsafe-inline' — the 'unsafe-inline' grant
|
||||||
// (via Vite) injects per-component <style> blocks at build time;
|
// is required by React's inline `style={...}` attribute model,
|
||||||
// without 'unsafe-inline' the dashboard would render unstyled.
|
// which emits HTML `style="..."` attributes that the browser
|
||||||
|
// treats as inline styles for CSP purposes. The dashboard has 5
|
||||||
|
// load-bearing dynamic-style sites: Tooltip's Floating-UI
|
||||||
|
// position (left/top px values computed per-tick),
|
||||||
|
// AgentFleetPage's dynamic color+width chart bars,
|
||||||
|
// dashboard/charts.tsx Recharts color props, CertificatesPage's
|
||||||
|
// progress-bar percent width, IssuerHierarchyPage's depth-based
|
||||||
|
// marginLeft. The static-pixel uses (UsersPage filter + table UI,
|
||||||
|
// DigestPage iframe min-height, AuthProvider demo-mode banner)
|
||||||
|
// were migrated to Tailwind utility classes via FE-M6 closure
|
||||||
|
// 2026-05-14.
|
||||||
|
//
|
||||||
|
// FE-M6 audit-framing correction: this comment USED TO say
|
||||||
|
// "Tailwind (via Vite) injects per-component <style> blocks at
|
||||||
|
// build time." That was factually wrong. Vite's CSS output is a
|
||||||
|
// single .css file linked via <link rel="stylesheet"> — verified
|
||||||
|
// against dist/index.html post-build: zero <style> tags emitted.
|
||||||
|
// The 'unsafe-inline' grant exists for React's style-attribute
|
||||||
|
// output path, not for Vite or Tailwind.
|
||||||
|
//
|
||||||
|
// Fully eliminating 'unsafe-inline' would require either banning
|
||||||
|
// dynamic `style={...}` (rewriting the 5 load-bearing sites with
|
||||||
|
// a CSS-in-JS library that emits hashed/nonce'd <style> blocks)
|
||||||
|
// or adopting CSP nonces with React 18+'s style runtime. Neither
|
||||||
|
// fits the original FE-M6 phase budget; tracked as a future
|
||||||
|
// security-hardening item.
|
||||||
|
//
|
||||||
// 'unsafe-inline' is intentionally NOT in script-src — the
|
// 'unsafe-inline' is intentionally NOT in script-src — the
|
||||||
// front-end ships as a bundled JS file, no inline scripts.
|
// front-end ships as a bundled JS file, no inline scripts.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -142,17 +142,13 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
the bypass — but the GUI still surfaces the state plainly.
|
the bypass — but the GUI still surfaces the state plainly.
|
||||||
*/}
|
*/}
|
||||||
{authType === 'none' && !loading && (
|
{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
|
<div
|
||||||
data-testid="demo-mode-banner"
|
data-testid="demo-mode-banner"
|
||||||
role="alert"
|
role="alert"
|
||||||
style={{
|
className="bg-red-700 text-white px-4 py-2 text-[13px] font-semibold text-center"
|
||||||
background: '#b91c1c',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '8px 16px',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
⚠️ Demo mode active (CERTCTL_AUTH_TYPE=none). Every caller is anonymous admin.
|
⚠️ Demo mode active (CERTCTL_AUTH_TYPE=none). Every caller is anonymous admin.
|
||||||
Production deployments MUST set CERTCTL_AUTH_TYPE=api-key or oidc.
|
Production deployments MUST set CERTCTL_AUTH_TYPE=api-key or oidc.
|
||||||
|
|||||||
@@ -76,8 +76,7 @@ export default function DigestPage() {
|
|||||||
<iframe
|
<iframe
|
||||||
srcDoc={html}
|
srcDoc={html}
|
||||||
title="Digest Preview"
|
title="Digest Preview"
|
||||||
className="w-full border-0"
|
className="w-full border-0 min-h-[600px]"
|
||||||
style={{ minHeight: '600px' }}
|
|
||||||
sandbox="allow-same-origin"
|
sandbox="allow-same-origin"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,23 +74,29 @@ export default function UsersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="Federated Users" subtitle="One row per (oidc_provider_id, oidc_subject) tuple." />
|
<PageHeader title="Federated Users" subtitle="One row per (oidc_provider_id, oidc_subject) tuple." />
|
||||||
<div style={{ marginBottom: 16 }}>
|
{/* FE-M6 closure 2026-05-14: migrated 9 inline-style attrs in this
|
||||||
<label style={{ marginRight: 8 }}>Filter by provider:</label>
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="op-keycloak (leave empty for all)"
|
placeholder="op-keycloak (leave empty for all)"
|
||||||
value={providerFilter}
|
value={providerFilter}
|
||||||
onChange={(e) => setProviderFilter(e.target.value)}
|
onChange={(e) => setProviderFilter(e.target.value)}
|
||||||
style={{ width: 280, padding: 4 }}
|
className="w-[280px] p-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{err && <ErrorState message={err} />}
|
{err && <ErrorState message={err} />}
|
||||||
{usersQuery.isLoading && <p>Loading users…</p>}
|
{usersQuery.isLoading && <p>Loading users…</p>}
|
||||||
{usersQuery.error && <ErrorState message={usersQuery.error.message} />}
|
{usersQuery.error && <ErrorState message={usersQuery.error.message} />}
|
||||||
{usersQuery.data && (
|
{usersQuery.data && (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table className="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ccc', textAlign: 'left' }}>
|
<tr className="border-b-2 border-gray-300 text-left">
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Display Name</th>
|
<th>Display Name</th>
|
||||||
@@ -104,7 +110,13 @@ export default function UsersPage() {
|
|||||||
{usersQuery.data.map((u) => {
|
{usersQuery.data.map((u) => {
|
||||||
const deactivated = Boolean(u.deactivated_at);
|
const deactivated = Boolean(u.deactivated_at);
|
||||||
return (
|
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><code>{u.id}</code></td>
|
||||||
<td>{u.email}</td>
|
<td>{u.email}</td>
|
||||||
<td>{u.display_name}</td>
|
<td>{u.display_name}</td>
|
||||||
@@ -116,7 +128,7 @@ export default function UsersPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => deactivate(u)}
|
onClick={() => deactivate(u)}
|
||||||
disabled={pending === u.id}
|
disabled={pending === u.id}
|
||||||
style={{ padding: '4px 12px' }}
|
className="px-3 py-1"
|
||||||
>
|
>
|
||||||
{pending === u.id ? 'Deactivating…' : 'Deactivate'}
|
{pending === u.id ? 'Deactivating…' : 'Deactivate'}
|
||||||
</button>
|
</button>
|
||||||
@@ -125,7 +137,7 @@ export default function UsersPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => reactivate(u)}
|
onClick={() => reactivate(u)}
|
||||||
disabled={pending === u.id}
|
disabled={pending === u.id}
|
||||||
style={{ padding: '4px 12px' }}
|
className="px-3 py-1"
|
||||||
>
|
>
|
||||||
{pending === u.id ? 'Reactivating…' : 'Reactivate'}
|
{pending === u.id ? 'Reactivating…' : 'Reactivate'}
|
||||||
</button>
|
</button>
|
||||||
@@ -135,7 +147,7 @@ export default function UsersPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{usersQuery.data.length === 0 && (
|
{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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user