Files

125 lines
9.6 KiB
TypeScript

"use client";
import { FormEvent, useState } from "react";
import { ReportView } from "./report-view";
import { demoInspection } from "@/lib/inspect/demo";
import { isInspectionResult } from "@/lib/inspect/validation";
import type { InspectionResult } from "@/lib/inspect/types";
import { CERTSCOPE_VERSION } from "@/lib/version";
type ReportSource = "demo" | "live";
type ActiveReport = { result: InspectionResult; source: ReportSource };
type RequestState =
| { status: "idle" | "success" }
| { status: "loading"; requestedHost: string }
| { status: "error"; requestedHost: string; message: string };
function ShieldLogo() {
return <svg viewBox="0 0 32 36" aria-hidden="true"><path d="M16 1.5 29 6v10.2c0 8.2-5.3 14.7-13 18.3C8.3 30.9 3 24.4 3 16.2V6L16 1.5Z" fill="currentColor" opacity=".95"/><path d="m10.3 17.6 3.6 3.7 7.8-9" fill="none" stroke="#06111f" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/></svg>;
}
function SearchIcon() {
return <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" strokeWidth="2"/><path d="m16 16 4 4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>;
}
function CheckIcon() {
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.2 4.2L19.5 6.5" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/></svg>;
}
function ArrowIcon() {
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h13m-5-5 5 5-5 5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>;
}
const capabilities = [
{ label: "DNS", detail: "A / AAAA, MX / NS, SPF and DMARC", icon: "01" },
{ label: "TLS", detail: "Trust, identity, expiry and connection", icon: "02" },
{ label: "HTTP", detail: "Redirect safety, HSTS, CSP and headers", icon: "03" },
];
const securityBoundaries = ["Public hosts only", "Ports 80 / 443", "Redirects revalidated", "Bounded deadlines", "No scan history"];
const unavailableMessage = "Inspection service is temporarily unavailable. Try again shortly.";
export default function Home() {
const [host, setHost] = useState("");
const [report, setReport] = useState<ActiveReport | null>(null);
const [request, setRequest] = useState<RequestState>({ status: "idle" });
const loading = request.status === "loading";
async function runInspection(rawHost: string) {
const requestedHost = rawHost.trim();
if (!requestedHost) return;
setRequest({ status: "loading", requestedHost });
try {
const response = await fetch(`/api/inspect?host=${encodeURIComponent(requestedHost)}`, { cache: "no-store" });
const body = await response.json().catch(() => null) as unknown;
if (!response.ok) {
const message = typeof body === "object" && body !== null && "error" in body && typeof body.error === "string"
? body.error
: unavailableMessage;
throw new Error(message);
}
if (!isInspectionResult(body)) throw new Error("Inspection returned an invalid response");
setReport({ result: body, source: "live" });
setHost(body.host);
setRequest({ status: "success" });
} catch (error) {
const message = error instanceof Error ? error.message : unavailableMessage;
setRequest({ status: "error", requestedHost, message });
}
}
function inspect(event: FormEvent) {
event.preventDefault();
void runInspection(host);
}
function loadDemo() {
setReport({ result: demoInspection, source: "demo" });
setRequest({ status: "idle" });
}
const demoWarning = demoInspection.findings.find((finding) => finding.severity === "warning") ?? demoInspection.findings[0];
const landingError = request.status === "error" ? request.message : "";
return <main className="cert-app-shell" aria-busy={loading}>
<header className="app-topbar">
<div className="brand-lockup"><span className="brand-mark"><ShieldLogo /></span><div><strong>CertScope</strong><small>DOMAIN POSTURE INSPECTOR</small></div></div>
<div className="product-context"><strong>DNS · TLS · HTTP</strong><span>Evidence-led checks for public infrastructure</span></div>
<div className="topbar-right"><span className="version-chip">v{CERTSCOPE_VERSION}</span><a className="repo-link" href="https://gitea.itworx.tech/Jens/CertScope-Public" target="_blank" rel="noreferrer">Repository</a></div>
</header>
<section className="cert-main">
{report ? <ReportView key={report.result.host + report.result.inspectedAt + report.source}
result={report.result}
source={report.source}
isLoading={loading}
requestedHost={request.status === "loading" ? request.requestedHost : null}
requestError={request.status === "error" ? request : null}
onReinspect={(currentHost) => void runInspection(currentHost)}
onInspectAnother={(nextHost) => void runInspection(nextHost)}
/> : <>
<section className="hero-workspace" aria-labelledby="certscope-title">
<div className="hero-copy">
<p className="tagline">Smaller risks. A more open internet.</p>
<p className="hero-kicker"><span aria-hidden="true">◆</span> Explainable domain inspection</p>
<h1 id="certscope-title">CertScope</h1>
<p className="hero-statement">Public infrastructure posture, made explainable.</p>
<p className="hero-description">Inspect the DNS, TLS and HTTP posture of any public hostname. Every score change points back to evidence, not a black box.</p>
<div className="hero-signals" aria-label="Inspection characteristics"><span>Single-host</span><span>Deterministic</span><span>Evidence-led</span></div>
</div>
<form onSubmit={inspect} className="inspect-cluster" aria-label="Inspect a hostname">
<div className="inspect-card-heading"><div><p>Start an inspection</p><strong>Inspect a hostname</strong></div><span className="inspect-card-status">Public targets only</span></div>
<div className="host-field-wrap"><label htmlFor="hostname">Public hostname</label><input id="hostname" value={host} onChange={(event) => setHost(event.target.value)} placeholder="example.com" autoComplete="off" spellCheck={false} aria-describedby="hostname-help" /><small id="hostname-help">Enter a hostname, not a URL or IP address.</small></div>
<div className="quick-hosts" aria-label="Example hostnames">{["example.com", "github.com", "cloudflare.com"].map((example) => <button type="button" key={example} onClick={() => setHost(example)} aria-label={"Use " + example}>{example}</button>)}</div>
<div className="inspect-actions"><button type="submit" className="primary-btn" disabled={loading || !host.trim()}><SearchIcon />{loading ? "Inspecting…" : "Inspect"}</button><button type="button" className="secondary-btn" onClick={loadDemo} disabled={loading}>Load demo <ArrowIcon /></button></div>
<p className="inspect-card-note">DNS, TLS and HTTP checks stay bounded; no scan history is stored.</p>
{landingError && <div className="error-banner" role="alert"><strong>Inspection unavailable</strong><span>{landingError}</span><small>Adjust the hostname and try again.</small></div>}
</form>
</section>
{loading && <section className="inspection-progress" role="status" aria-live="polite"><div className="progress-orb" aria-hidden="true"><ShieldLogo /></div><div><strong>Inspecting DNS, TLS and HTTP posture…</strong><p>Collecting bounded evidence for <code>{host.trim()}</code>. Existing evidence will appear as soon as the report is ready.</p></div><div className="progress-checks" aria-hidden="true"><span>Resolve</span><span>Verify</span><span>Explain</span></div></section>}
<section className="capability-section" aria-labelledby="capabilities-title"><div className="section-heading"><div><p>What CertScope checks</p><h2 id="capabilities-title">One clear report across the public-facing stack.</h2></div><span>Evidence, not guesswork</span></div><div className="capability-grid">{capabilities.map((capability) => <article className="capability-card" key={capability.label}><span className="capability-index" aria-hidden="true">{capability.icon}</span><h3>{capability.label}</h3><p>{capability.detail}</p></article>)}</div></section>
<section className="security-boundary" aria-label="Inspection security boundary"><div><p>Safe by design</p><strong>Strong boundaries around every connection.</strong></div><ul>{securityBoundaries.map((boundary) => <li key={boundary}><CheckIcon />{boundary}</li>)}</ul></section>
<section className="demo-preview" aria-labelledby="demo-preview-title"><div className="demo-preview-copy"><p>Deterministic demo</p><h2 id="demo-preview-title">See the report before you inspect.</h2><span>The built-in fixture is local, reproducible and ready to explore without a network request.</span><button type="button" className="preview-demo-button" onClick={loadDemo}>Open demo report <ArrowIcon /></button></div><div className="demo-preview-report" aria-label="Preview of the deterministic demo report"><header><div><span>Demo preview</span><strong>{demoInspection.host}</strong></div><b>{demoInspection.score.overall}<small>/100</small></b></header><div className="preview-score-grid">{(["dns", "tls", "http"] as const).map((category) => <div key={category}><span>{category.toUpperCase()}</span><strong>{demoInspection.score.categories[category]}</strong></div>)}</div><div className="preview-finding"><span className="preview-finding-icon">!</span><div><strong>{demoWarning.title}</strong><p>{demoWarning.summary}</p></div></div></div></section>
</>}
</section>
</main>;
}