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:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
import clsx from "clsx";
import type { CompanyStatus, SeverityLevel } from "@/lib/types";
const SEVERITY_CLASSES: Record<SeverityLevel, string> = {
critical: "bg-severity-critical/10 text-severity-critical",
high: "bg-severity-high/10 text-severity-high",
medium: "bg-severity-medium/10 text-severity-medium",
low: "bg-severity-low/10 text-severity-low",
};
export function SeverityBadge({ severity }: { severity: SeverityLevel }) {
return (
<span
className={clsx(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize",
SEVERITY_CLASSES[severity],
)}
>
{severity}
</span>
);
}
const STATUS_CLASSES: Record<CompanyStatus, string> = {
active: "bg-green-100 text-green-800",
paused: "bg-slate-200 text-slate-700",
};
export function CompanyStatusBadge({ status }: { status: CompanyStatus }) {
return (
<span
key={status}
className={clsx(
"inline-flex animate-fade-in items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize",
STATUS_CLASSES[status],
)}
>
{status}
</span>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
/**
* A company-name pill that links to that company's page if it's already
* monitored, or to the add-company wizard (pre-filled) otherwise. Shared
* by the company detail page's Overview Competitors section and the
* Enrichment tab's Competitors/Customers sections so all three behave
* identically.
*/
export function CompanyPillLink({
name,
subtitle,
monitoredByName,
returnTo,
}: {
name: string;
subtitle?: string;
monitoredByName: Map<string, string>;
returnTo: string;
}) {
const existingId = monitoredByName.get(name.trim().toLowerCase());
const href = existingId
? `/companies/${existingId}`
: `/companies/new?name=${encodeURIComponent(name)}&returnTo=${encodeURIComponent(returnTo)}`;
const title = existingId
? `${name} is already being monitored — view it`
: `Add ${name} to monitoring`;
return (
<Link
href={href}
title={title}
className="focus-ring inline-flex items-center gap-1 rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-700 transition-colors duration-150 hover:bg-brand-100 hover:text-brand-700"
>
{name}
{subtitle && <span className="text-slate-400">· {subtitle}</span>}
</Link>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
import { Check, Copy } from "lucide-react";
/**
* An email address that opens a mail client on click, with a copy button
* that fades in only on hover (invisible at rest) so lists of these don't
* look cluttered with icons.
*/
export function CopyableEmail({ email }: { email: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
await navigator.clipboard.writeText(email);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
};
return (
<span className="group inline-flex items-center gap-1">
<button
type="button"
onClick={copy}
title={copied ? "Copied!" : "Copy email address"}
className="focus-ring rounded p-0.5 text-slate-400 opacity-0 transition-opacity duration-200 hover:text-brand-600 group-hover:opacity-100"
>
{copied ? (
<Check className="h-3 w-3 text-green-600" aria-hidden />
) : (
<Copy className="h-3 w-3" aria-hidden />
)}
</button>
<a href={`mailto:${email}`} className="text-brand-600 hover:underline">
{email}
</a>
</span>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { type InputHTMLAttributes, forwardRef } from "react";
import clsx from "clsx";
interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
hint?: string;
}
export const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
({ label, error, hint, id, className, ...props }, ref) => {
const fieldId = id ?? props.name;
return (
<div>
<label htmlFor={fieldId} className="block text-sm font-medium text-slate-700">
{label}
</label>
<input
ref={ref}
id={fieldId}
className={clsx(
"focus-ring mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm",
error && "border-red-400",
className,
)}
aria-invalid={error ? "true" : "false"}
aria-describedby={error ? `${fieldId}-error` : hint ? `${fieldId}-hint` : undefined}
{...props}
/>
{hint && !error && (
<p id={`${fieldId}-hint`} className="mt-1 text-xs text-slate-500">
{hint}
</p>
)}
{error && (
<p id={`${fieldId}-error`} className="mt-1 text-xs text-red-600" role="alert">
{error}
</p>
)}
</div>
);
},
);
FormField.displayName = "FormField";
@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { Loader2, Minus, Plus } from "lucide-react";
import {
useCreateNotificationDestination,
useUnlinkNotificationDestinationCompany,
} from "@/hooks/use-notification-destinations";
import type { NotificationDestinationResponse, NotificationType } from "@/lib/types";
/**
* A simplified single-destination view over the full notification
* destinations list, scoped to one company - shows the first destination
* of `type` linked to this company (if any) with an add/remove control.
* Shares the same React Query cache as the Settings page's full
* multi-destination UI, so changes here show up there instantly.
*/
export function NotificationChannelBox({
type,
label,
placeholder,
companyId,
destination,
disabled,
disabledReason,
}: {
type: NotificationType;
label: string;
placeholder: string;
companyId: string;
destination: NotificationDestinationResponse | undefined;
disabled?: boolean;
disabledReason?: string;
}) {
const [value, setValue] = useState("");
const create = useCreateNotificationDestination();
const unlink = useUnlinkNotificationDestinationCompany();
return (
<div>
<label className="block text-sm font-medium text-slate-700">{label}</label>
<div className="mt-1 flex items-center gap-2">
<input
value={destination ? destination.destination_value : value}
onChange={(e) => setValue(e.target.value)}
readOnly={Boolean(destination)}
disabled={disabled}
placeholder={placeholder}
className="focus-ring block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm transition-colors duration-150 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
/>
{destination ? (
<button
type="button"
onClick={() => unlink.mutate({ destinationId: destination.id, companyId })}
disabled={disabled || unlink.isPending}
title={`Remove this ${label.toLowerCase()}`}
className="focus-ring inline-flex shrink-0 items-center justify-center rounded-md border border-red-300 p-2 text-red-600 transition-colors duration-150 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
>
{unlink.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<Minus className="h-4 w-4" aria-hidden />
)}
</button>
) : (
<button
type="button"
onClick={() => {
if (!value.trim()) return;
create.mutate(
{ type, destination_value: value.trim(), company_ids: [companyId] },
{ onSuccess: () => setValue("") },
);
}}
disabled={disabled || !value.trim() || create.isPending}
title={`Add this ${label.toLowerCase()}`}
className="focus-ring inline-flex shrink-0 items-center justify-center rounded-md border border-green-300 p-2 text-green-700 transition-colors duration-150 hover:bg-green-50 disabled:cursor-not-allowed disabled:opacity-40"
>
{create.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<Plus className="h-4 w-4" aria-hidden />
)}
</button>
)}
</div>
{disabled && disabledReason && <p className="mt-1 text-xs text-slate-500">{disabledReason}</p>}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { type InputHTMLAttributes, forwardRef, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import clsx from "clsx";
interface PasswordFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label: string;
error?: string;
hint?: string;
}
/** Same visual language as `FormField`, plus a show/hide toggle - same
* crossfade-icon-swap technique as `ApiKeyRow`'s Show/Hide button. */
export const PasswordField = forwardRef<HTMLInputElement, PasswordFieldProps>(
({ label, error, hint, id, className, ...props }, ref) => {
const [revealed, setRevealed] = useState(false);
const fieldId = id ?? props.name;
return (
<div>
<label htmlFor={fieldId} className="block text-sm font-medium text-slate-700">
{label}
</label>
<div className="relative mt-1">
<input
ref={ref}
id={fieldId}
type={revealed ? "text" : "password"}
className={clsx(
"focus-ring block w-full rounded-md border border-slate-300 px-3 py-2 pr-10 text-sm shadow-sm transition-colors duration-150",
error && "border-red-400",
className,
)}
aria-invalid={error ? "true" : "false"}
aria-describedby={error ? `${fieldId}-error` : hint ? `${fieldId}-hint` : undefined}
{...props}
/>
<button
type="button"
onClick={() => setRevealed((r) => !r)}
title={revealed ? "Hide password" : "Show password"}
tabIndex={-1}
className="focus-ring absolute inset-y-0 right-0 flex w-9 items-center justify-center text-slate-400 transition-colors duration-150 hover:text-slate-600"
>
<span className="relative block h-4 w-4">
<Eye
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-0" : "opacity-100"}`}
aria-hidden
/>
<EyeOff
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-100" : "opacity-0"}`}
aria-hidden
/>
</span>
</button>
</div>
{hint && !error && (
<p id={`${fieldId}-hint`} className="mt-1 text-xs text-slate-500">
{hint}
</p>
)}
{error && (
<p
id={`${fieldId}-error`}
className="mt-1 animate-fade-in text-xs text-red-600"
role="alert"
>
{error}
</p>
)}
</div>
);
},
);
PasswordField.displayName = "PasswordField";
@@ -0,0 +1,43 @@
"use client";
const LEVELS = [
{ label: "Weak", bar: "bg-red-500", text: "text-red-600" },
{ label: "Fair", bar: "bg-amber-500", text: "text-amber-600" },
{ label: "Good", bar: "bg-brand-500", text: "text-brand-600" },
{ label: "Strong", bar: "bg-green-500", text: "text-green-600" },
] as const;
/** A purely visual affordance - length/variety heuristics scored 0-4, shown
* as an animated bar. Doesn't gate submission or loosen the real policy
* (still enforced by the password field's own Zod schema). */
function scorePassword(password: string): number {
if (!password) return 0;
let score = 0;
if (password.length >= 10) score += 1;
if (password.length >= 14) score += 1;
if (/[^a-zA-Z0-9]/.test(password)) score += 1;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score += 1;
return Math.min(score, 4);
}
export function PasswordStrengthMeter({ password }: { password: string }) {
const score = scorePassword(password);
if (!password) return null;
const level = LEVELS[Math.max(score - 1, 0)] ?? LEVELS[0];
const widthPercent = (score / 4) * 100;
return (
<div className="mt-2 animate-fade-in">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full transition-[width,background-color] duration-300 ${level.bar}`}
style={{ width: `${widthPercent}%` }}
/>
</div>
<p key={level.label} className={`mt-1 animate-fade-in text-xs ${level.text}`}>
{level.label}
</p>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import clsx from "clsx";
import { Check, ChevronDown } from "lucide-react";
export interface SelectOption {
value: string;
label: string;
}
// Radix disallows an empty-string Item value (it's reserved to mean "no
// selection" internally) - but several call sites here use "" to mean "all"
// / "no filter". This sentinel maps "" <-> a real string at the Radix
// boundary only, so callers can keep using "" as usual.
const EMPTY_VALUE_SENTINEL = "__select-empty__";
/**
* Custom dropdown replacing native <select> everywhere in the app. Native
* select option lists are rendered by the OS/browser itself and cannot be
* animated with CSS/JS in any browser - this is a real component (Radix's
* accessible, keyboard-navigable primitive) so the open/close transition
* (see select-in/select-out in tailwind.config.ts) actually applies.
*/
export function Select({
value,
onValueChange,
options,
placeholder,
name,
ariaLabel,
triggerClassName,
}: {
value: string;
onValueChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
name?: string;
ariaLabel?: string;
triggerClassName?: string;
}) {
return (
<SelectPrimitive.Root
value={value === "" ? EMPTY_VALUE_SENTINEL : value}
onValueChange={(v) => onValueChange(v === EMPTY_VALUE_SENTINEL ? "" : v)}
name={name}
>
<SelectPrimitive.Trigger
aria-label={ariaLabel}
className={clsx(
"focus-ring flex items-center justify-between gap-2 rounded-md border border-slate-300 bg-white text-left shadow-sm outline-none data-[placeholder]:text-slate-400",
triggerClassName ?? "px-3 py-2 text-sm",
)}
>
<SelectPrimitive.Value placeholder={placeholder} />
<SelectPrimitive.Icon>
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
position="popper"
sideOffset={4}
className="z-50 overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg data-[state=closed]:animate-select-out data-[state=open]:animate-select-in"
>
<SelectPrimitive.Viewport className="p-1">
{options.map((option) => (
<SelectPrimitive.Item
key={option.value}
value={option.value === "" ? EMPTY_VALUE_SENTINEL : option.value}
className="relative flex cursor-pointer select-none items-center rounded px-2 py-1.5 pl-7 text-sm text-slate-700 outline-none data-[highlighted]:bg-brand-50 data-[highlighted]:text-brand-700"
>
<SelectPrimitive.ItemIndicator className="absolute left-2 inline-flex items-center">
<Check className="h-3.5 w-3.5" aria-hidden />
</SelectPrimitive.ItemIndicator>
<SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { LucideIcon } from "lucide-react";
export function StatCard({
label,
value,
icon: Icon,
hint,
}: {
label: string;
value: string | number;
icon: LucideIcon;
hint?: string;
}) {
return (
<div className="rounded-lg border border-slate-200 bg-white p-5">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-500">{label}</span>
<Icon className="h-4 w-4 text-slate-400" aria-hidden />
</div>
<p className="mt-2 text-2xl font-semibold text-slate-900">{value}</p>
{hint && <p className="mt-1 text-xs text-slate-500">{hint}</p>}
</div>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, Eye, EyeOff, Loader2, Save } from "lucide-react";
import { useSetSystemSecret } from "@/hooks/use-auth";
import type { SystemSecretStatus } from "@/lib/types";
/** One server-wide secret's admin-editable row - label, masked textbox with
* a hide/unhide toggle, and a blue Update button, using the same
* crossfade-icon transition language as UserApiKeyRow. Unlike that
* component, this is one shared value for the whole app (Turnstile site
* key/secret today), not scoped to the calling user. */
export function SystemSecretRow({ secret }: { secret: SystemSecretStatus }) {
const [value, setValue] = useState(secret.value ?? "");
const [revealed, setRevealed] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const savedTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const setSecret = useSetSystemSecret();
useEffect(
() => () => {
if (savedTimeout.current) clearTimeout(savedTimeout.current);
},
[],
);
const dirty = value !== (secret.value ?? "");
const handleUpdate = () => {
setSecret.mutate(
{ key: secret.key, payload: { value } },
{
onSuccess: (updated) => {
setValue(updated.value ?? "");
setJustSaved(true);
if (savedTimeout.current) clearTimeout(savedTimeout.current);
savedTimeout.current = setTimeout(() => setJustSaved(false), 1800);
},
},
);
};
return (
<div>
<label className="block text-sm font-medium text-slate-700">{secret.label}</label>
<div className="mt-1 flex items-center gap-2">
<input
type={revealed ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={secret.configured ? undefined : "Not set"}
className="focus-ring block w-full rounded-md border border-slate-300 px-3 py-2 font-mono text-sm text-slate-900 shadow-sm transition-colors duration-150 placeholder:font-sans placeholder:text-slate-400"
/>
<button
type="button"
onClick={() => setRevealed((r) => !r)}
title={revealed ? "Hide key" : "Show key"}
className="focus-ring inline-flex shrink-0 items-center justify-center rounded-md border border-slate-300 p-2 text-slate-500 transition-colors duration-200 hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
>
<span className="relative block h-4 w-4">
<Eye
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-0" : "opacity-100"}`}
aria-hidden
/>
<EyeOff
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-100" : "opacity-0"}`}
aria-hidden
/>
</span>
</button>
<button
type="button"
onClick={handleUpdate}
disabled={!dirty || setSecret.isPending}
title="Update key"
className="focus-ring inline-flex shrink-0 items-center gap-1.5 rounded-md bg-brand-600 px-3 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="relative block h-4 w-4">
<Save
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${
setSecret.isPending || justSaved ? "opacity-0" : "opacity-100"
}`}
aria-hidden
/>
<Loader2
className={`absolute inset-0 h-4 w-4 animate-spin transition-opacity duration-200 ${
setSecret.isPending ? "opacity-100" : "opacity-0"
}`}
aria-hidden
/>
<Check
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${
justSaved && !setSecret.isPending ? "opacity-100" : "opacity-0"
}`}
aria-hidden
/>
</span>
Update
</button>
</div>
{setSecret.isError && (
<p className="mt-1 text-xs text-red-600">Couldn&apos;t save that key. Try again.</p>
)}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import Script from "next/script";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
interface TurnstileRenderOptions {
sitekey: string;
action?: string;
callback: (token: string) => void;
"error-callback"?: () => void;
"expired-callback"?: () => void;
}
declare global {
interface Window {
turnstile?: {
render: (container: HTMLElement, options: TurnstileRenderOptions) => string;
reset: (widgetId?: string) => void;
remove: (widgetId?: string) => void;
};
}
}
export interface TurnstileWidgetHandle {
/** Tokens are single-use - call this after any failed submission before
* retrying, or the retry is rejected as timeout-or-duplicate. */
reset: () => void;
}
interface TurnstileWidgetProps {
/** Sourced live from useSystemStatus()'s turnstile_site_key - not a
* build-time env var, so an admin-updated key (see the Settings page's
* Server secrets box) takes effect without rebuilding the frontend. */
siteKey: string | null | undefined;
onToken: (token: string) => void;
className?: string;
}
export const TurnstileWidget = forwardRef<TurnstileWidgetHandle, TurnstileWidgetProps>(
function TurnstileWidget({ siteKey, onToken, className }, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
const onTokenRef = useRef(onToken);
const [scriptLoaded, setScriptLoaded] = useState(
() => typeof window !== "undefined" && !!window.turnstile,
);
useEffect(() => {
onTokenRef.current = onToken;
}, [onToken]);
// next/script's onLoad only reliably fires for the mount that actually
// injects the tag - a later page that mounts this same widget after an
// earlier page already loaded the Cloudflare script (same src, so Next
// skips re-injecting it) can otherwise wait on an onLoad that never
// comes. Poll briefly as a fallback for that case.
useEffect(() => {
if (scriptLoaded) return;
const interval = setInterval(() => {
if (window.turnstile) {
setScriptLoaded(true);
clearInterval(interval);
}
}, 100);
return () => clearInterval(interval);
}, [scriptLoaded]);
useImperativeHandle(ref, () => ({
reset: () => {
if (window.turnstile && widgetIdRef.current) {
window.turnstile.reset(widgetIdRef.current);
}
},
}));
useEffect(() => {
if (!scriptLoaded || !containerRef.current || !window.turnstile || !siteKey) return;
// Explicit render, not implicit auto-render, into our own ref'd div.
const widgetId = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
action: "turnstile-spin-v2",
callback: (token) => onTokenRef.current(token),
});
widgetIdRef.current = widgetId;
return () => {
window.turnstile?.remove(widgetId);
widgetIdRef.current = null;
};
}, [scriptLoaded, siteKey]);
if (!siteKey) return null;
return (
<>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
strategy="afterInteractive"
onLoad={() => setScriptLoaded(true)}
/>
<div ref={containerRef} className={className} />
</>
);
},
);
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, Eye, EyeOff, Loader2, Save } from "lucide-react";
import { useSetUserApiKey } from "@/hooks/use-auth";
import type { UserApiKeyStatus } from "@/lib/types";
/** One provider's own-key row: label, editable masked textbox with a
* hide/unhide toggle, credit/free-tier info, and a blue Update button - all
* with the same crossfade-icon transition language as the admin-only
* server-key box's Show/Hide toggle (api-key-row.tsx). Unlike that box,
* this one is per-user and editable: each user only ever sees/sets their
* own key here, never anyone else's.
*
* `liveCredits`, when passed, overrides the number shown for the numeric
* "N credits remaining" line - used for NinjaPear, which sources its
* balance from useSystemStatus()'s already-fetched ninjapear_credit_balance
* (the same number shown in System configuration below) rather than this
* component making its own separate, redundant live call. */
export function UserApiKeyRow({
apiKey,
liveCredits,
}: {
apiKey: UserApiKeyStatus;
liveCredits?: number | null;
}) {
const [value, setValue] = useState(apiKey.value ?? "");
const [revealed, setRevealed] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const savedTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const setKey = useSetUserApiKey();
useEffect(
() => () => {
if (savedTimeout.current) clearTimeout(savedTimeout.current);
},
[],
);
const dirty = value !== (apiKey.value ?? "");
const credits = liveCredits !== undefined ? liveCredits : apiKey.credits;
const handleUpdate = () => {
setKey.mutate(
{ provider: apiKey.provider, payload: { key: value } },
{
onSuccess: (updated) => {
setValue(updated.value ?? "");
setJustSaved(true);
if (savedTimeout.current) clearTimeout(savedTimeout.current);
savedTimeout.current = setTimeout(() => setJustSaved(false), 1800);
},
},
);
};
return (
<div>
<div className="flex items-baseline justify-between gap-2">
<label className="block text-sm font-medium text-slate-700">{apiKey.label}</label>
{apiKey.free && (
<span className="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-[11px] font-medium text-green-700">
Free{apiKey.requires_government_id ? " · requires government ID approval" : ""}
</span>
)}
</div>
<div className="mt-1 flex items-center gap-2">
<input
type={revealed ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={apiKey.configured ? undefined : "Not set"}
className="focus-ring block w-full rounded-md border border-slate-300 px-3 py-2 font-mono text-sm text-slate-900 shadow-sm transition-colors duration-150 placeholder:font-sans placeholder:text-slate-400"
/>
<button
type="button"
onClick={() => setRevealed((r) => !r)}
title={revealed ? "Hide key" : "Show key"}
className="focus-ring inline-flex shrink-0 items-center justify-center rounded-md border border-slate-300 p-2 text-slate-500 transition-colors duration-200 hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
>
<span className="relative block h-4 w-4">
<Eye
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-0" : "opacity-100"}`}
aria-hidden
/>
<EyeOff
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-100" : "opacity-0"}`}
aria-hidden
/>
</span>
</button>
<button
type="button"
onClick={handleUpdate}
disabled={!dirty || setKey.isPending}
title="Update key"
className="focus-ring inline-flex shrink-0 items-center gap-1.5 rounded-md bg-brand-600 px-3 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="relative block h-4 w-4">
<Save
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${
setKey.isPending || justSaved ? "opacity-0" : "opacity-100"
}`}
aria-hidden
/>
<Loader2
className={`absolute inset-0 h-4 w-4 animate-spin transition-opacity duration-200 ${
setKey.isPending ? "opacity-100" : "opacity-0"
}`}
aria-hidden
/>
<Check
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${
justSaved && !setKey.isPending ? "opacity-100" : "opacity-0"
}`}
aria-hidden
/>
</span>
Update
</button>
</div>
<p className="mt-1 text-xs text-slate-500">
{apiKey.configured
? credits !== null && credits !== undefined
? `${credits} credits remaining. ${apiKey.credits_note ?? ""}`
: (apiKey.credits_note ?? "")
: (apiKey.credits_note ?? "")}
</p>
{setKey.isError && (
<p className="mt-1 text-xs text-red-600">Couldn&apos;t save that key. Try again.</p>
)}
</div>
);
}