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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user