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
+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>
);
}