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:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
@@ -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&apos;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>
);
}
+22
View File
@@ -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&apos;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>
);
}
+19
View File
@@ -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>
);
}
+249
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+84
View File
@@ -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>
);
}
+24
View File
@@ -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&apos;t save that key. Try again.</p>
)}
</div>
);
}
+104
View File
@@ -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} />
</>
);
},
);
+134
View File
@@ -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&apos;t save that key. Try again.</p>
)}
</div>
);
}