Security

IP Allowlist

A CIDR allowlist manager that validates ranges on the fly and animates rows in and out as entries are added or removed.

Install

npx shadcn@latest add @paragon/ip-allowlist

ip-allowlist.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Globe, Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";

export interface CidrEntry {
  id: string;
  cidr: string;
  note?: string;
}

export interface IpAllowlistProps extends React.ComponentProps<"div"> {
  /** Initial entries. */
  initial?: CidrEntry[];
  /** Disables enter animation. */
  static?: boolean;
}

const DEFAULT_ENTRIES: CidrEntry[] = [
  { id: "hq", cidr: "203.0.113.0/24", note: "SF office" },
  { id: "vpn", cidr: "198.51.100.14/32", note: "Corp VPN gateway" },
  { id: "ci", cidr: "10.20.0.0/16", note: "CI runners" },
];

/** Validates an IPv4 CIDR like 10.0.0.0/8. Returns a reason when invalid. */
function validateCidr(input: string): string | null {
  const trimmed = input.trim();
  const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(
    trimmed,
  );
  if (!match) return "Use CIDR notation, e.g. 10.0.0.0/24";
  const octets = match.slice(1, 5).map(Number);
  if (octets.some((o) => o > 255)) return "Each octet must be 0–255";
  const prefix = Number(match[5]);
  if (prefix > 32) return "Prefix must be /0–/32";
  return null;
}

/**
 * A CIDR allowlist manager: type a range, it validates on the fly, and adding
 * inserts a row that slides in while the list settles. Removing sweeps a row out
 * with layout animation via AnimatePresence. Validation is synchronous and
 * deterministic. Reduced motion drops the enter/exit movement but keeps the
 * list correct. IDs are seeded from a useId counter, never Math.random.
 */
export function IpAllowlist({
  initial = DEFAULT_ENTRIES,
  static: isStatic = false,
  className,
  ...props
}: IpAllowlistProps) {
  const reduced = useReducedMotion();
  const seed = React.useId();
  const counter = React.useRef(0);
  const [entries, setEntries] = React.useState<CidrEntry[]>(initial);
  const [draft, setDraft] = React.useState("");
  const [touched, setTouched] = React.useState(false);

  const error = draft.trim() ? validateCidr(draft) : null;
  const duplicate =
    !error && entries.some((e) => e.cidr === draft.trim())
      ? "Range already in the allowlist"
      : null;
  const problem = touched ? error || duplicate : null;
  const canAdd = draft.trim().length > 0 && !error && !duplicate;

  const add = () => {
    if (!canAdd) {
      setTouched(true);
      return;
    }
    counter.current += 1;
    setEntries((prev) => [
      ...prev,
      { id: `${seed}-${counter.current}`, cidr: draft.trim() },
    ]);
    setDraft("");
    setTouched(false);
  };

  const remove = (id: string) => {
    setEntries((prev) => prev.filter((e) => e.id !== id));
  };

  return (
    <div
      data-slot="ip-allowlist"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
        className,
      )}
      style={
        !isStatic && !reduced
          ? ({
              animation: "ip-allowlist-enter var(--duration-base) var(--ease-out)",
            } as React.CSSProperties)
          : undefined
      }
      {...props}
    >
      <style href="paragon-ip-allowlist" precedence="paragon">{`
        @keyframes ip-allowlist-enter {
          from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
          to { opacity: 1; transform: translateY(0); filter: blur(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-slot="ip-allowlist"] { animation: none !important; }
        }
      `}</style>

      <div className="flex items-center gap-2.5">
        <span className="flex size-8 items-center justify-center rounded-lg bg-secondary text-foreground">
          <Globe className="size-4" />
        </span>
        <div>
          <h3 className="text-sm font-semibold">IP allowlist</h3>
          <p className="text-xs text-muted-foreground tabular-nums">
            {entries.length} range{entries.length === 1 ? "" : "s"} can reach the API
          </p>
        </div>
      </div>

      {/* Add form */}
      <div className="mt-4">
        <div className="flex gap-2">
          <input
            value={draft}
            onChange={(e) => {
              setDraft(e.target.value);
              if (!touched) setTouched(true);
            }}
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                e.preventDefault();
                add();
              }
            }}
            placeholder="e.g. 192.0.2.0/24"
            aria-label="CIDR range"
            aria-invalid={!!problem}
            className={cn(
              "h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 font-mono text-[13px] outline-none transition-[border-color,box-shadow] duration-(--duration-fast)",
              problem
                ? "border-destructive focus-visible:ring-2 focus-visible:ring-destructive/40"
                : "border-input focus-visible:ring-2 focus-visible:ring-ring",
            )}
          />
          <button
            type="button"
            onClick={add}
            disabled={!canAdd}
            className="pressable inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-primary px-3 text-[13px] font-medium text-primary-foreground transition-[opacity] duration-(--duration-fast) hover:opacity-90 disabled:opacity-40 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            <Plus className="size-4" />
            Add
          </button>
        </div>
        <p
          className="mt-1.5 h-4 text-[11px] text-destructive"
          aria-live="polite"
        >
          {problem}
        </p>
      </div>

      {/* Entries */}
      <ul className="space-y-1.5">
        <AnimatePresence initial={false} mode="popLayout">
          {entries.map((entry) => (
            <motion.li
              key={entry.id}
              layout={!reduced}
              initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }}
              animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, filter: "blur(4px)" }}
              transition={{ type: "spring", duration: 0.35, bounce: 0 }}
            >
              <div className="flex items-center gap-3 rounded-lg border bg-background px-3 py-2">
                <span className="flex-1 truncate font-mono text-[13px] tabular-nums">
                  {entry.cidr}
                </span>
                {entry.note && (
                  <span className="shrink-0 truncate text-[11px] text-muted-foreground">
                    {entry.note}
                  </span>
                )}
                <button
                  type="button"
                  aria-label={`Remove ${entry.cidr}`}
                  onClick={() => remove(entry.id)}
                  className="pressable relative flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--duration-fast) hover:text-destructive outline-none focus-visible:ring-2 focus-visible:ring-ring after:absolute after:top-1/2 after:left-1/2 after:size-9 after:-translate-1/2"
                >
                  <Trash2 className="size-4" />
                </button>
              </div>
            </motion.li>
          ))}
        </AnimatePresence>
      </ul>
    </div>
  );
}