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