shadimage
Shadcn UI Auth
authformscomponentclaimssource: ok · refreshed

OIDC Claims Pieces

The shared pieces every provider form composes: the ClaimsList row editor for additional claims, the ClaimChip label marker, and the pure helpers that merge rows into a policy and read them back out.

npx shadcn add "https://ui.shadcn.com/oidc/r/claims.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
lucide-react

Registry dependencies

buttonfieldinput

Files1 · source cached 10/7/2026, 2:37:22 AM

registry/oidc/components/claims.tsx
"use client"

// ClaimsList is a controlled composite input — mount it like any other input:
// Controller in React Hook Form, form.Field in TanStack Form, plain state
// otherwise. The pure helpers also work with useFieldArray-style rows.

import * as React from "react"
import { PlusIcon, Trash2Icon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

type ClaimRow = { name: string; values: string }

function claimRowError(row: ClaimRow, reserved: string[] = []) {
  const name = row.name.trim()
  if (!name && !row.values.trim()) {
    return null
  }
  if (!name) {
    return "Name the claim."
  }
  if (reserved.includes(name)) {
    return "This claim has a dedicated field above."
  }
  // Prototype-chain names silently vanish from plain-object claim maps.
  if (["__proto__", "constructor", "prototype"].includes(name)) {
    return "This claim name is not allowed."
  }
  if (!/^[\w.-]+$/.test(name)) {
    return "Claim names: letters, digits, dots, dashes, underscores."
  }
  if (!row.values.trim()) {
    return "Add at least one accepted value."
  }
  return null
}

function claimsListValid(rows: ClaimRow[], reserved: string[] = []) {
  return rows.every((row) => claimRowError(row, reserved) === null)
}

function splitValues(text: string) {
  return text
    .split(",")
    .map((value) => value.trim())
    .filter(Boolean)
}

/** Compile side: fold rows into a claims map, never shadowing reserved names. */
function mergeClaimRows(
  claims: Record<string, string[]>,
  rows: ClaimRow[],
  reserved: string[]
) {
  for (const row of rows) {
    const name = row.name.trim()
    const values = splitValues(row.values)
    if (!name || values.length === 0) {
      continue
    }
    if (reserved.includes(name)) {
      continue
    }
    claims[name] = values
  }
}

/**
 * Parse side: a row for every claim the form has no dedicated field for, or
 * null when rows cannot faithfully represent them (comma-bearing values,
 * names the row editor rejects) — recompiling would alter the policy.
 */
function claimRowsFromPolicy(
  claims: Record<string, string[]>,
  reserved: string[]
) {
  const rows: ClaimRow[] = []
  for (const [name, values] of Object.entries(claims)) {
    if (reserved.includes(name)) {
      continue
    }
    const row = { name, values: values.join(", ") }
    const split = splitValues(row.values)
    if (
      claimRowError(row) !== null ||
      split.length !== values.length ||
      split.some((value, index) => value !== values[index])
    ) {
      return null
    }
    rows.push(row)
  }
  return rows
}

function ClaimChip({ className, ...props }: React.ComponentProps<"code">) {
  return (
    <code
      data-slot="claim-chip"
      className={cn(
        "ml-auto font-mono text-xs font-normal text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

function ClaimsList({
  value,
  onChange,
  reserved = [],
  label = "Additional claims",
  className,
}: {
  value: ClaimRow[]
  onChange: (rows: ClaimRow[]) => void
  /** Claims with dedicated fields — rows may not shadow them. */
  reserved?: string[]
  label?: string
  className?: string
}) {
  // Index keys would re-key the rows below a removal and drop focus. Replace
  // the value from outside by remounting (key the component).
  const [ids, setIds] = React.useState<string[]>(() =>
    value.map((_, index) => `seed-${index}`)
  )
  const counterRef = React.useRef(0)

  const update = (index: number, patch: Partial<ClaimRow>) =>
    onChange(value.map((row, i) => (i === index ? { ...row, ...patch } : row)))

  const remove = (index: number) => {
    setIds(ids.filter((_, i) => i !== index))
    onChange(value.filter((_, i) => i !== index))
  }

  const add = () => {
    setIds([...ids, `row-${counterRef.current++}`])
    onChange([...value, { name: "", values: "" }])
  }

  return (
    <div
      data-slot="claims-list"
      className={cn("flex flex-col gap-2", className)}
    >
      <FieldLabel>{label}</FieldLabel>
      {value.map((row, index) => {
        const rowError = claimRowError(row, reserved)
        return (
          <div key={ids[index] ?? index} className="flex flex-col gap-1">
            <div className="flex items-center gap-2">
              <Input
                value={row.name}
                placeholder="claim"
                aria-label="Claim name"
                className="w-40 shrink-0 font-mono text-xs"
                onChange={(event) =>
                  update(index, { name: event.target.value })
                }
              />
              <Input
                value={row.values}
                placeholder="value1, value2"
                aria-label="Accepted values"
                className="font-mono text-xs"
                onChange={(event) =>
                  update(index, { values: event.target.value })
                }
              />
              <Button
                type="button"
                variant="ghost"
                size="icon-sm"
                aria-label="Remove claim"
                onClick={() => remove(index)}
              >
                <Trash2Icon />
              </Button>
            </div>
            {rowError ? <FieldError>{rowError}</FieldError> : null}
          </div>
        )
      })}
      <Button
        type="button"
        variant="outline"
        size="sm"
        className="self-start"
        onClick={add}
      >
        <PlusIcon data-icon="inline-start" />
        Add claim
      </Button>
    </div>
  )
}

export {
  ClaimsList,
  ClaimChip,
  claimRowError,
  claimsListValid,
  mergeClaimRows,
  claimRowsFromPolicy,
}
export type { ClaimRow }

More from Shadcn UI Auth

Browse all components in this library →