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]>
826 lines
29 KiB
TypeScript
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'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>
|
|
);
|
|
}
|