Security

SSO Connection

An SSO/SAML connection card showing provider, protocol, verified domain, and live status, with a test-connection button that reports pass or fail inline.

Install

npx shadcn@latest add @paragon/sso-connection

sso-connection.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  CheckCircle2,
  Loader2,
  Lock,
  RefreshCw,
  XCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";

export type SsoStatus = "connected" | "error" | "disabled";

export interface SsoConnectionProps extends React.ComponentProps<"div"> {
  /** Identity provider name. */
  provider?: string;
  /** Protocol label. */
  protocol?: "SAML 2.0" | "OIDC" | "SCIM";
  /** Metadata / ACS endpoint shown as the connection subject. */
  endpoint?: string;
  status?: SsoStatus;
  /** Verified email domain. */
  domain?: string;
  /** Disables enter animation. */
  static?: boolean;
}

type TestState = "idle" | "testing" | "passed" | "failed";

const STATUS_META: Record<
  SsoStatus,
  { label: string; dot: string; text: string }
> = {
  connected: {
    label: "Connected",
    dot: "bg-success",
    text: "text-success",
  },
  error: { label: "Needs attention", dot: "bg-destructive", text: "text-destructive" },
  disabled: { label: "Disabled", dot: "bg-muted-foreground", text: "text-muted-foreground" },
};

/**
 * An SSO/SAML connection card with live status and a test-connection button.
 * Running a test spins the trigger, then blur-crossfades to a pass/fail chip
 * that clears itself after a moment; the swap uses AnimatePresence popLayout so
 * the button width stays put. Reduced motion drops the spinner. Deterministic —
 * pass/fail is driven by the given status, no randomness.
 */
export function SsoConnection({
  provider = "Okta",
  protocol = "SAML 2.0",
  endpoint = "acme.okta.com/app/acme_prod/sso/saml",
  status = "connected",
  domain = "acme.com",
  static: isStatic = false,
  className,
  ...props
}: SsoConnectionProps) {
  const reduced = useReducedMotion();
  const [test, setTest] = React.useState<TestState>("idle");
  const runTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
  const clearTimer = React.useRef<ReturnType<typeof setTimeout>>(null);

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

  const runTest = () => {
    if (test === "testing") return;
    setTest("testing");
    if (runTimer.current) clearTimeout(runTimer.current);
    runTimer.current = setTimeout(() => {
      const result: TestState = status === "connected" ? "passed" : "failed";
      setTest(result);
      if (clearTimer.current) clearTimeout(clearTimer.current);
      clearTimer.current = setTimeout(() => setTest("idle"), 2600);
    }, 900);
  };

  const meta = STATUS_META[status];

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

      <div className="flex items-start justify-between gap-3">
        <div className="flex items-center gap-3">
          <span className="flex size-10 items-center justify-center rounded-lg bg-secondary text-foreground">
            <Lock className="size-5" />
          </span>
          <div>
            <div className="flex items-center gap-2">
              <h3 className="text-sm font-semibold">{provider}</h3>
              <span className="rounded-md bg-secondary px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">
                {protocol}
              </span>
            </div>
            <p className="mt-0.5 text-xs text-muted-foreground">
              Verified domain{" "}
              <span className="font-medium text-foreground">{domain}</span>
            </p>
          </div>
        </div>
        <span className={cn("flex items-center gap-1.5 text-xs font-medium", meta.text)}>
          <span className={cn("size-2 rounded-full", meta.dot)} />
          {meta.label}
        </span>
      </div>

      <div className="mt-4 rounded-lg border bg-background px-3 py-2.5">
        <p className="text-[11px] font-medium text-muted-foreground">
          Single sign-on URL
        </p>
        <p className="mt-0.5 truncate font-mono text-[13px]">{endpoint}</p>
      </div>

      <div className="mt-4 flex items-center justify-between">
        <p className="text-xs text-muted-foreground">
          Last verified 2 hours ago
        </p>
        <button
          type="button"
          onClick={runTest}
          disabled={test === "testing" || status === "disabled"}
          className={cn(
            "pressable relative flex h-8 min-w-[7.5rem] items-center justify-center gap-1.5 rounded-lg bg-card px-3 text-[13px] font-medium shadow-border transition-[box-shadow] duration-(--duration-fast) hover:shadow-border-hover",
            "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            "disabled:opacity-50",
          )}
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={test}
              className="flex items-center gap-1.5 whitespace-nowrap"
              initial={reduced ? false : { opacity: 0, filter: "blur(4px)", scale: 0.9 }}
              animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
              exit={reduced ? { opacity: 0 } : { opacity: 0, filter: "blur(4px)", scale: 0.9 }}
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
            >
              {test === "testing" && (
                <>
                  <Loader2 className="size-3.5 animate-spin" />
                  Testing…
                </>
              )}
              {test === "passed" && (
                <>
                  <CheckCircle2 className="size-3.5 text-success" />
                  Test passed
                </>
              )}
              {test === "failed" && (
                <>
                  <XCircle className="size-3.5 text-destructive" />
                  Test failed
                </>
              )}
              {test === "idle" && (
                <>
                  <RefreshCw className="size-3.5" />
                  Test connection
                </>
              )}
            </motion.span>
          </AnimatePresence>
        </button>
      </div>
    </div>
  );
}