Security

Vault Item

A password-manager entry with title, username, a mask-toggling password field, per-field copy, and a deterministic strength meter.

Install

npx shadcn@latest add @paragon/vault-item

vault-item.tsx

"use client";

import * as React from "react";
import { Check, Copy, Eye, EyeOff, Globe } from "lucide-react";
import { cn } from "@/lib/utils";

export interface VaultItemProps extends React.ComponentProps<"div"> {
  /** Entry title. */
  title?: string;
  /** Account username / email. */
  username?: string;
  /** The stored password. Masked by default; never logged. */
  password?: string;
  /** Site the entry belongs to. */
  site?: string;
  /**
   * Password strength 0–4 (weak → strong). If omitted it is derived
   * deterministically from the password composition.
   */
  strength?: number;
  /** Disables enter animation. */
  static?: boolean;
}

const STRENGTH_META = [
  { label: "Very weak", color: "bg-destructive", text: "text-destructive" },
  { label: "Weak", color: "bg-destructive", text: "text-destructive" },
  { label: "Fair", color: "bg-warning", text: "text-warning" },
  { label: "Good", color: "bg-success", text: "text-success" },
  { label: "Strong", color: "bg-success", text: "text-success" },
];

/** Deterministic strength estimate from composition — no randomness. */
function scoreOf(pw: string) {
  let s = 0;
  if (pw.length >= 12) s += 2;
  else if (pw.length >= 8) s += 1;
  if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s += 1;
  if (/\d/.test(pw)) s += 1;
  if (/[^A-Za-z0-9]/.test(pw)) s += 1;
  return Math.min(s, 4);
}

/**
 * A password-manager vault entry: title, username, masked password, and a
 * strength meter. The password toggles between a fixed-width dot mask and the
 * value via a CSS opacity/blur transition, and each field copies with an
 * inline check swap. Copying never logs the value. Strength defaults to a
 * deterministic estimate from the password composition.
 */
export function VaultItem({
  title = "Production database",
  username = "svc-billing@acme.com",
  password = "9xK!mQ2$vLp7#nR4",
  site = "console.aws.amazon.com",
  strength,
  static: isStatic = false,
  className,
  ...props
}: VaultItemProps) {
  const [revealed, setRevealed] = React.useState(false);
  const [copied, setCopied] = React.useState<"user" | "pass" | null>(null);
  const timer = React.useRef<ReturnType<typeof setTimeout>>(null);

  React.useEffect(() => {
    return () => {
      if (timer.current) clearTimeout(timer.current);
    };
  }, []);

  const score = strength ?? scoreOf(password);
  const meta = STRENGTH_META[Math.min(Math.max(score, 0), 4)];
  const mask = "•".repeat(Math.max(password.length, 12));

  const copy = (value: string, which: "user" | "pass") => {
    navigator.clipboard.writeText(value);
    setCopied(which);
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(null), 1500);
  };

  return (
    <div
      data-slot="vault-item"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      style={
        !isStatic
          ? ({
              animation: "vault-enter var(--duration-base) var(--ease-out)",
            } as React.CSSProperties)
          : undefined
      }
      {...props}
    >
      <style href="paragon-vault-item" precedence="paragon">{`
        @keyframes vault-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="vault-item"] { animation: none !important; }
        }
      `}</style>

      <div className="flex items-center gap-3">
        <span className="flex size-9 items-center justify-center rounded-lg bg-secondary text-muted-foreground">
          <Globe className="size-4.5" />
        </span>
        <div className="min-w-0">
          <h3 className="truncate text-sm font-semibold">{title}</h3>
          <p className="truncate text-xs text-muted-foreground">{site}</p>
        </div>
      </div>

      <dl className="mt-4 space-y-2">
        <Field
          label="Username"
          copied={copied === "user"}
          onCopy={() => copy(username, "user")}
        >
          <span className="truncate font-mono text-[13px]">{username}</span>
        </Field>

        <Field
          label="Password"
          copied={copied === "pass"}
          onCopy={() => copy(password, "pass")}
          leading={
            <button
              type="button"
              aria-label={revealed ? "Hide password" : "Show password"}
              onClick={() => setRevealed((r) => !r)}
              className="pressable flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--duration-fast) hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              {revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
            </button>
          }
        >
          <span className="relative block min-w-0 flex-1 font-mono text-[13px]">
            <span
              className={cn(
                "block truncate transition-[opacity,filter] duration-(--duration-quick) ease-(--ease-out)",
                revealed ? "opacity-100 blur-0" : "opacity-0 blur-[3px]",
              )}
              aria-hidden={!revealed}
            >
              {password}
            </span>
            <span
              className={cn(
                "absolute inset-0 block truncate transition-[opacity] duration-(--duration-quick) ease-(--ease-out)",
                revealed ? "opacity-0" : "opacity-100",
              )}
              aria-hidden={revealed}
            >
              {mask}
            </span>
          </span>
        </Field>
      </dl>

      {/* Strength meter */}
      <div className="mt-4 flex items-center gap-3">
        <div className="flex flex-1 gap-1" role="img" aria-label={`Password strength: ${meta.label}`}>
          {[0, 1, 2, 3].map((i) => (
            <span
              key={i}
              className={cn(
                "h-1.5 flex-1 rounded-full transition-colors duration-(--duration-base) ease-(--ease-out)",
                i < score ? meta.color : "bg-border",
              )}
            />
          ))}
        </div>
        <span className={cn("text-xs font-medium tabular-nums", meta.text)}>
          {meta.label}
        </span>
      </div>
    </div>
  );
}

function Field({
  label,
  copied,
  onCopy,
  leading,
  children,
}: {
  label: string;
  copied: boolean;
  onCopy: () => void;
  leading?: React.ReactNode;
  children: React.ReactNode;
}) {
  return (
    <div className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2">
      <div className="min-w-0 flex-1">
        <dt className="text-[11px] font-medium text-muted-foreground">{label}</dt>
        <dd className="mt-0.5 flex items-center">{children}</dd>
      </div>
      {leading}
      <button
        type="button"
        aria-label={`Copy ${label.toLowerCase()}`}
        onClick={onCopy}
        className="pressable relative flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--duration-fast) hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
      >
        <span className="relative flex size-4 items-center justify-center">
          <Copy
            className={cn(
              "absolute size-4 transition-[opacity,scale] duration-(--duration-quick) ease-(--ease-out)",
              copied ? "scale-50 opacity-0" : "scale-100 opacity-100",
            )}
          />
          <Check
            className={cn(
              "absolute size-4 text-success transition-[opacity,scale] duration-(--duration-quick) ease-(--ease-out)",
              copied ? "scale-100 opacity-100" : "scale-50 opacity-0",
            )}
          />
        </span>
      </button>
    </div>
  );
}