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
hiddenonly for compile-time drops; preferaccessfor dynamic / provider-driven hide. - Do not treat Access as a security boundary — the backend must still authorize and omit sensitive fields.
Features
| Area | Behavior |
|---|---|
| Policy | AccessPolicy + AccessPolicy.decide (sync-first, async-optional) |
| Declarative | FieldConfig.access / FormField.access / SchemaField x-access |
| Surfaces | table · form · search · descriptions |
| Strength | hidden → readonly → masked (most-restrictive-wins with provider) |
| Masks | FieldConfig.mask strategies (as-is / head-tail / head / tail / full); kind maskValue fallback when no mask |
| Async | Pending → skeleton cells (list) or fail-closed gate; optional decideMany + TanStack cache |
| Submit scrub | scrubMaskedValues(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.
| Strategy | Behavior |
|---|---|
as-is | Show String(raw) unchanged — use when the backend already redacted the value |
head-tail | Keep head + tail characters with an ellipsis (defaults 8 / 4) |
head | Keep a head prefix + ellipsis |
tail | Ellipsis + a tail suffix |
full | Fixed four bullets •••• |
Reveal and Copy
| Condition | Reveal | Copy (if copyable) |
|---|---|---|
| Access masked | No | No |
Strategy as-is | No | Copies as-is string |
| Display mask + plaintext on client | Yes (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.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/access.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/access.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/access.jsonWith 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_1 | Acme renewal | 4,200.00 | •••• | me | Open |
| ord_2 | Globex add-on | — | •••• | other | Paid |
- ID
- ord_1
- Title
- Acme renewal
- Cost
- USD 4,200.00
- SSN
- ••••
- Owner
- me
- Status
- Open
"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
| Threat | Default |
|---|---|
| Treating Access as authz | Not supported — backend must authorize and trim sensitive fields |
Client mask as authz | Display formatting only — does not replace Access or server redaction |
Data flash while async decide() pending | Fail-closed pending decision; list surfaces show skeleton cells (no raw value) |
| Copying masked Descriptions | Copy affordance (FieldConfig.copyable → CopyAffordance) is suppressed when masked / pending |
| Re-revealing via Formily reactions | Access reactions re-assert after user reactions (clamp-only) |
API Reference
Provider
| Prop | Default | Description |
|---|---|---|
policy | allowAllPolicy | decide(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 |
cache | internal Map store | Inject createTanStackAccessCache(queryClient) to share TanStack Query |
Hooks & Helpers
| Export | Description |
|---|---|
useAccess(query) | { decision, isPending } |
useAccessResolver() | Bound access.can |
formatMask(raw, config) / MaskConfig | Pure display strategies (as-is, head-tail, head, tail, full) |
MaskedText | Light read UI with optional Reveal when Access is not masked |
maskValue(kind, raw) / registerMask | Kind mask formatters (fallback when no FieldConfig.mask) |
scrubMaskedValues(values, decisions) | Drop masked / hidden keys |
resolveFieldAccess / resolveMany | Pure 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).