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:
1I can do this.
The control is there and it works.
2I can see it but not change it.
The value is there as plain text, and the page says who can change it.
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.
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.
| Treatment | Use when | Example |
|---|---|---|
| Hide | The 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 reason | One 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-only | The 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 arrival | Someone 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. |
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.
[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.
<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>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
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
Error 403: insufficient permissions
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
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.
| Role | Does |
|---|---|
| Owner | Runs the workspace: billing, usage, membership and deletion. Can't be removed by accident. |
| Admin | Runs the workspace with the owner: settings, agents, integrations, roles. Views billing. |
| Member | Works inside the workspace with narrower permissions. Reads most settings. |
| Supervisor | A human contact-center role. Manages queues, dispositions and presence, and doesn't use the Agents Platform. |
| Settings area | Owner | Admin | Member | Supervisor |
|---|---|---|---|---|
| Workspace | Changes | Changes | Views | No access |
| Business hours | Changes | Changes | Views | Views |
| Members | Changes | Changes | Views | Views |
| Teams | Changes | Changes | Views | Views |
| Billing | Changes | Views | No access | No access |
| Usage | Changes | Views | No access | No access |
| Agents Platform | Changes | Changes | Views | Sent to Contact Center |
| Contact Center | Changes | Changes | Views | Changes |
| Ticketing | Changes | Changes | Views | Views |
| Roles, API, webhooks, audit log and other workspace settings | Changes | Changes | Views | No 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 disabledmakes every native control in the page disabled for assistive tech, not just visually quiet. Base UI controls needdisabledtoo.- A gated action uses
focusableWhenDisabledso it stays reachable, is announced as dimmed, and its reason is read through the tooltip andaria-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.
- Settings sectionThe rhythm of every settings page: sections, groups and label-left rows.
- TooltipA short Graphite Ink label that names an icon or explains a control, after the app's 400ms delay.
- ButtonThe action trigger: six variants, eight sizes and at most one filled indigo button per view.
- Data stateOne wrapper that renders a component's skeleton, empty, error and success states.
- SwitchAn immediate on and off setting that takes effect without a save.
- SidebarThe app rail: workspace switcher, search, grouped navigation and the user menu.