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
+6
View File
@@ -0,0 +1,6 @@
{
"extends": ["next/core-web-vitals"],
"rules": {
"@next/next/no-html-link-for-pages": "off"
}
}
+7
View File
@@ -0,0 +1,7 @@
.next/
node_modules/
next-env.d.ts
tsconfig.json
coverage/
playwright-report/
test-results/
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useParams } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Mail, MessageSquare, Terminal } from "lucide-react";
import { SeverityBadge } from "@/components/ui/badge";
import { useAlert, useMarkAlertRead, useResolveAlert } from "@/hooks/use-alerts";
import { useCompany } from "@/hooks/use-companies";
import { formatDateTime } from "@/lib/format";
import type { NotificationDeliveryStatus } from "@/lib/types";
const DELIVERY_STATUS_CLASSES: Record<NotificationDeliveryStatus, string> = {
sent: "bg-green-100 text-green-800",
failed: "bg-red-100 text-red-800",
pending: "bg-slate-200 text-slate-700",
};
const PROVIDER_ICON = {
smtp: Mail,
twilio_sms: MessageSquare,
console: Terminal,
} as const;
export default function AlertDetailPage() {
const params = useParams<{ id: string }>();
const { data: alert, isLoading } = useAlert(params.id);
const { data: company } = useCompany(alert?.company_id);
const markRead = useMarkAlertRead();
const resolve = useResolveAlert();
if (isLoading || !alert) {
return <p className="text-sm text-slate-500">Loading</p>;
}
return (
<div>
<Link
href="/alerts"
className="inline-flex items-center gap-1 text-sm font-medium text-slate-600 hover:text-slate-900"
>
<ArrowLeft className="h-4 w-4" aria-hidden /> Back to alerts
</Link>
<div className="mt-4 rounded-lg border border-slate-200 bg-white p-6">
<div className="flex flex-wrap items-center gap-2">
<SeverityBadge severity={alert.severity} />
{!alert.read && (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">
Unread
</span>
)}
{alert.resolved && (
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-xs font-medium text-slate-700">
Resolved
</span>
)}
<span className="text-xs text-slate-500">
{Math.round(alert.confidence * 100)}% confidence
</span>
</div>
<h1 className="mt-3 text-xl font-semibold text-slate-900">{alert.title}</h1>
<p className="mt-1 text-sm text-slate-500">
{company ? (
<Link href={`/companies/${company.id}`} className="hover:text-brand-600">
{company.name}
</Link>
) : (
"—"
)}{" "}
· {formatDateTime(alert.created_at)}
</p>
<div className="mt-6 grid gap-6 sm:grid-cols-2">
<div>
<h2 className="text-sm font-semibold text-slate-700">What changed</h2>
<p className="mt-1 text-sm text-slate-600">{alert.summary}</p>
</div>
<div>
<h2 className="text-sm font-semibold text-slate-700">Why it matters</h2>
<p className="mt-1 text-sm text-slate-600">{alert.why_it_matters}</p>
</div>
</div>
<div className="mt-6 flex gap-2">
{!alert.read && (
<button
onClick={() => markRead.mutate(alert.id)}
className="focus-ring rounded-md border border-slate-300 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Mark read
</button>
)}
{!alert.resolved && (
<button
onClick={() => resolve.mutate(alert.id)}
className="focus-ring rounded-md bg-brand-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-700"
>
Resolve
</button>
)}
</div>
</div>
<div className="mt-6 rounded-lg border border-slate-200 bg-white p-6">
<h2 className="text-sm font-semibold text-slate-700">Notification deliveries</h2>
{alert.deliveries.length === 0 ? (
<p className="mt-2 text-sm text-slate-500">
No destinations were notified for this alert.
</p>
) : (
<ul className="mt-3 divide-y divide-slate-100">
{alert.deliveries.map((delivery) => {
const Icon =
PROVIDER_ICON[delivery.provider as keyof typeof PROVIDER_ICON] ?? Terminal;
return (
<li key={delivery.id} className="flex items-center justify-between py-3 text-sm">
<div className="flex items-center gap-2 text-slate-700">
<Icon className="h-4 w-4 text-slate-400" aria-hidden />
<span className="capitalize">{delivery.provider.replace("_", " ")}</span>
{delivery.error_message && (
<span className="text-xs text-red-600">{delivery.error_message}</span>
)}
</div>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize ${DELIVERY_STATUS_CLASSES[delivery.status]}`}
>
{delivery.status}
</span>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { AlertTriangle, Check, CheckCheck } from "lucide-react";
import { SeverityBadge } from "@/components/ui/badge";
import { Select } from "@/components/ui/select";
import { useAlerts, useMarkAlertRead, useResolveAlert } from "@/hooks/use-alerts";
import { useCompanies } from "@/hooks/use-companies";
import { formatRelative } from "@/lib/format";
import { SEVERITY_LABELS, SEVERITY_LEVELS, type SeverityLevel } from "@/lib/types";
export default function AlertsPage() {
const [companyId, setCompanyId] = useState<string>("");
const [severity, setSeverity] = useState<SeverityLevel | "">("");
const [unreadOnly, setUnreadOnly] = useState(false);
const [showResolved, setShowResolved] = useState(false);
const { data: companies } = useCompanies();
const { data: alerts, isLoading } = useAlerts({
company_id: companyId || undefined,
severity: severity || undefined,
read: unreadOnly ? false : undefined,
resolved: showResolved ? undefined : false,
});
const markRead = useMarkAlertRead();
const resolve = useResolveAlert();
const companyNameById = useMemo(
() => new Map((companies ?? []).map((c) => [c.id, c.name])),
[companies],
);
const alertList = alerts ?? [];
return (
<div>
<div>
<h1 className="text-2xl font-semibold text-slate-900">Alerts</h1>
<p className="mt-1 text-sm text-slate-600">
Meaningful changes detected across your monitored companies.
</p>
</div>
<div className="mt-6 flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 bg-white p-4">
<Select
value={companyId}
onValueChange={setCompanyId}
placeholder="All companies"
ariaLabel="Filter by company"
options={[
{ value: "", label: "All companies" },
...(companies ?? []).map((c) => ({ value: c.id, label: c.name })),
]}
triggerClassName="px-3 py-1.5 text-sm"
/>
<Select
value={severity}
onValueChange={(v) => setSeverity(v as SeverityLevel | "")}
placeholder="All severities"
ariaLabel="Filter by severity"
options={[
{ value: "", label: "All severities" },
...SEVERITY_LEVELS.map((s) => ({ value: s, label: SEVERITY_LABELS[s] })),
]}
triggerClassName="px-3 py-1.5 text-sm"
/>
<label className="flex items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
checked={unreadOnly}
onChange={(e) => setUnreadOnly(e.target.checked)}
className="focus-ring rounded border-slate-300"
/>
Unread only
</label>
<label className="flex items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
checked={showResolved}
onChange={(e) => setShowResolved(e.target.checked)}
className="focus-ring rounded border-slate-300"
/>
Show resolved
</label>
</div>
{isLoading ? (
<p className="mt-8 text-sm text-slate-500">Loading</p>
) : alertList.length === 0 ? (
<div className="mt-8 rounded-lg border border-dashed border-slate-300 bg-white p-10 text-center">
<AlertTriangle className="mx-auto h-8 w-8 text-slate-400" aria-hidden />
<p className="mt-3 text-sm text-slate-600">No alerts match these filters.</p>
</div>
) : (
<ul className="mt-6 divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white">
{alertList.map((alert) => (
<li key={alert.id} className="flex items-start justify-between gap-4 px-4 py-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<SeverityBadge severity={alert.severity} />
{!alert.read && (
<span className="h-1.5 w-1.5 rounded-full bg-brand-600" aria-label="Unread" />
)}
{alert.resolved && (
<span className="text-xs font-medium text-slate-400">Resolved</span>
)}
</div>
<Link
href={`/alerts/${alert.id}`}
className="mt-1 block truncate font-medium text-slate-900 hover:text-brand-600"
>
{alert.title}
</Link>
<p className="mt-0.5 text-sm text-slate-600">
{companyNameById.get(alert.company_id) ?? "Unknown company"} ·{" "}
{formatRelative(alert.created_at)} · {Math.round(alert.confidence * 100)}%
confidence
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{!alert.read && (
<button
title="Mark read"
onClick={() => markRead.mutate(alert.id)}
className="focus-ring rounded-md p-2 text-slate-500 hover:bg-slate-100"
>
<Check className="h-4 w-4" aria-hidden />
<span className="sr-only">Mark read</span>
</button>
)}
{!alert.resolved && (
<button
title="Resolve"
onClick={() => resolve.mutate(alert.id)}
className="focus-ring rounded-md p-2 text-slate-500 hover:bg-slate-100"
>
<CheckCheck className="h-4 w-4" aria-hidden />
<span className="sr-only">Resolve</span>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+680
View File
@@ -0,0 +1,680 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Loader2 } from "lucide-react";
import { z } from "zod";
import { FormField } from "@/components/ui/form-field";
import { Select } from "@/components/ui/select";
import { api } from "@/lib/api-client";
import { useCompanies, useCreateCompany } from "@/hooks/use-companies";
import { useCreateNotificationDestination } from "@/hooks/use-notification-destinations";
import { useDiscoverCompany } from "@/hooks/use-discovery";
import { authErrorMessage, useSystemStatus } from "@/hooks/use-auth";
import {
FREQUENCY_LABELS,
MONITORING_FREQUENCIES,
SEVERITY_LABELS,
SEVERITY_LEVELS,
type CompanyResponse,
type DiscoveredCompanyProfile,
type MonitoringFrequency,
type SeverityLevel,
} from "@/lib/types";
const _LEGAL_SUFFIX_RE = /\b(inc|incorporated|llc|ltd|limited|corp|corporation|co|company|plc)\b/g;
function normalizeCompanyName(name: string): string {
return name
.toLowerCase()
.replace(/[.,]/g, "")
.replace(_LEGAL_SUFFIX_RE, "")
.replace(/\s+/g, " ")
.trim();
}
function findPossibleDuplicate(
name: string,
companies: CompanyResponse[],
): CompanyResponse | undefined {
const normalized = normalizeCompanyName(name);
if (!normalized) return undefined;
return companies.find((c) => {
const existing = normalizeCompanyName(c.name);
return (
existing.length > 0 &&
(existing === normalized || existing.includes(normalized) || normalized.includes(existing))
);
});
}
const companySchema = z
.object({
name: z.string().min(1, "Company name is required").max(200),
officialWebsite: z.string().max(500).optional().or(z.literal("")),
industry: z.string().max(120).optional().or(z.literal("")),
country: z.string().max(120).optional().or(z.literal("")),
region: z.string().max(120).optional().or(z.literal("")),
headquarters: z.string().max(200).optional().or(z.literal("")),
description: z.string().max(4000).optional().or(z.literal("")),
monitoringFocus: z.string().max(2000).optional().or(z.literal("")),
competitorNames: z.string().optional().or(z.literal("")),
aliasNames: z.string().optional().or(z.literal("")),
frequencyType: z.enum(MONITORING_FREQUENCIES),
intervalMinutes: z.coerce.number().int().positive().optional(),
cronExpression: z.string().max(120).optional().or(z.literal("")),
timezone: z.string().min(1).max(64),
severityThreshold: z.enum(SEVERITY_LEVELS),
notificationEmail: z.string().email("Enter a valid email address"),
consent: z.literal(true, {
errorMap: () => ({ message: "You must acknowledge the collection policy to continue" }),
}),
})
.refine(
(data) =>
data.frequencyType !== "custom" ||
Boolean(data.intervalMinutes) ||
Boolean(data.cronExpression),
{
message: "Provide either an interval in minutes or a cron expression for a custom schedule",
path: ["intervalMinutes"],
},
);
type CompanyForm = z.infer<typeof companySchema>;
const STEPS = ["Discover", "Review", "Schedule", "Notifications", "Confirm"] as const;
const STEP_FIELDS: Record<number, (keyof CompanyForm)[]> = {
0: ["name"],
1: [],
2: ["frequencyType", "intervalMinutes", "cronExpression", "timezone"],
3: ["severityThreshold", "notificationEmail", "consent"],
4: [],
};
function splitNames(value: string | undefined): string[] {
return (value ?? "")
.split(",")
.map((v) => v.trim())
.filter(Boolean);
}
const MONITORING_FOCUS_PLACEHOLDER =
"E.g. new product or pricing changes, leadership hires/departures, hiring trends, " +
"funding/financial signals, partnerships and acquisitions, patents, manufacturing or " +
"expansion moves, regulatory or legal news, and how they compare to competitors.";
function AddCompanyWizard() {
const router = useRouter();
const searchParams = useSearchParams();
const prefillName = searchParams.get("name") ?? "";
const returnTo = searchParams.get("returnTo");
const [step, setStep] = useState(0);
const [discovery, setDiscovery] = useState<DiscoveredCompanyProfile | null>(null);
const [isFinalizing, setIsFinalizing] = useState(false);
const [duplicateWarning, setDuplicateWarning] = useState<{
company: CompanyResponse;
forName: string;
} | null>(null);
const [acknowledgedDuplicateFor, setAcknowledgedDuplicateFor] = useState<string | null>(null);
const { data: allCompanies } = useCompanies();
const createCompany = useCreateCompany();
const createDestination = useCreateNotificationDestination();
const discoverCompany = useDiscoverCompany();
const { data: systemStatus } = useSystemStatus();
const {
register,
handleSubmit,
control,
watch,
setValue,
getValues,
trigger,
formState: { errors },
} = useForm<CompanyForm>({
resolver: zodResolver(companySchema),
defaultValues: {
name: prefillName,
frequencyType: "weekly",
timezone: "America/New_York",
severityThreshold: "medium",
},
});
const frequencyType = watch("frequencyType");
const goNext = async () => {
const valid = await trigger(STEP_FIELDS[step]);
if (valid) setStep((s) => Math.min(s + 1, STEPS.length - 1));
};
const goBack = () => {
if (step === 0) {
// Nothing earlier in the wizard to step back to - leave to wherever
// the user actually came from (Dashboard, Companies, or a rival
// company's page via the "add this competitor" link). A `returnTo`
// param (set by the company page's competitor/customer pills) is
// preferred over plain browser-history back, since a client
// component's local state (e.g. which tab was open) doesn't reliably
// survive a back-navigation in the App Router.
if (returnTo) {
router.push(returnTo);
return;
}
router.back();
return;
}
setStep((s) => Math.max(s - 1, 0));
};
const handleDiscover = async () => {
const valid = await trigger(["name"]);
if (!valid) return;
const values = getValues();
if (acknowledgedDuplicateFor !== values.name) {
const possibleDuplicate = findPossibleDuplicate(values.name, allCompanies ?? []);
if (possibleDuplicate) {
setDuplicateWarning({ company: possibleDuplicate, forName: values.name });
return;
}
}
setDuplicateWarning(null);
const profile = await discoverCompany.mutateAsync({
name: values.name,
official_website: values.officialWebsite || undefined,
monitoring_focus: values.monitoringFocus || undefined,
competitor_names: splitNames(values.competitorNames),
alias_names: splitNames(values.aliasNames),
});
setDiscovery(profile);
setValue("officialWebsite", profile.official_website ?? "");
setValue("description", profile.description ?? "");
setValue("industry", profile.industry ?? "");
setValue("country", profile.country ?? "");
setValue("region", profile.region ?? "");
setValue("headquarters", profile.headquarters ?? "");
setValue("monitoringFocus", profile.monitoring_focus ?? values.monitoringFocus ?? "");
setValue("competitorNames", profile.competitors.join(", "));
setValue("aliasNames", profile.aliases.join(", "));
setStep(1);
};
const onSubmit = handleSubmit(async (values) => {
setIsFinalizing(true);
try {
const company = await createCompany.mutateAsync({
name: values.name,
official_website: values.officialWebsite || null,
industry: values.industry || null,
country: values.country || null,
region: values.region || null,
headquarters: values.headquarters || null,
public_identifiers: discovery?.public_identifiers ?? {},
description: values.description || null,
monitoring_focus: values.monitoringFocus || null,
competitor_names: splitNames(values.competitorNames),
alias_names: splitNames(values.aliasNames),
frequency_type: values.frequencyType,
interval_minutes:
values.frequencyType === "custom" ? (values.intervalMinutes ?? null) : null,
cron_expression: values.frequencyType === "custom" ? values.cronExpression || null : null,
timezone: values.timezone,
severity_threshold: values.severityThreshold,
});
await createDestination
.mutateAsync({
type: "email",
destination_value: values.notificationEmail,
company_ids: [company.id],
})
.catch(() => undefined); // Best-effort; company creation still succeeded either way.
// Best-effort, same as the destination above - a failed enqueue here
// shouldn't block landing on the new company's page, it just means
// the user sees an idle Run Now button instead of the running state.
await api.runCompanyNow(company.id).catch(() => undefined);
router.push(`/companies/${company.id}`);
} catch {
setIsFinalizing(false);
}
});
return (
<div className="mx-auto max-w-2xl">
<h1 className="text-2xl font-semibold text-slate-900">Add a company</h1>
<p className="mt-1 text-sm text-slate-600">
Give us a name we&apos;ll find the rest and let you correct anything before we start
monitoring.
</p>
<ol className="mt-6 flex items-center gap-2 text-xs font-medium text-slate-500">
{STEPS.map((label, i) => (
<li
key={label}
className={`flex items-center gap-2 transition-colors duration-300 ${i === step ? "text-brand-600" : ""}`}
>
<span
className={`flex h-5 w-5 items-center justify-center rounded-full border text-[11px] transition-all duration-300 ${
i === step
? "scale-110 border-brand-600 bg-brand-600 text-white"
: i < step
? "border-brand-600 text-brand-600"
: "border-slate-300"
}`}
>
{i + 1}
</span>
{label}
{i < STEPS.length - 1 && <span className="mx-1 text-slate-300">/</span>}
</li>
))}
</ol>
<form
onSubmit={onSubmit}
className="mt-6 space-y-5 rounded-lg border border-slate-200 bg-white p-6"
noValidate
>
<div key={step} className="animate-fade-in space-y-5">
{step === 0 && (
<div className="space-y-4">
<FormField label="Company name" {...register("name")} error={errors.name?.message} />
{duplicateWarning && duplicateWarning.forName === watch("name") && (
<div className="animate-fade-in rounded-md border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
<p>
You might already be monitoring{" "}
<strong>&quot;{duplicateWarning.company.name}&quot;</strong>. Adding another
with a similar name is fine, but double-check it&apos;s not the same company.
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Link
href={`/companies/${duplicateWarning.company.id}`}
className="focus-ring rounded-md border border-amber-400 bg-white px-3 py-1.5 text-xs font-semibold text-amber-900 hover:bg-amber-100"
>
View existing company
</Link>
<button
type="button"
onClick={() => {
setAcknowledgedDuplicateFor(duplicateWarning.forName);
setDuplicateWarning(null);
}}
className="focus-ring rounded-md px-3 py-1.5 text-xs font-semibold text-amber-900 underline decoration-amber-400 underline-offset-2 hover:bg-amber-100"
>
Continue anyway
</button>
</div>
</div>
)}
<div className="rounded-md border border-dashed border-slate-300 p-4">
<p className="text-sm font-medium text-slate-700">
Optional helps discovery be more accurate
</p>
<div className="mt-3 space-y-4">
<FormField
label="Official website"
placeholder="example.com"
hint="If you know it, we'll skip searching for it."
{...register("officialWebsite")}
error={errors.officialWebsite?.message}
/>
<div>
<label
htmlFor="monitoringFocus"
className="block text-sm font-medium text-slate-700"
>
What do you want to know?
</label>
<textarea
id="monitoringFocus"
rows={3}
placeholder={MONITORING_FOCUS_PLACEHOLDER}
className="focus-ring mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm"
{...register("monitoringFocus")}
/>
</div>
<FormField
label="Known competitors (comma-separated)"
placeholder="Rival Motors, Other Corp"
{...register("competitorNames")}
/>
<FormField
label="Known aliases (comma-separated)"
placeholder="Brand name, subsidiary name"
{...register("aliasNames")}
/>
</div>
</div>
{discoverCompany.isError && (
<p className="text-sm text-red-600" role="alert">
{authErrorMessage(discoverCompany.error)}
</p>
)}
</div>
)}
{step === 1 && (
<div className="space-y-4">
<p className="text-sm text-slate-600">
Here&apos;s what we found for <strong>{watch("name")}</strong>. Edit anything before
continuing fields we couldn&apos;t find are left blank for you to fill in.
</p>
<FormField
label="Official website"
placeholder="example.com"
{...register("officialWebsite")}
error={errors.officialWebsite?.message}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
label="Industry"
placeholder="Not found — fill in"
{...register("industry")}
/>
<FormField
label="Headquarters"
placeholder="Not found — fill in"
{...register("headquarters")}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
label="Country"
placeholder="Not found — fill in"
{...register("country")}
/>
<FormField
label="Region"
placeholder="Not found — fill in"
{...register("region")}
/>
</div>
<FormField
label="Competitors (comma-separated)"
placeholder="Rival Motors, Other Corp"
{...register("competitorNames")}
/>
<FormField
label="Known aliases (comma-separated)"
placeholder="Brand name, subsidiary name"
{...register("aliasNames")}
/>
<div>
<label htmlFor="description" className="block text-sm font-medium text-slate-700">
Description
</label>
<textarea
id="description"
rows={2}
placeholder="Not found — fill in"
className="focus-ring mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm"
{...register("description")}
/>
</div>
{discovery && discovery.potential_sources.length > 0 && (
<div className="rounded-md bg-slate-50 p-4">
<p className="text-sm font-medium text-slate-700">
We&apos;ll start monitoring {discovery.potential_sources.length} source
{discovery.potential_sources.length === 1 ? "" : "s"}
</p>
<ul className="mt-2 space-y-1 text-xs text-slate-600">
{discovery.potential_sources.map((source, i) => (
<li key={i}>
{source.name} {source.base_url ?? "n/a"}
</li>
))}
</ul>
</div>
)}
{discovery && Object.keys(discovery.public_identifiers).length > 0 && (
<p className="text-xs text-slate-500">
{Object.entries(discovery.public_identifiers).map(([key, value]) => (
<span key={key} className="mr-3">
{key}: {value}
</span>
))}
</p>
)}
{discovery && discovery.sources_consulted.length > 0 && (
<p className="text-xs text-slate-400">
Sources consulted: {discovery.sources_consulted.join(", ")}
</p>
)}
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700">
Monitoring frequency
</label>
<div className="mt-1">
<Select
value={frequencyType}
onValueChange={(v) => setValue("frequencyType", v as MonitoringFrequency)}
options={MONITORING_FREQUENCIES.map((f: MonitoringFrequency) => ({
value: f,
label: FREQUENCY_LABELS[f],
}))}
triggerClassName="w-full px-3 py-2 text-sm"
/>
</div>
</div>
{frequencyType === "custom" && (
<div className="grid grid-cols-2 gap-4">
<FormField
label="Interval (minutes)"
type="number"
{...register("intervalMinutes")}
error={errors.intervalMinutes?.message}
/>
<FormField
label="Cron expression (advanced)"
placeholder="0 9 * * MON"
{...register("cronExpression")}
/>
</div>
)}
<FormField
label="Timezone"
hint="IANA timezone name, e.g. America/New_York"
{...register("timezone")}
error={errors.timezone?.message}
/>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700">
Alert severity threshold
</label>
<div className="mt-1">
<Select
value={watch("severityThreshold")}
onValueChange={(v) => setValue("severityThreshold", v as SeverityLevel)}
options={SEVERITY_LEVELS.map((s: SeverityLevel) => ({
value: s,
label: SEVERITY_LABELS[s],
}))}
triggerClassName="w-full px-3 py-2 text-sm"
/>
</div>
<p className="mt-1 text-xs text-slate-500">
You&apos;ll be notified for this severity and above.
</p>
</div>
<FormField
label="Notification email"
type="email"
{...register("notificationEmail")}
error={errors.notificationEmail?.message}
/>
<Controller
name="consent"
control={control}
render={({ field }) => (
<label className="flex items-start gap-2 text-sm text-slate-700">
<input
type="checkbox"
className="mt-0.5"
checked={field.value === true}
onChange={(e) => field.onChange(e.target.checked)}
/>
<span>
I understand CI Agent only collects publicly available information and will
never bypass logins, CAPTCHAs, paywalls, or anti-bot controls.
</span>
</label>
)}
/>
{errors.consent && (
<p className="text-sm text-red-600" role="alert">
{errors.consent.message}
</p>
)}
</div>
)}
{step === 4 && isFinalizing && (
<div className="flex flex-col items-center justify-center gap-3 py-10 text-sm text-slate-600">
<Loader2 className="h-6 w-6 animate-spin text-brand-600" aria-hidden />
<p>Setting up monitoring for {watch("name")}</p>
</div>
)}
{step === 4 && !isFinalizing && (
<div className="space-y-2 text-sm">
<p>
<span className="font-medium text-slate-700">Company:</span> {watch("name")}
</p>
<p>
<span className="font-medium text-slate-700">Website:</span>{" "}
{watch("officialWebsite") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Industry:</span>{" "}
{watch("industry") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Headquarters:</span>{" "}
{watch("headquarters") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Region:</span>{" "}
{[watch("country"), watch("region")].filter(Boolean).join(", ") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Competitors:</span>{" "}
{watch("competitorNames") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Aliases:</span>{" "}
{watch("aliasNames") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Focus:</span>{" "}
{watch("monitoringFocus") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Frequency:</span>{" "}
{FREQUENCY_LABELS[watch("frequencyType")]}
</p>
<p>
<span className="font-medium text-slate-700">Severity threshold:</span>{" "}
{SEVERITY_LABELS[watch("severityThreshold")]}
</p>
<p>
<span className="font-medium text-slate-700">Email:</span>{" "}
{watch("notificationEmail")}
</p>
{systemStatus?.ninjapear_configured && (
<p className="rounded-md bg-slate-50 p-3 text-xs text-slate-600">
NinjaPear company enrichment is on:{" "}
{systemStatus.ninjapear_credit_balance !== null
? `${systemStatus.ninjapear_credit_balance} credits remaining, `
: ""}
this company will use up to ~
{systemStatus.ninjapear_estimated_credits_per_company ?? "?"} credits.
</p>
)}
{createCompany.isError && (
<p className="text-sm text-red-600" role="alert">
{authErrorMessage(createCompany.error)}
</p>
)}
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
<button
type="button"
onClick={goBack}
disabled={isFinalizing}
className="focus-ring rounded-md px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 disabled:opacity-40"
>
Back
</button>
{step === 0 ? (
<button
type="button"
onClick={handleDiscover}
disabled={discoverCompany.isPending}
className="focus-ring inline-flex items-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
>
{discoverCompany.isPending && (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
)}
{discoverCompany.isPending ? "Discovering…" : "Discover company"}
</button>
) : step < STEPS.length - 1 ? (
<button
type="button"
onClick={goNext}
className="focus-ring rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700"
>
Continue
</button>
) : (
<button
type="submit"
disabled={isFinalizing}
className="focus-ring rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
>
{isFinalizing ? "Creating…" : "Create company"}
</button>
)}
</div>
</form>
</div>
);
}
export default function AddCompanyWizardPage() {
return (
<Suspense fallback={null}>
<AddCompanyWizard />
</Suspense>
);
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Building2, Pause, Play, Plus, RefreshCw, Trash2 } from "lucide-react";
import { CompanyStatusBadge } from "@/components/ui/badge";
import { useCompanies, useDeleteCompany, useSetCompanyPaused } from "@/hooks/use-companies";
import { useRunAnyCompanyNow } from "@/hooks/use-monitoring-runs";
import { FREQUENCY_LABELS } from "@/lib/types";
import { formatDateTime } from "@/lib/format";
export default function CompaniesPage() {
const { data: companies, isLoading } = useCompanies();
const setPaused = useSetCompanyPaused();
const deleteCompany = useDeleteCompany();
const runNow = useRunAnyCompanyNow();
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const companyList = companies ?? [];
return (
<div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-900">Companies</h1>
<p className="mt-1 text-sm text-slate-600">
Everything you&apos;re currently monitoring.
</p>
</div>
<Link
href="/companies/new"
className="focus-ring inline-flex items-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700"
>
<Plus className="h-4 w-4" aria-hidden /> Add company
</Link>
</div>
{isLoading ? (
<p className="mt-8 text-sm text-slate-500">Loading</p>
) : companyList.length === 0 ? (
<div className="mt-8 rounded-lg border border-dashed border-slate-300 bg-white p-10 text-center">
<Building2 className="mx-auto h-8 w-8 text-slate-400" aria-hidden />
<p className="mt-3 text-sm text-slate-600">No companies yet.</p>
<Link
href="/companies/new"
className="focus-ring mt-4 inline-block rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700"
>
Add your first company
</Link>
</div>
) : (
<div className="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-3">Company</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Frequency</th>
<th className="px-4 py-3">Last checked</th>
<th className="px-4 py-3">Next check</th>
<th className="px-4 py-3">Alerts</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{companyList.map((company) => (
<tr key={company.id}>
<td className="px-4 py-3">
<Link
href={`/companies/${company.id}`}
className="font-medium text-slate-900 hover:text-brand-600"
>
{company.name}
</Link>
{company.official_website && (
<div className="text-xs text-slate-500">{company.official_website}</div>
)}
</td>
<td className="px-4 py-3">
<CompanyStatusBadge status={company.status} />
</td>
<td className="px-4 py-3 text-slate-600">
{company.monitor_configuration
? FREQUENCY_LABELS[company.monitor_configuration.frequency_type]
: "—"}
</td>
<td className="px-4 py-3 text-slate-600">
{formatDateTime(company.monitor_configuration?.last_run)}
</td>
<td className="px-4 py-3 text-slate-600">
{formatDateTime(company.monitor_configuration?.next_run)}
</td>
<td className="px-4 py-3 text-slate-600">{company.unresolved_alert_count}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
title={
company.status === "active" ? "Pause monitoring" : "Resume monitoring"
}
onClick={() =>
setPaused.mutate({
companyId: company.id,
paused: company.status === "active",
})
}
className="focus-ring rounded-md p-2 text-slate-500 hover:bg-slate-100"
>
{company.status === "active" ? (
<Pause className="h-4 w-4" aria-hidden />
) : (
<Play className="h-4 w-4" aria-hidden />
)}
<span className="sr-only">
{company.status === "active" ? "Pause" : "Resume"} {company.name}
</span>
</button>
<button
title="Run now"
onClick={() => runNow.mutate(company.id)}
disabled={runNow.isPending}
className="focus-ring rounded-md p-2 text-slate-500 hover:bg-slate-100 disabled:opacity-60"
>
<RefreshCw className="h-4 w-4" aria-hidden />
<span className="sr-only">Run now for {company.name}</span>
</button>
{pendingDeleteId === company.id ? (
<div className="flex items-center gap-1 text-xs">
<span className="text-slate-600">Delete?</span>
<button
onClick={() => deleteCompany.mutate(company.id)}
className="focus-ring rounded-md bg-red-600 px-2 py-1 font-semibold text-white hover:bg-red-700"
>
Yes
</button>
<button
onClick={() => setPendingDeleteId(null)}
className="focus-ring rounded-md px-2 py-1 text-slate-600 hover:bg-slate-100"
>
No
</button>
</div>
) : (
<button
title="Delete company"
onClick={() => setPendingDeleteId(company.id)}
className="focus-ring rounded-md p-2 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" aria-hidden />
<span className="sr-only">Delete {company.name}</span>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { AlertTriangle, Building2, Clock, FileText, Plus, Radio, ServerCog } from "lucide-react";
import Link from "next/link";
import { useCurrentUser, useSystemStatus } from "@/hooks/use-auth";
import { useCompanies } from "@/hooks/use-companies";
import { useDashboardAnalytics } from "@/hooks/use-analytics";
import { StatCard } from "@/components/ui/stat-card";
import { DashboardAnalyticsSection } from "@/components/dashboard/analytics-section";
import { formatDateTime } from "@/lib/format";
import type { CompanyResponse } from "@/lib/types";
function mostRecentLastRun(companies: CompanyResponse[]): string | null {
const runs = companies
.map((c) => c.monitor_configuration?.last_run)
.filter((v): v is string => Boolean(v));
if (runs.length === 0) return null;
return runs.sort().at(-1) ?? null;
}
function nextUpcomingRun(companies: CompanyResponse[]): string | null {
const runs = companies
.filter((c) => c.monitor_configuration?.enabled)
.map((c) => c.monitor_configuration?.next_run)
.filter((v): v is string => Boolean(v));
if (runs.length === 0) return null;
return runs.sort().at(0) ?? null;
}
export default function DashboardPage() {
const { data: user } = useCurrentUser();
const { data: systemStatus } = useSystemStatus();
const { data: companies, isLoading } = useCompanies();
const { data: analytics } = useDashboardAnalytics();
const companyList = companies ?? [];
const activeCount = companyList.filter((c) => c.status === "active").length;
const reportCount = companyList.reduce((sum, c) => sum + c.report_count, 0);
const unreadAlertCount = companyList.reduce((sum, c) => sum + c.unresolved_alert_count, 0);
return (
<div>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-slate-900">
Welcome{user ? `, ${user.display_name}` : ""}
</h1>
<p className="mt-1 text-sm text-slate-600">
Here&apos;s what&apos;s happening across your monitors.
</p>
</div>
<Link
href="/companies/new"
className="focus-ring inline-flex items-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 active:scale-95"
>
<Plus className="h-4 w-4" aria-hidden /> Add company
</Link>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Monitored companies" value={companyList.length} icon={Building2} />
<StatCard label="Active monitors" value={activeCount} icon={Radio} />
<StatCard label="Reports generated" value={reportCount} icon={FileText} />
<StatCard label="Unread alerts" value={unreadAlertCount} icon={AlertTriangle} />
</div>
{analytics && <DashboardAnalyticsSection analytics={analytics} />}
<div className="mt-6 grid gap-4 lg:grid-cols-2">
<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">
<Clock className="h-4 w-4" aria-hidden /> Monitoring schedule
</div>
<dl className="mt-3 space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-slate-500">Last successful run</dt>
<dd className="text-slate-900">{formatDateTime(mostRecentLastRun(companyList))}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500">Next scheduled run</dt>
<dd className="text-slate-900">{formatDateTime(nextUpcomingRun(companyList))}</dd>
</div>
</dl>
</div>
<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">
<ServerCog className="h-4 w-4" aria-hidden /> System health
</div>
<dl className="mt-3 space-y-2 text-sm">
{systemStatus?.components.map((c) => (
<div key={c.name} className="flex justify-between capitalize">
<dt className="text-slate-500">{c.name}</dt>
<dd className={c.status === "ok" ? "text-green-700" : "text-red-700"}>
{c.status}
</dd>
</div>
))}
<div className="flex justify-between">
<dt className="text-slate-500">LLM provider</dt>
<dd className="text-slate-900">{systemStatus?.llm_provider ?? "—"}</dd>
</div>
</dl>
</div>
</div>
<div className="mt-6 rounded-lg border border-slate-200 bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-slate-700">Companies</h2>
<Link
href="/companies"
className="text-sm font-medium text-brand-600 hover:text-brand-700"
>
View all
</Link>
</div>
{isLoading ? (
<p className="mt-4 text-sm text-slate-500">Loading</p>
) : companyList.length === 0 ? (
<div className="mt-4 rounded-md border border-dashed border-slate-300 p-6 text-center">
<p className="text-sm text-slate-600">You&apos;re not monitoring any companies yet.</p>
<Link
href="/companies/new"
className="focus-ring mt-3 inline-block rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700"
>
Add your first company
</Link>
</div>
) : (
<ul className="mt-4 divide-y divide-slate-100">
{companyList.slice(0, 5).map((c) => (
<li key={c.id} className="flex items-center justify-between py-3 text-sm">
<Link
href={`/companies/${c.id}`}
className="font-medium text-slate-900 hover:text-brand-600"
>
{c.name}
</Link>
<span className="text-slate-500">{c.status}</span>
</li>
))}
</ul>
)}
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { LocalModeBanner } from "@/components/local-mode-banner";
import { PageTransition } from "@/components/page-transition";
import { useCurrentUser, useLogout, useSystemStatus } from "@/hooks/use-auth";
import { isLocalConvenience } from "@/lib/auth";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { data: systemStatus } = useSystemStatus();
const { data: user, isLoading, isError } = useCurrentUser();
const logout = useLogout();
const requiresLogin = systemStatus ? !isLocalConvenience(systemStatus) : false;
useEffect(() => {
if (requiresLogin && !isLoading && isError) {
router.replace("/login");
}
}, [requiresLogin, isLoading, isError, router]);
if (requiresLogin && (isLoading || isError)) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500">
Loading
</div>
);
}
return (
<div className="min-h-screen bg-slate-50">
<LocalModeBanner />
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-3">
<Link href="/dashboard" className="text-lg font-semibold tracking-tight text-slate-900">
CI&nbsp;Agent
</Link>
<nav className="flex items-center gap-4 text-sm">
<Link href="/dashboard" className="text-slate-600 hover:text-slate-900">
Dashboard
</Link>
<Link href="/companies" className="text-slate-600 hover:text-slate-900">
Companies
</Link>
<Link href="/alerts" className="text-slate-600 hover:text-slate-900">
Alerts
</Link>
<Link href="/settings" className="text-slate-600 hover:text-slate-900">
Settings
</Link>
{user && (
<span className="ml-2 flex items-center gap-3 border-l border-slate-200 pl-4">
<span className="text-slate-500">{user.display_name}</span>
{requiresLogin && (
<button
onClick={() => logout.mutate()}
className="focus-ring rounded-md px-2 py-1 font-medium text-slate-600 hover:bg-slate-100"
>
Sign out
</button>
)}
</span>
)}
</nav>
</div>
</header>
<div className="mx-auto max-w-6xl px-6 py-8">
<PageTransition>{children}</PageTransition>
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { use, useState } from "react";
import Link from "next/link";
import { ReportView } from "@/components/report-view";
import { useReport, useReportMarkdown } from "@/hooks/use-reports";
export default function ReportPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { data: report, isLoading } = useReport(id);
const [showMarkdown, setShowMarkdown] = useState(false);
const { data: markdown, isLoading: markdownLoading } = useReportMarkdown(
showMarkdown ? id : undefined,
);
if (isLoading) {
return <p className="text-sm text-slate-500">Loading</p>;
}
if (!report) {
return <p className="text-sm text-slate-500">Report not found.</p>;
}
return (
<div>
<div className="flex items-center justify-between print:hidden">
<Link
href={`/companies/${report.company_id}`}
className="text-sm text-slate-500 hover:text-slate-700"
>
Back to company
</Link>
<button
onClick={() => setShowMarkdown((v) => !v)}
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"
>
{showMarkdown ? "Show structured view" : "Show Markdown (with sources)"}
</button>
</div>
{showMarkdown ? (
<div className="mt-6 rounded-lg border border-slate-200 bg-white p-6">
{markdownLoading ? (
<p className="text-sm text-slate-500">Loading</p>
) : (
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-slate-800">
{markdown}
</pre>
)}
</div>
) : (
<ReportView report={report} />
)}
</div>
);
}
+766
View File
@@ -0,0 +1,766 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
Bell,
CheckCircle2,
KeyRound,
Loader2,
Mail,
MessageSquare,
Plus,
ScrollText,
Send,
ServerCog,
ShieldAlert,
ShieldBan,
ShieldCheck,
ShieldOff,
Trash2,
XCircle,
type LucideIcon,
} from "lucide-react";
import { authErrorMessage } from "@/hooks/use-auth";
import {
useAcceptUnbanRequest,
useCreateIpBan,
useCurrentUser,
useDeleteIpBan,
useIpBans,
useRejectUnbanRequest,
useSecurityEvents,
useSystemLogs,
useSystemSecrets,
useSystemStatus,
useUnbanRequests,
useUserApiKeys,
} from "@/hooks/use-auth";
import { useCompanies } from "@/hooks/use-companies";
import {
useCreateNotificationDestination,
useDeleteNotificationDestination,
useNotificationDestinations,
useTestNotificationDestination,
useUpdateNotificationDestination,
} from "@/hooks/use-notification-destinations";
import { FormField } from "@/components/ui/form-field";
import { Select } from "@/components/ui/select";
import { SystemSecretRow } from "@/components/ui/system-secret-row";
import { UserApiKeyRow } from "@/components/ui/user-api-key-row";
import { formatDateTime } from "@/lib/format";
import {
SEVERITY_LABELS,
SEVERITY_LEVELS,
type LinkedCompany,
type LogCategory,
type NotificationType,
type SeverityLevel,
} from "@/lib/types";
function TestResultBadge({ result }: { result: { success: boolean; error: string | null } }) {
return (
<span className={`text-xs ${result.success ? "text-green-700" : "text-red-600"}`}>
{result.success ? "Test sent" : result.error}
</span>
);
}
function LinkedCompanyChips({ companies }: { companies: LinkedCompany[] }) {
if (companies.length === 0) {
return (
<p className="text-xs italic text-slate-400">
Not linked to any company will be removed automatically.
</p>
);
}
return (
<div className="flex max-w-full gap-1.5 overflow-x-auto scroll-smooth py-0.5">
{companies.map((company) => (
<Link
key={company.id}
href={`/companies/${company.id}`}
className="focus-ring shrink-0 whitespace-nowrap rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-600 transition-colors duration-150 hover:bg-brand-100 hover:text-brand-700"
>
{company.name}
</Link>
))}
</div>
);
}
function DestinationRow({
destination,
}: {
destination: {
id: string;
type: NotificationType;
destination_value: string;
enabled: boolean;
verified: boolean;
minimum_severity: SeverityLevel;
companies: LinkedCompany[];
};
}) {
const update = useUpdateNotificationDestination();
const remove = useDeleteNotificationDestination();
const test = useTestNotificationDestination();
const Icon = destination.type === "sms" ? MessageSquare : Mail;
return (
<li className="flex animate-fade-in flex-wrap items-center justify-between gap-3 py-4">
<div className="flex min-w-0 items-center gap-3">
<Icon className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{destination.destination_value}</p>
<p className="text-xs capitalize text-slate-500">
{destination.type} · min severity {destination.minimum_severity}
</p>
<div className="mt-1.5 max-w-xs sm:max-w-sm">
<LinkedCompanyChips companies={destination.companies} />
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select
value={destination.minimum_severity}
onValueChange={(v) =>
update.mutate({
destinationId: destination.id,
payload: { minimum_severity: v as SeverityLevel },
})
}
options={SEVERITY_LEVELS.map((s) => ({ value: s, label: SEVERITY_LABELS[s] }))}
triggerClassName="px-2 py-1 text-xs"
/>
<label className="flex items-center gap-1.5 text-xs text-slate-600">
<input
type="checkbox"
checked={destination.enabled}
onChange={(e) =>
update.mutate({
destinationId: destination.id,
payload: { enabled: e.target.checked },
})
}
className="focus-ring rounded border-slate-300"
/>
Enabled
</label>
<button
onClick={() => test.mutate(destination.id)}
disabled={test.isPending}
title="Send test notification"
className="focus-ring inline-flex items-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-60"
>
{test.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Send className="h-3.5 w-3.5" aria-hidden />
)}
Test
</button>
<button
onClick={() => remove.mutate(destination.id)}
title="Remove destination"
className="focus-ring rounded-md p-1.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" aria-hidden />
<span className="sr-only">Remove {destination.destination_value}</span>
</button>
</div>
{test.data && (
<div className="basis-full">
<TestResultBadge result={test.data} />
</div>
)}
</li>
);
}
function AddDestinationForm() {
const [type, setType] = useState<NotificationType>("email");
const [value, setValue] = useState("");
const [minimumSeverity, setMinimumSeverity] = useState<SeverityLevel>("medium");
const [companyIds, setCompanyIds] = useState<Set<string>>(new Set());
const create = useCreateNotificationDestination();
const { data: systemStatus } = useSystemStatus();
const { data: companies } = useCompanies();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (companyIds.size === 0) return;
create.mutate(
{
type,
destination_value: value,
minimum_severity: minimumSeverity,
company_ids: [...companyIds],
},
{ onSuccess: () => setValue("") },
);
};
const toggleCompany = (id: string) => {
setCompanyIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<form onSubmit={handleSubmit} className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-sm font-medium text-slate-700">Type</label>
<div className="mt-1">
<Select
value={type}
onValueChange={(v) => setType(v as NotificationType)}
options={[
{ value: "email", label: "Email" },
{ value: "sms", label: "SMS" },
]}
triggerClassName="px-3 py-2 text-sm"
/>
</div>
</div>
<div className="min-w-[220px] flex-1">
<FormField
label={type === "sms" ? "Phone number" : "Email address"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={type === "sms" ? "+15551234567" : "[email protected]"}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700">Min severity</label>
<div className="mt-1">
<Select
value={minimumSeverity}
onValueChange={(v) => setMinimumSeverity(v as SeverityLevel)}
options={SEVERITY_LEVELS.map((s) => ({ value: s, label: SEVERITY_LABELS[s] }))}
triggerClassName="px-3 py-2 text-sm"
/>
</div>
</div>
<button
type="submit"
disabled={create.isPending || !value.trim() || companyIds.size === 0}
className="focus-ring inline-flex items-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
>
<Plus className="h-4 w-4" aria-hidden /> Add
</button>
</div>
{companies && companies.length > 0 ? (
<div>
<p className="text-xs font-medium text-slate-600">
Companies to notify for <span className="text-red-500">*</span>
</p>
<div className="mt-1.5 flex flex-wrap gap-2">
{companies.map((c) => {
const selected = companyIds.has(c.id);
return (
<button
key={c.id}
type="button"
onClick={() => toggleCompany(c.id)}
aria-pressed={selected}
className={`focus-ring rounded-full border px-3 py-1 text-xs font-medium transition-colors duration-150 ${
selected
? "border-brand-600 bg-brand-600 text-white"
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
{c.name}
</button>
);
})}
</div>
</div>
) : (
<p className="text-xs text-slate-500">
Add a company first destinations must be linked to at least one.
</p>
)}
{create.isError && <p className="text-xs text-red-600">{authErrorMessage(create.error)}</p>}
{type === "sms" && systemStatus && !systemStatus.sms_enabled && (
<p className="text-xs text-amber-700">
SMS delivery is temporarily disabled while carrier registration (10DLC) for our sending
number completes. You can still add your number now it&apos;ll start receiving alerts
automatically once registration finishes and delivery is re-enabled.
</p>
)}
</form>
);
}
const LOG_CATEGORY_STYLES: Record<LogCategory, { dot: string; label: string; text: string }> = {
internal_error: { dot: "bg-red-500", label: "Internal error", text: "text-red-700" },
api_error: { dot: "bg-amber-500", label: "API error", text: "text-amber-700" },
important: { dot: "bg-blue-500", label: "Important", text: "text-blue-700" },
normal: { dot: "bg-slate-300", label: "Normal", text: "text-slate-500" },
};
function LoggingBox() {
const { data: logs, isLoading } = useSystemLogs();
return (
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<ScrollText className="h-4 w-4" aria-hidden /> Logging
</div>
<p className="mt-1 text-sm text-slate-500">
A live feed of what the application is doing, most recent first.
</p>
{isLoading ? (
<p className="mt-4 text-sm text-slate-500">Loading</p>
) : logs && logs.length > 0 ? (
<ul className="mt-3 max-h-96 space-y-1 overflow-y-auto">
{logs.map((log, i) => {
const style = LOG_CATEGORY_STYLES[log.category];
return (
<li
key={i}
className="flex items-start gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-slate-50"
>
<span
className={`mt-1 h-2 w-2 shrink-0 rounded-full ${style.dot}`}
title={style.label}
aria-hidden
/>
<span className="shrink-0 whitespace-nowrap text-slate-400">
{formatDateTime(log.ts)}
</span>
<span className="shrink-0 whitespace-nowrap text-slate-400">{log.logger}</span>
<span className={`min-w-0 break-words ${style.text}`}>{log.event}</span>
</li>
);
})}
</ul>
) : (
<p className="mt-4 text-sm text-slate-500">No log entries yet.</p>
)}
</div>
);
}
function UserApiKeysBox() {
const { data: apiKeys, isLoading } = useUserApiKeys();
const { data: systemStatus } = useSystemStatus();
return (
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<KeyRound className="h-4 w-4" aria-hidden /> Your API keys
</div>
<p className="mt-1 text-sm text-slate-500">
Set your own key for each provider below to power discovery, enrichment, and monitoring for
your companies.
</p>
{isLoading ? (
<p className="mt-4 text-sm text-slate-500">Loading</p>
) : (
<div className="mt-4 space-y-4">
{apiKeys?.map((k) => (
<UserApiKeyRow
key={k.provider}
apiKey={k}
liveCredits={
k.provider === "ninjapear"
? (systemStatus?.ninjapear_credit_balance ?? null)
: undefined
}
/>
))}
</div>
)}
</div>
);
}
function ServerSecretsBox() {
const { data: secrets, isLoading } = useSystemSecrets();
return (
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<KeyRound className="h-4 w-4" aria-hidden /> Server secrets
</div>
<p className="mt-1 text-sm text-slate-500">
Server-wide configuration shared by every visitor, used for Cloudflare Turnstile CAPTCHA.
</p>
{isLoading ? (
<p className="mt-4 text-sm text-slate-500">Loading</p>
) : (
<div className="mt-4 space-y-4">
{secrets?.map((s) => <SystemSecretRow key={s.key} secret={s} />)}
</div>
)}
</div>
);
}
const SECURITY_EVENT_LABELS: Record<string, string> = {
login_success: "Signed in",
login_failed: "Failed sign-in attempt",
account_locked: "Account locked",
password_reset_requested: "Password reset requested",
password_reset_completed: "Password reset completed",
email_verification_sent: "Verification email sent",
email_verified: "Email verified",
server_secret_updated: "Server secret updated",
api_key_updated: "API key updated",
};
const SECURITY_EVENT_DOT: Record<string, string> = {
login_success: "bg-green-500",
login_failed: "bg-amber-500",
account_locked: "bg-red-500",
password_reset_requested: "bg-blue-500",
password_reset_completed: "bg-blue-500",
email_verification_sent: "bg-slate-300",
email_verified: "bg-green-500",
server_secret_updated: "bg-purple-500",
api_key_updated: "bg-purple-500",
};
function AccountActivityBox() {
const { data: events, isLoading } = useSecurityEvents();
return (
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<ShieldCheck className="h-4 w-4" aria-hidden /> Account activity
</div>
<p className="mt-1 text-sm text-slate-500">
Recent sign-ins and security events for your account.
</p>
{isLoading ? (
<p className="mt-4 text-sm text-slate-500">Loading</p>
) : events && events.length > 0 ? (
<ul className="mt-3 max-h-96 space-y-1 overflow-y-auto">
{events.map((event, i) => (
<li
key={i}
className="flex flex-wrap items-start gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-slate-50"
>
<span
className={`mt-1 h-2 w-2 shrink-0 rounded-full ${
SECURITY_EVENT_DOT[event.event_type] ?? "bg-slate-300"
}`}
aria-hidden
/>
<span className="shrink-0 whitespace-nowrap text-slate-400">
{formatDateTime(event.created_at)}
</span>
<span className="min-w-0 break-words text-slate-700">
{SECURITY_EVENT_LABELS[event.event_type] ?? event.event_type}
</span>
<span className="shrink-0 whitespace-nowrap text-slate-400">{event.ip_address}</span>
</li>
))}
</ul>
) : (
<p className="mt-4 text-sm text-slate-500">No activity yet.</p>
)}
</div>
);
}
function ActionButton({
onClick,
pending,
disabled,
colorClass,
icon: Icon,
label,
}: {
onClick: () => void;
pending: boolean;
disabled?: boolean;
colorClass: string;
icon: LucideIcon;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={pending || disabled}
className={`focus-ring inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-semibold text-white transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-50 ${colorClass}`}
>
<span className="relative block h-3.5 w-3.5">
<Icon
className={`absolute inset-0 h-3.5 w-3.5 transition-opacity duration-200 ${pending ? "opacity-0" : "opacity-100"}`}
aria-hidden
/>
<Loader2
className={`absolute inset-0 h-3.5 w-3.5 animate-spin transition-opacity duration-200 ${pending ? "opacity-100" : "opacity-0"}`}
aria-hidden
/>
</span>
{label}
</button>
);
}
function BanIpForm() {
const [ipAddress, setIpAddress] = useState("");
const createBan = useCreateIpBan();
const handleBan = () => {
if (!ipAddress.trim()) return;
createBan.mutate({ ip_address: ipAddress.trim() }, { onSuccess: () => setIpAddress("") });
};
return (
<div className="flex items-start gap-2">
<div className="flex-1">
<input
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleBan();
}}
placeholder="e.g. 203.0.113.42"
className="focus-ring block w-full rounded-md border border-slate-300 px-3 py-1.5 font-mono text-sm text-slate-900 shadow-sm transition-colors duration-150 placeholder:font-sans placeholder:text-slate-400"
/>
{createBan.isError && (
<p className="mt-1 text-xs text-red-600">{authErrorMessage(createBan.error)}</p>
)}
</div>
<ActionButton
onClick={handleBan}
pending={createBan.isPending}
disabled={!ipAddress.trim()}
colorClass="bg-red-600 hover:bg-red-700"
icon={ShieldBan}
label="Ban"
/>
</div>
);
}
function IpBansBox() {
const { data: bans, isLoading: bansLoading } = useIpBans();
const { data: unbanRequests, isLoading: requestsLoading } = useUnbanRequests();
const deleteBan = useDeleteIpBan();
const acceptRequest = useAcceptUnbanRequest();
const rejectRequest = useRejectUnbanRequest();
return (
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<ShieldAlert className="h-4 w-4" aria-hidden /> IP bans
</div>
<p className="mt-1 text-sm text-slate-500">
IPs permanently blocked after repeated abuse, and pending unban requests.
</p>
<div className="mt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Ban an IP manually
</p>
<div className="mt-2">
<BanIpForm />
</div>
</div>
<div className="mt-6 border-t border-slate-100 pt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Banned IPs</p>
{bansLoading ? (
<p className="mt-2 text-sm text-slate-500">Loading</p>
) : bans && bans.length > 0 ? (
<ul className="mt-2 divide-y divide-slate-100">
{bans.map((ban) => (
<li
key={ban.ip_address}
className="flex animate-fade-in items-center justify-between gap-3 py-2 text-sm"
>
<div className="min-w-0">
<p className="font-mono text-slate-900">{ban.ip_address}</p>
<p className="text-xs text-slate-500">
{ban.reason} · banned {formatDateTime(ban.banned_at)}
</p>
</div>
<ActionButton
onClick={() => deleteBan.mutate(ban.ip_address)}
pending={deleteBan.isPending && deleteBan.variables === ban.ip_address}
colorClass="bg-red-600 hover:bg-red-700"
icon={ShieldOff}
label="Unban"
/>
</li>
))}
</ul>
) : (
<p className="mt-2 text-sm text-slate-500">No IPs currently banned.</p>
)}
</div>
<div className="mt-6 border-t border-slate-100 pt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Unban requests
</p>
{requestsLoading ? (
<p className="mt-2 text-sm text-slate-500">Loading</p>
) : unbanRequests && unbanRequests.length > 0 ? (
<ul className="mt-2 space-y-2">
{unbanRequests.map((req) => (
<li key={req.id} className="animate-fade-in rounded-md bg-slate-50 px-3 py-2 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<span className="font-mono text-slate-900">{req.ip_address}</span>
<span className="ml-2 text-xs text-slate-400">
{formatDateTime(req.created_at)}
</span>
{req.message && <p className="mt-1 text-xs text-slate-600">{req.message}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
<ActionButton
onClick={() => acceptRequest.mutate(req.id)}
pending={acceptRequest.isPending && acceptRequest.variables === req.id}
disabled={rejectRequest.isPending}
colorClass="bg-green-600 hover:bg-green-700"
icon={CheckCircle2}
label="Unban"
/>
<ActionButton
onClick={() => rejectRequest.mutate(req.id)}
pending={rejectRequest.isPending && rejectRequest.variables === req.id}
disabled={acceptRequest.isPending}
colorClass="bg-red-600 hover:bg-red-700"
icon={XCircle}
label="Reject"
/>
</div>
</div>
</li>
))}
</ul>
) : (
<p className="mt-2 text-sm text-slate-500">No unban requests.</p>
)}
</div>
</div>
);
}
export default function SettingsPage() {
const { data: user } = useCurrentUser();
const { data: systemStatus } = useSystemStatus();
const { data: destinations, isLoading } = useNotificationDestinations();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-slate-900">Settings</h1>
<p className="mt-1 text-sm text-slate-600">
Manage where alerts are delivered and check system configuration.
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<Bell className="h-4 w-4" aria-hidden /> Notification destinations
</div>
<p className="mt-1 text-sm text-slate-500">
Alerts are dispatched to every enabled destination whose minimum severity is met.
</p>
<div className="mt-4 border-t border-slate-100 pt-4">
<AddDestinationForm />
</div>
{isLoading ? (
<p className="mt-4 text-sm text-slate-500">Loading</p>
) : destinations && destinations.length > 0 ? (
<ul className="mt-2 divide-y divide-slate-100">
{destinations.map((d) => (
<DestinationRow key={d.id} destination={d} />
))}
</ul>
) : (
<p className="mt-4 text-sm text-slate-500">
No notification destinations yet add one above.
</p>
)}
</div>
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<ServerCog className="h-4 w-4" aria-hidden /> System configuration
</div>
<dl className="mt-3 space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-slate-500">Account</dt>
<dd className="text-slate-900">{user?.email ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500">Auth mode</dt>
<dd className="uppercase text-slate-900">{systemStatus?.auth_mode ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500">LLM provider</dt>
<dd className="text-slate-900">{systemStatus?.llm_provider ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500">Search provider</dt>
<dd className="text-slate-900">{systemStatus?.search_provider ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500">SMS delivery</dt>
<dd className="text-slate-900">
{systemStatus?.sms_enabled ? "Enabled" : "Disabled"}
{systemStatus?.sms_enabled && ` (${systemStatus.sms_provider})`}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500">Company enrichment (NinjaPear)</dt>
<dd className="text-slate-900">
{systemStatus?.ninjapear_configured
? systemStatus.ninjapear_credit_balance !== null
? `Enabled — ${systemStatus.ninjapear_credit_balance} credits remaining`
: "Enabled — credit balance unavailable"
: "Not configured"}
</dd>
</div>
{systemStatus?.components.map((c) => (
<div key={c.name} className="flex justify-between capitalize">
<dt className="text-slate-500">{c.name}</dt>
<dd className={c.status === "ok" ? "text-green-700" : "text-red-700"}>{c.status}</dd>
</div>
))}
</dl>
</div>
<AccountActivityBox />
<UserApiKeysBox />
{user?.is_admin && (
<>
<ServerSecretsBox />
<IpBansBox />
<LoggingBox />
</>
)}
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { KeyRound, Loader2 } from "lucide-react";
import { z } from "zod";
import { FormField } from "@/components/ui/form-field";
import { TurnstileWidget, type TurnstileWidgetHandle } from "@/components/ui/turnstile-widget";
import { authErrorMessage, useRequestPasswordReset, useSystemStatus } from "@/hooks/use-auth";
const forgotSchema = z.object({
email: z.string().email("Enter a valid email address"),
});
type ForgotForm = z.infer<typeof forgotSchema>;
export default function ForgotPasswordPage() {
const { data: status } = useSystemStatus();
const resetMutation = useRequestPasswordReset();
const turnstileRef = useRef<TurnstileWidgetHandle>(null);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ForgotForm>({ resolver: zodResolver(forgotSchema) });
// Mirrors the backend's turnstile_required(is_localhost, settings) gate
// exactly - skipped on loopback regardless of auth mode.
const turnstileRequired = status ? !status.is_localhost : false;
const onSubmit = handleSubmit(async (values) => {
try {
await resetMutation.mutateAsync({
email: values.email,
turnstile_token: turnstileToken ?? undefined,
});
setSubmittedEmail(values.email);
} catch {
// Surfaced via resetMutation.isError below.
} finally {
turnstileRef.current?.reset();
setTurnstileToken(null);
}
});
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12">
<div className="w-full max-w-sm animate-fade-in overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="h-1.5 bg-gradient-to-r from-brand-500 via-brand-600 to-brand-700" />
<div className="p-8">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<KeyRound className="h-4.5 w-4.5" aria-hidden />
</span>
<div>
<h1 className="text-xl font-semibold text-slate-900">Reset your password</h1>
<p className="text-sm text-slate-600">We&apos;ll email you a code to reset it.</p>
</div>
</div>
{submittedEmail ? (
<div className="mt-6 animate-fade-in space-y-4">
<p className="rounded-md bg-green-50 px-3 py-2 text-sm text-green-700">
If an account exists for {submittedEmail}, a reset code is on its way.
</p>
<Link
href={`/reset-password?email=${encodeURIComponent(submittedEmail)}`}
className="focus-ring inline-flex w-full items-center justify-center rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700"
>
I have a code
</Link>
</div>
) : (
<form onSubmit={onSubmit} className="mt-6 space-y-4" noValidate>
<FormField
label="Email"
type="email"
autoComplete="email"
{...register("email")}
error={errors.email?.message}
/>
{turnstileRequired && (
<TurnstileWidget
ref={turnstileRef}
siteKey={status?.turnstile_site_key}
onToken={setTurnstileToken}
/>
)}
{resetMutation.isError && (
<p className="animate-fade-in text-sm text-red-600" role="alert">
{authErrorMessage(resetMutation.error)}
</p>
)}
<button
type="submit"
disabled={resetMutation.isPending}
className="focus-ring inline-flex w-full items-center justify-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{resetMutation.isPending && (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
)}
{resetMutation.isPending ? "Sending…" : "Send reset code"}
</button>
</form>
)}
<p className="mt-6 text-center text-sm text-slate-600">
<Link
href="/login"
className="font-medium text-brand-600 transition-colors duration-150 hover:text-brand-700"
>
Back to sign in
</Link>
</p>
</div>
</div>
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
html {
@apply antialiased;
}
body {
@apply bg-slate-50 text-slate-900;
}
@layer base {
/* Every interactive element gets a smooth color/background/border
transition by default, so hover/active/disabled state changes never
feel like an instant snap - individual components don't have to
remember to add this themselves. */
button,
a,
input,
select,
textarea {
@apply transition-[color,background-color,border-color,box-shadow,opacity] duration-150 ease-out;
}
}
@layer utilities {
/* Ring only appears on real keyboard focus (:focus-visible), never on a
mouse click - and uses slate instead of blue so it doesn't read as a
copy of the browser's native tab-focus outline. */
.focus-ring {
@apply outline-none;
}
.focus-ring:focus-visible {
@apply ring-2 ring-slate-900 ring-offset-2 ring-offset-white;
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";
export const metadata: Metadata = {
title: "CI Agent — Competitive Intelligence Monitoring",
description:
"Monitor competitors across public sources, get structured intelligence reports, and get alerted when something meaningful changes.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Loader2, ShieldCheck } from "lucide-react";
import { z } from "zod";
import { FormField } from "@/components/ui/form-field";
import { PasswordField } from "@/components/ui/password-field";
import { TurnstileWidget, type TurnstileWidgetHandle } from "@/components/ui/turnstile-widget";
import { useCountdown } from "@/hooks/use-countdown";
import { classifyLoginError, useCurrentUser, useLogin, useSystemStatus } from "@/hooks/use-auth";
const loginSchema = z.object({
email: z.string().email("Enter a valid email address"),
password: z.string().min(1, "Enter your password"),
});
type LoginForm = z.infer<typeof loginSchema>;
function LoginFormCard() {
const router = useRouter();
const searchParams = useSearchParams();
const loginMutation = useLogin();
const { data: currentUser } = useCurrentUser();
const { data: status } = useSystemStatus();
const turnstileRef = useRef<TurnstileWidgetHandle>(null);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const countdown = useCountdown();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({ resolver: zodResolver(loginSchema) });
// Mirrors the backend's turnstile_required(is_localhost, settings) gate -
// skipped on loopback regardless of auth mode.
const turnstileRequired = status ? !status.is_localhost : false;
useEffect(() => {
if (currentUser) {
router.replace("/dashboard");
}
}, [currentUser, router]);
const onSubmit = handleSubmit(async (values) => {
try {
await loginMutation.mutateAsync({
...values,
turnstile_token: turnstileToken ?? undefined,
});
router.push("/dashboard");
} catch (error) {
const failure = classifyLoginError(error);
if (failure.kind === "verify_email") {
router.push(`/verify-email?email=${encodeURIComponent(values.email)}`);
return;
}
if (failure.retryAfterSeconds) {
countdown.start(failure.retryAfterSeconds);
}
} finally {
// Tokens are single-use - always reset after a submit attempt,
// success or failure, so a retry never reuses a stale token.
turnstileRef.current?.reset();
setTurnstileToken(null);
}
});
const failure = loginMutation.isError ? classifyLoginError(loginMutation.error) : null;
return (
<div className="w-full max-w-sm animate-fade-in overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="h-1.5 bg-gradient-to-r from-brand-500 via-brand-600 to-brand-700" />
<div className="p-8">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<ShieldCheck className="h-4.5 w-4.5" aria-hidden />
</span>
<div>
<h1 className="text-xl font-semibold text-slate-900">Sign in</h1>
<p className="text-sm text-slate-600">Welcome back to CI Agent.</p>
</div>
</div>
{searchParams.get("registered") && (
<p className="mt-4 animate-fade-in rounded-md bg-green-50 px-3 py-2 text-sm text-green-700">
Account created. Sign in below.
</p>
)}
{searchParams.get("verified") && (
<p className="mt-4 animate-fade-in rounded-md bg-green-50 px-3 py-2 text-sm text-green-700">
Email verified. Sign in below.
</p>
)}
{searchParams.get("reset") && (
<p className="mt-4 animate-fade-in rounded-md bg-green-50 px-3 py-2 text-sm text-green-700">
Password reset. Sign in with your new password.
</p>
)}
<form onSubmit={onSubmit} className="mt-6 space-y-4" noValidate>
<FormField
label="Email"
type="email"
autoComplete="email"
{...register("email")}
error={errors.email?.message}
/>
<PasswordField
label="Password"
autoComplete="current-password"
{...register("password")}
error={errors.password?.message}
/>
{turnstileRequired && (
<TurnstileWidget
ref={turnstileRef}
siteKey={status?.turnstile_site_key}
onToken={setTurnstileToken}
/>
)}
{failure && (
<div className="animate-fade-in space-y-1 text-sm text-red-600" role="alert">
<p>{failure.message}</p>
{failure.kind === "account_locked" && (
<Link href="/forgot-password" className="font-medium underline">
Reset your password to unlock it
</Link>
)}
{failure.kind === "banned" && (
<Link href="/unban-request" className="font-medium underline">
Request an unban
</Link>
)}
</div>
)}
<button
type="submit"
disabled={loginMutation.isPending || countdown.remaining > 0}
className="focus-ring inline-flex w-full items-center justify-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{loginMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{countdown.remaining > 0
? `Try again in ${countdown.remaining}s`
: loginMutation.isPending
? "Signing in…"
: "Sign in"}
</button>
</form>
<p className="mt-4 text-center text-sm">
<Link
href="/forgot-password"
className="font-medium text-brand-600 transition-colors duration-150 hover:text-brand-700"
>
Forgot your password?
</Link>
</p>
<p className="mt-6 text-center text-sm text-slate-600">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="font-medium text-brand-600 transition-colors duration-150 hover:text-brand-700"
>
Create one
</Link>
</p>
</div>
</div>
);
}
export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12">
<Suspense fallback={null}>
<LoginFormCard />
</Suspense>
</main>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
import Link from "next/link";
import { Building2, LineChart, Mail, Radar, ShieldCheck, Sparkles } from "lucide-react";
import { useSystemStatus } from "@/hooks/use-auth";
import { isLocalConvenience } from "@/lib/auth";
const steps = [
{
title: "Add a company",
body: "Point CI Agent at a competitor's public website and tell it what you actually care about — manufacturing expansion, pricing, hiring, patents, whatever matters to you.",
icon: Building2,
},
{
title: "Choose a schedule",
body: "Hourly to monthly, or a custom cron expression. The scheduler picks up changes automatically — no code changes required to add a company.",
icon: Radar,
},
{
title: "We collect public sources",
body: "Official site, press releases, RSS/news feeds, SEC filings, GitHub, careers pages, and any custom URLs you add — all fetched through an SSRF-safe, robots.txt-respecting pipeline.",
icon: ShieldCheck,
},
{
title: "An LLM turns evidence into a report",
body: "Every claim in the report traces back to a stored source document. Inferences are clearly labeled and confidence-scored — never presented as fact.",
icon: Sparkles,
},
{
title: "Changes are scored and explained",
body: "New runs are compared to prior snapshots. Meaningful changes get a severity (Critical/High/Medium/Low) and a confidence score, with noise filtered out.",
icon: LineChart,
},
{
title: "You get notified",
body: "Email (and optional SMS) alerts above your chosen severity threshold, with evidence and a link back to the full report in your dashboard.",
icon: Mail,
},
];
export default function LandingPage() {
const { data: systemStatus } = useSystemStatus();
const isLocalhost = systemStatus ? isLocalConvenience(systemStatus) : false;
return (
<main className="min-h-screen">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<span className="text-lg font-semibold tracking-tight text-slate-900">CI&nbsp;Agent</span>
<nav className="flex items-center gap-3">
<Link
href="/login"
className="rounded-md px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100"
>
Sign in
</Link>
<Link
href="/register"
className="rounded-md px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100"
>
Create account
</Link>
<Link
href="/dashboard"
className="focus-ring rounded-md bg-brand-600 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-700"
>
{isLocalhost ? "Open dashboard (local mode)" : "Open dashboard"}
</Link>
</nav>
</div>
</header>
<section className="mx-auto max-w-4xl px-6 py-20 text-center">
<h1 className="text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
Know what your competitors are doing {" "}
<span className="text-brand-600">before it shows up on your roadmap.</span>
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg text-slate-600">
CI Agent monitors the companies you choose, collects only publicly available information,
and turns it into evidence-linked intelligence reports with severity- and
confidence-scored alerts.
</p>
<div className="mt-8 flex items-center justify-center gap-3">
<Link
href="/dashboard"
className="focus-ring rounded-md bg-brand-600 px-5 py-3 text-sm font-semibold text-white shadow-sm hover:bg-brand-700"
>
{isLocalhost ? "Try it in local mode" : "Try it now"}
</Link>
<Link
href="/register"
className="focus-ring rounded-md border border-slate-300 bg-white px-5 py-3 text-sm font-semibold text-slate-800 hover:bg-slate-50"
>
Create an account
</Link>
</div>
</section>
<section className="border-t border-slate-200 bg-white py-16">
<div className="mx-auto max-w-6xl px-6">
<h2 className="text-center text-2xl font-semibold text-slate-900">
How monitoring works
</h2>
<div className="mt-10 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{steps.map((step) => (
<div key={step.title} className="rounded-lg border border-slate-200 p-6">
<step.icon className="h-6 w-6 text-brand-600" aria-hidden />
<h3 className="mt-4 font-semibold text-slate-900">{step.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-slate-600">{step.body}</p>
</div>
))}
</div>
</div>
</section>
<section className="border-t border-slate-200 bg-slate-900 py-14 text-slate-100">
<div className="mx-auto max-w-4xl px-6 text-center">
<h2 className="text-xl font-semibold">Only publicly available information</h2>
<p className="mx-auto mt-3 max-w-2xl text-sm leading-relaxed text-slate-300">
CI Agent never bypasses CAPTCHAs, login walls, paywalls, or anti-bot controls, and
respects robots.txt. Every source document keeps its original URL and retrieval
timestamp, and every source failure is recorded transparently a report is never
presented as complete when a source failed to collect.
</p>
</div>
</section>
<footer className="bg-white py-8">
<p className="text-center text-xs text-slate-500">
CI Agent local-first competitive intelligence monitoring.
</p>
</footer>
</main>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
}),
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
+152
View File
@@ -0,0 +1,152 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Loader2, UserPlus } from "lucide-react";
import { z } from "zod";
import { FormField } from "@/components/ui/form-field";
import { PasswordField } from "@/components/ui/password-field";
import { PasswordStrengthMeter } from "@/components/ui/password-strength-meter";
import { TurnstileWidget, type TurnstileWidgetHandle } from "@/components/ui/turnstile-widget";
import { authErrorMessage, useCurrentUser, useRegister, useSystemStatus } from "@/hooks/use-auth";
const registerSchema = z.object({
displayName: z.string().min(1, "Enter your name"),
email: z.string().email("Enter a valid email address"),
password: z
.string()
.min(10, "Must be at least 10 characters")
.refine((v) => /[a-zA-Z]/.test(v) && /\d/.test(v), {
message: "Must contain at least one letter and one digit",
}),
});
type RegisterForm = z.infer<typeof registerSchema>;
export default function RegisterPage() {
const router = useRouter();
const registerMutation = useRegister();
const { data: currentUser } = useCurrentUser();
const { data: status } = useSystemStatus();
const turnstileRef = useRef<TurnstileWidgetHandle>(null);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<RegisterForm>({ resolver: zodResolver(registerSchema) });
// Mirrors the backend's turnstile_required(is_localhost, settings) gate -
// skipped on loopback regardless of auth mode.
const turnstileRequired = status ? !status.is_localhost : false;
useEffect(() => {
if (currentUser) {
router.replace("/dashboard");
}
}, [currentUser, router]);
const onSubmit = handleSubmit(async (values) => {
try {
await registerMutation.mutateAsync({
email: values.email,
password: values.password,
display_name: values.displayName,
turnstile_token: turnstileToken ?? undefined,
});
router.push(`/verify-email?email=${encodeURIComponent(values.email)}`);
} catch {
// Surfaced via registerMutation.isError below.
} finally {
// Tokens are single-use - always reset after a submit attempt,
// success or failure, so a retry never reuses a stale token.
turnstileRef.current?.reset();
setTurnstileToken(null);
}
});
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12">
<div className="w-full max-w-sm animate-fade-in overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="h-1.5 bg-gradient-to-r from-brand-500 via-brand-600 to-brand-700" />
<div className="p-8">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<UserPlus className="h-4.5 w-4.5" aria-hidden />
</span>
<div>
<h1 className="text-xl font-semibold text-slate-900">Create your account</h1>
<p className="text-sm text-slate-600">Start monitoring your competitors.</p>
</div>
</div>
<form onSubmit={onSubmit} className="mt-6 space-y-4" noValidate>
<FormField
label="Full name"
type="text"
autoComplete="name"
{...register("displayName")}
error={errors.displayName?.message}
/>
<FormField
label="Email"
type="email"
autoComplete="email"
{...register("email")}
error={errors.email?.message}
/>
<div>
<PasswordField
label="Password"
autoComplete="new-password"
hint="At least 10 characters, with a letter and a digit."
{...register("password")}
error={errors.password?.message}
/>
<PasswordStrengthMeter password={watch("password") ?? ""} />
</div>
{turnstileRequired && (
<TurnstileWidget
ref={turnstileRef}
siteKey={status?.turnstile_site_key}
onToken={setTurnstileToken}
/>
)}
{registerMutation.isError && (
<p className="animate-fade-in text-sm text-red-600" role="alert">
{authErrorMessage(registerMutation.error)}
</p>
)}
<button
type="submit"
disabled={registerMutation.isPending}
className="focus-ring inline-flex w-full items-center justify-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{registerMutation.isPending && (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
)}
{registerMutation.isPending ? "Creating account…" : "Create account"}
</button>
</form>
<p className="mt-6 text-center text-sm text-slate-600">
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-brand-600 transition-colors duration-150 hover:text-brand-700"
>
Sign in
</Link>
</p>
</div>
</div>
</main>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { useForm } from "react-hook-form";
import { KeyRound, Loader2 } from "lucide-react";
import { z } from "zod";
import { FormField } from "@/components/ui/form-field";
import { PasswordField } from "@/components/ui/password-field";
import { PasswordStrengthMeter } from "@/components/ui/password-strength-meter";
import { authErrorMessage, useConfirmPasswordReset } from "@/hooks/use-auth";
const resetSchema = z.object({
email: z.string().email("Enter a valid email address"),
code: z
.string()
.length(6, "Enter the 6-digit code")
.regex(/^\d{6}$/, "Digits only"),
newPassword: z
.string()
.min(10, "Must be at least 10 characters")
.refine((v) => /[a-zA-Z]/.test(v) && /\d/.test(v), {
message: "Must contain at least one letter and one digit",
}),
});
type ResetForm = z.infer<typeof resetSchema>;
function ResetPasswordCard() {
const router = useRouter();
const searchParams = useSearchParams();
const resetMutation = useConfirmPasswordReset();
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<ResetForm>({
resolver: zodResolver(resetSchema),
defaultValues: { email: searchParams.get("email") ?? "" },
});
const onSubmit = handleSubmit(async (values) => {
try {
await resetMutation.mutateAsync({
email: values.email,
code: values.code,
new_password: values.newPassword,
});
router.push("/login?reset=1");
} catch {
// Surfaced via resetMutation.isError below.
}
});
return (
<div className="w-full max-w-sm animate-fade-in overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="h-1.5 bg-gradient-to-r from-brand-500 via-brand-600 to-brand-700" />
<div className="p-8">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<KeyRound className="h-4.5 w-4.5" aria-hidden />
</span>
<div>
<h1 className="text-xl font-semibold text-slate-900">Choose a new password</h1>
<p className="text-sm text-slate-600">Enter the code we emailed you.</p>
</div>
</div>
<form onSubmit={onSubmit} className="mt-6 space-y-4" noValidate>
<FormField
label="Email"
type="email"
autoComplete="email"
{...register("email")}
error={errors.email?.message}
/>
<FormField
label="Reset code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="123456"
{...register("code", {
onChange: (e) => {
e.target.value = e.target.value.replace(/\D/g, "").slice(0, 6);
},
})}
error={errors.code?.message}
/>
<div>
<PasswordField
label="New password"
autoComplete="new-password"
hint="At least 10 characters, with a letter and a digit."
{...register("newPassword")}
error={errors.newPassword?.message}
/>
<PasswordStrengthMeter password={watch("newPassword") ?? ""} />
</div>
{resetMutation.isError && (
<p className="animate-fade-in text-sm text-red-600" role="alert">
{authErrorMessage(resetMutation.error)}
</p>
)}
<button
type="submit"
disabled={resetMutation.isPending}
className="focus-ring inline-flex w-full items-center justify-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{resetMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{resetMutation.isPending ? "Resetting…" : "Reset password"}
</button>
</form>
<p className="mt-6 text-center text-sm text-slate-600">
<Link
href="/login"
className="font-medium text-brand-600 transition-colors duration-150 hover:text-brand-700"
>
Back to sign in
</Link>
</p>
</div>
</div>
);
}
export default function ResetPasswordPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12">
<Suspense fallback={null}>
<ResetPasswordCard />
</Suspense>
</main>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { type FormEvent, useState } from "react";
import { Loader2, ShieldAlert } from "lucide-react";
import { authErrorMessage, useSubmitUnbanRequest } from "@/hooks/use-auth";
export default function UnbanRequestPage() {
const submitMutation = useSubmitUnbanRequest();
const [message, setMessage] = useState("");
const [submitted, setSubmitted] = useState(false);
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
try {
await submitMutation.mutateAsync({ message: message.trim() || undefined });
setSubmitted(true);
} catch {
// Surfaced via submitMutation.isError below.
}
};
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12">
<div className="w-full max-w-sm animate-fade-in overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="h-1.5 bg-gradient-to-r from-brand-500 via-brand-600 to-brand-700" />
<div className="p-8">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<ShieldAlert className="h-4.5 w-4.5" aria-hidden />
</span>
<div>
<h1 className="text-xl font-semibold text-slate-900">Request an unban</h1>
<p className="text-sm text-slate-600">
Your network address was blocked after repeated failed attempts.
</p>
</div>
</div>
{submitted ? (
<p className="mt-6 animate-fade-in rounded-md bg-green-50 px-3 py-2 text-sm text-green-700">
Request received - an admin will review it. You can send another request in 24
hours if this one doesn&apos;t go through.
</p>
) : (
<form onSubmit={onSubmit} className="mt-6 space-y-4" noValidate>
<div>
<label htmlFor="unban-message" className="block text-sm font-medium text-slate-700">
Message (optional)
</label>
<textarea
id="unban-message"
rows={4}
maxLength={2000}
value={message}
onChange={(event) => setMessage(event.target.value)}
className="focus-ring mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm"
placeholder="Tell us why you think this was a mistake."
/>
</div>
{submitMutation.isError && (
<p className="animate-fade-in text-sm text-red-600" role="alert">
{authErrorMessage(submitMutation.error)}
</p>
)}
<button
type="submit"
disabled={submitMutation.isPending}
className="focus-ring inline-flex w-full items-center justify-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitMutation.isPending && (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
)}
{submitMutation.isPending ? "Sending…" : "Send request"}
</button>
</form>
)}
<p className="mt-6 text-center text-sm text-slate-600">
<Link
href="/login"
className="font-medium text-brand-600 transition-colors duration-150 hover:text-brand-700"
>
Back to sign in
</Link>
</p>
</div>
</div>
</main>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Loader2, MailCheck } from "lucide-react";
import { z } from "zod";
import { FormField } from "@/components/ui/form-field";
import { useCountdown } from "@/hooks/use-countdown";
import {
authErrorMessage,
retryAfterSeconds,
useCurrentUser,
useResendVerification,
useVerifyEmail,
} from "@/hooks/use-auth";
const verifySchema = z.object({
code: z
.string()
.length(6, "Enter the 6-digit code")
.regex(/^\d{6}$/, "Digits only"),
});
type VerifyForm = z.infer<typeof verifySchema>;
function VerifyEmailCard() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get("email") ?? "";
const verifyMutation = useVerifyEmail();
const resendMutation = useResendVerification();
const { data: currentUser } = useCurrentUser();
const countdown = useCountdown();
const [resent, setResent] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<VerifyForm>({ resolver: zodResolver(verifySchema) });
useEffect(() => {
if (currentUser) {
router.replace("/dashboard");
}
}, [currentUser, router]);
const onSubmit = handleSubmit(async (values) => {
try {
await verifyMutation.mutateAsync({ email, code: values.code });
router.push("/login?verified=1");
} catch {
// Surfaced via verifyMutation.isError below - swallow here so a
// wrong/expired code doesn't also trip Next's unhandled-rejection
// dev overlay on top of the inline error message.
}
});
const onResend = async () => {
setResent(false);
try {
await resendMutation.mutateAsync({ email });
setResent(true);
countdown.start(30);
} catch (error) {
const retryAfter = retryAfterSeconds(error);
if (retryAfter) countdown.start(retryAfter);
}
};
return (
<div className="w-full max-w-sm animate-fade-in overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="h-1.5 bg-gradient-to-r from-brand-500 via-brand-600 to-brand-700" />
<div className="p-8">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<MailCheck className="h-4.5 w-4.5" aria-hidden />
</span>
<div>
<h1 className="text-xl font-semibold text-slate-900">Verify your email</h1>
<p className="text-sm text-slate-600">
{email ? (
<>
We sent a 6-digit code to <span className="font-medium">{email}</span>.
</>
) : (
"Enter the 6-digit code we emailed you."
)}
</p>
</div>
</div>
<form onSubmit={onSubmit} className="mt-6 space-y-4" noValidate>
<FormField
label="Verification code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="123456"
{...register("code", {
onChange: (e) => {
e.target.value = e.target.value.replace(/\D/g, "").slice(0, 6);
},
})}
error={errors.code?.message}
/>
{verifyMutation.isError && (
<p className="animate-fade-in text-sm text-red-600" role="alert">
{authErrorMessage(verifyMutation.error)}
</p>
)}
<button
type="submit"
disabled={verifyMutation.isPending}
className="focus-ring inline-flex w-full items-center justify-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{verifyMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{verifyMutation.isPending ? "Verifying…" : "Verify email"}
</button>
</form>
<div className="mt-4 text-center text-sm text-slate-600">
{resent && countdown.remaining === 0 && !resendMutation.isPending && (
<p className="mb-2 animate-fade-in text-green-700">Code resent - check your inbox.</p>
)}
<button
type="button"
onClick={onResend}
disabled={resendMutation.isPending || countdown.remaining > 0 || !email}
className="focus-ring font-medium text-brand-600 transition-colors duration-150 hover:text-brand-700 disabled:cursor-not-allowed disabled:text-slate-400"
>
{countdown.remaining > 0
? `Resend in ${countdown.remaining}s`
: resendMutation.isPending
? "Sending…"
: "Resend code"}
</button>
</div>
<p className="mt-6 text-center text-sm text-slate-600">
<Link
href="/login"
className="font-medium text-brand-600 transition-colors duration-150 hover:text-brand-700"
>
Back to sign in
</Link>
</p>
</div>
</div>
);
}
export default function VerifyEmailPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12">
<Suspense fallback={null}>
<VerifyEmailCard />
</Suspense>
</main>
);
}
@@ -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>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import type { AlertListFilters, AlertUpdatePayload } from "@/lib/types";
export function useAlerts(filters: AlertListFilters = {}) {
return useQuery({
queryKey: ["alerts", filters],
queryFn: () => api.listAlerts(filters),
});
}
export function useAlert(alertId: string | undefined) {
return useQuery({
queryKey: ["alerts", "detail", alertId],
queryFn: () => api.getAlert(alertId as string),
enabled: Boolean(alertId),
});
}
function useInvalidateAlerts() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: ["alerts"] });
}
export function useUpdateAlert(alertId: string) {
const invalidate = useInvalidateAlerts();
return useMutation({
mutationFn: (payload: AlertUpdatePayload) => api.updateAlert(alertId, payload),
onSuccess: invalidate,
});
}
export function useMarkAlertRead() {
const invalidate = useInvalidateAlerts();
return useMutation({
mutationFn: (alertId: string) => api.markAlertRead(alertId),
onSuccess: invalidate,
});
}
export function useResolveAlert() {
const invalidate = useInvalidateAlerts();
return useMutation({
mutationFn: (alertId: string) => api.resolveAlert(alertId),
onSuccess: invalidate,
});
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
export function useDashboardAnalytics() {
return useQuery({
queryKey: ["dashboard-analytics"],
queryFn: api.getDashboardAnalytics,
});
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, clearTokens, getRefreshToken, setTokens } from "@/lib/api-client";
import type {
BanIpPayload,
ConfirmPasswordResetPayload,
LoginPayload,
RegisterPayload,
RequestPasswordResetPayload,
ResendVerificationPayload,
SetSystemSecretPayload,
SetUserApiKeyPayload,
SystemSecretStatus,
UnbanRequestPayload,
UserApiKeyStatus,
VerifyEmailPayload,
} from "@/lib/types";
export function useSystemStatus() {
return useQuery({
queryKey: ["system-status"],
queryFn: api.systemStatus,
staleTime: 60_000,
});
}
export function useSystemSecrets() {
return useQuery({
queryKey: ["system-secrets"],
queryFn: api.listSystemSecrets,
});
}
export function useSetSystemSecret() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, payload }: { key: string; payload: SetSystemSecretPayload }) =>
api.setSystemSecret(key, payload),
onSuccess: (updated) => {
queryClient.setQueryData<SystemSecretStatus[]>(["system-secrets"], (prev) =>
prev?.map((s) => (s.key === updated.key ? updated : s)),
);
queryClient.invalidateQueries({ queryKey: ["system-status"] });
},
});
}
export function useSystemLogs() {
return useQuery({
queryKey: ["system-logs"],
queryFn: api.systemLogs,
refetchInterval: 5_000,
});
}
export function useCurrentUser() {
return useQuery({
queryKey: ["me"],
queryFn: api.me,
retry: false,
});
}
export function useLogin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: LoginPayload) => api.login(payload),
onSuccess: (tokens) => {
setTokens(tokens.access_token, tokens.refresh_token);
return queryClient.invalidateQueries({ queryKey: ["me"] });
},
});
}
export function useRegister() {
return useMutation({
mutationFn: (payload: RegisterPayload) => api.register(payload),
});
}
export function useLogout() {
const router = useRouter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const refreshToken = getRefreshToken();
if (refreshToken) {
await api.logout(refreshToken).catch(() => undefined);
}
},
onSuccess: () => {
clearTokens();
// We already know definitively the user is logged out here - clear
// the whole cache and navigate immediately, rather than invalidating
// and waiting for a background refetch of ["me"] to fail with 401.
// That indirection left a window where the dashboard kept rendering
// stale cached data until the reactive layout redirect eventually
// caught up, flickering between dashboard and login on every logout.
queryClient.clear();
router.replace("/login");
},
});
}
export function useVerifyEmail() {
return useMutation({
mutationFn: (payload: VerifyEmailPayload) => api.verifyEmail(payload),
});
}
export function useResendVerification() {
return useMutation({
mutationFn: (payload: ResendVerificationPayload) => api.resendVerification(payload),
});
}
export function useRequestPasswordReset() {
return useMutation({
mutationFn: (payload: RequestPasswordResetPayload) => api.requestPasswordReset(payload),
});
}
export function useConfirmPasswordReset() {
return useMutation({
mutationFn: (payload: ConfirmPasswordResetPayload) => api.confirmPasswordReset(payload),
});
}
export function useSecurityEvents() {
return useQuery({
queryKey: ["security-events"],
queryFn: api.securityEvents,
});
}
export function useSubmitUnbanRequest() {
return useMutation({
mutationFn: (payload: UnbanRequestPayload) => api.submitUnbanRequest(payload),
});
}
export function useIpBans() {
return useQuery({
queryKey: ["ip-bans"],
queryFn: api.listIpBans,
});
}
export function useUnbanRequests() {
return useQuery({
queryKey: ["unban-requests"],
queryFn: api.listUnbanRequests,
});
}
export function useDeleteIpBan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (ipAddress: string) => api.deleteIpBan(ipAddress),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["ip-bans"] }),
});
}
export function useCreateIpBan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: BanIpPayload) => api.createIpBan(payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["ip-bans"] }),
});
}
export function useAcceptUnbanRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (requestId: string) => api.acceptUnbanRequest(requestId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ip-bans"] });
queryClient.invalidateQueries({ queryKey: ["unban-requests"] });
},
});
}
export function useRejectUnbanRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (requestId: string) => api.rejectUnbanRequest(requestId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["unban-requests"] }),
});
}
export function useUserApiKeys() {
return useQuery({
queryKey: ["user-api-keys"],
queryFn: api.listUserApiKeys,
});
}
export function useSetUserApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ provider, payload }: { provider: string; payload: SetUserApiKeyPayload }) =>
api.setUserApiKey(provider, payload),
onSuccess: (updated) => {
queryClient.setQueryData<UserApiKeyStatus[]>(["user-api-keys"], (prev) =>
prev?.map((k) => (k.provider === updated.provider ? updated : k)),
);
},
});
}
/** Seconds until a throttled action (resend/reset) can be retried, or null
* if the most recent error wasn't a throttle response - drives a live
* countdown on the resend button. */
export function retryAfterSeconds(error: unknown): number | null {
if (error instanceof ApiError && typeof error.body?.retry_after_seconds === "number") {
return error.body.retry_after_seconds;
}
return null;
}
export type LoginFailureKind =
| "verify_email"
| "account_locked"
| "throttled"
| "banned"
| "generic";
export interface LoginFailure {
kind: LoginFailureKind;
message: string;
retryAfterSeconds: number | null;
}
/** The backend keeps login error messages generic on purpose (never
* confirms account existence), so the UI distinguishes cases by status code
* and message shape rather than a structured error code. */
export function classifyLoginError(error: unknown): LoginFailure {
const message = authErrorMessage(error);
if (error instanceof ApiError && error.status === 429) {
const retry = retryAfterSeconds(error);
return { kind: retry !== null ? "throttled" : "banned", message, retryAfterSeconds: retry };
}
if (/verify your email/i.test(message)) {
return { kind: "verify_email", message, retryAfterSeconds: null };
}
if (/account locked/i.test(message)) {
return { kind: "account_locked", message, retryAfterSeconds: null };
}
return { kind: "generic", message, retryAfterSeconds: null };
}
export function authErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
if (error.body?.detail) return error.body.detail;
if (error.body?.errors?.length) {
return error.body.errors
.map((e) => e.msg)
.filter(Boolean)
.join(", ");
}
}
return "Something went wrong. Please try again.";
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import type {
CompanyCreatePayload,
CompanyResponse,
CompanyUpdatePayload,
MonitorConfigurationUpdatePayload,
} from "@/lib/types";
export function useCompanies() {
return useQuery({ queryKey: ["companies"], queryFn: api.listCompanies });
}
export function useCompany(companyId: string | undefined) {
return useQuery({
queryKey: ["companies", companyId],
queryFn: () => api.getCompany(companyId as string),
enabled: Boolean(companyId),
// Enrichment (NinjaPear) runs in the background after company
// creation - poll while it's still pending so the Enrichment tab
// updates itself once the task finishes, without a manual refresh.
refetchInterval: (query) => {
const company = query.state.data as CompanyResponse | undefined;
return company?.enrichment?.status === "pending" ? 3000 : false;
},
});
}
export function useCreateCompany() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CompanyCreatePayload) => api.createCompany(payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["companies"] }),
});
}
export function useUpdateCompany(companyId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CompanyUpdatePayload) => api.updateCompany(companyId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["companies"] });
queryClient.invalidateQueries({ queryKey: ["companies", companyId] });
},
});
}
export function useDeleteCompany() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (companyId: string) => api.deleteCompany(companyId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["companies"] }),
});
}
export function useSetCompanyPaused() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ companyId, paused }: { companyId: string; paused: boolean }) =>
paused ? api.pauseCompany(companyId) : api.resumeCompany(companyId),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["companies"] });
queryClient.invalidateQueries({ queryKey: ["companies", variables.companyId] });
},
});
}
export function useUpdateMonitorConfiguration(companyId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: MonitorConfigurationUpdatePayload) =>
api.updateMonitorConfiguration(companyId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["companies"] });
queryClient.invalidateQueries({ queryKey: ["companies", companyId] });
},
});
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { useEffect, useState } from "react";
/** A countdown in whole seconds, started on demand via `start(seconds)` -
* drives the "Resend in Ns" button state after a 429 throttle response. */
export function useCountdown() {
const [remaining, setRemaining] = useState(0);
useEffect(() => {
if (remaining <= 0) return;
const timer = setTimeout(() => setRemaining((s) => Math.max(0, s - 1)), 1000);
return () => clearTimeout(timer);
}, [remaining]);
return { remaining, start: (seconds: number) => setRemaining(seconds) };
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import type { DiscoverCompanyRequest } from "@/lib/types";
export function useDiscoverCompany() {
return useMutation({
mutationFn: (payload: DiscoverCompanyRequest) => api.discoverCompany(payload),
});
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import type { MonitoringRunResponse } from "@/lib/types";
const ACTIVE_STATUSES = new Set(["queued", "running"]);
export function useCompanyRuns(companyId: string) {
return useQuery({
queryKey: ["runs", companyId],
queryFn: () => api.listCompanyRuns(companyId),
refetchInterval: (query) => {
const runs = query.state.data as MonitoringRunResponse[] | undefined;
return runs?.some((r) => ACTIVE_STATUSES.has(r.status)) ? 2000 : false;
},
});
}
export function useRunCompanyNow(companyId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.runCompanyNow(companyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["runs", companyId] });
queryClient.invalidateQueries({ queryKey: ["companies", companyId] });
queryClient.invalidateQueries({ queryKey: ["sources", companyId] });
},
});
}
/** Same action, for call sites (like the companies list) that don't want to
* fix a single companyId at the hook-call site. */
export function useRunAnyCompanyNow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (companyId: string) => api.runCompanyNow(companyId),
onSuccess: (_data, companyId) => {
queryClient.invalidateQueries({ queryKey: ["runs", companyId] });
queryClient.invalidateQueries({ queryKey: ["companies"] });
},
});
}
@@ -0,0 +1,61 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import type {
NotificationDestinationCreatePayload,
NotificationDestinationUpdatePayload,
} from "@/lib/types";
export function useNotificationDestinations() {
return useQuery({
queryKey: ["notification-destinations"],
queryFn: api.listNotificationDestinations,
});
}
export function useCreateNotificationDestination() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: NotificationDestinationCreatePayload) =>
api.createNotificationDestination(payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["notification-destinations"] }),
});
}
export function useUpdateNotificationDestination() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
destinationId,
payload,
}: {
destinationId: string;
payload: NotificationDestinationUpdatePayload;
}) => api.updateNotificationDestination(destinationId, payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["notification-destinations"] }),
});
}
export function useDeleteNotificationDestination() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (destinationId: string) => api.deleteNotificationDestination(destinationId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["notification-destinations"] }),
});
}
export function useUnlinkNotificationDestinationCompany() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ destinationId, companyId }: { destinationId: string; companyId: string }) =>
api.unlinkNotificationDestinationCompany(destinationId, companyId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["notification-destinations"] }),
});
}
export function useTestNotificationDestination() {
return useMutation({
mutationFn: (destinationId: string) => api.testNotificationDestination(destinationId),
});
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
export function useCompanyReports(companyId: string) {
return useQuery({ queryKey: ["reports", companyId], queryFn: () => api.listReports(companyId) });
}
export function useReport(reportId: string | undefined) {
return useQuery({
queryKey: ["report", reportId],
queryFn: () => api.getReport(reportId as string),
enabled: Boolean(reportId),
});
}
export function useReportMarkdown(reportId: string | undefined) {
return useQuery({
queryKey: ["report", reportId, "markdown"],
queryFn: () => api.getReportMarkdown(reportId as string),
enabled: Boolean(reportId),
});
}
export function useGenerateReport(companyId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.generateReport(companyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["reports", companyId] });
queryClient.invalidateQueries({ queryKey: ["companies", companyId] });
},
});
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
export function useSnapshots(companyId: string) {
return useQuery({
queryKey: ["snapshots", companyId],
queryFn: () => api.listSnapshots(companyId),
});
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import type { SourceCreatePayload, SourceUpdatePayload } from "@/lib/types";
export function useSources(companyId: string) {
return useQuery({ queryKey: ["sources", companyId], queryFn: () => api.listSources(companyId) });
}
export function useCreateSource(companyId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: SourceCreatePayload) => api.createSource(companyId, payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["sources", companyId] }),
});
}
export function useUpdateSource(companyId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ sourceId, payload }: { sourceId: string; payload: SourceUpdatePayload }) =>
api.updateSource(sourceId, payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["sources", companyId] }),
});
}
export function useDeleteSource(companyId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (sourceId: string) => api.deleteSource(sourceId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["sources", companyId] }),
});
}
export function useTestSource(companyId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (sourceId: string) => api.testSource(sourceId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["sources", companyId] }),
});
}
+415
View File
@@ -0,0 +1,415 @@
import { API_BASE_URL } from "./config";
import type {
AlertDetailResponse,
AlertListFilters,
AlertResponse,
AlertUpdatePayload,
ApiErrorBody,
BanIpPayload,
CompanyCreatePayload,
CompanyResponse,
CompanyUpdatePayload,
ConfirmPasswordResetPayload,
DashboardAnalytics,
DiscoverCompanyRequest,
DiscoveredCompanyProfile,
IpBan,
LogEntry,
LoginPayload,
MeResponse,
MonitorConfigurationResponse,
MonitorConfigurationUpdatePayload,
MonitoringRunResponse,
NotificationDestinationCreatePayload,
NotificationDestinationResponse,
NotificationDestinationUpdatePayload,
NotificationTestResult,
RegisterPayload,
ReportListItem,
ReportResponse,
RequestPasswordResetPayload,
ResendVerificationPayload,
SecurityEvent,
SetSystemSecretPayload,
SetUserApiKeyPayload,
SnapshotResponse,
SourceCreatePayload,
SourceResponse,
SourceTestResult,
SourceUpdatePayload,
SystemSecretStatus,
SystemStatus,
TokenResponse,
UnbanRequestPayload,
UnbanRequestRecord,
UserApiKeyStatus,
UserResponse,
VerifyEmailPayload,
} from "./types";
const ACCESS_TOKEN_KEY = "ciagent_access_token";
const REFRESH_TOKEN_KEY = "ciagent_refresh_token";
// Local-storage token persistence keeps this MVP's client simple to run
// entirely outside Docker/without a backend session store. Tokens are
// short-lived (see JWT_ACCESS_TOKEN_MINUTES) and refresh tokens rotate on
// use; see KNOWN_LIMITATIONS.md for the httpOnly-cookie hardening this
// would need before a real multi-user production deployment.
export function getAccessToken(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setTokens(accessToken: string, refreshToken: string): void {
window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
export function clearTokens(): void {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
}
export class ApiError extends Error {
constructor(
public status: number,
public body: ApiErrorBody | undefined,
) {
super(body?.detail ?? `Request failed with status ${status}`);
this.name = "ApiError";
}
}
interface RequestOptions extends RequestInit {
auth?: boolean;
}
// Access tokens are short-lived (JWT_ACCESS_TOKEN_MINUTES, 15min) while
// refresh tokens last days (JWT_REFRESH_TOKEN_DAYS, 7) - without this, any
// idle period past 15 minutes turned every authenticated request into a
// hard 401, which bounced the dashboard layout to /login while /login's own
// stale-cache "am I logged in?" check bounced right back, flickering
// between the two. A single in-flight refresh is shared across concurrent
// 401s so simultaneous requests don't each rotate (and invalidate) the
// refresh token out from under one another.
let refreshPromise: Promise<void> | null = null;
async function performRefresh(): Promise<void> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new ApiError(401, { detail: "No refresh token available" });
}
const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
clearTokens();
throw new ApiError(response.status, undefined);
}
const tokens = (await response.json()) as TokenResponse;
setTokens(tokens.access_token, tokens.refresh_token);
}
async function request<T>(path: string, options: RequestOptions = {}, isRetry = false): Promise<T> {
const { auth = true, headers, ...rest } = options;
const finalHeaders = new Headers(headers);
finalHeaders.set("Content-Type", "application/json");
if (auth) {
const token = getAccessToken();
if (token) finalHeaders.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(`${API_BASE_URL}${path}`, { ...rest, headers: finalHeaders });
if (response.status === 401 && auth && !isRetry && getRefreshToken()) {
try {
refreshPromise ??= performRefresh().finally(() => {
refreshPromise = null;
});
await refreshPromise;
return request<T>(path, options, true);
} catch {
// Refresh itself failed (refresh token also expired/revoked) - fall
// through and report the original 401 below, which is now a real,
// final "you're actually logged out" rather than a transient one.
}
}
if (!response.ok) {
let body: ApiErrorBody | undefined;
try {
body = await response.json();
} catch {
body = undefined;
}
throw new ApiError(response.status, body);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export const api = {
systemStatus: () => request<SystemStatus>("/api/v1/system/status", { auth: false }),
systemLogs: () => request<LogEntry[]>("/api/v1/system/logs"),
listSystemSecrets: () => request<SystemSecretStatus[]>("/api/v1/system/secrets"),
setSystemSecret: (key: string, payload: SetSystemSecretPayload) =>
request<SystemSecretStatus>(`/api/v1/system/secrets/${key}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
register: (payload: RegisterPayload) =>
request<UserResponse>("/api/v1/auth/register", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
login: (payload: LoginPayload) =>
request<TokenResponse>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
refresh: (refreshToken: string) =>
request<TokenResponse>("/api/v1/auth/refresh", {
method: "POST",
body: JSON.stringify({ refresh_token: refreshToken }),
auth: false,
}),
logout: (refreshToken: string) =>
request<void>("/api/v1/auth/logout", {
method: "POST",
body: JSON.stringify({ refresh_token: refreshToken }),
}),
me: () => request<MeResponse>("/api/v1/auth/me"),
verifyEmail: (payload: VerifyEmailPayload) =>
request<void>("/api/v1/auth/verify-email", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
resendVerification: (payload: ResendVerificationPayload) =>
request<void>("/api/v1/auth/resend-verification", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
requestPasswordReset: (payload: RequestPasswordResetPayload) =>
request<void>("/api/v1/auth/request-password-reset", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
confirmPasswordReset: (payload: ConfirmPasswordResetPayload) =>
request<void>("/api/v1/auth/confirm-password-reset", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
securityEvents: () => request<SecurityEvent[]>("/api/v1/auth/security-events"),
submitUnbanRequest: (payload: UnbanRequestPayload) =>
request<void>("/api/v1/unban-requests", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
listIpBans: () => request<IpBan[]>("/api/v1/admin/ip-bans"),
createIpBan: (payload: BanIpPayload) =>
request<IpBan>("/api/v1/admin/ip-bans", {
method: "POST",
body: JSON.stringify(payload),
}),
deleteIpBan: (ipAddress: string) =>
request<void>(`/api/v1/admin/ip-bans/${encodeURIComponent(ipAddress)}`, {
method: "DELETE",
}),
listUnbanRequests: () => request<UnbanRequestRecord[]>("/api/v1/admin/unban-requests"),
acceptUnbanRequest: (requestId: string) =>
request<void>(`/api/v1/admin/unban-requests/${requestId}/accept`, { method: "POST" }),
rejectUnbanRequest: (requestId: string) =>
request<void>(`/api/v1/admin/unban-requests/${requestId}`, { method: "DELETE" }),
listUserApiKeys: () => request<UserApiKeyStatus[]>("/api/v1/user-api-keys"),
setUserApiKey: (provider: string, payload: SetUserApiKeyPayload) =>
request<UserApiKeyStatus>(`/api/v1/user-api-keys/${provider}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
listCompanies: () => request<CompanyResponse[]>("/api/v1/companies"),
discoverCompany: (payload: DiscoverCompanyRequest) =>
request<DiscoveredCompanyProfile>("/api/v1/companies/discover", {
method: "POST",
body: JSON.stringify(payload),
}),
getCompany: (companyId: string) => request<CompanyResponse>(`/api/v1/companies/${companyId}`),
createCompany: (payload: CompanyCreatePayload) =>
request<CompanyResponse>("/api/v1/companies", {
method: "POST",
body: JSON.stringify(payload),
}),
updateCompany: (companyId: string, payload: CompanyUpdatePayload) =>
request<CompanyResponse>(`/api/v1/companies/${companyId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteCompany: (companyId: string) =>
request<void>(`/api/v1/companies/${companyId}`, { method: "DELETE" }),
pauseCompany: (companyId: string) =>
request<CompanyResponse>(`/api/v1/companies/${companyId}/pause`, { method: "POST" }),
resumeCompany: (companyId: string) =>
request<CompanyResponse>(`/api/v1/companies/${companyId}/resume`, { method: "POST" }),
updateMonitorConfiguration: (companyId: string, payload: MonitorConfigurationUpdatePayload) =>
request<MonitorConfigurationResponse>(`/api/v1/companies/${companyId}/monitor`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
listNotificationDestinations: () =>
request<NotificationDestinationResponse[]>("/api/v1/notification-destinations"),
createNotificationDestination: (payload: NotificationDestinationCreatePayload) =>
request<NotificationDestinationResponse>("/api/v1/notification-destinations", {
method: "POST",
body: JSON.stringify(payload),
}),
updateNotificationDestination: (
destinationId: string,
payload: NotificationDestinationUpdatePayload,
) =>
request<NotificationDestinationResponse>(`/api/v1/notification-destinations/${destinationId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteNotificationDestination: (destinationId: string) =>
request<void>(`/api/v1/notification-destinations/${destinationId}`, { method: "DELETE" }),
unlinkNotificationDestinationCompany: (destinationId: string, companyId: string) =>
request<void>(`/api/v1/notification-destinations/${destinationId}/companies/${companyId}`, {
method: "DELETE",
}),
testNotificationDestination: (destinationId: string) =>
request<NotificationTestResult>(`/api/v1/notification-destinations/${destinationId}/test`, {
method: "POST",
}),
listAlerts: (filters: AlertListFilters = {}) => {
const params = new URLSearchParams();
if (filters.company_id) params.set("company_id", filters.company_id);
if (filters.severity) params.set("severity", filters.severity);
if (filters.read !== undefined) params.set("read", String(filters.read));
if (filters.resolved !== undefined) params.set("resolved", String(filters.resolved));
const query = params.toString();
return request<AlertResponse[]>(`/api/v1/alerts${query ? `?${query}` : ""}`);
},
getAlert: (alertId: string) => request<AlertDetailResponse>(`/api/v1/alerts/${alertId}`),
updateAlert: (alertId: string, payload: AlertUpdatePayload) =>
request<AlertResponse>(`/api/v1/alerts/${alertId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
markAlertRead: (alertId: string) =>
request<AlertResponse>(`/api/v1/alerts/${alertId}/read`, { method: "POST" }),
resolveAlert: (alertId: string) =>
request<AlertResponse>(`/api/v1/alerts/${alertId}/resolve`, { method: "POST" }),
getDashboardAnalytics: () => request<DashboardAnalytics>("/api/v1/dashboard/analytics"),
listSources: (companyId: string) =>
request<SourceResponse[]>(`/api/v1/companies/${companyId}/sources`),
listSnapshots: (companyId: string) =>
request<SnapshotResponse[]>(`/api/v1/companies/${companyId}/snapshots`),
createSource: (companyId: string, payload: SourceCreatePayload) =>
request<SourceResponse>(`/api/v1/companies/${companyId}/sources`, {
method: "POST",
body: JSON.stringify(payload),
}),
updateSource: (sourceId: string, payload: SourceUpdatePayload) =>
request<SourceResponse>(`/api/v1/sources/${sourceId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteSource: (sourceId: string) =>
request<void>(`/api/v1/sources/${sourceId}`, { method: "DELETE" }),
testSource: (sourceId: string) =>
request<SourceTestResult>(`/api/v1/sources/${sourceId}/test`, { method: "POST" }),
runCompanyNow: (companyId: string) =>
request<MonitoringRunResponse>(`/api/v1/companies/${companyId}/run`, { method: "POST" }),
listCompanyRuns: (companyId: string) =>
request<MonitoringRunResponse[]>(`/api/v1/companies/${companyId}/runs`),
getRun: (runId: string) => request<MonitoringRunResponse>(`/api/v1/runs/${runId}`),
listReports: (companyId: string) =>
request<ReportListItem[]>(`/api/v1/companies/${companyId}/reports`),
generateReport: (companyId: string) =>
request<ReportResponse>(`/api/v1/companies/${companyId}/reports/generate`, {
method: "POST",
}),
getReport: (reportId: string) => request<ReportResponse>(`/api/v1/reports/${reportId}`),
getReportMarkdown: (reportId: string) =>
fetch(`${API_BASE_URL}/api/v1/reports/${reportId}/markdown`, {
headers: (() => {
const h = new Headers();
const token = getAccessToken();
if (token) h.set("Authorization", `Bearer ${token}`);
return h;
})(),
}).then((r) => r.text()),
};
+13
View File
@@ -0,0 +1,13 @@
import type { SystemStatus } from "./types";
/**
* True when this connection is getting the local-dev free pass (fixed
* account, no login) - both that the operator hasn't forced full lockdown
* (`auth_mode === "jwt"`) and that the backend actually recognized this
* specific request as coming from loopback (`is_localhost`). Used anywhere
* the UI needs to decide whether to show a login screen, a sign-out
* button, or local-dev-only copy.
*/
export function isLocalConvenience(status: SystemStatus): boolean {
return status.auth_mode !== "jwt" && status.is_localhost;
}
+1
View File
@@ -0,0 +1 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
+76
View File
@@ -0,0 +1,76 @@
import { format, formatDistanceToNow } from "date-fns";
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—";
return format(new Date(iso), "MMM d, yyyy h:mm a");
}
export function formatRelative(iso: string | null | undefined): string {
if (!iso) return "—";
return formatDistanceToNow(new Date(iso), { addSuffix: true });
}
/**
* NinjaPear's funding fields (`total_raised`, a round's `amount`) come back
* as raw numeric strings (e.g. "500000000"), with no currency formatting.
* Turns that into "$500,000,000"; leaves already-formatted or non-numeric
* values (e.g. "$1M", "Undisclosed") untouched.
*/
export function formatMoney(value: string): string {
const trimmed = value.trim();
if (!trimmed || !/^\d+(\.\d+)?$/.test(trimmed)) return trimmed;
const amount = Number(trimmed);
if (!Number.isFinite(amount)) return trimmed;
return `$${amount.toLocaleString("en-US")}`;
}
/**
* NinjaPear's competitor/customer `name` fields sometimes come back as a
* bare URL (e.g. "https://squareup.com") rather than a company name. Turns
* that into something readable; leaves already-clean names untouched.
*/
export function companyNameFromUrl(value: string): string {
const trimmed = value.trim();
const withoutProtocol = trimmed.replace(/^https?:\/\//i, "").replace(/^www\./i, "");
if (!withoutProtocol.includes(".")) return trimmed;
const label = withoutProtocol.split("/")[0]?.split(".")[0];
if (!label) return trimmed;
return label.charAt(0).toUpperCase() + label.slice(1).toLowerCase();
}
const ENRICHMENT_REASON_LABELS: Record<string, string> = {
product_overlap: "Similar products",
organic_keyword_overlap: "Overlapping search keywords",
};
/** Turns a NinjaPear competitor-match slug (e.g. "product_overlap") into a
* human-readable label; unrecognized slugs still read as words. */
export function formatEnrichmentReason(reason: string): string {
const known = ENRICHMENT_REASON_LABELS[reason];
if (known) return known;
return reason
.split("_")
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
/** Light heuristics to turn a raw exception string from an enrichment
* section failure into a short, non-technical summary. */
export function summarizeEnrichmentError(raw: string): string {
const firstLine = raw.split("\n")[0]?.trim() ?? raw;
if (/^\d+ validation errors?/i.test(firstLine)) {
return "The data returned didn't match the expected format.";
}
if (/404 not found/i.test(raw)) {
return "The requested data wasn't found.";
}
if (/timed? ?out/i.test(raw)) {
return "The request timed out.";
}
if (/\b5\d\d\b/.test(raw) && /error/i.test(raw)) {
return "The data provider returned a server error.";
}
return firstLine.length > 100 ? `${firstLine.slice(0, 100)}` : firstLine;
}
+588
View File
@@ -0,0 +1,588 @@
// Mirrors apps/api/app/schemas/*.py. Kept in sync by hand for now (see
// packages/shared for the plain-constant subset shared cross-language).
export interface SystemStatusComponent {
name: string;
status: "ok" | "error";
detail: string | null;
}
export interface SystemStatus {
app_env: string;
auth_mode: "local" | "jwt";
llm_provider: string;
search_provider: string;
sms_enabled: boolean;
sms_provider: string;
ninjapear_configured: boolean;
ninjapear_credit_balance: number | null;
ninjapear_estimated_credits_per_company: number | null;
is_localhost: boolean;
turnstile_site_key: string | null;
components: SystemStatusComponent[];
}
export interface SystemSecretStatus {
key: string;
label: string;
configured: boolean;
value: string | null;
}
export interface SetSystemSecretPayload {
value: string;
}
export interface UserApiKeyStatus {
provider: string;
label: string;
configured: boolean;
value: string | null;
credits: number | null;
credits_note: string | null;
free: boolean;
requires_government_id: boolean;
}
export interface SetUserApiKeyPayload {
key: string;
}
export type LogCategory = "internal_error" | "api_error" | "important" | "normal";
export interface LogEntry {
ts: string;
level: string;
category: LogCategory;
logger: string;
event: string;
context: Record<string, unknown>;
}
export interface UserResponse {
id: string;
email: string;
display_name: string;
timezone: string;
is_active: boolean;
is_admin: boolean;
}
export interface MeResponse extends UserResponse {
auth_mode: "local" | "jwt";
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in_minutes: number;
}
export interface RegisterPayload {
email: string;
password: string;
display_name: string;
timezone?: string;
turnstile_token?: string;
}
export interface LoginPayload {
email: string;
password: string;
turnstile_token?: string;
}
export interface ApiErrorBody {
detail?: string;
errors?: Array<{ msg?: string; loc?: (string | number)[] }>;
retry_after_seconds?: number;
}
export interface VerifyEmailPayload {
email: string;
code: string;
}
export interface ResendVerificationPayload {
email: string;
turnstile_token?: string;
}
export interface RequestPasswordResetPayload {
email: string;
turnstile_token?: string;
}
export interface ConfirmPasswordResetPayload {
email: string;
code: string;
new_password: string;
}
export interface SecurityEvent {
event_type: string;
ip_address: string;
created_at: string;
}
export interface IpBan {
ip_address: string;
banned_at: string;
reason: string;
}
export interface BanIpPayload {
ip_address: string;
}
export interface UnbanRequestPayload {
message?: string;
}
export interface UnbanRequestRecord {
id: string;
ip_address: string;
message: string | null;
created_at: string;
}
export const MONITORING_FREQUENCIES = [
"hourly",
"every_6_hours",
"every_12_hours",
"daily",
"every_2_days",
"weekly",
"every_2_weeks",
"monthly",
"custom",
] as const;
export type MonitoringFrequency = (typeof MONITORING_FREQUENCIES)[number];
export const FREQUENCY_LABELS: Record<MonitoringFrequency, string> = {
hourly: "Hourly",
every_6_hours: "Every 6 hours",
every_12_hours: "Every 12 hours",
daily: "Daily",
every_2_days: "Every 2 days",
weekly: "Weekly",
every_2_weeks: "Every 2 weeks",
monthly: "Monthly",
custom: "Custom",
};
export const SEVERITY_LEVELS = ["critical", "high", "medium", "low"] as const;
export type SeverityLevel = (typeof SEVERITY_LEVELS)[number];
export const SEVERITY_LABELS: Record<SeverityLevel, string> = {
critical: "Critical",
high: "High",
medium: "Medium",
low: "Low",
};
export type CompanyStatus = "active" | "paused";
export interface MonitorConfigurationResponse {
id: string;
frequency_type: MonitoringFrequency;
interval_minutes: number | null;
cron_expression: string | null;
timezone: string;
enabled: boolean;
next_run: string | null;
last_run: string | null;
severity_threshold: SeverityLevel;
}
export interface MonitorConfigurationUpdatePayload {
frequency_type?: MonitoringFrequency;
interval_minutes?: number | null;
cron_expression?: string | null;
timezone?: string;
enabled?: boolean;
severity_threshold?: SeverityLevel;
}
export type EnrichmentStatus = "pending" | "partial" | "complete" | "failed";
export interface CompanyEnrichmentResponse {
status: EnrichmentStatus;
data: Record<string, unknown>;
errors: Record<string, string>;
credits_spent: number | null;
fetched_at: string | null;
}
export interface CompanyResponse {
id: string;
name: string;
slug: string;
official_website: string | null;
description: string | null;
monitoring_focus: string | null;
industry: string | null;
country: string | null;
region: string | null;
headquarters: string | null;
public_identifiers: Record<string, string>;
status: CompanyStatus;
created_at: string;
updated_at: string;
aliases: string[];
competitors: string[];
monitor_configuration: MonitorConfigurationResponse | null;
enrichment: CompanyEnrichmentResponse | null;
report_count: number;
unresolved_alert_count: number;
}
export interface CompanyCreatePayload {
name: string;
official_website?: string | null;
description?: string | null;
monitoring_focus?: string | null;
industry?: string | null;
country?: string | null;
region?: string | null;
headquarters?: string | null;
public_identifiers?: Record<string, string>;
competitor_names?: string[];
alias_names?: string[];
frequency_type?: MonitoringFrequency;
interval_minutes?: number | null;
cron_expression?: string | null;
timezone?: string;
severity_threshold?: SeverityLevel;
}
export interface CompanyUpdatePayload {
name?: string;
official_website?: string | null;
description?: string | null;
monitoring_focus?: string | null;
industry?: string | null;
country?: string | null;
region?: string | null;
headquarters?: string | null;
public_identifiers?: Record<string, string>;
competitor_names?: string[];
alias_names?: string[];
}
export interface DiscoverCompanyRequest {
name: string;
official_website?: string | null;
monitoring_focus?: string | null;
competitor_names?: string[];
alias_names?: string[];
}
export interface PotentialSource {
source_type: SourceType;
name: string;
base_url: string | null;
}
export interface DiscoveredCompanyProfile {
name: string;
official_website: string | null;
description: string | null;
monitoring_focus: string | null;
industry: string | null;
country: string | null;
region: string | null;
headquarters: string | null;
aliases: string[];
competitors: string[];
public_identifiers: Record<string, string>;
potential_sources: PotentialSource[];
sources_consulted: string[];
}
export type NotificationType = "email" | "sms" | "console";
export interface LinkedCompany {
id: string;
name: string;
}
export interface NotificationDestinationResponse {
id: string;
type: NotificationType;
destination_value: string;
verified: boolean;
enabled: boolean;
minimum_severity: SeverityLevel;
created_at: string;
companies: LinkedCompany[];
}
export interface NotificationDestinationCreatePayload {
type: NotificationType;
destination_value: string;
minimum_severity?: SeverityLevel;
enabled?: boolean;
company_ids: string[];
}
export interface NotificationDestinationUpdatePayload {
destination_value?: string;
enabled?: boolean;
minimum_severity?: SeverityLevel;
}
export interface NotificationTestResult {
success: boolean;
error: string | null;
}
export type NotificationDeliveryStatus = "pending" | "sent" | "failed";
export interface NotificationDeliverySummary {
id: string;
destination_id: string;
provider: string;
status: NotificationDeliveryStatus;
external_message_id: string | null;
error_message: string | null;
}
export interface AlertResponse {
id: string;
company_id: string;
detected_change_id: string;
title: string;
summary: string;
why_it_matters: string;
severity: SeverityLevel;
confidence: number;
read: boolean;
resolved: boolean;
created_at: string;
}
export interface AlertDetailResponse extends AlertResponse {
deliveries: NotificationDeliverySummary[];
}
export interface AlertUpdatePayload {
read?: boolean;
resolved?: boolean;
}
export interface AlertListFilters {
company_id?: string;
severity?: SeverityLevel;
read?: boolean;
resolved?: boolean;
}
export type SourceType =
| "website"
| "rss"
| "custom_url"
| "sec_edgar"
| "github"
| "job_posting"
| "patent"
| "review"
| "gov_contract";
export type SourceStatus =
| "active"
| "disabled"
| "rate_limited"
| "auth_required"
| "blocked_by_policy"
| "failed";
export interface SourceResponse {
id: string;
source_type: SourceType;
name: string;
base_url: string | null;
active: boolean;
status: SourceStatus;
trust_score: number;
last_checked: string | null;
last_successful_check: string | null;
failure_count: number;
// null means "inherit the company's default monitoring cadence".
frequency_type: MonitoringFrequency | null;
interval_minutes: number | null;
cron_expression: string | null;
next_check: string | null;
}
export interface SourceCreatePayload {
source_type: "custom_url" | "rss";
name: string;
base_url: string;
}
export interface SourceUpdatePayload {
name?: string;
active?: boolean;
frequency_type?: MonitoringFrequency | null;
interval_minutes?: number | null;
cron_expression?: string | null;
}
export interface SourceTestResult {
status: SourceStatus;
documents_found: number;
error: string | null;
}
export interface SnapshotResponse {
id: string;
source_id: string;
snapshot_type: string;
hash: string;
text_summary: string | null;
structured_summary: Record<string, unknown>;
monitoring_run_id: string | null;
created_at: string;
}
export type ReportType = "baseline" | "update" | "monthly" | "manual";
export type ConfidenceLabel =
| "confirmed"
| "strongly_indicated"
| "likely"
| "possible"
| "unconfirmed"
| "insufficient_evidence";
export interface EvidenceRef {
source_document_id: string | null;
detected_change_id: string | null;
url: string | null;
description: string;
}
export interface Finding {
headline: string;
summary: string;
evidence: EvidenceRef[];
confidence: ConfidenceLabel;
category: string | null;
date: string | null;
}
export interface InferredProject {
project_name: string;
status: ConfidenceLabel;
summary: string;
confidence: number;
evidence: EvidenceRef[];
signal_types: string[];
alternative_explanations: string[];
}
export interface SwotAnalysis {
strengths: string[];
weaknesses: string[];
opportunities: string[];
threats: string[];
}
export interface ReportContent {
executive_summary: string;
company_overview: string;
products_and_services: Finding[];
market_positioning: string;
recent_developments: Finding[];
strategic_initiatives: Finding[];
key_inferred_projects: InferredProject[];
leadership_changes: Finding[];
hiring_signals: Finding[];
technology_signals: Finding[];
patent_signals: Finding[];
manufacturing_and_expansion_signals: Finding[];
partnerships_and_acquisitions: Finding[];
financial_signals: Finding[];
regulatory_and_legal_signals: Finding[];
customer_sentiment: string;
competitor_comparison: string;
swot: SwotAnalysis;
risks: string[];
opportunities: string[];
unknowns_and_missing_data: string[];
monitoring_recommendations: string[];
methodology: string;
limitations: string;
}
export interface ReportListItem {
id: string;
report_type: ReportType;
title: string;
executive_summary: string;
model_provider: string;
model_name: string;
created_at: string;
}
export interface ReportResponse extends ReportListItem {
company_id: string;
monitoring_run_id: string | null;
structured_report: ReportContent;
prompt_version: string;
}
export interface RunsByDayPoint {
date: string;
successful: number;
failed: number;
other: number;
}
export interface RecentSignal {
id: string;
company_id: string;
company_name: string;
change_type: string;
severity: SeverityLevel;
confidence_score: number;
summary: string;
created_at: string;
}
export interface DashboardAnalytics {
changes_by_type: Record<string, number>;
alerts_by_severity: Record<string, number>;
sources_by_status: Record<string, number>;
runs_by_day: RunsByDayPoint[];
recent_signals: RecentSignal[];
}
export const CHANGE_TYPE_LABELS: Record<string, string> = {
new_document: "New document",
removed_document: "Removed document",
content_modified: "Content modified",
price_change: "Price change",
leadership_change: "Leadership change",
filing_new: "New filing",
};
export type MonitoringRunTrigger = "scheduled" | "manual" | "initial" | "retry";
export type MonitoringRunStatus = "queued" | "running" | "successful" | "partial" | "failed";
export interface MonitoringRunResponse {
id: string;
company_id: string;
trigger_type: MonitoringRunTrigger;
status: MonitoringRunStatus;
started_at: string | null;
completed_at: string | null;
sources_attempted: number;
sources_successful: number;
sources_failed: number;
items_collected: number;
changes_detected: number;
error_summary: string | null;
created_at: string;
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: "standalone",
};
module.exports = nextConfig;
+10505
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "ci-agent-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test"
},
"dependencies": {
"@hookform/resolvers": "3.9.1",
"@radix-ui/react-select": "^2.3.7",
"@tanstack/react-query": "5.59.20",
"clsx": "2.1.1",
"date-fns": "4.1.0",
"lucide-react": "0.462.0",
"next": "15.5.22",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "7.53.2",
"recharts": "3.0.2",
"zod": "3.23.8"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.5.2",
"@types/node": "22.9.0",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.2",
"@vitejs/plugin-react": "4.7.0",
"autoprefixer": "10.4.20",
"eslint": "8.57.1",
"eslint-config-next": "15.5.22",
"jsdom": "25.0.1",
"postcss": "8.5.25",
"prettier": "3.3.3",
"prettier-plugin-tailwindcss": "0.6.8",
"tailwindcss": "3.4.14",
"typescript": "5.6.3",
"vitest": "3.2.7"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig, devices } from "@playwright/test";
// Runs against the already-running docker-compose stack (web on :3000, api
// on :8000) - see e2e/README.md. Not started automatically because the
// full pipeline (real worker/beat/Postgres/Redis/Mailpit) can't be spun up
// by Playwright's webServer option alone.
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
workers: 1,
retries: 0,
reporter: "list",
timeout: 60_000,
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+53
View File
@@ -0,0 +1,53 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./features/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
brand: {
50: "#eef4ff",
100: "#d9e6ff",
200: "#bcd2ff",
300: "#8fb4ff",
400: "#5c8cff",
500: "#3766f7",
600: "#2549ec",
700: "#1e3ad0",
800: "#1f34a8",
900: "#1f3184",
950: "#161d4d",
},
severity: {
critical: "#dc2626",
high: "#ea580c",
medium: "#ca8a04",
low: "#65a30d",
},
},
keyframes: {
"fade-in": {
"0%": { opacity: "0", transform: "translateY(4px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
"select-in": {
"0%": { opacity: "0", transform: "scale(0.96) translateY(-4px)" },
"100%": { opacity: "1", transform: "scale(1) translateY(0)" },
},
"select-out": {
"0%": { opacity: "1", transform: "scale(1) translateY(0)" },
"100%": { opacity: "0", transform: "scale(0.96) translateY(-4px)" },
},
},
animation: {
"fade-in": "fade-in 220ms ease-out",
"select-in": "select-in 150ms ease-out",
"select-out": "select-out 120ms ease-in",
},
},
},
plugins: [],
};
export default config;
+131
View File
@@ -0,0 +1,131 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AddCompanyWizardPage from "@/app/(app)/companies/new/page";
import { renderWithQueryClient } from "./test-utils";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
const DISCOVERED_PROFILE = {
name: "Acme Widgets",
official_website: "https://acmewidgets.com",
description: "Acme Widgets designs and sells handcrafted mechanical widgets.",
monitoring_focus: null,
industry: null,
country: null,
region: null,
headquarters: "Austin, Texas",
aliases: ["Acme Industries"],
competitors: [],
public_identifiers: {},
potential_sources: [
{ source_type: "website", name: "Acme Widgets - Website", base_url: "https://acmewidgets.com" },
],
sources_consulted: ["https://acmewidgets.com"],
};
const EXISTING_COMPANIES = [
{
id: "existing-1",
name: "Acme Widgets",
slug: "acme-widgets",
official_website: null,
description: null,
monitoring_focus: null,
industry: null,
country: null,
region: null,
headquarters: null,
public_identifiers: {},
status: "active",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
aliases: [],
competitors: [],
monitor_configuration: null,
report_count: 0,
unresolved_alert_count: 0,
},
];
function mockFetchImplementation(existingCompanies: unknown[] = []) {
return vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = url.replace("http://localhost:8000", "");
if (path === "/api/v1/companies/discover" && init?.method === "POST") {
return Promise.resolve({ ok: true, status: 200, json: async () => DISCOVERED_PROFILE });
}
if (path === "/api/v1/companies" && (!init?.method || init.method === "GET")) {
return Promise.resolve({ ok: true, status: 200, json: async () => existingCompanies });
}
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
});
}
beforeEach(() => {
vi.stubGlobal("fetch", mockFetchImplementation());
});
describe("AddCompanyWizardPage", () => {
it("only requires a company name on the Discover step", async () => {
const user = userEvent.setup();
renderWithQueryClient(<AddCompanyWizardPage />);
await user.click(screen.getByRole("button", { name: /discover company/i }));
expect(await screen.findByText(/company name is required/i)).toBeInTheDocument();
});
it("discovers a profile and pre-fills the Review step, editable", async () => {
const user = userEvent.setup();
renderWithQueryClient(<AddCompanyWizardPage />);
await user.type(screen.getByLabelText("Company name"), "Acme Widgets");
await user.click(screen.getByRole("button", { name: /discover company/i }));
await waitFor(() => {
expect(screen.getByText(/here's what we found for/i)).toBeInTheDocument();
});
const website = screen.getByLabelText("Official website") as HTMLInputElement;
expect(website.value).toBe("https://acmewidgets.com");
const headquarters = screen.getByLabelText("Headquarters") as HTMLInputElement;
expect(headquarters.value).toBe("Austin, Texas");
const description = screen.getByLabelText("Description") as HTMLTextAreaElement;
expect(description.value).toBe(
"Acme Widgets designs and sells handcrafted mechanical widgets.",
);
const aliases = screen.getByLabelText(/known aliases/i) as HTMLInputElement;
expect(aliases.value).toBe("Acme Industries");
expect(screen.getByText(/we'll start monitoring 1 source/i)).toBeInTheDocument();
// Still editable - the whole point of the review step.
await user.clear(headquarters);
await user.type(headquarters, "Denver, Colorado");
expect(headquarters.value).toBe("Denver, Colorado");
});
it("warns about a likely duplicate before discovering, and proceeds after Continue anyway", async () => {
vi.stubGlobal("fetch", mockFetchImplementation(EXISTING_COMPANIES));
const user = userEvent.setup();
renderWithQueryClient(<AddCompanyWizardPage />);
await user.type(screen.getByLabelText("Company name"), "Acme Widgets");
await user.click(screen.getByRole("button", { name: /discover company/i }));
expect(await screen.findByText(/you might already be monitoring/i)).toBeInTheDocument();
// Discovery must not have fired yet - still on the Discover step.
expect(screen.queryByText(/here's what we found for/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /continue anyway/i }));
await user.click(screen.getByRole("button", { name: /discover company/i }));
await waitFor(() => {
expect(screen.getByText(/here's what we found for/i)).toBeInTheDocument();
});
expect(screen.queryByText(/you might already be monitoring/i)).not.toBeInTheDocument();
});
});
+71
View File
@@ -0,0 +1,71 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AlertsPage from "@/app/(app)/alerts/page";
import { renderWithQueryClient } from "./test-utils";
const COMPANIES = [
{ id: "company-1", name: "Acme Mobility Systems" },
{ id: "company-2", name: "Globex Corp" },
];
const ALERTS = [
{
id: "alert-1",
company_id: "company-1",
detected_change_id: "change-1",
title: "Base plan price increased",
summary: "Pricing changed.",
why_it_matters: "Signals margin pressure.",
severity: "high",
confidence: 0.82,
read: false,
resolved: false,
created_at: new Date().toISOString(),
},
];
function mockFetchImplementation() {
return vi.fn().mockImplementation((url: string) => {
const path = url.replace("http://localhost:8000", "");
if (path.startsWith("/api/v1/companies")) {
return Promise.resolve({ ok: true, status: 200, json: async () => COMPANIES });
}
if (path.startsWith("/api/v1/alerts")) {
return Promise.resolve({ ok: true, status: 200, json: async () => ALERTS });
}
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
});
}
beforeEach(() => {
vi.stubGlobal("fetch", mockFetchImplementation());
});
describe("AlertsPage", () => {
it("renders alerts with the resolved company name", async () => {
renderWithQueryClient(<AlertsPage />);
expect(await screen.findByText("Base plan price increased")).toBeInTheDocument();
const list = screen.getByRole("list");
expect(within(list).getByText(/Acme Mobility Systems/)).toBeInTheDocument();
expect(within(list).getByText("high")).toBeInTheDocument();
});
it("re-fetches with a severity filter when changed", async () => {
const fetchMock = mockFetchImplementation();
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
renderWithQueryClient(<AlertsPage />);
await screen.findByText("Base plan price increased");
await user.click(screen.getByRole("combobox", { name: "Filter by severity" }));
await user.click(await screen.findByRole("option", { name: "Critical" }));
await waitFor(() => {
const calledUrls = fetchMock.mock.calls.map((call) => call[0] as string);
expect(calledUrls.some((u) => u.includes("severity=critical"))).toBe(true);
});
});
});
+64
View File
@@ -0,0 +1,64 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DashboardAnalyticsSection } from "@/components/dashboard/analytics-section";
import type { DashboardAnalytics } from "@/lib/types";
const EMPTY_ANALYTICS: DashboardAnalytics = {
changes_by_type: {
new_document: 0,
removed_document: 0,
content_modified: 0,
price_change: 0,
leadership_change: 0,
filing_new: 0,
},
alerts_by_severity: { critical: 0, high: 0, medium: 0, low: 0 },
sources_by_status: {
active: 0,
disabled: 0,
rate_limited: 0,
auth_required: 0,
blocked_by_policy: 0,
failed: 0,
},
runs_by_day: [],
recent_signals: [],
};
describe("DashboardAnalyticsSection", () => {
it("shows empty states when there is no activity yet", () => {
render(<DashboardAnalyticsSection analytics={EMPTY_ANALYTICS} />);
expect(screen.getByText(/no changes detected in the last 30 days/i)).toBeInTheDocument();
expect(screen.getByText(/no alerts in the last 30 days/i)).toBeInTheDocument();
expect(screen.getByText(/no monitoring runs in the last 30 days/i)).toBeInTheDocument();
expect(screen.getByText(/no signals detected yet/i)).toBeInTheDocument();
});
it("renders a recent signal with company link and severity", () => {
const analytics: DashboardAnalytics = {
...EMPTY_ANALYTICS,
recent_signals: [
{
id: "change-1",
company_id: "company-1",
company_name: "Acme Mobility",
change_type: "leadership_change",
severity: "high",
confidence_score: 0.8,
summary: "New CEO announced",
created_at: new Date().toISOString(),
},
],
};
render(<DashboardAnalyticsSection analytics={analytics} />);
expect(screen.getByText("New CEO announced")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Acme Mobility" })).toHaveAttribute(
"href",
"/companies/company-1",
);
expect(screen.getByText("high")).toBeInTheDocument();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
beforeEach(() => {
window.localStorage.clear();
vi.unstubAllGlobals();
vi.resetModules();
});
// Fresh module import per test so the internal `refreshPromise` module-level
// state (shared across concurrent 401s) never leaks between tests.
async function loadApiClient() {
return import("@/lib/api-client");
}
describe("api client automatic token refresh", () => {
it("transparently retries once after a 401 when the refresh succeeds", async () => {
const { api, setTokens, getAccessToken } = await loadApiClient();
setTokens("expired-access", "valid-refresh");
const fetchMock = vi
.fn()
// The actual request, with the now-expired access token -> 401.
.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ detail: "Invalid or expired token" }),
})
// POST /auth/refresh -> a fresh token pair.
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: "new-access",
refresh_token: "new-refresh",
token_type: "bearer",
expires_in_minutes: 15,
}),
})
// The retried original request, now with the new access token.
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: "1", email: "[email protected]" }),
});
vi.stubGlobal("fetch", fetchMock);
const result = await api.me();
expect(result).toEqual({ id: "1", email: "[email protected]" });
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(getAccessToken()).toBe("new-access");
const retryCall = fetchMock.mock.calls[2];
const retryHeaders = retryCall?.[1]?.headers as Headers;
expect(retryHeaders.get("Authorization")).toBe("Bearer new-access");
});
it("clears tokens and reports the original 401 when the refresh itself also fails", async () => {
const { api, setTokens, getAccessToken, getRefreshToken, ApiError } = await loadApiClient();
setTokens("expired-access", "expired-refresh");
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ detail: "Invalid or expired token" }),
})
.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ detail: "Invalid or expired refresh token" }),
});
vi.stubGlobal("fetch", fetchMock);
await expect(api.me()).rejects.toBeInstanceOf(ApiError);
expect(fetchMock).toHaveBeenCalledTimes(2); // original request + failed refresh, no retry
expect(getAccessToken()).toBeNull();
expect(getRefreshToken()).toBeNull();
});
it("does not attempt a refresh when there is no refresh token at all", async () => {
const { api, clearTokens } = await loadApiClient();
clearTokens();
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ detail: "Not authenticated" }),
});
vi.stubGlobal("fetch", fetchMock);
await expect(api.me()).rejects.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(1); // no refresh attempt made
});
});
+65
View File
@@ -0,0 +1,65 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "@/app/login/page";
import RegisterPage from "@/app/register/page";
import { renderWithQueryClient } from "./test-utils";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ detail: "Not authenticated" }),
}),
);
});
describe("LoginPage", () => {
it("shows validation errors for an empty submission", async () => {
const user = userEvent.setup();
renderWithQueryClient(<LoginPage />);
await user.click(await screen.findByRole("button", { name: /sign in/i }));
expect(await screen.findByText(/enter a valid email address/i)).toBeInTheDocument();
expect(await screen.findByText(/enter your password/i)).toBeInTheDocument();
});
it("links to the register page", async () => {
renderWithQueryClient(<LoginPage />);
expect(await screen.findByRole("link", { name: /create one/i })).toHaveAttribute(
"href",
"/register",
);
});
});
describe("RegisterPage", () => {
it("rejects a weak password before submitting", async () => {
const user = userEvent.setup();
renderWithQueryClient(<RegisterPage />);
await user.type(screen.getByLabelText(/full name/i), "Ada Lovelace");
await user.type(screen.getByLabelText(/^email$/i), "[email protected]");
await user.type(screen.getByLabelText(/^password$/i), "allletters");
await user.click(screen.getByRole("button", { name: /create account/i }));
await waitFor(() => {
expect(
screen.getByText(/must contain at least one letter and one digit/i),
).toBeInTheDocument();
});
});
it("links to the login page", async () => {
renderWithQueryClient(<RegisterPage />);
expect(await screen.findByRole("link", { name: /sign in/i })).toHaveAttribute("href", "/login");
});
});
+23
View File
@@ -0,0 +1,23 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CompanyStatusBadge, SeverityBadge } from "@/components/ui/badge";
import { SEVERITY_LEVELS } from "@/lib/types";
describe("SeverityBadge", () => {
it.each(SEVERITY_LEVELS)("renders the %s severity label", (severity) => {
render(<SeverityBadge severity={severity} />);
expect(screen.getByText(severity)).toBeInTheDocument();
});
});
describe("CompanyStatusBadge", () => {
it("renders active status", () => {
render(<CompanyStatusBadge status="active" />);
expect(screen.getByText("active")).toBeInTheDocument();
});
it("renders paused status", () => {
render(<CompanyStatusBadge status="paused" />);
expect(screen.getByText("paused")).toBeInTheDocument();
});
});
+70
View File
@@ -0,0 +1,70 @@
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LandingPage from "@/app/page";
import { renderWithQueryClient } from "./test-utils";
function mockFetchImplementation(isLocalhost: boolean) {
return vi.fn().mockImplementation((url: string) => {
const path = url.replace("http://localhost:8000", "");
if (path.startsWith("/api/v1/system/status")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
app_env: "development",
auth_mode: "local",
llm_provider: "mock",
search_provider: "mock",
sms_enabled: false,
sms_provider: "twilio",
ninjapear_configured: false,
ninjapear_credit_balance: null,
ninjapear_estimated_credits_per_company: null,
is_localhost: isLocalhost,
components: [],
}),
});
}
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
});
}
describe("LandingPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetchImplementation(true));
});
it("renders the value proposition and primary calls to action", async () => {
renderWithQueryClient(<LandingPage />);
expect(
screen.getByRole("heading", { name: /know what your competitors are doing/i }),
).toBeInTheDocument();
expect(
await screen.findByRole("link", { name: /open dashboard \(local mode\)/i }),
).toHaveAttribute("href", "/dashboard");
expect(screen.getByRole("link", { name: /sign in/i })).toHaveAttribute("href", "/login");
expect(screen.getByRole("link", { name: /create account/i })).toHaveAttribute(
"href",
"/register",
);
});
it("discloses the public-information-only collection policy", () => {
renderWithQueryClient(<LandingPage />);
expect(
screen.getByRole("heading", { name: /only publicly available information/i }),
).toBeInTheDocument();
});
it("drops the local-mode wording when the request isn't from localhost", async () => {
vi.stubGlobal("fetch", mockFetchImplementation(false));
renderWithQueryClient(<LandingPage />);
expect(await screen.findByRole("link", { name: /^open dashboard$/i })).toHaveAttribute(
"href",
"/dashboard",
);
expect(screen.queryByText(/local mode/i)).not.toBeInTheDocument();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ForgotPasswordPage from "@/app/forgot-password/page";
import ResetPasswordPage from "@/app/reset-password/page";
import UnbanRequestPage from "@/app/unban-request/page";
import VerifyEmailPage from "@/app/verify-email/page";
import { renderWithQueryClient } from "./test-utils";
let searchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useSearchParams: () => searchParams,
}));
beforeEach(() => {
searchParams = new URLSearchParams();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ detail: "Not authenticated" }),
}),
);
});
describe("VerifyEmailPage", () => {
it("shows a validation error for an empty code", async () => {
const user = userEvent.setup();
renderWithQueryClient(<VerifyEmailPage />);
await user.click(await screen.findByRole("button", { name: /verify email/i }));
expect(await screen.findByText(/^enter the 6-digit code$/i)).toBeInTheDocument();
});
it("shows the email address pulled from the query string", async () => {
searchParams = new URLSearchParams({ email: "[email protected]" });
renderWithQueryClient(<VerifyEmailPage />);
expect(await screen.findByText(/ada@example\.com/i)).toBeInTheDocument();
});
});
describe("ForgotPasswordPage", () => {
it("rejects an invalid email", async () => {
const user = userEvent.setup();
renderWithQueryClient(<ForgotPasswordPage />);
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.click(screen.getByRole("button", { name: /send reset code/i }));
await waitFor(() => {
expect(screen.getByText(/enter a valid email address/i)).toBeInTheDocument();
});
});
it("links back to sign in", async () => {
renderWithQueryClient(<ForgotPasswordPage />);
expect(await screen.findByRole("link", { name: /back to sign in/i })).toHaveAttribute(
"href",
"/login",
);
});
});
describe("ResetPasswordPage", () => {
it("prefills email from the query string", async () => {
searchParams = new URLSearchParams({ email: "[email protected]" });
renderWithQueryClient(<ResetPasswordPage />);
expect(await screen.findByDisplayValue("[email protected]")).toBeInTheDocument();
});
it("rejects a weak new password", async () => {
const user = userEvent.setup();
renderWithQueryClient(<ResetPasswordPage />);
await user.type(screen.getByLabelText(/^email$/i), "[email protected]");
await user.type(screen.getByLabelText(/reset code/i), "123456");
await user.type(screen.getByLabelText(/new password/i), "allletters");
await user.click(screen.getByRole("button", { name: /reset password/i }));
await waitFor(() => {
expect(
screen.getByText(/must contain at least one letter and one digit/i),
).toBeInTheDocument();
});
});
});
describe("UnbanRequestPage", () => {
it("submits and shows a confirmation", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
status: 204,
json: async () => ({}),
}),
);
const user = userEvent.setup();
renderWithQueryClient(<UnbanRequestPage />);
await user.type(screen.getByLabelText(/message/i), "This looks like a mistake.");
await user.click(screen.getByRole("button", { name: /send request/i }));
expect(await screen.findByText(/request received/i)).toBeInTheDocument();
});
it("links back to sign in", async () => {
renderWithQueryClient(<UnbanRequestPage />);
expect(await screen.findByRole("link", { name: /back to sign in/i })).toHaveAttribute(
"href",
"/login",
);
});
});
+17
View File
@@ -0,0 +1,17 @@
import "@testing-library/jest-dom/vitest";
// jsdom doesn't implement these - Radix UI's Select (and other primitives
// built on pointer events) call them unconditionally, so tests that open
// one throw without these no-op polyfills.
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = () => false;
}
if (!Element.prototype.setPointerCapture) {
Element.prototype.setPointerCapture = () => {};
}
if (!Element.prototype.releasePointerCapture) {
Element.prototype.releasePointerCapture = () => {};
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
+87
View File
@@ -0,0 +1,87 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SystemSecretRow } from "@/components/ui/system-secret-row";
import type { SystemSecretStatus } from "@/lib/types";
import { renderWithQueryClient } from "./test-utils";
const unconfiguredSecret: SystemSecretStatus = {
key: "turnstile_secret",
label: "Turnstile Secret Key",
configured: false,
value: null,
};
const configuredSecret: SystemSecretStatus = {
key: "turnstile_site_key",
label: "Turnstile Site Key",
configured: true,
value: "0x-my-site-key",
};
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
describe("SystemSecretRow", () => {
it("shows the label and a masked input for a configured secret", () => {
renderWithQueryClient(<SystemSecretRow secret={configuredSecret} />);
expect(screen.getByText("Turnstile Site Key")).toBeInTheDocument();
const textbox = screen.getByDisplayValue("0x-my-site-key") as HTMLInputElement;
expect(textbox.type).toBe("password");
});
it("toggles the input between masked and revealed", async () => {
const user = userEvent.setup();
renderWithQueryClient(<SystemSecretRow secret={configuredSecret} />);
const textbox = screen.getByDisplayValue("0x-my-site-key") as HTMLInputElement;
expect(textbox.type).toBe("password");
await user.click(screen.getByTitle(/show key/i));
expect(textbox.type).toBe("text");
await user.click(screen.getByTitle(/hide key/i));
expect(textbox.type).toBe("password");
});
it("disables Update until the value actually changes", async () => {
const user = userEvent.setup();
renderWithQueryClient(<SystemSecretRow secret={unconfiguredSecret} />);
const updateButton = screen.getByRole("button", { name: /update/i });
expect(updateButton).toBeDisabled();
const textbox = screen.getByPlaceholderText(/not set/i);
await user.type(textbox, "sk-new-secret");
expect(updateButton).toBeEnabled();
});
it("submits the new value via PUT to the keyed endpoint and reflects the saved value", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ ...unconfiguredSecret, configured: true, value: "sk-new-secret" }),
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
renderWithQueryClient(<SystemSecretRow secret={unconfiguredSecret} />);
const textbox = screen.getByPlaceholderText(/not set/i);
await user.type(textbox, "sk-new-secret");
await user.click(screen.getByRole("button", { name: /update/i }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/system/secrets/turnstile_secret"),
expect.objectContaining({
method: "PUT",
body: JSON.stringify({ value: "sk-new-secret" }),
}),
);
});
await waitFor(() => {
expect(screen.getByDisplayValue("sk-new-secret")).toBeInTheDocument();
});
});
});
+10
View File
@@ -0,0 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import type { ReactElement } from "react";
export function renderWithQueryClient(ui: ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
+55
View File
@@ -0,0 +1,55 @@
import { act, render } from "@testing-library/react";
import { createRef } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TurnstileWidget, type TurnstileWidgetHandle } from "@/components/ui/turnstile-widget";
afterEach(() => {
delete (window as unknown as { turnstile?: unknown }).turnstile;
});
describe("TurnstileWidget", () => {
it("renders nothing when no site key is configured", () => {
const { container } = render(<TurnstileWidget siteKey={null} onToken={() => {}} />);
expect(container).toBeEmptyDOMElement();
});
it("renders via window.turnstile once the script loads, reports tokens, and resets", () => {
let capturedCallback: ((token: string) => void) | undefined;
const renderMock = vi.fn(
(_container: HTMLElement, options: { callback: (t: string) => void }) => {
capturedCallback = options.callback;
return "widget-1";
},
);
const resetMock = vi.fn();
const removeMock = vi.fn();
window.turnstile = { render: renderMock, reset: resetMock, remove: removeMock };
const onToken = vi.fn();
const ref = createRef<TurnstileWidgetHandle>();
render(<TurnstileWidget ref={ref} siteKey="test-site-key" onToken={onToken} />);
// jsdom never actually executes the remote Cloudflare script - simulate
// next/script's load detection by firing "load" on the tag it inserted.
const scriptEl = document.querySelector('script[src*="turnstile"]');
expect(scriptEl).not.toBeNull();
act(() => {
scriptEl?.dispatchEvent(new Event("load"));
});
expect(renderMock).toHaveBeenCalledTimes(1);
expect(renderMock.mock.calls[0]?.[1]).toMatchObject({
sitekey: "test-site-key",
action: "turnstile-spin-v2",
});
capturedCallback?.("fake-token");
expect(onToken).toHaveBeenCalledWith("fake-token");
act(() => {
ref.current?.reset();
});
expect(resetMock).toHaveBeenCalledWith("widget-1");
});
});
+137
View File
@@ -0,0 +1,137 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UserApiKeyRow } from "@/components/ui/user-api-key-row";
import type { UserApiKeyStatus } from "@/lib/types";
import { renderWithQueryClient } from "./test-utils";
const anthropicKey: UserApiKeyStatus = {
provider: "anthropic",
label: "Anthropic",
configured: false,
value: null,
credits: null,
credits_note: "Anthropic doesn't expose a credit/usage-balance API.",
free: false,
requires_government_id: false,
};
const usptoKey: UserApiKeyStatus = {
provider: "uspto",
label: "USPTO",
configured: true,
value: "my-uspto-key",
credits: null,
credits_note: "Free - USPTO Open Data Portal has no usage limit or credit cost.",
free: true,
requires_government_id: true,
};
const ninjapearKey: UserApiKeyStatus = {
provider: "ninjapear",
label: "NinjaPear",
configured: true,
value: "my-ninjapear-key",
credits: null,
credits_note: "Credit balance shown in System configuration below.",
free: false,
requires_government_id: false,
};
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
describe("UserApiKeyRow", () => {
it("shows the provider label and a masked input for a configured key", () => {
renderWithQueryClient(<UserApiKeyRow apiKey={usptoKey} />);
expect(screen.getByText("USPTO")).toBeInTheDocument();
const textbox = screen.getByDisplayValue("my-uspto-key") as HTMLInputElement;
expect(textbox.type).toBe("password");
});
it("shows the free + government-ID badge for USPTO", () => {
renderWithQueryClient(<UserApiKeyRow apiKey={usptoKey} />);
expect(screen.getByText(/free · requires government id approval/i)).toBeInTheDocument();
});
it("does not show the free badge for a paid provider", () => {
renderWithQueryClient(<UserApiKeyRow apiKey={anthropicKey} />);
expect(screen.queryByText(/requires government id approval/i)).not.toBeInTheDocument();
});
it("toggles the input between masked and revealed", async () => {
const user = userEvent.setup();
renderWithQueryClient(<UserApiKeyRow apiKey={usptoKey} />);
const textbox = screen.getByDisplayValue("my-uspto-key") as HTMLInputElement;
expect(textbox.type).toBe("password");
await user.click(screen.getByTitle(/show key/i));
expect(textbox.type).toBe("text");
await user.click(screen.getByTitle(/hide key/i));
expect(textbox.type).toBe("password");
});
it("disables Update until the value actually changes", async () => {
const user = userEvent.setup();
renderWithQueryClient(<UserApiKeyRow apiKey={anthropicKey} />);
const updateButton = screen.getByRole("button", { name: /update/i });
expect(updateButton).toBeDisabled();
const textbox = screen.getByPlaceholderText(/^not set$/i);
await user.type(textbox, "sk-new-key");
expect(updateButton).toBeEnabled();
});
it("submits the new key via PUT and reflects the saved value", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ ...anthropicKey, configured: true, value: "sk-new-key" }),
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
renderWithQueryClient(<UserApiKeyRow apiKey={anthropicKey} />);
const textbox = screen.getByPlaceholderText(/^not set$/i);
await user.type(textbox, "sk-new-key");
await user.click(screen.getByRole("button", { name: /update/i }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/user-api-keys/anthropic"),
expect.objectContaining({
method: "PUT",
body: JSON.stringify({ key: "sk-new-key" }),
}),
);
});
await waitFor(() => {
expect(screen.getByDisplayValue("sk-new-key")).toBeInTheDocument();
});
});
it("ignores the backend's own credits field and shows nothing extra when liveCredits is not passed", () => {
renderWithQueryClient(<UserApiKeyRow apiKey={ninjapearKey} />);
expect(
screen.getByText(/credit balance shown in system configuration below/i),
).toBeInTheDocument();
expect(screen.queryByText(/credits remaining/i)).not.toBeInTheDocument();
});
it("shows the liveCredits number when passed, sourced from system status rather than this row's own fetch", () => {
renderWithQueryClient(<UserApiKeyRow apiKey={ninjapearKey} liveCredits={891} />);
expect(screen.getByText(/891 credits remaining/i)).toBeInTheDocument();
});
it("falls back to the note-only text when liveCredits is explicitly null", () => {
renderWithQueryClient(<UserApiKeyRow apiKey={ninjapearKey} liveCredits={null} />);
expect(screen.queryByText(/credits remaining/i)).not.toBeInTheDocument();
expect(
screen.getByText(/credit balance shown in system configuration below/i),
).toBeInTheDocument();
});
});
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules", "playwright-report", "test-results"]
}
+18
View File
@@ -0,0 +1,18 @@
import path from "node:path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./tests/setup.ts"],
exclude: ["node_modules", ".next", "e2e/**"],
},
});