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