f-ui
Components

Access

Provider-agnostic field permissions across Table, Form, Descriptions, and QueryFilter — hide, readonly, or mask without baking RBAC into f-ui.

Access is the injection point for field-level permissions. Mount one <AccessProvider policy={…}>, declare access on your data-list schema (and Form fields), and every surface — Table, Form, Descriptions, QueryFilter — applies the same judgment with a strength ladder of hidden → readonly → masked.

f-ui does not ship RBAC. Your app plugs Casbin, CASL, Cerbos, or a simple role map into AccessPolicy.decide().

When To Use

  • One schema, many roles — the same order fields must look different for admin / owner / viewer.
  • Per-surface rules — hide cost from search, mask it in Descriptions, allow readonly edit on the form.
  • Record-level rules — owners see their own cost clearly; other rows stay masked.
  • Use static hidden only for compile-time drops; prefer access for dynamic / provider-driven hide.
  • Do not treat Access as a security boundary — the backend must still authorize and omit sensitive fields.

Features

AreaBehavior
PolicyAccessPolicy + AccessPolicy.decide (sync-first, async-optional)
DeclarativeFieldConfig.access / FormField.access / SchemaField x-access
Surfacestable · form · search · descriptions
Strengthhiddenreadonlymasked (most-restrictive-wins with provider)
MasksFieldConfig.mask strategies (as-is / head-tail / head / tail / full); kind maskValue fallback when no mask
AsyncPending → skeleton cells (list) or fail-closed gate; optional decideMany + TanStack cache
Submit scrubscrubMaskedValues(values, decisions) at the app boundary

Mask Strategies

FieldConfig.mask controls how a value is formatted for display (and whether the client may Reveal plaintext). Access strength masked is the authority layer: it forces a masked render, suppresses Reveal, and omits copy — even when copyable is set.

StrategyBehavior
as-isShow String(raw) unchanged — use when the backend already redacted the value
head-tailKeep head + tail characters with an ellipsis (defaults 8 / 4)
headKeep a head prefix + ellipsis
tailEllipsis + a tail suffix
fullFixed four bullets ••••

Reveal and Copy

ConditionRevealCopy (if copyable)
Access maskedNoNo
Strategy as-isNoCopies as-is string
Display mask + plaintext on clientYes (default hidden)Copies plaintext

Client-side masking is not authorization. Keep calling scrubMaskedValues before submit, and never ship secrets the viewer must not hold.

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/access.json
npx shadcn@latest add https://ui.isaacfei.com/r/access.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/access.json
bun x shadcn@latest add https://ui.isaacfei.com/r/access.json

With a namespace: npx shadcn@latest add @f-ui/access.

Surface demos below also use Plus packages (Table, Query Filter, Form (Formily), Descriptions). Install those separately when composing full pages.

Usage

import { AccessProvider } from "@/components/f-ui/access/access-provider";

<AccessProvider
  policy={{
    decide: ({ resource, field, action, surface }) => {
      // your RBAC / ABAC here
      return { visible: true, editable: action !== "write" ? true : false };
    },
  }}
>
  <App />
</AccessProvider>

On a data-list field:

cost: f.currency({
  label: "Cost",
  currency: "USD",
  access: {
    resource: "order",
    table: (ctx) => (ctx.record?.ownerId === me ? true : "masked"),
    form: "readonly",
    search: false,
    descriptions: "masked",
  },
}),

Examples

Same Order, Three Roles

One schema and three policies. Switch admin / owner / viewer — Table, search, Descriptions, and Form update together. Only the policy changes.

ord_1Acme renewal4,200.00••••meOpen
ord_2Globex add-on••••otherPaid
2 rows
Rows per page
Order detail
ID
ord_1
Title
Acme renewal
Cost
USD 4,200.00
SSN
••••
Owner
me
Status
Open
Read-only
••••
"use client";

import { useMemo, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { AccessProvider } from "@/components/f-ui/access/access-provider";
import { createInMemoryListAdapter } from "@/components/f-ui/data-list-internals/adapters/in-memory-list-adapter";
import { Descriptions } from "@/components/f-ui/descriptions/descriptions";
import { descriptionsFieldsFromSchema } from "@/components/f-ui/descriptions/lib/descriptions-field";
import { Form } from "@/components/f-ui/formily/form";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { QueryList } from "@/components/f-ui/query-list/query-list";
import { useSchemaFieldAccess } from "@/components/f-ui/table/hooks/use-schema-field-access";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";

import {
  ORDER_ROWS,
  ORDER_SCHEMA,
  roleProviders,
  type AccessDemoRole,
  type Order,
} from "@/demos/access/order-access-fixture";

const ROLES: AccessDemoRole[] = ["admin", "owner", "viewer"];

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } },
});

const adapter = createInMemoryListAdapter<Order>({ items: ORDER_ROWS });

export function AccessThreeRolesDemo() {
  const [role, setRole] = useState<AccessDemoRole>("admin");
  const [params, setParams] = useState<Record<string, unknown>>({});

  return (
    <QueryClientProvider client={queryClient}>
      <TooltipProvider>
        <div className="flex flex-col gap-4">
          <div
            className="flex flex-wrap gap-2"
            role="tablist"
            aria-label="Access role"
          >
            {ROLES.map((r) => (
              <Button
                key={r}
                type="button"
                role="tab"
                aria-selected={role === r}
                variant={role === r ? "default" : "outline"}
                onClick={() => setRole(r)}
              >
                {r}
              </Button>
            ))}
          </div>
          <AccessProvider key={role} policy={roleProviders[role]}>
            <ThreeRolesBody
              params={params}
              onParamsChange={(updates) =>
                setParams((prev) => ({ ...prev, ...updates }))
              }
            />
          </AccessProvider>
        </div>
      </TooltipProvider>
    </QueryClientProvider>
  );
}

function ThreeRolesBody({
  params,
  onParamsChange,
}: {
  params: Record<string, unknown>;
  onParamsChange: (updates: Record<string, unknown>) => void;
}) {
  const descAccess = useSchemaFieldAccess(ORDER_SCHEMA, "descriptions");
  const fields = useMemo(
    () =>
      descriptionsFieldsFromSchema(ORDER_SCHEMA, undefined, {
        fieldAccess: descAccess.decisions,
        accessPendingIds: descAccess.pendingIds,
      }),
    [descAccess.decisions, descAccess.pendingIds],
  );

  const form = useMemo(
    () => createForm<Order>({ values: { ...ORDER_ROWS[0]! } }),
    [],
  );

  return (
    <div className="flex flex-col gap-6">
      <QueryList<Order>
        schema={ORDER_SCHEMA}
        listCode="access-three-roles"
        adapter={adapter}
        params={params}
        onParamsChange={onParamsChange}
        getRowId={(row) => row.id}
        fillHeight={false}
      />
      <Descriptions
        title="Order detail"
        fields={fields}
        record={ORDER_ROWS[0]!}
      />
      <Form form={form} className="max-w-md">
        <FormField
          name="title"
          kind="text"
          label="Title"
          access={{ resource: "order", form: true }}
        />
        <FormField
          name="cost"
          kind="currency"
          label="Cost"
          access={{ resource: "order", form: "readonly" }}
        />
        <FormField
          name="ssn"
          kind="text"
          label="SSN"
          access={{ resource: "order", form: "masked" }}
        />
      </Form>
    </div>
  );
}

Imperative Gate

Use <Access> (or useAccess) for buttons and bespoke UI outside the schema compilers.

Cost is readable.

Edit blocked.

"use client";

import { Access } from "@/components/f-ui/access/access";
import { AccessProvider } from "@/components/f-ui/access/access-provider";
import type { AccessPolicy } from "@/components/f-ui/access/access-types";
import { Button } from "@/components/ui/button";

const provider: AccessPolicy = {
  decide: ({ action }) =>
    action === "write" ? { visible: false } : { visible: true, editable: true },
};

export function AccessGateDemo() {
  return (
    <AccessProvider policy={provider}>
      <div className="flex flex-col gap-3">
        <Access action="read" resource="order" field="cost">
          <p className="text-sm text-muted-foreground">Cost is readable.</p>
        </Access>
        <Access
          action="write"
          resource="order"
          field="cost"
          fallback={<p className="text-sm text-muted-foreground">Edit blocked.</p>}
        >
          <Button type="button">Edit cost</Button>
        </Access>
      </div>
    </AccessProvider>
  );
}

Scrub Masked Values on Submit

Front-end Access is UX hygiene. Call scrubMaskedValues before sending a payload so masked / hidden keys do not leave the client as cleartext — the server must still enforce authorization.

(not submitted)
"use client";

import { scrubMaskedValues } from "@/components/f-ui/access/scrub-masked-values";
import { Button } from "@/components/ui/button";
import { useState } from "react";

const values = { title: "Acme", cost: 4200, ssn: "123-45-6789" };
const decisions = {
  title: { visible: true, editable: true },
  cost: { visible: true, editable: true, masked: true },
  ssn: { visible: false },
};

export function AccessScrubDemo() {
  const [payload, setPayload] = useState<string>("(not submitted)");
  return (
    <div className="flex flex-col gap-3">
      <Button
        type="button"
        onClick={() => setPayload(JSON.stringify(scrubMaskedValues(values, decisions)))}
      >
        Submit (scrubbed)
      </Button>
      <pre className="rounded-md bg-muted p-3 text-xs">{payload}</pre>
    </div>
  );
}

Display Mask (Token)

Display-only mask with head-tail: the cell shows a redacted form, offers Reveal, and stays copyable because Access is not masked.

API key
fui_abcd…2345
"use client";

import { Descriptions } from "@/components/f-ui/descriptions/descriptions";
import { descriptionsFieldsFromSchema } from "@/components/f-ui/descriptions/lib/descriptions-field";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { f } from "@/components/f-ui/field-types/catalog";

type Row = { apiKey: string };

const schema = defineDataListSchema<Row>({
  apiKey: f.text({
    label: "API key",
    copyable: true,
    mask: { strategy: "head-tail", head: 8, tail: 4 },
  }),
});

const record: Row = {
  apiKey: "fui_abcdefghijklmnopqrstuvwxyz012345",
};

export function AccessMaskTokenDemo() {
  const fields = descriptionsFieldsFromSchema(schema);
  return (
    <Descriptions
      record={record}
      fields={fields}
      column={1}
      size="small"
      className="max-w-md"
    />
  );
}

Backend Already Redacted (as-is)

When the API returns a server-redacted string, use mask: { strategy: "as-is" } under Access masked. There is no Reveal control and copy is suppressed — the UI shows the redacted value as sent.

Order ID
ORD-1001
API key
fui_abcd…wxyz
"use client";

import { AccessProvider } from "@/components/f-ui/access/access-provider";
import type { AccessPolicy } from "@/components/f-ui/access/access-types";
import { Descriptions } from "@/components/f-ui/descriptions/descriptions";
import { descriptionsFieldsFromSchema } from "@/components/f-ui/descriptions/lib/descriptions-field";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { f } from "@/components/f-ui/field-types/catalog";

type Row = {
  orderId: string;
  apiKey: string;
};

const schema = defineDataListSchema<Row>({
  orderId: f.text({ label: "Order ID", copyable: true }),
  apiKey: f.text({
    label: "API key",
    copyable: true,
    mask: { strategy: "as-is" },
    access: { descriptions: "masked" },
  }),
});

/** Server already redacted — display as-is; Access masked blocks Reveal and copy. */
const record: Row = {
  orderId: "ORD-1001",
  apiKey: "fui_abcd…wxyz",
};

const policy: AccessPolicy = {
  decide: () => ({ visible: true, editable: true }),
};

export function AccessMaskAsIsDemo() {
  const fields = descriptionsFieldsFromSchema(schema, undefined, {
    fieldAccess: {
      orderId: { visible: true, editable: true },
      apiKey: { visible: true, editable: true, masked: true },
    },
  });

  return (
    <AccessProvider policy={policy}>
      <Descriptions
        record={record}
        fields={fields}
        column={1}
        size="small"
        className="max-w-md"
      />
    </AccessProvider>
  );
}

Copyable and Masked

FieldConfig.copyable attaches a hover copy control on Table and Descriptions. When Access marks the field masked or pending, the control is omitted so users cannot one-click copy the raw value. Order ID stays copyable; API key is masked — hover both and compare.

Order ID
ORD-1001
API key
••••
"use client";

import { AccessProvider } from "@/components/f-ui/access/access-provider";
import type { AccessPolicy } from "@/components/f-ui/access/access-types";
import { Descriptions } from "@/components/f-ui/descriptions/descriptions";
import { descriptionsFieldsFromSchema } from "@/components/f-ui/descriptions/lib/descriptions-field";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { f } from "@/components/f-ui/field-types/catalog";

type Row = {
  orderId: string;
  apiKey: string;
};

const schema = defineDataListSchema<Row>({
  orderId: f.text({ label: "Order ID", copyable: true }),
  apiKey: f.text({
    label: "API key",
    copyable: true,
    access: { descriptions: "masked" },
  }),
});

const record: Row = {
  orderId: "ORD-1001",
  apiKey: "sk_live_do_not_copy_when_masked",
};

const policy: AccessPolicy = {
  decide: () => ({ visible: true, editable: true }),
};

/**
 * Masked + copyable: Access suppresses the copy control so raw secrets
 * cannot be one-click copied (same rule as Table cells).
 */
export function FieldCopyableMaskedDemo() {
  const fields = descriptionsFieldsFromSchema(schema, undefined, {
    fieldAccess: {
      orderId: { visible: true, editable: true },
      apiKey: { visible: true, editable: true, masked: true },
    },
  });

  return (
    <AccessProvider policy={policy}>
      <Descriptions
        record={record}
        fields={fields}
        column={1}
        size="small"
        className="max-w-md"
      />
    </AccessProvider>
  );
}

See also Field Types — Copyable Fields.

Security

ThreatDefault
Treating Access as authzNot supported — backend must authorize and trim sensitive fields
Client mask as authzDisplay formatting only — does not replace Access or server redaction
Data flash while async decide() pendingFail-closed pending decision; list surfaces show skeleton cells (no raw value)
Copying masked DescriptionsCopy affordance (FieldConfig.copyableCopyAffordance) is suppressed when masked / pending
Re-revealing via Formily reactionsAccess reactions re-assert after user reactions (clamp-only)

API Reference

Provider

PropDefaultDescription
policyallowAllPolicydecide(query) → decision or Promise; optional decideMany
pendingDecision{ visible: false }Shown while async decide is pending (imperative gates)
defaultDecision{ visible: true, editable: true }When no rule applies
cacheinternal Map storeInject createTanStackAccessCache(queryClient) to share TanStack Query

Hooks & Helpers

ExportDescription
useAccess(query){ decision, isPending }
useAccessResolver()Bound access.can
formatMask(raw, config) / MaskConfigPure display strategies (as-is, head-tail, head, tail, full)
MaskedTextLight read UI with optional Reveal when Access is not masked
maskValue(kind, raw) / registerMaskKind mask formatters (fallback when no FieldConfig.mask)
scrubMaskedValues(values, decisions)Drop masked / hidden keys
resolveFieldAccess / resolveManyPure compilers / batch helper

Declarative Shapes

See FieldAccess on data-list FieldConfig.access and FormField / SchemaField x-access — strengths "hidden" | "readonly" | "masked" or booleans / functions of { record }. Display format lives on FieldConfig.mask (MaskConfig).

On this page