Skip to content
All components

Restricted Shield

Stable

Consent · meta.securityPrimitive · System · Clinical

Behavioral health, substance use under 42 CFR Part 2, reproductive care, HIV status, and minor confidentiality can each be restricted independently of the rest of the chart. The redacted state is the default render, not the fallback, and every path fails closed. The subtle requirement is the middle ground — stating that restricted content exists without revealing what it is — with a concealExistence variant for the narrower case where even that acknowledgement is not permitted.

pnpm dlx shadcn@latest add @oxygenui/restricted-shield

First install? Add "@oxygenui": "https://oxygenui.design/r/{name}.json" to the registries block of your components.json first — or skip the config and pass https://oxygenui.design/r/restricted-shield.json directly.

Preview

Every state, switchable.

Live · synthetic data

Hidden — Substance use — 42 CFR Part 2

Content exists and is withheld. Disclosure is recorded and reviewed.

Redacted by default. Disclosure needs a stated reason and re-hides on its own — try it.

Restricted, disclosure permittedRestricted, disclosure not permittedExistence concealedDisclosed with countdownAuto re-redactedNot restricted

Usage

Props are the FHIR resource.

import { RestrictedShield } from "@/components/oxygen/restricted-shield";

<RestrictedShield
  restricted={isPart2Protected}
  category="Substance use — 42 CFR Part 2"
  onDisclose={(event) => auditLog.record(event)}
>
  <ObservationPanel observations={toxicology} />
</RestrictedShield>

Dependencies

  • lucide-react
  • clsx
  • tailwind-merge
Restricted Shield props
PropTypeDefault
children

React.ReactNode
category

Category shown to the reader, e.g. "Substance use — 42 CFR Part 2".

string | undefined"Restricted record"
className

string | undefined
concealExistence

Do not acknowledge that content exists. For the narrow cases where the existence of a record is itself the sensitive fact.

boolean | undefinedfalse
disclosurePermitted

Whether this viewer may break the glass. False renders the shield with no disclosure path and an explanation, which is different from being able to ask and choosing not to.

boolean | undefinedtrue
durationSeconds

How long a disclosure lasts before the shield closes again.

number | undefined300
onDisclose

Emitted when content is disclosed. The application persists this — the component does not, and says so rather than implying an audit trail it cannot provide.

((event: DisclosureEvent) => void) | undefined
onRedact

(() => void) | undefined
reasons

Reasons a viewer may choose from. Free text alone is not auditable.

string[] | undefinedDEFAULT_REASONS
restricted

Whether the content is restricted at all. Decided by the application.

boolean | undefinedfalse

Guidance

When to use it, and when not to.

DECISION SURFACE / 6 RULES

Recommended context

Use it

03
  • Around any content subject to a sensitivity policy, at field, section, or record level.
  • With onDisclose wired to your audit store \u2014 the event is emitted, not saved.
  • With concealExistence only where the existence of the record is itself sensitive.

Guardrails

Don't

03
  • Treating it as access control. Restricted data still reached the browser; enforce server-side.
  • Long durationSeconds values. A disclosure that lasts until logout is not time-boxed.
  • Putting the sensitive category name in a category string that itself reveals the content.

Quality

What was tested, and what is still missing.

Announced as withheld
The redacted state reads as restricted content with an available action, never as an empty section.
Polite countdown
The re-hide timer uses a polite live region. A countdown that interrupts every second is worse than the risk it mitigates.
Real form controls
The reason is a labelled select, not a free-text box, so the disclosure record is auditable.

Open gaps

Known limitations

03
  • Renders policy outcomes; it does not evaluate consent, security labels, or role.
  • The audit event is emitted only. Persistence, retention, and review are yours.
  • Timer state is per-instance and resets on remount.

Source

Exactly what lands in your repository.

Read directly from the published registry, so this can never drift from what the CLI installs.

Show source242 lines
"use client";

/**
 * RestrictedShield — wraps content that may be restricted by sensitivity policy.
 *
 * The redacted state is the DEFAULT render, not the fallback. Behavioral
 * health, substance use under 42 CFR Part 2, reproductive care, HIV status,
 * and minor confidentiality can each be restricted independently of the rest
 * of the chart, and every path here fails closed.
 *
 * The subtle requirement is the middle ground: stating that restricted content
 * EXISTS without revealing what it is. A reader who cannot tell the difference
 * between "nothing here" and "something here you cannot see" will misread the
 * chart in one direction or the other. The `concealExistence` variant handles
 * the narrower case where even that acknowledgement is not permitted.
 *
 * Disclosure is deliberate, reasoned, time-boxed, and logged. It is not a
 * session-wide unlock: the shield re-closes on its own, because a disclosure
 * that persists until logout is not really time-boxed.
 *
 * This renders policy outcomes. It does not evaluate policy — the application
 * decides who may see what, and passes the answer in.
 */

import * as React from "react";
import { Eye, Lock } from "lucide-react";
import { cn } from "@/lib/utils";

export interface DisclosureEvent {
  reason: string;
  /** Seconds the disclosure was granted for. */
  durationSeconds: number;
  at: Date;
}

export interface RestrictedShieldProps {
  /** Whether the content is restricted at all. Decided by the application. */
  restricted?: boolean;
  /**
   * Whether this viewer may break the glass. False renders the shield with no
   * disclosure path and an explanation, which is different from being able to
   * ask and choosing not to.
   */
  disclosurePermitted?: boolean;
  /**
   * Do not acknowledge that content exists. For the narrow cases where the
   * existence of a record is itself the sensitive fact.
   */
  concealExistence?: boolean;
  /** Category shown to the reader, e.g. "Substance use — 42 CFR Part 2". */
  category?: string;
  /** Reasons a viewer may choose from. Free text alone is not auditable. */
  reasons?: string[];
  /** How long a disclosure lasts before the shield closes again. */
  durationSeconds?: number;
  /**
   * Emitted when content is disclosed. The application persists this — the
   * component does not, and says so rather than implying an audit trail it
   * cannot provide.
   */
  onDisclose?: (event: DisclosureEvent) => void;
  onRedact?: () => void;
  children: React.ReactNode;
  className?: string;
}

const DEFAULT_REASONS = [
  "Emergency treatment",
  "Direct treatment relationship",
  "Patient request",
  "Care coordination",
];

export function RestrictedShield({
  restricted = false,
  disclosurePermitted = true,
  concealExistence = false,
  category = "Restricted record",
  reasons = DEFAULT_REASONS,
  durationSeconds = 300,
  onDisclose,
  onRedact,
  children,
  className,
}: RestrictedShieldProps) {
  const [disclosed, setDisclosed] = React.useState(false);
  const [asking, setAsking] = React.useState(false);
  const [reason, setReason] = React.useState("");
  const [remaining, setRemaining] = React.useState(durationSeconds);

  // Auto re-redact. A disclosure that lasts until logout is not time-boxed,
  // and "I forgot it was open" is how sensitive data ends up on a shared screen.
  React.useEffect(() => {
    if (!disclosed) return;
    setRemaining(durationSeconds);
    const timer = setInterval(() => {
      setRemaining((value) => {
        if (value <= 1) {
          setDisclosed(false);
          onRedact?.();
          return durationSeconds;
        }
        return value - 1;
      });
    }, 1000);
    return () => clearInterval(timer);
  }, [disclosed, durationSeconds, onRedact]);

  if (!restricted) return <>{children}</>;

  // Existence itself is concealed: render nothing at all, with no gap that
  // would betray that something was removed.
  if (concealExistence && !disclosed) return null;

  if (disclosed) {
    const minutes = Math.floor(remaining / 60);
    const seconds = String(remaining % 60).padStart(2, "0");
    return (
      <div
        className={cn(
          "rounded-[var(--ox-radius)] border-2 border-[var(--ox-flag-restricted)] p-[var(--ox-density-pad-x)]",
          className,
        )}
      >
        <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
          <span className="inline-flex items-center gap-1.5 text-[length:var(--ox-text-xs)] font-semibold text-[var(--ox-flag-restricted)]">
            <Eye className="size-3.5" />
            Disclosed · {category}
          </span>
          {/* Polite, not assertive: a countdown that interrupts a reader every
              second is worse than the risk it mitigates. */}
          <span
            aria-live="polite"
            className="font-[family-name:var(--ox-font-numeric)] text-[length:var(--ox-text-xs)] tabular-nums text-[var(--ox-text-muted)]"
          >
            Re-hides in {minutes}:{seconds}
          </span>
          <button
            type="button"
            onClick={() => {
              setDisclosed(false);
              onRedact?.();
            }}
            className="rounded-[var(--ox-radius-sm)] border border-[var(--ox-border-strong)] px-2 py-1 text-[length:var(--ox-text-xs)] font-semibold hover:bg-[var(--ox-bg-muted)]"
          >
            Hide now
          </button>
        </div>
        {children}
      </div>
    );
  }

  return (
    <div
      className={cn(
        "rounded-[var(--ox-radius)] border border-dashed border-[#ddd6fe] bg-[var(--ox-flag-restricted-bg)] p-[var(--ox-density-pad-x)]",
        className,
      )}
    >
      <div className="flex items-start gap-2">
        <Lock
          aria-hidden="true"
          className="mt-0.5 size-4 shrink-0 text-[var(--ox-flag-restricted)]"
        />
        <div className="min-w-0 flex-1">
          {/* Text, not an icon: the reader must know this is withheld content
              and not an empty section. */}
          <p className="text-[length:var(--ox-text-sm)] font-semibold text-[var(--ox-flag-restricted)]">
            Hidden — {category}
          </p>
          <p className="mt-0.5 text-[length:var(--ox-text-xs)] text-[var(--ox-text-muted)]">
            {disclosurePermitted
              ? "Content exists and is withheld. Disclosure is recorded and reviewed."
              : "Content exists and your role cannot disclose it. Ask the privacy office."}
          </p>

          {disclosurePermitted && !asking && (
            <button
              type="button"
              onClick={() => setAsking(true)}
              className="mt-2 rounded-[var(--ox-radius-sm)] border border-[var(--ox-flag-restricted)] px-2 py-1 text-[length:var(--ox-text-xs)] font-semibold text-[var(--ox-flag-restricted)] hover:bg-[var(--ox-surface)]"
            >
              Disclose with a reason
            </button>
          )}

          {disclosurePermitted && asking && (
            <form
              className="mt-2 flex flex-wrap items-end gap-2"
              onSubmit={(event) => {
                event.preventDefault();
                if (!reason) return;
                setDisclosed(true);
                setAsking(false);
                onDisclose?.({ reason, durationSeconds, at: new Date() });
              }}
            >
              <span className="flex flex-col gap-1">
                <label
                  htmlFor="ox-shield-reason"
                  className="text-[length:var(--ox-text-2xs)] font-semibold uppercase tracking-wider text-[var(--ox-text-muted)]"
                >
                  Reason for access
                </label>
                <select
                  id="ox-shield-reason"
                  required
                  value={reason}
                  onChange={(event) => setReason(event.target.value)}
                  className="rounded-[var(--ox-radius-sm)] border border-[var(--ox-border-strong)] bg-[var(--ox-surface)] px-2 py-1 text-[length:var(--ox-text-xs)]"
                >
                  <option value="">Select a reason…</option>
                  {reasons.map((item) => (
                    <option key={item} value={item}>
                      {item}
                    </option>
                  ))}
                </select>
              </span>
              <button
                type="submit"
                className="rounded-[var(--ox-radius-sm)] bg-[var(--ox-flag-restricted)] px-2.5 py-1.5 text-[length:var(--ox-text-xs)] font-semibold text-white disabled:opacity-50"
                disabled={!reason}
              >
                Disclose
              </button>
              <button
                type="button"
                onClick={() => setAsking(false)}
                className="px-2 py-1.5 text-[length:var(--ox-text-xs)] font-semibold text-[var(--ox-text-muted)] hover:text-[var(--ox-text)]"
              >
                Cancel
              </button>
            </form>
          )}
        </div>
      </div>
    </div>
  );
}