Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
LabelList,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { BarChart3, type LucideIcon, Radar } from "lucide-react";
|
||||
import { SeverityBadge } from "@/components/ui/badge";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import {
|
||||
CHANGE_TYPE_LABELS,
|
||||
SEVERITY_LABELS,
|
||||
SEVERITY_LEVELS,
|
||||
type DashboardAnalytics,
|
||||
type SeverityLevel,
|
||||
} from "@/lib/types";
|
||||
|
||||
// Same hex values as tailwind.config.ts's `severity` ramp - a reserved
|
||||
// status scale (good -> critical), never reused as generic categorical
|
||||
// series color. Kept in sync manually since chart libraries need literal
|
||||
// values, not Tailwind classes.
|
||||
const SEVERITY_COLORS: Record<SeverityLevel, string> = {
|
||||
critical: "#dc2626",
|
||||
high: "#ea580c",
|
||||
medium: "#ca8a04",
|
||||
low: "#65a30d",
|
||||
};
|
||||
|
||||
const RUN_STATUS_COLORS = {
|
||||
successful: "#16a34a",
|
||||
failed: "#dc2626",
|
||||
other: "#94a3b8",
|
||||
};
|
||||
|
||||
const AXIS_TICK_STYLE = { fill: "#64748b", fontSize: 12 };
|
||||
const GRID_STROKE = "#e2e8f0";
|
||||
|
||||
function ChartCard({
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<Icon className="h-4 w-4" aria-hidden /> {title}
|
||||
</div>
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyChartState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-[180px] items-center justify-center text-sm text-slate-400">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangesByTypeChart({ data }: { data: DashboardAnalytics["changes_by_type"] }) {
|
||||
const rows = Object.entries(data)
|
||||
.map(([key, value]) => ({ key, label: CHANGE_TYPE_LABELS[key] ?? key, value }))
|
||||
.filter((r) => r.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <EmptyChartState message="No changes detected in the last 30 days yet." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(140, rows.length * 36)}>
|
||||
<BarChart data={rows} layout="vertical" margin={{ left: 8, right: 24, top: 4, bottom: 4 }}>
|
||||
<CartesianGrid horizontal={false} stroke={GRID_STROKE} />
|
||||
<XAxis type="number" allowDecimals={false} tick={AXIS_TICK_STYLE} axisLine={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={130}
|
||||
tick={AXIS_TICK_STYLE}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "#f8fafc" }}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8, borderColor: "#e2e8f0" }}
|
||||
formatter={(value: number) => [value, "Changes"]}
|
||||
/>
|
||||
<Bar dataKey="value" fill="#3766f7" radius={[0, 4, 4, 0]} maxBarSize={22}>
|
||||
<LabelList dataKey="value" position="right" style={{ fill: "#334155", fontSize: 12 }} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsBySeverityChart({ data }: { data: DashboardAnalytics["alerts_by_severity"] }) {
|
||||
const total = SEVERITY_LEVELS.reduce((sum, s) => sum + (data[s] ?? 0), 0);
|
||||
if (total === 0) {
|
||||
return <EmptyChartState message="No alerts in the last 30 days yet." />;
|
||||
}
|
||||
|
||||
const rows = SEVERITY_LEVELS.map((severity) => ({
|
||||
severity,
|
||||
label: SEVERITY_LABELS[severity],
|
||||
value: data[severity] ?? 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={rows} layout="vertical" margin={{ left: 8, right: 24, top: 4, bottom: 4 }}>
|
||||
<CartesianGrid horizontal={false} stroke={GRID_STROKE} />
|
||||
<XAxis type="number" allowDecimals={false} tick={AXIS_TICK_STYLE} axisLine={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={70}
|
||||
tick={AXIS_TICK_STYLE}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "#f8fafc" }}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8, borderColor: "#e2e8f0" }}
|
||||
formatter={(value: number) => [value, "Alerts"]}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={22}>
|
||||
{rows.map((row) => (
|
||||
<Cell key={row.severity} fill={SEVERITY_COLORS[row.severity]} />
|
||||
))}
|
||||
<LabelList dataKey="value" position="right" style={{ fill: "#334155", fontSize: 12 }} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RunsByDayChart({ data }: { data: DashboardAnalytics["runs_by_day"] }) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyChartState message="No monitoring runs in the last 30 days yet." />;
|
||||
}
|
||||
|
||||
const rows = data.map((d) => ({
|
||||
...d,
|
||||
label: new Date(d.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={rows} margin={{ left: 0, right: 8, top: 4, bottom: 4 }} barGap={2}>
|
||||
<CartesianGrid vertical={false} stroke={GRID_STROKE} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={AXIS_TICK_STYLE}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis allowDecimals={false} tick={AXIS_TICK_STYLE} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "#f8fafc" }}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8, borderColor: "#e2e8f0" }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} iconType="square" iconSize={10} />
|
||||
<Bar
|
||||
dataKey="successful"
|
||||
stackId="runs"
|
||||
name="Successful"
|
||||
fill={RUN_STATUS_COLORS.successful}
|
||||
radius={[0, 0, 0, 0]}
|
||||
maxBarSize={20}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="failed"
|
||||
stackId="runs"
|
||||
name="Failed"
|
||||
fill={RUN_STATUS_COLORS.failed}
|
||||
maxBarSize={20}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="other"
|
||||
stackId="runs"
|
||||
name="Other"
|
||||
fill={RUN_STATUS_COLORS.other}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={20}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentSignalsFeed({ signals }: { signals: DashboardAnalytics["recent_signals"] }) {
|
||||
if (signals.length === 0) {
|
||||
return (
|
||||
<p className="mt-4 text-sm text-slate-500">
|
||||
No signals detected yet — once a monitoring run finds a real change, it'll show up
|
||||
here.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="mt-4 divide-y divide-slate-100">
|
||||
{signals.map((signal) => (
|
||||
<li key={signal.id} className="flex items-start justify-between gap-4 py-3 text-sm">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<SeverityBadge severity={signal.severity} />
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
{CHANGE_TYPE_LABELS[signal.change_type] ?? signal.change_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-slate-900">{signal.summary}</p>
|
||||
<Link
|
||||
href={`/companies/${signal.company_id}`}
|
||||
className="text-xs text-brand-600 hover:text-brand-700"
|
||||
>
|
||||
{signal.company_name}
|
||||
</Link>
|
||||
</div>
|
||||
<span className="shrink-0 whitespace-nowrap text-xs text-slate-500">
|
||||
{formatRelative(signal.created_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardAnalyticsSection({ analytics }: { analytics: DashboardAnalytics }) {
|
||||
return (
|
||||
<div className="mt-6 animate-fade-in space-y-6">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="Changes detected (last 30 days)" icon={Radar}>
|
||||
<ChangesByTypeChart data={analytics.changes_by_type} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Alerts by severity (last 30 days)" icon={BarChart3}>
|
||||
<AlertsBySeverityChart data={analytics.alerts_by_severity} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<ChartCard title="Monitoring runs (last 30 days)" icon={BarChart3}>
|
||||
<RunsByDayChart data={analytics.runs_by_day} />
|
||||
</ChartCard>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<Radar className="h-4 w-4" aria-hidden /> Recent intelligence signals
|
||||
</div>
|
||||
<RecentSignalsFeed signals={analytics.recent_signals} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import { useSystemStatus } from "@/hooks/use-auth";
|
||||
import { isLocalConvenience } from "@/lib/auth";
|
||||
|
||||
export function LocalModeBanner() {
|
||||
const { data } = useSystemStatus();
|
||||
|
||||
if (!data || !isLocalConvenience(data)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 bg-amber-100 px-4 py-2 text-sm text-amber-900">
|
||||
<Info className="h-4 w-4 shrink-0" aria-hidden />
|
||||
<span>
|
||||
Local development mode — you're using a fixed local account, no login required. This
|
||||
only applies to requests from this machine; anyone reaching this app over a LAN or WAN
|
||||
connection needs a real account.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Next's App Router swaps route content instantly with no transition of its
|
||||
* own. Keying a wrapper div on the pathname forces React to remount it on
|
||||
* every navigation, which retriggers the `animate-fade-in` CSS animation
|
||||
* (see tailwind.config.ts) - the same technique used for tab switches on the
|
||||
* company detail page.
|
||||
*/
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div key={pathname} className="animate-fade-in">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ConfidenceLabel, Finding, InferredProject, ReportResponse } from "@/lib/types";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
const CONFIDENCE_LABELS: Record<ConfidenceLabel, string> = {
|
||||
confirmed: "Confirmed",
|
||||
strongly_indicated: "Strongly indicated",
|
||||
likely: "Likely",
|
||||
possible: "Possible",
|
||||
unconfirmed: "Unconfirmed",
|
||||
insufficient_evidence: "Insufficient evidence",
|
||||
};
|
||||
|
||||
const CONFIDENCE_CLASSES: Record<ConfidenceLabel, string> = {
|
||||
confirmed: "bg-green-100 text-green-800",
|
||||
strongly_indicated: "bg-green-50 text-green-700",
|
||||
likely: "bg-amber-100 text-amber-800",
|
||||
possible: "bg-amber-50 text-amber-700",
|
||||
unconfirmed: "bg-slate-100 text-slate-600",
|
||||
insufficient_evidence: "bg-slate-100 text-slate-500",
|
||||
};
|
||||
|
||||
function ConfidenceBadge({ confidence }: { confidence: ConfidenceLabel }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${CONFIDENCE_CLASSES[confidence]}`}
|
||||
>
|
||||
{CONFIDENCE_LABELS[confidence]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
number,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
number: number;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-b border-slate-200 py-6 first:pt-0 last:border-0">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
{number}. {title}
|
||||
</h2>
|
||||
<div className="mt-3 text-sm text-slate-700">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingList({ findings }: { findings: Finding[] }) {
|
||||
if (findings.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-slate-500">
|
||||
No findings for this section from the current evidence.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{findings.map((f, i) => (
|
||||
<li key={i} className="rounded-md border border-slate-200 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-medium text-slate-900">{f.headline}</p>
|
||||
<ConfidenceBadge confidence={f.confidence} />
|
||||
</div>
|
||||
<p className="mt-1 text-slate-600">{f.summary}</p>
|
||||
{f.date && <p className="mt-1 text-xs text-slate-400">{f.date}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectList({ projects }: { projects: InferredProject[] }) {
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-slate-500">
|
||||
No inferred strategic projects from the current evidence.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{projects.map((p, i) => (
|
||||
<li key={i} className="rounded-md border border-slate-200 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-medium text-slate-900">{p.project_name}</p>
|
||||
<ConfidenceBadge confidence={p.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-slate-600">{p.summary}</p>
|
||||
{p.alternative_explanations.length > 0 && (
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Alternative explanations: {p.alternative_explanations.join("; ")}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function StringList({ items, emptyText }: { items: string[]; emptyText: string }) {
|
||||
if (items.length === 0) return <p className="text-sm text-slate-500">{emptyText}</p>;
|
||||
return (
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{items.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportView({ report }: { report: ReportResponse }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const c = report.structured_report;
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(c, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const downloadJson = () => {
|
||||
const blob = new Blob([JSON.stringify(c, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${report.title}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 print:hidden">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">{report.title}</h1>
|
||||
<p className="text-xs text-slate-500">
|
||||
{report.report_type} report · generated {formatDateTime(report.created_at)} ·{" "}
|
||||
{report.model_provider}/{report.model_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="focus-ring rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy JSON"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="focus-ring rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Print
|
||||
</button>
|
||||
<button
|
||||
onClick={downloadJson}
|
||||
className="focus-ring rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg border border-slate-200 bg-white p-6">
|
||||
<Section number={1} title="Executive Summary">
|
||||
<p>{c.executive_summary}</p>
|
||||
</Section>
|
||||
<Section number={2} title="Company Overview">
|
||||
<p>{c.company_overview}</p>
|
||||
</Section>
|
||||
<Section number={3} title="Products and Service Landscape">
|
||||
<FindingList findings={c.products_and_services} />
|
||||
</Section>
|
||||
<Section number={4} title="Recent Developments">
|
||||
<FindingList findings={c.recent_developments} />
|
||||
</Section>
|
||||
<Section number={5} title="Strategic Initiatives">
|
||||
<FindingList findings={c.strategic_initiatives} />
|
||||
</Section>
|
||||
<Section number={6} title="Key Project Signals">
|
||||
<ProjectList projects={c.key_inferred_projects} />
|
||||
</Section>
|
||||
<Section number={7} title="Competitive Positioning">
|
||||
<p>{c.market_positioning}</p>
|
||||
<p className="mt-2">{c.competitor_comparison}</p>
|
||||
</Section>
|
||||
<Section number={8} title="SWOT Analysis">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-500">Strengths</h3>
|
||||
<StringList items={c.swot.strengths} emptyText="None noted." />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-500">Weaknesses</h3>
|
||||
<StringList items={c.swot.weaknesses} emptyText="None noted." />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-500">Opportunities</h3>
|
||||
<StringList items={c.swot.opportunities} emptyText="None noted." />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-500">Threats</h3>
|
||||
<StringList items={c.swot.threats} emptyText="None noted." />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<Section number={9} title="Hiring Signals">
|
||||
<FindingList findings={c.hiring_signals} />
|
||||
</Section>
|
||||
<Section number={10} title="Product and Technology Signals">
|
||||
<FindingList findings={[...c.technology_signals, ...c.patent_signals]} />
|
||||
</Section>
|
||||
<Section number={11} title="Customer Sentiment">
|
||||
<p>{c.customer_sentiment}</p>
|
||||
</Section>
|
||||
<Section number={12} title="Financial and Regulatory Signals">
|
||||
<FindingList findings={[...c.financial_signals, ...c.regulatory_and_legal_signals]} />
|
||||
</Section>
|
||||
<Section number={13} title="Risks and Opportunities">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-500">Risks</h3>
|
||||
<StringList items={c.risks} emptyText="None noted." />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-500">Opportunities</h3>
|
||||
<StringList items={c.opportunities} emptyText="None noted." />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<Section number={14} title="Important Unknowns">
|
||||
<StringList items={c.unknowns_and_missing_data} emptyText="None noted." />
|
||||
</Section>
|
||||
<Section number={15} title="Monitoring Recommendations">
|
||||
<StringList items={c.monitoring_recommendations} emptyText="None noted." />
|
||||
</Section>
|
||||
<Section number={16} title="Methodology and Limitations">
|
||||
<p>{c.methodology}</p>
|
||||
<p className="mt-2 text-slate-500">{c.limitations}</p>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import clsx from "clsx";
|
||||
import type { CompanyStatus, SeverityLevel } from "@/lib/types";
|
||||
|
||||
const SEVERITY_CLASSES: Record<SeverityLevel, string> = {
|
||||
critical: "bg-severity-critical/10 text-severity-critical",
|
||||
high: "bg-severity-high/10 text-severity-high",
|
||||
medium: "bg-severity-medium/10 text-severity-medium",
|
||||
low: "bg-severity-low/10 text-severity-low",
|
||||
};
|
||||
|
||||
export function SeverityBadge({ severity }: { severity: SeverityLevel }) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize",
|
||||
SEVERITY_CLASSES[severity],
|
||||
)}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_CLASSES: Record<CompanyStatus, string> = {
|
||||
active: "bg-green-100 text-green-800",
|
||||
paused: "bg-slate-200 text-slate-700",
|
||||
};
|
||||
|
||||
export function CompanyStatusBadge({ status }: { status: CompanyStatus }) {
|
||||
return (
|
||||
<span
|
||||
key={status}
|
||||
className={clsx(
|
||||
"inline-flex animate-fade-in items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize",
|
||||
STATUS_CLASSES[status],
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* A company-name pill that links to that company's page if it's already
|
||||
* monitored, or to the add-company wizard (pre-filled) otherwise. Shared
|
||||
* by the company detail page's Overview Competitors section and the
|
||||
* Enrichment tab's Competitors/Customers sections so all three behave
|
||||
* identically.
|
||||
*/
|
||||
export function CompanyPillLink({
|
||||
name,
|
||||
subtitle,
|
||||
monitoredByName,
|
||||
returnTo,
|
||||
}: {
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
monitoredByName: Map<string, string>;
|
||||
returnTo: string;
|
||||
}) {
|
||||
const existingId = monitoredByName.get(name.trim().toLowerCase());
|
||||
const href = existingId
|
||||
? `/companies/${existingId}`
|
||||
: `/companies/new?name=${encodeURIComponent(name)}&returnTo=${encodeURIComponent(returnTo)}`;
|
||||
const title = existingId
|
||||
? `${name} is already being monitored — view it`
|
||||
: `Add ${name} to monitoring`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
className="focus-ring inline-flex items-center gap-1 rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-700 transition-colors duration-150 hover:bg-brand-100 hover:text-brand-700"
|
||||
>
|
||||
{name}
|
||||
{subtitle && <span className="text-slate-400">· {subtitle}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
|
||||
/**
|
||||
* An email address that opens a mail client on click, with a copy button
|
||||
* that fades in only on hover (invisible at rest) so lists of these don't
|
||||
* look cluttered with icons.
|
||||
*/
|
||||
export function CopyableEmail({ email }: { email: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(email);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="group inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
title={copied ? "Copied!" : "Copy email address"}
|
||||
className="focus-ring rounded p-0.5 text-slate-400 opacity-0 transition-opacity duration-200 hover:text-brand-600 group-hover:opacity-100"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-600" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
<a href={`mailto:${email}`} className="text-brand-600 hover:underline">
|
||||
{email}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type InputHTMLAttributes, forwardRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
||||
({ label, error, hint, id, className, ...props }, ref) => {
|
||||
const fieldId = id ?? props.name;
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={fieldId} className="block text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
className={clsx(
|
||||
"focus-ring mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm",
|
||||
error && "border-red-400",
|
||||
className,
|
||||
)}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
aria-describedby={error ? `${fieldId}-error` : hint ? `${fieldId}-hint` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{hint && !error && (
|
||||
<p id={`${fieldId}-hint`} className="mt-1 text-xs text-slate-500">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p id={`${fieldId}-error`} className="mt-1 text-xs text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormField.displayName = "FormField";
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, Minus, Plus } from "lucide-react";
|
||||
import {
|
||||
useCreateNotificationDestination,
|
||||
useUnlinkNotificationDestinationCompany,
|
||||
} from "@/hooks/use-notification-destinations";
|
||||
import type { NotificationDestinationResponse, NotificationType } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* A simplified single-destination view over the full notification
|
||||
* destinations list, scoped to one company - shows the first destination
|
||||
* of `type` linked to this company (if any) with an add/remove control.
|
||||
* Shares the same React Query cache as the Settings page's full
|
||||
* multi-destination UI, so changes here show up there instantly.
|
||||
*/
|
||||
export function NotificationChannelBox({
|
||||
type,
|
||||
label,
|
||||
placeholder,
|
||||
companyId,
|
||||
destination,
|
||||
disabled,
|
||||
disabledReason,
|
||||
}: {
|
||||
type: NotificationType;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
companyId: string;
|
||||
destination: NotificationDestinationResponse | undefined;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const create = useCreateNotificationDestination();
|
||||
const unlink = useUnlinkNotificationDestinationCompany();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700">{label}</label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
value={destination ? destination.destination_value : value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
readOnly={Boolean(destination)}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
className="focus-ring block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm transition-colors duration-150 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
|
||||
/>
|
||||
{destination ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unlink.mutate({ destinationId: destination.id, companyId })}
|
||||
disabled={disabled || unlink.isPending}
|
||||
title={`Remove this ${label.toLowerCase()}`}
|
||||
className="focus-ring inline-flex shrink-0 items-center justify-center rounded-md border border-red-300 p-2 text-red-600 transition-colors duration-150 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{unlink.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Minus className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!value.trim()) return;
|
||||
create.mutate(
|
||||
{ type, destination_value: value.trim(), company_ids: [companyId] },
|
||||
{ onSuccess: () => setValue("") },
|
||||
);
|
||||
}}
|
||||
disabled={disabled || !value.trim() || create.isPending}
|
||||
title={`Add this ${label.toLowerCase()}`}
|
||||
className="focus-ring inline-flex shrink-0 items-center justify-center rounded-md border border-green-300 p-2 text-green-700 transition-colors duration-150 hover:bg-green-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{disabled && disabledReason && <p className="mt-1 text-xs text-slate-500">{disabledReason}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { type InputHTMLAttributes, forwardRef, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface PasswordFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** Same visual language as `FormField`, plus a show/hide toggle - same
|
||||
* crossfade-icon-swap technique as `ApiKeyRow`'s Show/Hide button. */
|
||||
export const PasswordField = forwardRef<HTMLInputElement, PasswordFieldProps>(
|
||||
({ label, error, hint, id, className, ...props }, ref) => {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const fieldId = id ?? props.name;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={fieldId} className="block text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
type={revealed ? "text" : "password"}
|
||||
className={clsx(
|
||||
"focus-ring block w-full rounded-md border border-slate-300 px-3 py-2 pr-10 text-sm shadow-sm transition-colors duration-150",
|
||||
error && "border-red-400",
|
||||
className,
|
||||
)}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
aria-describedby={error ? `${fieldId}-error` : hint ? `${fieldId}-hint` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((r) => !r)}
|
||||
title={revealed ? "Hide password" : "Show password"}
|
||||
tabIndex={-1}
|
||||
className="focus-ring absolute inset-y-0 right-0 flex w-9 items-center justify-center text-slate-400 transition-colors duration-150 hover:text-slate-600"
|
||||
>
|
||||
<span className="relative block h-4 w-4">
|
||||
<Eye
|
||||
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-0" : "opacity-100"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<EyeOff
|
||||
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-100" : "opacity-0"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{hint && !error && (
|
||||
<p id={`${fieldId}-hint`} className="mt-1 text-xs text-slate-500">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
id={`${fieldId}-error`}
|
||||
className="mt-1 animate-fade-in text-xs text-red-600"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
PasswordField.displayName = "PasswordField";
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
const LEVELS = [
|
||||
{ label: "Weak", bar: "bg-red-500", text: "text-red-600" },
|
||||
{ label: "Fair", bar: "bg-amber-500", text: "text-amber-600" },
|
||||
{ label: "Good", bar: "bg-brand-500", text: "text-brand-600" },
|
||||
{ label: "Strong", bar: "bg-green-500", text: "text-green-600" },
|
||||
] as const;
|
||||
|
||||
/** A purely visual affordance - length/variety heuristics scored 0-4, shown
|
||||
* as an animated bar. Doesn't gate submission or loosen the real policy
|
||||
* (still enforced by the password field's own Zod schema). */
|
||||
function scorePassword(password: string): number {
|
||||
if (!password) return 0;
|
||||
let score = 0;
|
||||
if (password.length >= 10) score += 1;
|
||||
if (password.length >= 14) score += 1;
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score += 1;
|
||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score += 1;
|
||||
return Math.min(score, 4);
|
||||
}
|
||||
|
||||
export function PasswordStrengthMeter({ password }: { password: string }) {
|
||||
const score = scorePassword(password);
|
||||
if (!password) return null;
|
||||
|
||||
const level = LEVELS[Math.max(score - 1, 0)] ?? LEVELS[0];
|
||||
const widthPercent = (score / 4) * 100;
|
||||
|
||||
return (
|
||||
<div className="mt-2 animate-fade-in">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width,background-color] duration-300 ${level.bar}`}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p key={level.label} className={`mt-1 animate-fade-in text-xs ${level.text}`}>
|
||||
{level.label}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import clsx from "clsx";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Radix disallows an empty-string Item value (it's reserved to mean "no
|
||||
// selection" internally) - but several call sites here use "" to mean "all"
|
||||
// / "no filter". This sentinel maps "" <-> a real string at the Radix
|
||||
// boundary only, so callers can keep using "" as usual.
|
||||
const EMPTY_VALUE_SENTINEL = "__select-empty__";
|
||||
|
||||
/**
|
||||
* Custom dropdown replacing native <select> everywhere in the app. Native
|
||||
* select option lists are rendered by the OS/browser itself and cannot be
|
||||
* animated with CSS/JS in any browser - this is a real component (Radix's
|
||||
* accessible, keyboard-navigable primitive) so the open/close transition
|
||||
* (see select-in/select-out in tailwind.config.ts) actually applies.
|
||||
*/
|
||||
export function Select({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder,
|
||||
name,
|
||||
ariaLabel,
|
||||
triggerClassName,
|
||||
}: {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
name?: string;
|
||||
ariaLabel?: string;
|
||||
triggerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Root
|
||||
value={value === "" ? EMPTY_VALUE_SENTINEL : value}
|
||||
onValueChange={(v) => onValueChange(v === EMPTY_VALUE_SENTINEL ? "" : v)}
|
||||
name={name}
|
||||
>
|
||||
<SelectPrimitive.Trigger
|
||||
aria-label={ariaLabel}
|
||||
className={clsx(
|
||||
"focus-ring flex items-center justify-between gap-2 rounded-md border border-slate-300 bg-white text-left shadow-sm outline-none data-[placeholder]:text-slate-400",
|
||||
triggerClassName ?? "px-3 py-2 text-sm",
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value placeholder={placeholder} />
|
||||
<SelectPrimitive.Icon>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
className="z-50 overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg data-[state=closed]:animate-select-out data-[state=open]:animate-select-in"
|
||||
>
|
||||
<SelectPrimitive.Viewport className="p-1">
|
||||
{options.map((option) => (
|
||||
<SelectPrimitive.Item
|
||||
key={option.value}
|
||||
value={option.value === "" ? EMPTY_VALUE_SENTINEL : option.value}
|
||||
className="relative flex cursor-pointer select-none items-center rounded px-2 py-1.5 pl-7 text-sm text-slate-700 outline-none data-[highlighted]:bg-brand-50 data-[highlighted]:text-brand-700"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator className="absolute left-2 inline-flex items-center">
|
||||
<Check className="h-3.5 w-3.5" aria-hidden />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
<SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-500">{label}</span>
|
||||
<Icon className="h-4 w-4 text-slate-400" aria-hidden />
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-semibold text-slate-900">{value}</p>
|
||||
{hint && <p className="mt-1 text-xs text-slate-500">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Eye, EyeOff, Loader2, Save } from "lucide-react";
|
||||
import { useSetSystemSecret } from "@/hooks/use-auth";
|
||||
import type { SystemSecretStatus } from "@/lib/types";
|
||||
|
||||
/** One server-wide secret's admin-editable row - label, masked textbox with
|
||||
* a hide/unhide toggle, and a blue Update button, using the same
|
||||
* crossfade-icon transition language as UserApiKeyRow. Unlike that
|
||||
* component, this is one shared value for the whole app (Turnstile site
|
||||
* key/secret today), not scoped to the calling user. */
|
||||
export function SystemSecretRow({ secret }: { secret: SystemSecretStatus }) {
|
||||
const [value, setValue] = useState(secret.value ?? "");
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const savedTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const setSecret = useSetSystemSecret();
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (savedTimeout.current) clearTimeout(savedTimeout.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dirty = value !== (secret.value ?? "");
|
||||
|
||||
const handleUpdate = () => {
|
||||
setSecret.mutate(
|
||||
{ key: secret.key, payload: { value } },
|
||||
{
|
||||
onSuccess: (updated) => {
|
||||
setValue(updated.value ?? "");
|
||||
setJustSaved(true);
|
||||
if (savedTimeout.current) clearTimeout(savedTimeout.current);
|
||||
savedTimeout.current = setTimeout(() => setJustSaved(false), 1800);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700">{secret.label}</label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type={revealed ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={secret.configured ? undefined : "Not set"}
|
||||
className="focus-ring block w-full rounded-md border border-slate-300 px-3 py-2 font-mono text-sm text-slate-900 shadow-sm transition-colors duration-150 placeholder:font-sans placeholder:text-slate-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((r) => !r)}
|
||||
title={revealed ? "Hide key" : "Show key"}
|
||||
className="focus-ring inline-flex shrink-0 items-center justify-center rounded-md border border-slate-300 p-2 text-slate-500 transition-colors duration-200 hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
|
||||
>
|
||||
<span className="relative block h-4 w-4">
|
||||
<Eye
|
||||
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-0" : "opacity-100"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<EyeOff
|
||||
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-100" : "opacity-0"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdate}
|
||||
disabled={!dirty || setSecret.isPending}
|
||||
title="Update key"
|
||||
className="focus-ring inline-flex shrink-0 items-center gap-1.5 rounded-md bg-brand-600 px-3 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="relative block h-4 w-4">
|
||||
<Save
|
||||
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${
|
||||
setSecret.isPending || justSaved ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<Loader2
|
||||
className={`absolute inset-0 h-4 w-4 animate-spin transition-opacity duration-200 ${
|
||||
setSecret.isPending ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<Check
|
||||
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${
|
||||
justSaved && !setSecret.isPending ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
{setSecret.isError && (
|
||||
<p className="mt-1 text-xs text-red-600">Couldn't save that key. Try again.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import Script from "next/script";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
|
||||
interface TurnstileRenderOptions {
|
||||
sitekey: string;
|
||||
action?: string;
|
||||
callback: (token: string) => void;
|
||||
"error-callback"?: () => void;
|
||||
"expired-callback"?: () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (container: HTMLElement, options: TurnstileRenderOptions) => string;
|
||||
reset: (widgetId?: string) => void;
|
||||
remove: (widgetId?: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface TurnstileWidgetHandle {
|
||||
/** Tokens are single-use - call this after any failed submission before
|
||||
* retrying, or the retry is rejected as timeout-or-duplicate. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface TurnstileWidgetProps {
|
||||
/** Sourced live from useSystemStatus()'s turnstile_site_key - not a
|
||||
* build-time env var, so an admin-updated key (see the Settings page's
|
||||
* Server secrets box) takes effect without rebuilding the frontend. */
|
||||
siteKey: string | null | undefined;
|
||||
onToken: (token: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TurnstileWidget = forwardRef<TurnstileWidgetHandle, TurnstileWidgetProps>(
|
||||
function TurnstileWidget({ siteKey, onToken, className }, ref) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
const onTokenRef = useRef(onToken);
|
||||
const [scriptLoaded, setScriptLoaded] = useState(
|
||||
() => typeof window !== "undefined" && !!window.turnstile,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onTokenRef.current = onToken;
|
||||
}, [onToken]);
|
||||
|
||||
// next/script's onLoad only reliably fires for the mount that actually
|
||||
// injects the tag - a later page that mounts this same widget after an
|
||||
// earlier page already loaded the Cloudflare script (same src, so Next
|
||||
// skips re-injecting it) can otherwise wait on an onLoad that never
|
||||
// comes. Poll briefly as a fallback for that case.
|
||||
useEffect(() => {
|
||||
if (scriptLoaded) return;
|
||||
const interval = setInterval(() => {
|
||||
if (window.turnstile) {
|
||||
setScriptLoaded(true);
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [scriptLoaded]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset: () => {
|
||||
if (window.turnstile && widgetIdRef.current) {
|
||||
window.turnstile.reset(widgetIdRef.current);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!scriptLoaded || !containerRef.current || !window.turnstile || !siteKey) return;
|
||||
// Explicit render, not implicit auto-render, into our own ref'd div.
|
||||
const widgetId = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
action: "turnstile-spin-v2",
|
||||
callback: (token) => onTokenRef.current(token),
|
||||
});
|
||||
widgetIdRef.current = widgetId;
|
||||
return () => {
|
||||
window.turnstile?.remove(widgetId);
|
||||
widgetIdRef.current = null;
|
||||
};
|
||||
}, [scriptLoaded, siteKey]);
|
||||
|
||||
if (!siteKey) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
|
||||
strategy="afterInteractive"
|
||||
onLoad={() => setScriptLoaded(true)}
|
||||
/>
|
||||
<div ref={containerRef} className={className} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Eye, EyeOff, Loader2, Save } from "lucide-react";
|
||||
import { useSetUserApiKey } from "@/hooks/use-auth";
|
||||
import type { UserApiKeyStatus } from "@/lib/types";
|
||||
|
||||
/** One provider's own-key row: label, editable masked textbox with a
|
||||
* hide/unhide toggle, credit/free-tier info, and a blue Update button - all
|
||||
* with the same crossfade-icon transition language as the admin-only
|
||||
* server-key box's Show/Hide toggle (api-key-row.tsx). Unlike that box,
|
||||
* this one is per-user and editable: each user only ever sees/sets their
|
||||
* own key here, never anyone else's.
|
||||
*
|
||||
* `liveCredits`, when passed, overrides the number shown for the numeric
|
||||
* "N credits remaining" line - used for NinjaPear, which sources its
|
||||
* balance from useSystemStatus()'s already-fetched ninjapear_credit_balance
|
||||
* (the same number shown in System configuration below) rather than this
|
||||
* component making its own separate, redundant live call. */
|
||||
export function UserApiKeyRow({
|
||||
apiKey,
|
||||
liveCredits,
|
||||
}: {
|
||||
apiKey: UserApiKeyStatus;
|
||||
liveCredits?: number | null;
|
||||
}) {
|
||||
const [value, setValue] = useState(apiKey.value ?? "");
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const savedTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const setKey = useSetUserApiKey();
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (savedTimeout.current) clearTimeout(savedTimeout.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dirty = value !== (apiKey.value ?? "");
|
||||
const credits = liveCredits !== undefined ? liveCredits : apiKey.credits;
|
||||
|
||||
const handleUpdate = () => {
|
||||
setKey.mutate(
|
||||
{ provider: apiKey.provider, payload: { key: value } },
|
||||
{
|
||||
onSuccess: (updated) => {
|
||||
setValue(updated.value ?? "");
|
||||
setJustSaved(true);
|
||||
if (savedTimeout.current) clearTimeout(savedTimeout.current);
|
||||
savedTimeout.current = setTimeout(() => setJustSaved(false), 1800);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<label className="block text-sm font-medium text-slate-700">{apiKey.label}</label>
|
||||
{apiKey.free && (
|
||||
<span className="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-[11px] font-medium text-green-700">
|
||||
Free{apiKey.requires_government_id ? " · requires government ID approval" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type={revealed ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={apiKey.configured ? undefined : "Not set"}
|
||||
className="focus-ring block w-full rounded-md border border-slate-300 px-3 py-2 font-mono text-sm text-slate-900 shadow-sm transition-colors duration-150 placeholder:font-sans placeholder:text-slate-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((r) => !r)}
|
||||
title={revealed ? "Hide key" : "Show key"}
|
||||
className="focus-ring inline-flex shrink-0 items-center justify-center rounded-md border border-slate-300 p-2 text-slate-500 transition-colors duration-200 hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
|
||||
>
|
||||
<span className="relative block h-4 w-4">
|
||||
<Eye
|
||||
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-0" : "opacity-100"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<EyeOff
|
||||
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-100" : "opacity-0"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdate}
|
||||
disabled={!dirty || setKey.isPending}
|
||||
title="Update key"
|
||||
className="focus-ring inline-flex shrink-0 items-center gap-1.5 rounded-md bg-brand-600 px-3 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="relative block h-4 w-4">
|
||||
<Save
|
||||
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${
|
||||
setKey.isPending || justSaved ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<Loader2
|
||||
className={`absolute inset-0 h-4 w-4 animate-spin transition-opacity duration-200 ${
|
||||
setKey.isPending ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<Check
|
||||
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${
|
||||
justSaved && !setKey.isPending ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{apiKey.configured
|
||||
? credits !== null && credits !== undefined
|
||||
? `${credits} credits remaining. ${apiKey.credits_note ?? ""}`
|
||||
: (apiKey.credits_note ?? "")
|
||||
: (apiKey.credits_note ?? "")}
|
||||
</p>
|
||||
{setKey.isError && (
|
||||
<p className="mt-1 text-xs text-red-600">Couldn't save that key. Try again.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user