Skip to content

Permissions and availability

Designing for can do, can see but not change, and does not exist for this workspace.

The problem#

A Cedarline workspace has owners, admins, members and supervisors, and some features are only on for some workspaces. The same screen has to work for all of them.

Get it wrong and people meet buttons that do nothing, settings that look editable until they fail, and locked teasers for features they can't buy. Every screen has to be designed for three statements, in the words of the product brief:

  1. 1I can do this.

    The control is there and it works.

  2. 2I can see it but not change it.

    The value is there as plain text, and the page says who can change it.

  3. 3This does not exist for this workspace.

    Nothing is drawn. A direct link explains, plainly, without selling.

The solution#

One settings row in all three states. Switch the view to see the row editable, read-only, and gone.

Three states, one row

Member wraps the group in data-settings-mode="read" and a disabled fieldset: the input reads as text, the row's button disappears, and a status note says who can change it. Feature off removes the row.

Invoices over this amount need a second approver.
Pay early when a supplier offers a discount and cash stays above the floor.

I can do this. Owners and admins change the value; the row's button and switch work.

import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { descriptionId, SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { EyeIcon } from "lucide-react";import * as React from "react";export function ThreeStates() {    const [view, setView] = React.useState<"edit" | "read" | "off">("edit");    const [limit, setLimit] = React.useState("$25,000.00");    const [discounts, setDiscounts] = React.useState(true);    const readOnly = view === "read";    const group = (        <SettingsGroup>            <SettingsRow                label="Approval limit"                htmlFor="perm-limit"                description="Invoices over this amount need a second approver."            >                <Input                    id="perm-limit"                    value={limit}                    onChange={(event) => setLimit(event.target.value)}                    aria-describedby={descriptionId("perm-limit")}                    className="tabular-nums sm:w-36"                />            </SettingsRow>            {view === "off" ? null : (                <SettingsRow                    label="Early-pay discounts"                    htmlFor="perm-discounts"                    description="Pay early when a supplier offers a discount and cash stays above the floor."                >                    <Button                        variant="outline"                        size="sm"                        onClick={() =>                            toast.add({                                title: "Cash floor",                                description:                                    "Currently $250,000.00 across operating accounts.",                            })                        }                    >                        Set cash floor                    </Button>                    <Switch                        id="perm-discounts"                        checked={discounts}                        disabled={readOnly}                        aria-describedby={descriptionId("perm-discounts")}                        onCheckedChange={(checked) => {                            setDiscounts(checked);                            toast.add({                                title: checked                                    ? "Early-pay discounts on"                                    : "Early-pay discounts off",                            });                        }}                    />                </SettingsRow>            )}        </SettingsGroup>    );    const notes = {        edit: "I can do this. Owners and admins change the value; the row's button and switch work.",        read: "I can see it but not change it. A member sees the same row as plain text, with a note that says who can change it.",        off: "This does not exist for this workspace. Cedarline hasn't turned on early-pay discounts, so the row isn't drawn at all.",    };    return (        <div className="flex w-full max-w-2xl flex-col gap-4 text-left">            <SegmentedControl                label="View as"                value={view}                onValueChange={setView}                options={[                    { value: "edit", label: "Admin" },                    { value: "read", label: "Member" },                    { value: "off", label: "Feature off" },                ]}            />            {readOnly ? (                <div data-settings-mode="read">                    <p                        role="status"                        className="mb-4 flex items-center gap-2 rounded-[10px] bg-muted/70 px-3 py-2 text-13 text-muted-foreground"                    >                        <EyeIcon                            aria-hidden="true"                            className="size-3.5 shrink-0"                        />                        You can view these settings. Only admins can change                        them.                    </p>                    <fieldset disabled className="contents">                        {group}                    </fieldset>                </div>            ) : (                group            )}            <p className="text-13 text-pretty text-muted-foreground">                {notes[view]}            </p>        </div>    );}

Hide, disable or read-only#

Choose by asking whether knowing the thing exists helps this person.

Hide, disable or show read-only
TreatmentUse whenExample
HideThe feature isn't on for the workspace, or the person's role never uses it. Knowing it exists only adds noise.Campaigns in the nav when Cedarline hasn't turned them on. Agents Platform settings for a supervisor.
Disable, with the reasonOne action on an otherwise usable screen is gated by role or by state, and the person can do something about it.Approve run on a run over $1M, with a tooltip naming Maya Okafor and a way to ask her.
Read-onlyThe person needs to see the values to do their job but can't change them. Usually a whole page or section.A member reading workspace settings, business hours or ticket settings.
Explain on arrivalSomeone follows a link to a page they can't open or that doesn't exist for the workspace.You don't have access to Billing. Ask a Cedarline admin: Maya Okafor or Priya Raman.
  • Home
  • Workflows
  • Reports
Do. Hide a feature the workspace doesn't have. The nav lists what Cedarline can use.
  • Home
  • Workflows
  • CampaignsPRO
  • Reports
Don't. Show it locked with an upgrade badge. It's an ad inside the product, and the uppercase badge breaks the Sentence Case Rule.

The read-only mode#

A CSS rule in apps/web/src/app/globals.css turns a settings page into a page of values. Wrap it in data-settings-mode="read", add the status note, and put the content in a disabled fieldset.

The rule#

Read from the stylesheet. Disabled controls keep full opacity, fields lose their stroke, fill and padding, select chevrons and row buttons disappear, and the save bar never shows.

apps/web/src/app/globals.css
[data-settings-mode="read"] {  & :disabled,  & [data-disabled] {    opacity: 1;    cursor: default;  }  &    :is(      [data-slot="input"],      [data-slot="textarea"],      [data-slot="select-trigger"]    ) {    border-color: transparent;    background: transparent;    box-shadow: none;    padding-inline: 0;    color: var(--foreground);  }  & [data-slot="select-trigger"] > svg {    display: none;  }  & [data-slot="settings-row"] [data-slot="button"],  & [data-slot="save-bar"] {    display: none;  }}

The wrapper#

What SettingsAccess renders around a settings page in read mode. fieldset disabled disables native inputs and buttons; pass disabled to Base UI controls such as Switch yourself.

Read-only settings
<div data-settings-mode="read">  <p role="status" className="mb-6 flex items-center gap-2 rounded-[10px] bg-muted/70 px-3 py-2 text-13 text-muted-foreground">    <EyeIcon aria-hidden="true" className="size-3.5 shrink-0" />    You can view these settings. Only admins can change them.  </p>  <fieldset disabled className="contents">    {children}  </fieldset></div>
Approval limit
Do. Show values as plain text under a note that says who can change them.
Approval limit
Don't. Leave the form as it is at 50% opacity. It looks broken, it's hard to read, and it doesn't say why.

Explaining why#

A disabled control says what unlocks it. Natively disabled buttons can't be hovered or focused, so their tooltip never shows; keep them focusable.

A gated action with its reason

Hover or tab to Approve run. focusableWhenDisabled keeps it in the tab order with aria-disabled, and the reason is also wired with aria-describedby.

Payment run for Thursday, October 1

31 invoices, $1,204,816.40

Runs over $1M need Maya Okafor's approval.
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";export function DisabledWithReason() {    return (        <div className="flex w-full max-w-lg flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex flex-col gap-0.5">                <p className="text-sm font-semibold text-foreground">                    Payment run for Thursday, October 1                </p>                <p className="text-13 text-muted-foreground tabular-nums">                    31 invoices, $1,204,816.40                </p>            </div>            <div className="flex flex-wrap items-center justify-end gap-2">                <Button                    variant="ghost"                    onClick={() =>                        toast.add({                            title: "Approval requested",                            description:                                "Maya Okafor will get a notification and an email.",                        })                    }                >                    Ask Maya to approve                </Button>                <Tooltip>                    <TooltipTrigger                        render={                            <Button                                disabled                                focusableWhenDisabled                                aria-describedby="perm-run-reason"                                className="aria-disabled:opacity-50"                            />                        }                    >                        Approve run                    </TooltipTrigger>                    <TooltipContent>                        Runs over $1M need Maya Okafor                    </TooltipContent>                </Tooltip>                <span id="perm-run-reason" className="sr-only">                    Runs over $1M need Maya Okafor's approval.                </span>            </div>        </div>    );}

Focusable disabled buttons don't dim

With focusableWhenDisabled, Button gets aria-disabled instead of disabled, and its styles only dim on disabled:. Add aria-disabled:opacity-50 as the example does, or it looks enabled.

Runs over $1M need Maya Okafor

Do. Name who can do it, and how to reach them.

Error 403: insufficient permissions

Don't. Report a permission error. It blames the person and leaves them stuck.

Feature-flagged sections#

When a workspace feature is off (campaigns, flows, scorecards, billing, spam filters and others), its nav items, sections and settings rows aren't drawn. A direct link still lands somewhere that explains.

Arriving where you can't go

The three pages SettingsAccess draws instead of a page the person can't use. Each says what happened and who can change it, without blame.

You don't have access to Billing

Ask a Cedarline admin: Maya Okafor or Priya Raman.
import { Button } from "@oration/canon/components/button";import { EmptyState } from "@oration/canon/components/data-state";import { SegmentedControl } from "@oration/canon/components/segmented-control";import Link from "next/link";import * as React from "react";export function DirectUrl() {    const [view, setView] = React.useState<"none" | "off" | "supervisor">(        "none",    );    return (        <div className="flex w-full max-w-xl flex-col gap-4 text-left">            <SegmentedControl                label="Someone opens a link to"                value={view}                onValueChange={setView}                options={[                    { value: "none", label: "Billing as a member" },                    { value: "off", label: "A feature that's off" },                    { value: "supervisor", label: "Agents as a supervisor" },                ]}            />            <div className="rounded-xl bg-card shadow-border">                {view === "none" ? (                    <EmptyState                        illustration="customers"                        title="You don't have access to Billing"                        description="Ask a Cedarline admin: Maya Okafor or Priya Raman."                    />                ) : view === "off" ? (                    <EmptyState                        illustration="tools"                        title="Spam filters aren't turned on for Cedarline"                        description="The workspace owner can turn them on from General."                    />                ) : (                    <EmptyState                        illustration="conversations"                        title="Supervisors work in Contact Center"                        description="Agents Platform settings are for admins. Queues, dispositions and presence are yours to manage."                        action={                            <Button                                variant="outline"                                render={                                    <Link href="/design/templates/workspace" />                                }                            >                                Go to Contact Center settings                            </Button>                        }                    />                )}            </div>        </div>    );}
  • Hide the section and its nav item together. A nav item that leads to an explanation is a dead end.
  • Don't leave gaps: groups close up around a missing row, and a settings page with nothing left doesn't appear in the settings nav.
  • Name the workspace in the explanation (isn't turned on for Cedarline), so people in several workspaces know which one they're in.
  • Never show prices, plans or upgrade prompts in place of a feature.

Roles#

Four roles matter, from the product brief. A person can hold a different role in each workspace they belong to.

Workspace roles
RoleDoes
OwnerRuns the workspace: billing, usage, membership and deletion. Can't be removed by accident.
AdminRuns the workspace with the owner: settings, agents, integrations, roles. Views billing.
MemberWorks inside the workspace with narrower permissions. Reads most settings.
SupervisorA human contact-center role. Manages queues, dispositions and presence, and doesn't use the Agents Platform.
Settings access by role, read from settings-access.tsx
Settings areaOwnerAdminMemberSupervisor
WorkspaceChangesChangesViewsNo access
Business hoursChangesChangesViewsViews
MembersChangesChangesViewsViews
TeamsChangesChangesViewsViews
BillingChangesViewsNo accessNo access
UsageChangesViewsNo accessNo access
Agents PlatformChangesChangesViewsSent to Contact Center
Contact CenterChangesChangesViewsChanges
TicketingChangesChangesViewsViews
Roles, API, webhooks, audit log and other workspace settingsChangesChangesViewsNo access

In running text, role names are lowercase common nouns: “ask an admin”. As a value in a role select, a tag or a table, they're capitalized like any option: Owner, Admin, Member, Supervisor.

Accessibility#

Every state has to read correctly without sight of the screen.

  • The read-only note is role="status", so it's announced when the page switches into read mode.
  • fieldset disabled makes every native control in the page disabled for assistive tech, not just visually quiet. Base UI controls need disabled too.
  • A gated action uses focusableWhenDisabled so it stays reachable, is announced as dimmed, and its reason is read through the tooltip and aria-describedby.
  • Hidden features are absent from the DOM, not hidden with CSS, so they don't appear in the accessibility tree or the tab order.
  • Never rely on dimming alone to say something is read-only; the note says it in words.

Components#

The parts this pattern is built from.