From 4e6c4da99a05971d681b80510e3c0b3580c67038 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 08:39:41 -0700 Subject: [PATCH] Integrate insecure-connection (HTTP) warning as a clean top strip, not a floating overlay The HTTP warning was a fixed corner badge (`fixed top-4 right-4`) that floated over the UI and constantly overlapped page content. Replace it with a single, slim, dismissible warning strip in a standard location. - New `InsecureConnectionBanner` (replaces the floating `TlsStatusIndicator` and the unused `TlsSecurityBanner`): a full-width strip shown ONLY over HTTP, dismissible for the session. Two placements: - in-app: in-flow at the very top of the shell, so it reserves its own space and pushes the app down instead of overlapping it; - auth pages (no app chrome): a pinned top strip, portaled to so a transformed ancestor can't offset it. - Layout shell is now a flex column (h-screen) with the banner as the first row and the app filling the rest; app page roots use h-full instead of h-screen so they shrink to the banner. With no banner (HTTPS) this is identical to before. Verified (over HTTP): banner at top:0, slim (33px), app starts exactly at its bottom (no overlap), no page scroll introduced, dismiss reclaims the space. New tests/diagnostics/insecure-connection-banner.spec.ts asserts all of this; THE GATE 5/5; web typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/src/components/Layout.tsx | 17 +-- .../web/src/components/TlsStatusIndicator.tsx | 133 ++++++++++-------- packages/web/src/pages/Admin.tsx | 2 +- packages/web/src/pages/Agents.tsx | 2 +- packages/web/src/pages/Analytics.tsx | 2 +- packages/web/src/pages/Backend.tsx | 2 +- packages/web/src/pages/ForgotPassword.tsx | 6 +- packages/web/src/pages/Ontology.tsx | 2 +- packages/web/src/pages/ResetPassword.tsx | 4 +- packages/web/src/pages/Settings.tsx | 2 +- packages/web/src/pages/Signin.tsx | 6 +- packages/web/src/pages/Signup.tsx | 6 +- packages/web/src/pages/Workspace.tsx | 2 +- .../insecure-connection-banner.spec.ts | 78 ++++++++++ 14 files changed, 179 insertions(+), 85 deletions(-) create mode 100644 tests/diagnostics/insecure-connection-banner.spec.ts diff --git a/packages/web/src/components/Layout.tsx b/packages/web/src/components/Layout.tsx index d0d6f30a..76757289 100644 --- a/packages/web/src/components/Layout.tsx +++ b/packages/web/src/components/Layout.tsx @@ -6,7 +6,7 @@ import { GraphSelector } from './GraphSelector'; import { useAuth } from '../contexts/AuthContext'; import { McpHealthIndicator } from './McpHealthIndicator'; import FloatingConsole from './FloatingConsole'; -import { TlsStatusIndicator, TlsSecurityBanner } from './TlsStatusIndicator'; +import { InsecureConnectionBanner } from './TlsStatusIndicator'; import { APP_VERSION } from '../utils/version'; interface LayoutProps { @@ -31,12 +31,16 @@ export function Layout({ children }: LayoutProps) { ]; return ( -
+ {/* Insecure-connection warning — an in-flow strip at the very top, so it + reserves its own space and never overlaps the app (only over HTTP). */} + + {/* Static gradient background - optimized for all browsers */}
@@ -58,7 +62,7 @@ export function Layout({ children }: LayoutProps) {
-
+
{/* Sidebar */}
-
+
{children}
@@ -305,9 +309,6 @@ export function Layout({ children }: LayoutProps) { onToggle={() => setShowFloatingConsole(!showFloatingConsole)} onClose={() => setShowFloatingConsole(false)} /> - - {/* TLS/SSL Status Indicator */} -
); } \ No newline at end of file diff --git a/packages/web/src/components/TlsStatusIndicator.tsx b/packages/web/src/components/TlsStatusIndicator.tsx index 95819919..f8cae834 100644 --- a/packages/web/src/components/TlsStatusIndicator.tsx +++ b/packages/web/src/components/TlsStatusIndicator.tsx @@ -1,72 +1,87 @@ import React from 'react'; -import { Shield, ShieldOff, AlertTriangle } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { ShieldOff, AlertTriangle, X } from 'lucide-react'; -interface TlsStatusIndicatorProps { - className?: string; -} +const DISMISS_KEY = 'tlsBannerDismissed'; -export function TlsStatusIndicator({ className = '' }: TlsStatusIndicatorProps) { - // Detect if we're running over HTTPS +function readConnection() { const isSecure = window.location.protocol === 'https:'; - const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + const isLocalhost = + window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + return { isSecure, isLocalhost }; +} - // Don't show anything if we're on HTTPS (secure) - if (isSecure) { - return null; - } +interface InsecureConnectionBannerProps { + /** Render as a fixed full-width strip pinned to the very top (for pages that + * have no app chrome to sit under, e.g. the auth screens). Default is an + * in-flow strip that pushes the content below it down. */ + fixed?: boolean; + /** Called when the user dismisses the strip, so a parent can drop any layout + * offset it added to make room for the (fixed) banner. */ + onDismiss?: () => void; + className?: string; +} - return ( -
-
- {isLocalhost ? ( - <> - - Development Mode (HTTP) - - ) : ( - <> - - Insecure Connection (HTTP) - - )} -
-
- ); +/** Whether the current connection should trigger an insecure-connection warning + * (i.e. not HTTPS). Lets a layout reserve space for the fixed banner. */ +export function isInsecureConnection(): boolean { + return typeof window !== 'undefined' && window.location.protocol !== 'https:'; } -// For authenticated users, show a more prominent security status -export function TlsSecurityBanner({ className = '' }: TlsStatusIndicatorProps) { - const isSecure = window.location.protocol === 'https:'; - const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; +/** + * A slim, dismissible warning strip shown ONLY when the connection is not + * encrypted (HTTP). It lives in the document flow (or pinned to the top edge + * for chrome-less pages) instead of floating in a corner, so it never overlaps + * the rest of the UI. Dismissal is remembered for the browser session. + */ +export function InsecureConnectionBanner({ fixed = false, onDismiss, className = '' }: InsecureConnectionBannerProps) { + const { isSecure, isLocalhost } = readConnection(); + const [dismissed, setDismissed] = React.useState( + () => typeof sessionStorage !== 'undefined' && sessionStorage.getItem(DISMISS_KEY) === '1' + ); - if (isSecure) { - return ( -
- - Secure Connection -
- ); - } + // Nothing to warn about on HTTPS, or once the user has dismissed it. + if (isSecure || dismissed) return null; - if (isLocalhost) { - return ( -
- - Development Mode -
- ); - } + const dismiss = () => { + try { + sessionStorage.setItem(DISMISS_KEY, '1'); + } catch { + /* storage may be unavailable; dismissing for this mount is enough */ + } + setDismissed(true); + onDismiss?.(); + }; - return ( -
- - Insecure Connection + const tone = isLocalhost + ? 'bg-yellow-500/15 border-yellow-600/40 text-yellow-200' + : 'bg-red-500/15 border-red-600/40 text-red-200'; + const position = fixed ? 'fixed top-0 inset-x-0 z-[60]' : 'w-full'; + + const strip = ( +
+ {isLocalhost ? : } + + {isLocalhost + ? 'Development mode — this connection is not encrypted (HTTP).' + : 'Insecure connection — this site is served over HTTP, not HTTPS.'} + +
); -} \ No newline at end of file + + // When pinned, portal to so a transformed/blur ancestor (route + // transitions, backdrop-filter) can't turn `fixed` into a clipped/offset box. + return fixed && typeof document !== 'undefined' ? createPortal(strip, document.body) : strip; +} diff --git a/packages/web/src/pages/Admin.tsx b/packages/web/src/pages/Admin.tsx index cc175531..10123181 100644 --- a/packages/web/src/pages/Admin.tsx +++ b/packages/web/src/pages/Admin.tsx @@ -15,7 +15,7 @@ export function Admin() { // Redirect if not ADMIN if (currentUser?.role !== 'ADMIN') { return ( -
+

Access Denied

diff --git a/packages/web/src/pages/Agents.tsx b/packages/web/src/pages/Agents.tsx index 18e36293..2e778512 100644 --- a/packages/web/src/pages/Agents.tsx +++ b/packages/web/src/pages/Agents.tsx @@ -109,7 +109,7 @@ export function Agents() { const totalActiveCount = activeAgents.length + activeMcpServers.length; return ( -
+
{/* Header */}
diff --git a/packages/web/src/pages/Analytics.tsx b/packages/web/src/pages/Analytics.tsx index 87019b38..8f0ccb61 100644 --- a/packages/web/src/pages/Analytics.tsx +++ b/packages/web/src/pages/Analytics.tsx @@ -47,7 +47,7 @@ export function Analytics() { ]; return ( -
+
{/* Header */}
diff --git a/packages/web/src/pages/Backend.tsx b/packages/web/src/pages/Backend.tsx index af9382f2..c71c18cc 100644 --- a/packages/web/src/pages/Backend.tsx +++ b/packages/web/src/pages/Backend.tsx @@ -460,7 +460,7 @@ export function Backend() { }; return ( -
+
{/* Header */}
diff --git a/packages/web/src/pages/ForgotPassword.tsx b/packages/web/src/pages/ForgotPassword.tsx index 5e4bc16e..2261dd68 100644 --- a/packages/web/src/pages/ForgotPassword.tsx +++ b/packages/web/src/pages/ForgotPassword.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { Mail, ArrowLeft, CheckCircle, XCircle, Shield } from 'lucide-react'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { CodeCaptcha } from '../components/CodeCaptcha'; import { isValidEmail } from '../utils/validation'; @@ -246,8 +246,8 @@ export function ForgotPassword() {
- {/* TLS/SSL Status Indicator */} - + {/* Insecure-connection warning (top strip, only over HTTP) */} +
); } diff --git a/packages/web/src/pages/Ontology.tsx b/packages/web/src/pages/Ontology.tsx index 4970bc15..1ef4f714 100644 --- a/packages/web/src/pages/Ontology.tsx +++ b/packages/web/src/pages/Ontology.tsx @@ -115,7 +115,7 @@ export function Ontology() { }; return ( -
+
{/* Header */}
diff --git a/packages/web/src/pages/ResetPassword.tsx b/packages/web/src/pages/ResetPassword.tsx index 2f5b88a5..1648a3e3 100644 --- a/packages/web/src/pages/ResetPassword.tsx +++ b/packages/web/src/pages/ResetPassword.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams, useNavigate, Link } from 'react-router-dom'; import { Lock, Eye, EyeOff, CheckCircle, XCircle, ArrowLeft } from 'lucide-react'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { CodeCaptcha } from '../components/CodeCaptcha'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { validatePassword, getPasswordStrength } from '../utils/validation'; @@ -255,7 +255,7 @@ export function ResetPassword() {
- +
); } diff --git a/packages/web/src/pages/Settings.tsx b/packages/web/src/pages/Settings.tsx index b449c769..680787e3 100644 --- a/packages/web/src/pages/Settings.tsx +++ b/packages/web/src/pages/Settings.tsx @@ -11,7 +11,7 @@ export function Settings() { const { tier, override, setOverride } = useAdaptiveQuality(); return ( -
+
{/* Header */}
diff --git a/packages/web/src/pages/Signin.tsx b/packages/web/src/pages/Signin.tsx index fb007209..b3c2cdb9 100644 --- a/packages/web/src/pages/Signin.tsx +++ b/packages/web/src/pages/Signin.tsx @@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, gql } from '@apollo/client'; import { Eye, EyeOff, ArrowRight, Mail, Lock, Users, Github, Zap, Check, CheckCircle, XCircle, AlertTriangle, Shield } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { GuestModeDialog } from '../components/GuestModeDialog'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { isValidEmail } from '../utils/validation'; @@ -992,8 +992,8 @@ export function Signin() {
- {/* TLS/SSL Status Indicator */} - + {/* Insecure-connection warning (top strip, only over HTTP) */} +
); } \ No newline at end of file diff --git a/packages/web/src/pages/Signup.tsx b/packages/web/src/pages/Signup.tsx index f0f0f1f9..0fcaaa04 100644 --- a/packages/web/src/pages/Signup.tsx +++ b/packages/web/src/pages/Signup.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useMutation, gql } from '@apollo/client'; import { Eye, EyeOff, ArrowRight, CheckCircle, XCircle, Github, Mail, Info, Shield } from 'lucide-react'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { isValidEmail, getPasswordStrength } from '../utils/validation'; import { CodeCaptcha } from '../components/CodeCaptcha'; @@ -729,8 +729,8 @@ export function Signup() { )}
- {/* TLS/SSL Status Indicator */} - + {/* Insecure-connection warning (top strip, only over HTTP) */} +
); } \ No newline at end of file diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 606feda2..31007e2c 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -61,7 +61,7 @@ export function Workspace() { const actualEdgeCount = edgesData?.edges?.length || 0; return ( -
+
{/* Header with Graph Context */}
{/* Responsive Layout Container */} diff --git a/tests/diagnostics/insecure-connection-banner.spec.ts b/tests/diagnostics/insecure-connection-banner.spec.ts new file mode 100644 index 00000000..d3a16b4b --- /dev/null +++ b/tests/diagnostics/insecure-connection-banner.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * The insecure-connection (HTTP) warning must integrate cleanly: a slim strip + * at the very top that reserves its own space (never overlaps the app), and is + * dismissible. Runs over the dev HTTP origin, so the banner is expected. + * Report-only screenshots + hard assertions on placement. + */ +const OUT = path.resolve(process.cwd(), 'test-artifacts/tls-banner'); +const SEL = '[data-testid="insecure-connection-banner"]'; + +async function measure(page: Page) { + return page.evaluate((sel) => { + const b = document.querySelector(sel) as HTMLElement | null; + if (!b) return { present: false } as const; + const r = b.getBoundingClientRect(); + // The first app chrome under the banner: the sidebar or the main content. + const main = document.querySelector('main') as HTMLElement | null; + const sidebar = document.querySelector('nav')?.closest('div') as HTMLElement | null; + const topOfApp = Math.min( + main ? main.getBoundingClientRect().top : Infinity, + sidebar ? sidebar.getBoundingClientRect().top : Infinity + ); + return { + present: true, + top: Math.round(r.top), + bottom: Math.round(r.bottom), + height: Math.round(r.height), + width: Math.round(r.width), + topOfApp: Math.round(topOfApp), + scrollY: Math.round(window.scrollY), + }; + }, SEL); +} + +test.describe('insecure-connection banner @geometry', () => { + test.describe.configure({ timeout: 120_000 }); + + test('renders as a top strip, reserves space (no overlap), dismissible', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + + // Auth page (chrome-less): a pinned strip at the very top. + await page.goto('/signin'); + await page.waitForTimeout(1200); + const auth = await measure(page); + await page.screenshot({ path: path.join(OUT, 'auth-signin.png'), clip: { x: 0, y: 0, width: 1440, height: 220 } }); + expect(auth.present, 'banner shown over HTTP on auth page').toBe(true); + if (auth.present) { + expect(auth.top, 'auth banner pinned to the very top').toBeLessThanOrEqual(1); + expect(auth.height, 'auth banner is a slim strip').toBeLessThan(60); + } + + // In-app: an in-flow strip that pushes the app below it (no overlap, no scroll). + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(3000); + const app = await measure(page); + await page.screenshot({ path: path.join(OUT, 'app-top.png'), clip: { x: 0, y: 0, width: 1440, height: 220 } }); + // eslint-disable-next-line no-console + console.log('[tls-banner] ' + JSON.stringify(app)); + expect(app.present, 'banner shown in-app over HTTP').toBe(true); + if (app.present) { + expect(app.top, 'in-app banner sits at the top').toBeLessThanOrEqual(1); + expect(app.height, 'in-app banner is a slim strip').toBeLessThan(60); + expect(app.scrollY, 'banner must not introduce a page scroll').toBeLessThanOrEqual(1); + expect(app.topOfApp, 'app chrome starts at/below the banner (no overlap)').toBeGreaterThanOrEqual(app.bottom - 1); + } + + // Dismiss reclaims the space. + await page.locator(`${SEL} button[aria-label="Dismiss insecure-connection warning"]`).click(); + await page.waitForTimeout(500); + await page.screenshot({ path: path.join(OUT, 'app-after-dismiss.png'), clip: { x: 0, y: 0, width: 1440, height: 220 } }); + expect(await page.locator(SEL).count(), 'banner gone after dismiss').toBe(0); + }); +});