Files
CIAgent/apps/web/app/(app)/settings/page.tsx
T
sakshamandClaude Sonnet 5 3d6fe56991 Add Settings -> Database viewer (Adminer) for local devs and any admin
Local dev gets an unauthenticated Adminer instance bound to loopback
only. In production, any account with is_admin=true can open it -
the app mints a short-lived token from a live admin session, which
Nginx's new db.ciagent.org block exchanges for a session cookie that
re-checks admin status on every request, instead of a shared static
password that wouldn't scale to multiple admins or revoke live.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-05 22:18:16 -04:00

826 lines
29 KiB
TypeScript

"use client";
import Link from "next/link";
import { useState } from "react";
import {
Bell,
CheckCircle2,
Database,
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,
useCreateDbViewerSession,
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 { isLocalConvenience } from "@/lib/auth";
import { formatDateTime } from "@/lib/format";
import {
SEVERITY_LABELS,
SEVERITY_LEVELS,
type LinkedCompany,
type LogCategory,
type NotificationType,
type SeverityLevel,
} from "@/lib/types";
function TestResultBadge({ result }: { result: { success: boolean; error: string | null } }) {
return (
<span className={`text-xs ${result.success ? "text-green-700" : "text-red-600"}`}>
{result.success ? "Test sent" : result.error}
</span>
);
}
function LinkedCompanyChips({ companies }: { companies: LinkedCompany[] }) {
if (companies.length === 0) {
return (
<p className="text-xs italic text-slate-400">
Not linked to any company will be removed automatically.
</p>
);
}
return (
<div className="flex max-w-full gap-1.5 overflow-x-auto scroll-smooth py-0.5">
{companies.map((company) => (
<Link
key={company.id}
href={`/companies/${company.id}`}
className="focus-ring shrink-0 whitespace-nowrap rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-600 transition-colors duration-150 hover:bg-brand-100 hover:text-brand-700"
>
{company.name}
</Link>
))}
</div>
);
}
function DestinationRow({
destination,
}: {
destination: {
id: string;
type: NotificationType;
destination_value: string;
enabled: boolean;
verified: boolean;
minimum_severity: SeverityLevel;
companies: LinkedCompany[];
};
}) {
const update = useUpdateNotificationDestination();
const remove = useDeleteNotificationDestination();
const test = useTestNotificationDestination();
const Icon = destination.type === "sms" ? MessageSquare : Mail;
return (
<li className="flex animate-fade-in flex-wrap items-center justify-between gap-3 py-4">
<div className="flex min-w-0 items-center gap-3">
<Icon className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{destination.destination_value}</p>
<p className="text-xs capitalize text-slate-500">
{destination.type} · min severity {destination.minimum_severity}
</p>
<div className="mt-1.5 max-w-xs sm:max-w-sm">
<LinkedCompanyChips companies={destination.companies} />
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select
value={destination.minimum_severity}
onValueChange={(v) =>
update.mutate({
destinationId: destination.id,
payload: { minimum_severity: v as SeverityLevel },
})
}
options={SEVERITY_LEVELS.map((s) => ({ value: s, label: SEVERITY_LABELS[s] }))}
triggerClassName="px-2 py-1 text-xs"
/>
<label className="flex items-center gap-1.5 text-xs text-slate-600">
<input
type="checkbox"
checked={destination.enabled}
onChange={(e) =>
update.mutate({
destinationId: destination.id,
payload: { enabled: e.target.checked },
})
}
className="focus-ring rounded border-slate-300"
/>
Enabled
</label>
<button
onClick={() => test.mutate(destination.id)}
disabled={test.isPending}
title="Send test notification"
className="focus-ring inline-flex items-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-60"
>
{test.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Send className="h-3.5 w-3.5" aria-hidden />
)}
Test
</button>
<button
onClick={() => remove.mutate(destination.id)}
title="Remove destination"
className="focus-ring rounded-md p-1.5 text-slate-500 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" aria-hidden />
<span className="sr-only">Remove {destination.destination_value}</span>
</button>
</div>
{test.data && (
<div className="basis-full">
<TestResultBadge result={test.data} />
</div>
)}
</li>
);
}
function AddDestinationForm() {
const [type, setType] = useState<NotificationType>("email");
const [value, setValue] = useState("");
const [minimumSeverity, setMinimumSeverity] = useState<SeverityLevel>("medium");
const [companyIds, setCompanyIds] = useState<Set<string>>(new Set());
const create = useCreateNotificationDestination();
const { data: systemStatus } = useSystemStatus();
const { data: companies } = useCompanies();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (companyIds.size === 0) return;
create.mutate(
{
type,
destination_value: value,
minimum_severity: minimumSeverity,
company_ids: [...companyIds],
},
{ onSuccess: () => setValue("") },
);
};
const toggleCompany = (id: string) => {
setCompanyIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<form onSubmit={handleSubmit} className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-sm font-medium text-slate-700">Type</label>
<div className="mt-1">
<Select
value={type}
onValueChange={(v) => setType(v as NotificationType)}
options={[
{ value: "email", label: "Email" },
{ value: "sms", label: "SMS" },
]}
triggerClassName="px-3 py-2 text-sm"
/>
</div>
</div>
<div className="min-w-[220px] flex-1">
<FormField
label={type === "sms" ? "Phone number" : "Email address"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={type === "sms" ? "+15551234567" : "[email protected]"}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700">Min severity</label>
<div className="mt-1">
<Select
value={minimumSeverity}
onValueChange={(v) => setMinimumSeverity(v as SeverityLevel)}
options={SEVERITY_LEVELS.map((s) => ({ value: s, label: SEVERITY_LABELS[s] }))}
triggerClassName="px-3 py-2 text-sm"
/>
</div>
</div>
<button
type="submit"
disabled={create.isPending || !value.trim() || companyIds.size === 0}
className="focus-ring inline-flex items-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
>
<Plus className="h-4 w-4" aria-hidden /> Add
</button>
</div>
{companies && companies.length > 0 ? (
<div>
<p className="text-xs font-medium text-slate-600">
Companies to notify for <span className="text-red-500">*</span>
</p>
<div className="mt-1.5 flex flex-wrap gap-2">
{companies.map((c) => {
const selected = companyIds.has(c.id);
return (
<button
key={c.id}
type="button"
onClick={() => toggleCompany(c.id)}
aria-pressed={selected}
className={`focus-ring rounded-full border px-3 py-1 text-xs font-medium transition-colors duration-150 ${
selected
? "border-brand-600 bg-brand-600 text-white"
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
{c.name}
</button>
);
})}
</div>
</div>
) : (
<p className="text-xs text-slate-500">
Add a company first destinations must be linked to at least one.
</p>
)}
{create.isError && <p className="text-xs text-red-600">{authErrorMessage(create.error)}</p>}
{type === "sms" && systemStatus && !systemStatus.sms_enabled && (
<p className="text-xs text-amber-700">
SMS delivery is temporarily disabled while carrier registration (10DLC) for our sending
number completes. You can still add your number now it&apos;ll start receiving alerts
automatically once registration finishes and delivery is re-enabled.
</p>
)}
</form>
);
}
const LOG_CATEGORY_STYLES: Record<LogCategory, { dot: string; label: string; text: string }> = {
internal_error: { dot: "bg-red-500", label: "Internal error", text: "text-red-700" },
api_error: { dot: "bg-amber-500", label: "API error", text: "text-amber-700" },
important: { dot: "bg-blue-500", label: "Important", text: "text-blue-700" },
normal: { dot: "bg-slate-300", label: "Normal", text: "text-slate-500" },
};
function LoggingBox() {
const { data: logs, isLoading } = useSystemLogs();
return (
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<ScrollText className="h-4 w-4" aria-hidden /> Logging
</div>
<p className="mt-1 text-sm text-slate-500">
A live feed of what the application is doing, most recent first.
</p>
{isLoading ? (
<p className="mt-4 text-sm text-slate-500">Loading</p>
) : logs && logs.length > 0 ? (
<ul className="mt-3 max-h-96 space-y-1 overflow-y-auto">
{logs.map((log, i) => {
const style = LOG_CATEGORY_STYLES[log.category];
return (
<li
key={i}
className="flex items-start gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-slate-50"
>
<span
className={`mt-1 h-2 w-2 shrink-0 rounded-full ${style.dot}`}
title={style.label}
aria-hidden
/>
<span className="shrink-0 whitespace-nowrap text-slate-400">
{formatDateTime(log.ts)}
</span>
<span className="shrink-0 whitespace-nowrap text-slate-400">{log.logger}</span>
<span className={`min-w-0 break-words ${style.text}`}>{log.event}</span>
</li>
);
})}
</ul>
) : (
<p className="mt-4 text-sm text-slate-500">No log entries yet.</p>
)}
</div>
);
}
function UserApiKeysBox() {
const { data: apiKeys, isLoading } = useUserApiKeys();
const { data: systemStatus } = useSystemStatus();
return (
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<KeyRound className="h-4 w-4" aria-hidden /> Your API keys
</div>
<p className="mt-1 text-sm text-slate-500">
Set your own key for each provider below to power discovery, enrichment, and monitoring for
your companies.
</p>
{isLoading ? (
<p className="mt-4 text-sm text-slate-500">Loading</p>
) : (
<div className="mt-4 space-y-4">
{apiKeys?.map((k) => (
<UserApiKeyRow
key={k.provider}
apiKey={k}
liveCredits={
k.provider === "ninjapear"
? (systemStatus?.ninjapear_credit_balance ?? null)
: undefined
}
/>
))}
</div>
)}
</div>
);
}
function ServerSecretsBox() {
const { data: secrets, isLoading } = useSystemSecrets();
return (
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<KeyRound className="h-4 w-4" aria-hidden /> Server secrets
</div>
<p className="mt-1 text-sm text-slate-500">
Server-wide configuration shared by every visitor: Cloudflare Turnstile CAPTCHA and the
Resend API key used for transactional security email.
</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>
);
}
function DatabaseViewerBox() {
const { data: systemStatus } = useSystemStatus();
const createSession = useCreateDbViewerSession();
const [error, setError] = useState<string | null>(null);
const isLocal = systemStatus ? isLocalConvenience(systemStatus) : false;
const dbViewerUrl = process.env.NEXT_PUBLIC_DB_VIEWER_URL;
const handleOpen = async () => {
setError(null);
try {
const { token } = await createSession.mutateAsync();
window.open(`${dbViewerUrl}/_auth?token=${encodeURIComponent(token)}`, "_blank", "noopener,noreferrer");
} catch {
setError("Couldn't open the database viewer. Try again.");
}
};
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">
<Database className="h-4 w-4" aria-hidden /> Database
</div>
<p className="mt-1 text-sm text-slate-500">
Open a web-based Postgres client (Adminer) in a new tab to view and edit rows directly.
Local developers and server admins only.
</p>
<div className="mt-4">
{isLocal ? (
<a
href="http://localhost:8081"
target="_blank"
rel="noopener noreferrer"
className="focus-ring inline-flex items-center gap-1.5 rounded-md bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white transition-colors duration-200 hover:bg-slate-900"
>
<Database className="h-3.5 w-3.5" aria-hidden /> Open database viewer
</a>
) : dbViewerUrl ? (
<ActionButton
onClick={handleOpen}
pending={createSession.isPending}
colorClass="bg-slate-800 hover:bg-slate-900"
icon={Database}
label="Open database viewer"
/>
) : (
<p className="text-sm text-slate-500">Not configured for this deployment.</p>
)}
{error && <p className="mt-2 text-xs text-red-600">{error}</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 />
<DatabaseViewerBox />
<LoggingBox />
</>
)}
</div>
);
}