# Data Access

> The privacy page of an AI-first app: per source, what the assistant may read and do, an insight that suggests revoking unused access, and a summary of what it did this month.

Pattern · Halo kit · [Live page](https://ui.twentyfirst.media/halo/patterns/DataAccessScreen/)

The privacy page of an AI-first app: per source, what the assistant may read and do, an insight
that suggests revoking unused access, and a summary of what it did this month.

## Code

```tsx
import * as React from "react";
import {
  AppShell,
  ConsentList,
  InsightCard,
  KeyValue,
  PageHeader,
  Panel,
  Stagger,
  Switch,
} from "@twentyfirstmedia/halo";
import type { ConsentConnector, SidebarGroup } from "@twentyfirstmedia/halo";

const NAV: SidebarGroup[] = [
  {
    items: [
      { id: "today", label: "Today", icon: "sun" },
      { id: "assistant", label: "Assistant", icon: "sparkle" },
    ],
  },
  {
    label: "Settings",
    items: [
      { id: "access", label: "Data access", icon: "shield" },
      { id: "notifications", label: "Notifications", icon: "bell" },
      { id: "account", label: "Account", icon: "user" },
    ],
  },
];

/** What the assistant may read and do, per connected source, and what it did with it. */
export default function Preview() {
  const [list, setList] = React.useState<ConsentConnector[]>([
    {
      id: "bank",
      name: "Bank account",
      icon: "card",
      purpose: "Finds bills, subscriptions and price changes; never moves money on its own.",
      lastUsed: new Date(2026, 10, 12),
      scopes: [
        { id: "balance", label: "Balances", granted: true, required: true },
        { id: "tx", label: "Transactions", description: "The last 12 months", granted: true },
        {
          id: "pay",
          label: "Prepare transfers",
          description: "You approve each one",
          granted: false,
        },
      ],
    },
    {
      id: "health",
      name: "Health data",
      icon: "heartPulse",
      purpose: "Sleep, activity and vitals for your daily summary. Stays on this device.",
      lastUsed: new Date(2026, 10, 13),
      scopes: [
        { id: "sleep", label: "Sleep", granted: true },
        { id: "activity", label: "Activity", granted: true },
        { id: "vitals", label: "Heart rate and blood pressure", granted: false },
      ],
    },
    {
      id: "mail",
      name: "E-mail",
      icon: "mail",
      purpose: "Reads letters from insurers, landlords and authorities to track deadlines.",
      scopes: [
        { id: "read", label: "Read messages from known senders", granted: true },
        {
          id: "draft",
          label: "Draft replies",
          description: "Never sent without you",
          granted: false,
        },
      ],
    },
  ]);
  return (
    <AppShell
      navigation="sidebar"
      brand="Life"
      sidebarGroups={NAV}
      active="access"
      user={{ name: "Mara Ellis", email: "mara@halo.app" }}
      dock={false}
      side={
        <Panel side title="This month" subtitle="What the assistant did">
          <KeyValue
            items={[
              ["Sources read", "214 times"],
              ["Insights shown", "9"],
              ["Actions you approved", "3"],
              ["Data sent outside the EU", "None"],
            ]}
          />
          <Switch defaultChecked>Ask before every new kind of action</Switch>
        </Panel>
      }
    >
      <div className="tf-in">
        <PageHeader title="Data access" subtitle="You decide what the assistant can see and do" />
      </div>
      <Stagger className="tf-col" style={{ gap: 16 }}>
        <InsightCard
          tone="info"
          icon="shield"
          title="E-mail was not used in 30 days"
          why="The assistant only reads mail to find deadlines. None of your current deadlines came from e-mail."
          acceptLabel="Revoke e-mail access"
          onAccept={() => setList((all) => all.filter((c) => c.id !== "mail"))}
          onDismiss={() => {}}
        >
          You can turn it off; deadlines from letters you upload still work.
        </InsightCard>
        <ConsentList
          connectors={list}
          onScopeChange={(c, s, granted) =>
            setList((all) =>
              all.map((x) =>
                x.id === c
                  ? { ...x, scopes: x.scopes.map((y) => (y.id === s ? { ...y, granted } : y)) }
                  : x,
              ),
            )
          }
          onRevoke={(c) => setList((all) => all.filter((x) => x.id !== c))}
        />
      </Stagger>
    </AppShell>
  );
}
```

## Props

### AppShell

| Prop | Type | Notes |
| --- | --- | --- |
| `active?` | `string` |  |
| `brand?` | `ReactNode` | The product name, or a node (a logo mark with the name; collapsed sidebars show it whole). |
| `tag?` | `string` |  |
| `nav?` | `NavItem[]` |  |
| `user?` | `{ name: string; email?: string }` |  |
| `onNavigate?` | `(label: string) => void` |  |
| `topExtra?` | `ReactNode` |  |
| `sheetStyle?` | `CSSProperties` |  |
| `contentKey?` | `string \| number` |  |
| `side?` | `ReactNode` |  |
| `dock?` | `boolean` |  |
| `dockItems?` | `DockProps["items"]` |  |
| `railTop?` | `RailItem[]` |  |
| `railBottom?` | `RailItem[]` |  |
| `navigation?` | `"top" \| "sidebar"` | `top` (default): navigation in the top bar, tools in the side rail. `sidebar`: a labelled sidebar on the frame, with groups, nesting and a drawer on phones. |
| `sidebarGroups?` | `SidebarGroup[]` | With `navigation="sidebar"`: groups instead of the flat `nav` list. |
| `sidebar?` | `Partial<Omit<SidebarProps, "variant" \| "groups">>` | With `navigation="sidebar"`: more sidebar props (collapsed, storageKey, shortcut, linkAs …). |
| `workspaces?` | `WorkspaceSwitcherProps` | With `navigation="sidebar"`: the workspace switcher at the top instead of the brand. |
| `userMenu?` | `(MenuItem \| "-")[]` | The account menu's entries (with `navigation="sidebar"` at the foot of the sidebar). |
| `children?` | `ReactNode` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### ConsentList

| Prop | Type | Notes |
| --- | --- | --- |
| `surface?` | `Surface` | `"glass"`: translucent glass over a backdrop (`Ambient`, a photo, a map); `"solid"` opts out inside a glass `UIProvider`. |
| `connectors` | `ConsentConnector[]` | Required |
| `onScopeChange?` | `(connectorId: string, scopeId: string, granted: boolean) => void` |  |
| `onRevoke?` | `(connectorId: string) => void` |  |
| `label?` | `string` | Accessible name (default "Data access"). |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### InsightCard

| Prop | Type | Notes |
| --- | --- | --- |
| `surface?` | `Surface` | `"glass"`: translucent glass over a backdrop (`Ambient`, a photo, a map); `"solid"` opts out inside a glass `UIProvider`. |
| `title` | `string` | Required |
| `children?` | `ReactNode` |  |
| `icon?` | `IconName` |  |
| `tone?` | `"info" \| "success" \| "warning" \| "highlight"` |  |
| `meta?` | `ReactNode` | Above the title: area and time ("Money · 2 h ago"). |
| `sources?` | `Source[]` | Where the insight comes from, as chips (like chat sources). |
| `why?` | `ReactNode` | Why the assistant shows it; folded away behind "Why am I seeing this?". |
| `onAccept?` | `() => void` |  |
| `acceptLabel?` | `string` |  |
| `onSnooze?` | `() => void` |  |
| `onDismiss?` | `() => void` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### KeyValue

| Prop | Type | Notes |
| --- | --- | --- |
| `items` | `KeyValueItem[]` | Required |
| `dense?` | `boolean` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### PageHeader

| Prop | Type | Notes |
| --- | --- | --- |
| `crumbs?` | `(string \| Crumb)[]` | A breadcrumb above the title (see `Breadcrumb`): labels, or items with `href`/`onClick`. |
| `crumbLinkAs?` | `ElementType` | The element crumb links render as, e.g. Next.js `Link`. |
| `title` | `string` | Required |
| `subtitle?` | `string` |  |
| `actions?` | `ReactNode[]` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Panel

| Prop | Type | Notes |
| --- | --- | --- |
| `side?` | `boolean` |  |
| `title?` | `string` |  |
| `subtitle?` | `string` |  |
| `action?` | `ReactNode` |  |
| `children?` | `ReactNode` |  |
| `surface?` | `Surface` | `"glass"`: translucent glass over what is behind (maps, 3D, video, photos); `"solid"` opts out inside a glass `UIProvider`. |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Stagger

| Prop | Type | Notes |
| --- | --- | --- |
| `as?` | `string` |  |
| `className?` | `string` |  |
| `style?` | `CSSProperties` |  |
| `delay?` | `number` | Extra delay before the first child, in ms. |
| `reveal?` | `boolean` | Below the fold, wait until the group scrolls into view (default true). |
| `children?` | `ReactNode` |  |

### Switch

| Prop | Type | Notes |
| --- | --- | --- |
| `name?` | `string` |  |
| `checked?` | `boolean` |  |
| `defaultChecked?` | `boolean` |  |
| `onChange?` | `(e: ChangeEvent<HTMLInputElement>) => void` |  |
| `children?` | `ReactNode` |  |
| `spread?` | `boolean` |  |
| `disabled?` | `boolean` |  |
| `label?` | `string` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |
