Skip to content
UI Kit
Accent color

Roles and permissions

Access screens for apps. A role matrix, a role editor with live summary, access requests with a time limit, groups that add roles and an access check that explains itself.

One of 2 components in Security and access

5 variants

Variants

Role matrix

Permissions by area against roles, fixed roles locked, changed cells marked and a save bar. Phones show one role at a time with switches.

Open in Studio

Role editor

Name, a role to start from, permissions per area with select all, and a summary that counts sensitive rights as you pick.

Open in Studio

Access requests

Requests with reason and a time limit to pick, approve or decline, and a list of decisions you can undo.

Open in Studio

Groups

Groups with members and the role they add, add people with search and remove them in place.

Open in Studio

Access check

Pick a person and a permission and see the answer with its path through role and groups.

Open in Studio

Details

What Roles and permissions brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/permissions.tsx
npm packages
None
Languages
English and German, 76 texts of its own

Props

The PermissionsProps interface from components/ui/permissions.tsx, with the comments from the code.

  • variantoptional
    PermissionsVariant

    role-matrixrole-editoraccess-requestsgroupsaccess-check

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlerequired
    string
  • subtitleoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • rolesoptional
    Role[]
  • permissionsoptional
    Permission[]
  • grantsoptional
    Record<string, string[]>

    Permission ids per role id

  • peopleoptional
    Person[]
  • groupsoptional
    Group[]
  • requestsoptional
    AccessRequest[]
  • editRoleoptional
    string

    role-editor: the role that starts open, leave out for a new one

  • startFromoptional
    string

    role-editor: for a new role, the role whose permissions it starts with

  • defaultPersonoptional
    string

    access-check: who and what start out picked

  • defaultPermissionoptional
    string
  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on every saved change. Without it the block simulates the request.

Built-in texts

Roles and permissions writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 76 texts
permissionEnglish PermissionGerman Recht
rolesEnglish RolesGerman Rollen
roleEnglish RoleGerman Rolle
peopleEnglish {n} peopleGerman {n} Personen
personEnglish 1 personGerman 1 Person
lockedEnglish Fixed, cannot be changedGerman Fest, nicht änderbar
sensitiveEnglish SensitiveGerman Heikel
grantEnglish {perm} for {role}German {perm} für {role}
unsavedEnglish {n} unsaved changesGerman {n} ungespeicherte Änderungen
oneEnglish 1 unsaved changeGerman 1 ungespeicherte Änderung
discardEnglish DiscardGerman Verwerfen
saveEnglish Save changesGerman Änderungen speichern
savingEnglish Saving…German Wird gespeichert…
savedEnglish Roles saved. Changes apply on the next page load.German Rollen gespeichert. Änderungen gelten beim nächsten Laden der Seite.
showRoleEnglish Show permissions ofGerman Rechte zeigen von
newRoleEnglish New roleGerman Neue Rolle
editRoleEnglish Edit roleGerman Rolle bearbeiten
nameEnglish NameGerman Name
nameErrorEnglish Give the role a name.German Gib der Rolle einen Namen.
descriptionEnglish DescriptionGerman Beschreibung
descHintEnglish One line about who gets this role.German Eine Zeile dazu, wer diese Rolle bekommt.
startFromEnglish Start fromGerman Ausgehend von
blankEnglish NothingGerman Nichts
selectAllEnglish All in {group}German Alle in {group}
clearGroupEnglish None in {group}German Keine in {group}
pickedEnglish {n} of {total}German {n} von {total}
canDoEnglish This role canGerman Diese Rolle kann
nothingYetEnglish Nothing yet. Pick permissions on the left.German Noch nichts. Wähle links die Rechte.
sensitiveNEnglish {n} sensitiveGerman {n} heikel
createRoleEnglish Create roleGerman Rolle erstellen
saveRoleEnglish Save roleGerman Rolle speichern
createdEnglish Role {name} created.German Rolle {name} erstellt.
requestsEnglish RequestsGerman Anfragen
historyEnglish DecidedGerman Entschieden
noDecisionsEnglish Your decisions show up here, with a way to undo them.German Deine Entscheidungen erscheinen hier, mit Rückgängig.
noRequestsEnglish No open requests. New ones show up here.German Keine offenen Anfragen. Neue erscheinen hier.
asksEnglish asks for {role}German bittet um {role}
onEnglish on {resource}German für {resource}
forEnglish Access forGerman Zugriff für
hours24English 24 hoursGerman 24 Stunden
days7English 7 daysGerman 7 Tage
permanentEnglish Until removedGerman Bis auf Widerruf
approveEnglish ApproveGerman Freigeben
denyEnglish DeclineGerman Ablehnen
deniedEnglish DeclinedGerman Abgelehnt
approvedUntilEnglish Approved for {d}German Freigegeben für {d}
approvedForEnglish Approved {role} for {name}.German {role} für {name} freigegeben.
deniedForEnglish Declined {name}.German {name} abgelehnt.
undoEnglish UndoGerman Rückgängig
groupsEnglish GroupsGerman Gruppen
membersEnglish MembersGerman Mitglieder
noRoleEnglish No extra roleGerman Keine zusätzliche Rolle
addMemberEnglish Add memberGerman Mitglied hinzufügen
addToEnglish Add someone to {group}German Jemanden zu {group} hinzufügen
removeFromEnglish Remove {name} from {group}German {name} aus {group} entfernen
searchEnglish Search peopleGerman Personen suchen
nobodyEnglish Everyone is in this group already.German Alle sind schon in dieser Gruppe.
noMatchEnglish No one outside the group matches “{q}”.German Niemand außerhalb der Gruppe passt zu „{q}“.
openLabelEnglish openGerman offen
addedEnglish {name} added to {group}.German {name} zu {group} hinzugefügt.
removedEnglish {name} removed from {group}.German {name} aus {group} entfernt.
groupRoleEnglish Role of the groupGerman Rolle der Gruppe
checkEnglish Check accessGerman Zugriff prüfen
checkTextEnglish Pick a person and a permission. We show whether they have it and why.German Wähle Person und Recht. Wir zeigen, ob sie es hat und warum.
whoEnglish PersonGerman Person
whatEnglish PermissionGerman Recht
yesEnglish {name} can {perm}German {name} darf {perm}
noEnglish {name} cannot {perm}German {name} darf nicht {perm}
becauseEnglish WhyGerman Warum
viaRoleEnglish Role {role}German Rolle {role}
viaGroupEnglish Group {group}German Gruppe {group}
grantsEnglish grants itGerman gibt es
lacksEnglish does not grant itGerman gibt es nicht
noGroupsEnglish In no groupGerman In keiner Gruppe
fixEnglish To give access, change the role or add {name} to a group with a role that has it.German Für Zugriff die Rolle ändern oder {name} in eine Gruppe mit passender Rolle aufnehmen.
ownerEnglish OwnerGerman Inhaber

Accessibility

Read from the source of the component.

  • Roles and permissions handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-checked, aria-controls, aria-disabled, aria-expanded, aria-label, aria-labelledby, aria-live and aria-selected, so screen readers announce purpose and state.
  • Uses the roles checkbox, radio, radiogroup, status, tab, tablist and tabpanel.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,171 lines
components/ui/permissions.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useScrollFade } from '@/components/ui/motion';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type PermissionsVariant = 'role-matrix' | 'role-editor' | 'access-requests' | 'groups' | 'access-check';

export interface Role {
  id: string;
  name: string;
  description?: string;
  /** Cannot be changed, e.g. the owner */
  locked?: boolean;
  /** How many people have it */
  members?: number;
}

export interface Permission {
  id: string;
  label: string;
  /** Area, e.g. Projects */
  group: string;
  description?: string;
  /** Marked as risky, e.g. delete or billing */
  sensitive?: boolean;
}

export interface Person {
  id: string;
  name: string;
  email?: string;
  /** Role id */
  role: string;
  /** Group ids */
  groups?: string[];
}

export interface Group {
  id: string;
  name: string;
  description?: string;
  /** Person ids */
  members: string[];
  /** Extra role the group adds */
  role?: string;
}

export interface AccessRequest {
  id: string;
  /** Person id */
  person: string;
  /** What they want, e.g. Project Relaunch */
  resource: string;
  /** Role id they ask for */
  role: string;
  reason?: string;
  /** ISO date and time */
  time: string;
}

export interface PermissionsProps {
  variant?: PermissionsVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  /** ISO date and time that counts as now */
  now?: string;
  roles?: Role[];
  permissions?: Permission[];
  /** Permission ids per role id */
  grants?: Record<string, string[]>;
  people?: Person[];
  groups?: Group[];
  requests?: AccessRequest[];
  /** role-editor: the role that starts open, leave out for a new one */
  editRole?: string;
  /** role-editor: for a new role, the role whose permissions it starts with */
  startFrom?: string;
  /** access-check: who and what start out picked */
  defaultPerson?: string;
  defaultPermission?: string;
  /** Called on every saved change. Without it the block simulates the request. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    permission: 'Permission',
    roles: 'Roles',
    role: 'Role',
    people: '{n} people',
    person: '1 person',
    locked: 'Fixed, cannot be changed',
    sensitive: 'Sensitive',
    grant: '{perm} for {role}',
    unsaved: '{n} unsaved changes',
    one: '1 unsaved change',
    discard: 'Discard',
    save: 'Save changes',
    saving: 'Saving…',
    saved: 'Roles saved. Changes apply on the next page load.',
    showRole: 'Show permissions of',
    newRole: 'New role',
    editRole: 'Edit role',
    name: 'Name',
    nameError: 'Give the role a name.',
    description: 'Description',
    descHint: 'One line about who gets this role.',
    startFrom: 'Start from',
    blank: 'Nothing',
    selectAll: 'All in {group}',
    clearGroup: 'None in {group}',
    picked: '{n} of {total}',
    canDo: 'This role can',
    nothingYet: 'Nothing yet. Pick permissions on the left.',
    sensitiveN: '{n} sensitive',
    createRole: 'Create role',
    saveRole: 'Save role',
    created: 'Role {name} created.',
    requests: 'Requests',
    history: 'Decided',
    noDecisions: 'Your decisions show up here, with a way to undo them.',
    noRequests: 'No open requests. New ones show up here.',
    asks: 'asks for {role}',
    on: 'on {resource}',
    for: 'Access for',
    hours24: '24 hours',
    days7: '7 days',
    permanent: 'Until removed',
    approve: 'Approve',
    deny: 'Decline',
    denied: 'Declined',
    approvedUntil: 'Approved for {d}',
    approvedFor: 'Approved {role} for {name}.',
    deniedFor: 'Declined {name}.',
    undo: 'Undo',
    groups: 'Groups',
    members: 'Members',
    noRole: 'No extra role',
    addMember: 'Add member',
    addTo: 'Add someone to {group}',
    removeFrom: 'Remove {name} from {group}',
    search: 'Search people',
    nobody: 'Everyone is in this group already.',
    noMatch: 'No one outside the group matches “{q}”.',
    openLabel: 'open',
    added: '{name} added to {group}.',
    removed: '{name} removed from {group}.',
    groupRole: 'Role of the group',
    check: 'Check access',
    checkText: 'Pick a person and a permission. We show whether they have it and why.',
    who: 'Person',
    what: 'Permission',
    yes: '{name} can {perm}',
    no: '{name} cannot {perm}',
    because: 'Why',
    viaRole: 'Role {role}',
    viaGroup: 'Group {group}',
    grants: 'grants it',
    lacks: 'does not grant it',
    noGroups: 'In no group',
    fix: 'To give access, change the role or add {name} to a group with a role that has it.',
    owner: 'Owner',
  },
  de: {
    permission: 'Recht',
    roles: 'Rollen',
    role: 'Rolle',
    people: '{n} Personen',
    person: '1 Person',
    locked: 'Fest, nicht änderbar',
    sensitive: 'Heikel',
    grant: '{perm} für {role}',
    unsaved: '{n} ungespeicherte Änderungen',
    one: '1 ungespeicherte Änderung',
    discard: 'Verwerfen',
    save: 'Änderungen speichern',
    saving: 'Wird gespeichert…',
    saved: 'Rollen gespeichert. Änderungen gelten beim nächsten Laden der Seite.',
    showRole: 'Rechte zeigen von',
    newRole: 'Neue Rolle',
    editRole: 'Rolle bearbeiten',
    name: 'Name',
    nameError: 'Gib der Rolle einen Namen.',
    description: 'Beschreibung',
    descHint: 'Eine Zeile dazu, wer diese Rolle bekommt.',
    startFrom: 'Ausgehend von',
    blank: 'Nichts',
    selectAll: 'Alle in {group}',
    clearGroup: 'Keine in {group}',
    picked: '{n} von {total}',
    canDo: 'Diese Rolle kann',
    nothingYet: 'Noch nichts. Wähle links die Rechte.',
    sensitiveN: '{n} heikel',
    createRole: 'Rolle erstellen',
    saveRole: 'Rolle speichern',
    created: 'Rolle {name} erstellt.',
    requests: 'Anfragen',
    history: 'Entschieden',
    noDecisions: 'Deine Entscheidungen erscheinen hier, mit Rückgängig.',
    noRequests: 'Keine offenen Anfragen. Neue erscheinen hier.',
    asks: 'bittet um {role}',
    on: 'für {resource}',
    for: 'Zugriff für',
    hours24: '24 Stunden',
    days7: '7 Tage',
    permanent: 'Bis auf Widerruf',
    approve: 'Freigeben',
    deny: 'Ablehnen',
    denied: 'Abgelehnt',
    approvedUntil: 'Freigegeben für {d}',
    approvedFor: '{role} für {name} freigegeben.',
    deniedFor: '{name} abgelehnt.',
    undo: 'Rückgängig',
    groups: 'Gruppen',
    members: 'Mitglieder',
    noRole: 'Keine zusätzliche Rolle',
    addMember: 'Mitglied hinzufügen',
    addTo: 'Jemanden zu {group} hinzufügen',
    removeFrom: '{name} aus {group} entfernen',
    search: 'Personen suchen',
    nobody: 'Alle sind schon in dieser Gruppe.',
    noMatch: 'Niemand außerhalb der Gruppe passt zu „{q}“.',
    openLabel: 'offen',
    added: '{name} zu {group} hinzugefügt.',
    removed: '{name} aus {group} entfernt.',
    groupRole: 'Rolle der Gruppe',
    check: 'Zugriff prüfen',
    checkText: 'Wähle Person und Recht. Wir zeigen, ob sie es hat und warum.',
    who: 'Person',
    what: 'Recht',
    yes: '{name} darf {perm}',
    no: '{name} darf nicht {perm}',
    because: 'Warum',
    viaRole: 'Rolle {role}',
    viaGroup: 'Gruppe {group}',
    grants: 'gibt es',
    lacks: 'gibt es nicht',
    noGroups: 'In keiner Gruppe',
    fix: 'Für Zugriff die Rolle ändern oder {name} in eine Gruppe mit passender Rolle aufnehmen.',
    owner: 'Inhaber',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion of the app screens                                           */
/* ------------------------------------------------------------------ */
/*
 * The same small set in every app block (auth, dashboard, files, inbox …),
 * kept in the block so it ships on its own:
 *   useEnter   entrance that waits until its area scrolls in. The area gets
 *              data-kitm-scope and data-kitm={state}, children take kitm-wipe
 *              (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
 *              kitm-up and kitm-pop, with --kitm-d as delay.
 *   useGlide   a marker inside the active item ([data-glide]) glides over
 *              from where the previous one stood: tabs, segments, steps.
 *   kitm-in, kitm-fly, kitm-tick for things that appear after an action.
 * The server renders the end state, without JavaScript nothing waits longer
 * than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
 * clip-path change, so no layout shifts.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
  ['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
  ['grow-x', 'transform:scaleX(0)', 900],
  ['grow-y', 'transform:scaleY(0)', 800],
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
  KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
  '@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
  '.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
  '@media (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
  '[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
  '}';

function KitmStyle() {
  // React hoists it once into the head, however many app blocks render
  return (
    <style href="kitm" precedence="default">
      {KITM_CSS}
    </style>
  );
}

/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setState('play');
        io.disconnect();
      },
      { rootMargin: '0px 0px -8% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}

type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
  const r = el.getBoundingClientRect();
  const b = root.getBoundingClientRect();
  return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};

/**
 * The marker ([data-glide]) is rendered inside the active item, so the server
 * and a page without JavaScript show it in place. After a change it starts
 * where the old one stood and glides into the new item.
 */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  // Read during render, while the DOM still shows the old marker
  if (typeof window !== 'undefined' && ref.current) {
    const m = ref.current.querySelector('[data-glide]');
    before.current = m ? boxOf(m, ref.current) : null;
  }
  useIso(() => {
    const root = ref.current;
    const m = root?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!root || !m || !a || prefersReducedMotion()) return;
    const b = boxOf(m, root);
    if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

const MIN = 60000;
const toMin = (iso: string) => Math.round(Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z')) / MIN);
function ago(locale: Locale, iso: string, now: string) {
  const rtf = new Intl.RelativeTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { numeric: 'auto' });
  const d = toMin(iso) - toMin(now);
  const a = Math.abs(d);
  if (a < 60) return rtf.format(Math.round(d), 'minute');
  if (a < 1440) return rtf.format(Math.round(d / 60), 'hour');
  return rtf.format(Math.round(d / 1440), 'day');
}

type P = Omit<PermissionsProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; now: string; locale: Locale };

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: PermissionsProps['onAction'], action: string, data: Record<string, unknown>) {
  if (onAction) await onAction(action, data);
  else await wait(prefersReducedMotion() ? 0 : 700);
}

function Card({ title, description, action, children, className, bodyClassName }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string }) {
  return (
    <section className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
      {(title || action) && (
        <div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
          <div className="min-w-0">
            {title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
            {description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
          </div>
          {action}
        </div>
      )}
      <div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
    </section>
  );
}

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
  const ref = useGlide<HTMLDivElement>(value);
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (i + dir + options.length) % options.length;
    onChange(options[next].value);
    ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
  };
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
      {options.map((o, i) => (
        <button
          key={o.value}
          type="button"
          role="radio"
          aria-checked={value === o.value}
          tabIndex={value === o.value ? 0 : -1}
          onClick={() => onChange(o.value)}
          onKeyDown={(e) => onKey(e, i)}
          className={cn(
            'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
            value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
          )}
        >
          {value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
          <span className="relative inline-flex items-center gap-1.5">{o.label}</span>
        </button>
      ))}
    </div>
  );
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

function Initials({ name, index = 0, className }: { name: string; index?: number; className?: string }) {
  return (
    <span title={name} aria-hidden className={cn('grid size-8 shrink-0 place-items-center rounded-full text-[11px] font-semibold text-foreground', className)} style={{ background: `color-mix(in oklab, var(--primary) ${10 + (index % 4) * 6}%, var(--muted))` }}>
      {name
        .split(/\s+/)
        .map((w) => w[0])
        .slice(0, 2)
        .join('')}
    </span>
  );
}

/** A square check for a grant, filled when on, a lock when fixed */
function Tick({ on, locked, label, onToggle }: { on: boolean; locked?: boolean; label: string; onToggle: () => void }) {
  return (
    <button
      type="button"
      role="checkbox"
      aria-checked={on}
      aria-label={label}
      aria-disabled={locked || undefined}
      onClick={() => !locked && onToggle()}
      className={cn(
        'group/tick relative grid size-11 place-items-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:size-9',
        locked ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)]',
      )}
    >
      <span className={cn('grid size-5 place-items-center rounded-[6px] border transition-[background-color,border-color,transform] duration-200 ease-[var(--ease-kit)] group-active/tick:scale-90', on ? (locked ? 'border-transparent bg-[color-mix(in_oklab,var(--foreground)_30%,var(--card))] text-card' : 'border-primary bg-primary text-primary-foreground') : 'border-border-strong bg-background')}>
        {on && <Icon name={locked ? 'lock' : 'check'} size={locked ? 11 : 13} strokeWidth={2.4} className={locked ? undefined : 'kitm-tick'} />}
      </span>
    </button>
  );
}

const byGroup = (perms: Permission[]) => {
  const m = new Map<string, Permission[]>();
  for (const p of perms) m.set(p.group, [...(m.get(p.group) ?? []), p]);
  return [...m];
};

function SaveBar({ count, busy, done, t, onDiscard, onSave }: { count: number; busy: boolean; done: boolean; t: Texts; onDiscard: () => void; onSave: () => void }) {
  const show = count > 0 || done;
  return (
    <div className={cn('fixed inset-x-0 bottom-0 z-30 px-3 pb-[calc(env(safe-area-inset-bottom)+12px)] transition-[translate,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none sm:px-6', show ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-6 opacity-0')} inert={!show}>
      <div className="mx-auto flex max-w-[44rem] items-center gap-3 rounded-xl border border-border bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] p-2.5 ps-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl">
        <p className="min-w-0 flex-1 truncate text-[13.5px] text-foreground">
          {done && !count ? (
            <span className="kitm-in flex items-center gap-2 text-success-foreground">
              <Icon name="check-circle" size={16} className="shrink-0" />
              <span className="truncate">{t.saved}</span>
            </span>
          ) : (
            <span key={count} className="kitm-in flex items-center gap-2">
              <span aria-hidden className="size-1.5 shrink-0 rounded-full bg-warning" />
              <span className="truncate">{count === 1 ? t.one : fill(t.unsaved, { n: count })}</span>
            </span>
          )}
        </p>
        {count > 0 && (
          <>
            <Button variant="ghost" size="sm" onClick={onDiscard}>
              {t.discard}
            </Button>
            <Button size="sm" onClick={onSave} disabled={busy} iconLeft={busy ? 'loader' : undefined}>
              {busy ? t.saving : t.save}
            </Button>
          </>
        )}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* role-matrix                                                         */
/* ------------------------------------------------------------------ */

function RoleMatrix({ t, title, subtitle, roles = [], permissions = [], grants = {}, onAction }: P) {
  const [saved, setSaved] = useState(grants);
  const [g, setG] = useState(grants);
  const [busy, setBusy] = useState(false);
  const [done, setDone] = useState(false);
  const [phoneRole, setPhoneRole] = useState(roles.find((r) => !r.locked)?.id ?? roles[0]?.id ?? '');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const has = (r: string, p: string) => (g[r] ?? []).includes(p);
  const toggle = (r: string, p: string) => {
    setDone(false);
    setG((x) => ({ ...x, [r]: has(r, p) ? (x[r] ?? []).filter((y) => y !== p) : [...(x[r] ?? []), p] }));
  };
  const changed = roles.reduce((a, r) => a + permissions.filter((p) => (g[r.id] ?? []).includes(p.id) !== (saved[r.id] ?? []).includes(p.id)).length, 0);
  const dirtyCell = (r: string, p: string) => (g[r] ?? []).includes(p) !== (saved[r] ?? []).includes(p);
  const groups = byGroup(permissions);
  const save = async () => {
    setBusy(true);
    await run(onAction, 'save-roles', { grants: g });
    setSaved(g);
    setBusy(false);
    setDone(true);
  };
  const pr = roles.find((r) => r.id === phoneRole);
  return (
    <Main className="pb-28">
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6">
        {/* Tablets and desktop: the whole matrix. Phones: one role at a time with switches. */}
        <div className="kitm-up hidden overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] md:block">
          <table className="w-full table-fixed border-collapse text-left">
            <thead>
              <tr className="border-b border-border bg-surface">
                <th scope="col" className="w-[38%] px-5 py-3 text-[12.5px] font-medium text-muted-foreground lg:w-[34%]">
                  {t.permission}
                </th>
                {roles.map((r) => (
                  <th key={r.id} scope="col" className="px-2 py-3 text-center align-bottom">
                    <span className="flex items-center justify-center gap-1 text-[13px] font-semibold text-foreground">
                      {r.locked && <Icon name="lock" size={12} label={t.locked} className="text-subtle-foreground" />}
                      {r.name}
                    </span>
                    {r.members !== undefined && <span className="mt-0.5 block text-[11.5px] font-normal tabular-nums text-subtle-foreground">{r.members === 1 ? t.person : fill(t.people, { n: r.members })}</span>}
                  </th>
                ))}
              </tr>
            </thead>
            {groups.map(([group, list], gi) => (
              <tbody key={group} className="kitm-up" style={{ '--kitm-d': `${gi * 70}ms` } as React.CSSProperties}>
                <tr>
                  <th colSpan={roles.length + 1} scope="colgroup" className="border-b border-border bg-[color-mix(in_oklab,var(--foreground)_2%,var(--card))] px-5 pb-2 pt-4 text-left text-[11.5px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">
                    {group}
                  </th>
                </tr>
                {list.map((p) => (
                  <tr key={p.id} className="group/row border-b border-border last:border-b-0 hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]">
                    <th scope="row" className="px-5 py-2.5 text-left font-normal">
                      <span className="flex items-center gap-2 text-[13.5px] font-medium text-foreground">
                        {p.label}
                        {p.sensitive && <span className="rounded bg-[color-mix(in_oklab,var(--warning)_20%,transparent)] px-1.5 py-px text-[10.5px] font-semibold text-warning-foreground">{t.sensitive}</span>}
                      </span>
                      {p.description && <span className="mt-0.5 block truncate text-[12px] text-muted-foreground">{p.description}</span>}
                    </th>
                    {roles.map((r) => (
                      <td key={r.id} className={cn('px-2 py-1 text-center transition-colors duration-300', dirtyCell(r.id, p.id) && 'bg-[color-mix(in_oklab,var(--primary)_8%,transparent)]')}>
                        <span className="inline-grid">
                          <Tick on={has(r.id, p.id)} locked={r.locked} label={fill(t.grant, { perm: p.label, role: r.name })} onToggle={() => toggle(r.id, p.id)} />
                        </span>
                      </td>
                    ))}
                  </tr>
                ))}
              </tbody>
            ))}
          </table>
        </div>

        <div className="grid gap-4 md:hidden">
          <div className="kitm-up">
            <p className="mb-2 text-[12.5px] font-medium text-muted-foreground">{t.showRole}</p>
            <Segmented label={t.showRole} value={phoneRole} onChange={setPhoneRole} className="flex w-full" options={roles.map((r) => ({ value: r.id, label: r.name }))} />
            {pr && (
              <p key={pr.id} className="kitm-in mt-2 text-[12.5px] text-muted-foreground">
                {pr.description}
                {pr.locked && ` · ${t.locked}`}
              </p>
            )}
          </div>
          {groups.map(([group, list], gi) => (
            <section key={group} className="kitm-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]" style={{ '--kitm-d': `${gi * 60}ms` } as React.CSSProperties}>
              <h2 className="border-b border-border bg-surface px-4 py-2.5 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{group}</h2>
              <ul className="divide-y divide-border">
                {list.map((p) => (
                  <li key={p.id} className={cn('px-4 py-1 transition-colors', pr && dirtyCell(pr.id, p.id) && 'bg-[color-mix(in_oklab,var(--primary)_6%,transparent)]')}>
                    <Switch
                      checked={pr ? has(pr.id, p.id) : false}
                      disabled={pr?.locked}
                      onCheckedChange={() => pr && toggle(pr.id, p.id)}
                      label={
                        <span className="flex items-center gap-2">
                          {p.label}
                          {p.sensitive && <span className="rounded bg-[color-mix(in_oklab,var(--warning)_20%,transparent)] px-1.5 py-px text-[10.5px] font-semibold text-warning-foreground">{t.sensitive}</span>}
                        </span>
                      }
                      description={p.description}
                      className="py-2.5"
                    />
                  </li>
                ))}
              </ul>
            </section>
          ))}
        </div>
      </div>
      <SaveBar count={changed} busy={busy} done={done} t={t} onDiscard={() => setG(saved)} onSave={save} />
      <Live text={done && !changed ? t.saved : ''} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* role-editor                                                         */
/* ------------------------------------------------------------------ */

function RoleEditor({ t, title, subtitle, roles = [], permissions = [], grants = {}, editRole, startFrom = '', onAction }: P) {
  const id = useId();
  const base = roles.find((r) => r.id === editRole);
  const [name, setName] = useState(base?.name ?? '');
  const [desc, setDesc] = useState(base?.description ?? '');
  const [from, setFrom] = useState(base ? '' : startFrom);
  const [picked, setPicked] = useState<Set<string>>(new Set(base ? grants[base.id] ?? [] : grants[startFrom] ?? []));
  const [tried, setTried] = useState(false);
  const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
  const [scope, enter] = useEnter<HTMLFormElement>();
  const groups = byGroup(permissions);
  const list = permissions.filter((p) => picked.has(p.id));
  const sensitive = list.filter((p) => p.sensitive).length;
  const toggle = (pid: string) =>
    setPicked((s) => {
      const n = new Set(s);
      n.has(pid) ? n.delete(pid) : n.add(pid);
      return n;
    });
  const submit = async (e: React.FormEvent) => {
    e.preventDefault();
    setTried(true);
    if (!name.trim()) return;
    setState('busy');
    await run(onAction, base ? 'update-role' : 'create-role', { name, description: desc, permissions: [...picked] });
    setState('done');
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <form noValidate onSubmit={submit} ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start">
        <div className="grid min-w-0 gap-6">
          <Card title={base ? t.editRole : t.newRole} className="kitm-up">
            <div className="grid gap-4 sm:grid-cols-2">
              <Input label={t.name} value={name} error={tried && !name.trim() ? t.nameError : undefined} onChange={(e) => setName(e.target.value)} placeholder={t.newRole} />
              {!base && (
                <NativeSelect
                  label={t.startFrom}
                  value={from}
                  onChange={(e) => {
                    setFrom(e.target.value);
                    setPicked(new Set(e.target.value ? grants[e.target.value] ?? [] : []));
                  }}
                  options={[{ value: '', label: t.blank }, ...roles.map((r) => ({ value: r.id, label: r.name }))]}
                />
              )}
              <Input className={base ? undefined : 'sm:col-span-2'} label={t.description} optional hint={t.descHint} value={desc} onChange={(e) => setDesc(e.target.value)} />
            </div>
          </Card>
          {groups.map(([group, perms], gi) => {
            const on = perms.filter((p) => picked.has(p.id)).length;
            return (
              <fieldset key={group} className="kitm-up min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]" style={{ '--kitm-d': `${80 + gi * 60}ms` } as React.CSSProperties}>
                <legend className="sr-only">{group}</legend>
                <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3 sm:px-5">
                  <p className="flex items-baseline gap-2 text-[14px] font-semibold text-foreground">
                    {group}
                    <span key={on} className="kitm-in text-[12px] font-normal tabular-nums text-muted-foreground">
                      {fill(t.picked, { n: on, total: perms.length })}
                    </span>
                  </p>
                  <button
                    type="button"
                    onClick={() =>
                      setPicked((s) => {
                        const n = new Set(s);
                        perms.forEach((p) => (on === perms.length ? n.delete(p.id) : n.add(p.id)));
                        return n;
                      })
                    }
                    className="h-11 rounded-md px-2 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:bg-accent sm:h-8"
                  >
                    {fill(on === perms.length ? t.clearGroup : t.selectAll, { group })}
                  </button>
                </div>
                <div className="grid sm:grid-cols-2">
                  {perms.map((p, i) => (
                    <label key={p.id} className={cn('relative flex min-h-14 cursor-pointer items-start gap-3 border-border px-4 py-3 transition-colors sm:px-5', i > 0 && 'border-t', i === 1 && 'sm:border-t-0', i % 2 === 1 && 'sm:border-s', i === perms.length - 1 && i % 2 === 0 && 'sm:col-span-2', picked.has(p.id) ? 'bg-[color-mix(in_oklab,var(--primary)_5%,transparent)]' : 'hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]')}>
                      <input type="checkbox" checked={picked.has(p.id)} onChange={() => toggle(p.id)} className="peer sr-only" />
                      <span aria-hidden className={cn('mt-0.5 grid size-[18px] shrink-0 place-items-center rounded-[5px] border transition-colors duration-200 peer-focus-visible:ring-2 peer-focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', picked.has(p.id) ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
                        {picked.has(p.id) && <Icon name="check" size={13} strokeWidth={2.4} className="kitm-tick" />}
                      </span>
                      <span className="min-w-0">
                        <span className="flex flex-wrap items-center gap-2 text-[13.5px] font-medium text-foreground">
                          {p.label}
                          {p.sensitive && <span className="rounded bg-[color-mix(in_oklab,var(--warning)_20%,transparent)] px-1.5 py-px text-[10.5px] font-semibold text-warning-foreground">{t.sensitive}</span>}
                        </span>
                        {p.description && <span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{p.description}</span>}
                      </span>
                    </label>
                  ))}
                </div>
              </fieldset>
            );
          })}
        </div>
        {/* Desktop: a live summary that stays in view. Tablets and phones: the same summary closes the form. */}
        <aside aria-labelledby={`${id}-s`} className="kitm-up [--kitm-d:120ms] rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5 lg:sticky lg:top-6">
          <h2 id={`${id}-s`} className="flex items-center justify-between gap-3 text-[14px] font-semibold text-foreground">
            {t.canDo}
            <span className="flex items-center gap-2 text-[12px] font-normal tabular-nums">
              <span className="text-muted-foreground">{fill(t.picked, { n: list.length, total: permissions.length })}</span>
              {sensitive > 0 && <span className="rounded bg-[color-mix(in_oklab,var(--warning)_20%,transparent)] px-1.5 py-px font-semibold text-warning-foreground">{fill(t.sensitiveN, { n: sensitive })}</span>}
            </span>
          </h2>
          <span aria-hidden className="mt-3 block h-1.5 overflow-hidden rounded-full bg-track">
            <span className="block h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${permissions.length ? (list.length / permissions.length).toFixed(3) : 0})` }} />
          </span>
          {list.length ? (
            <ul className="mt-4 grid gap-1.5">
              {list.map((p) => (
                <li key={p.id} className="kitm-in flex items-start gap-2 text-[13px] leading-snug text-foreground">
                  <Icon name="check" size={14} className={cn('mt-0.5 shrink-0', p.sensitive ? 'text-warning-foreground' : 'text-primary')} />
                  <span className="min-w-0">
                    {p.label} <span className="text-subtle-foreground">· {p.group}</span>
                  </span>
                </li>
              ))}
            </ul>
          ) : (
            <p className="mt-4 rounded-lg border border-dashed border-border p-3 text-[12.5px] text-muted-foreground">{t.nothingYet}</p>
          )}
          <Button type="submit" className="mt-5 w-full" disabled={state === 'busy'} iconLeft={state === 'busy' ? 'loader' : state === 'done' ? 'check' : undefined}>
            {state === 'busy' ? t.saving : state === 'done' ? fill(t.created, { name: name.trim() }) : base ? t.saveRole : t.createRole}
          </Button>
        </aside>
      </form>
      <Live text={state === 'done' ? fill(t.created, { name: name.trim() }) : ''} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* access-requests                                                     */
/* ------------------------------------------------------------------ */

type Decision = { id: string; ok: boolean; span: string };

function AccessRequests({ t, title, subtitle, requests = [], people = [], roles = [], now, locale, onAction }: P) {
  const [open, setOpen] = useState(requests);
  const [span, setSpan] = useState<Record<string, '24h' | '7d' | 'forever'>>({});
  const [decided, setDecided] = useState<(AccessRequest & Decision)[]>([]);
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const person = (pid: string) => people.find((p) => p.id === pid);
  const role = (rid: string) => roles.find((r) => r.id === rid)?.name ?? rid;
  const label = { '24h': t.hours24, '7d': t.days7, forever: t.permanent };
  const decide = (r: AccessRequest, ok: boolean) => {
    const s = span[r.id] ?? '7d';
    setOpen((l) => l.filter((x) => x.id !== r.id));
    setDecided((l) => [{ ...r, id: r.id, ok, span: s }, ...l]);
    const name = person(r.person)?.name ?? '';
    setMsg(ok ? fill(t.approvedFor, { role: role(r.role), name }) : fill(t.deniedFor, { name }));
    void run(onAction, ok ? 'approve' : 'decline', { id: r.id, span: s });
  };
  const undo = (d: AccessRequest & Decision) => {
    setDecided((l) => l.filter((x) => x.id !== d.id));
    setOpen((l) => [requests.find((x) => x.id === d.id) ?? d, ...l]);
    setMsg('');
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <span className="inline-flex h-8 items-center gap-2 rounded-lg bg-muted px-3 text-[13px] font-medium text-foreground">
          <span key={open.length} className="kitm-pop tabular-nums">
            {open.length}
          </span>
          <span className="text-muted-foreground">{t.openLabel}</span>
        </span>
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
        <div className="grid min-w-0 gap-3">
          {open.map((r, i) => {
            const p = person(r.person);
            const s = span[r.id] ?? '7d';
            return (
              <article key={r.id} className="kitm-up rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5" style={{ '--kitm-d': `${i * 70}ms` } as React.CSSProperties}>
                <div className="flex items-start gap-3.5">
                  <Initials name={p?.name ?? '?'} index={i} className="size-10 text-[12px]" />
                  <div className="min-w-0 flex-1">
                    <p className="text-pretty text-[14px] leading-snug text-muted-foreground">
                      <span className="font-semibold text-foreground">{p?.name}</span> {fill(t.asks, { role: '' })}
                      <span className="font-medium text-foreground">{role(r.role)}</span> {fill(t.on, { resource: '' })}
                      <span className="font-medium text-foreground">{r.resource}</span>
                    </p>
                    <p className="mt-0.5 text-[12.5px] text-subtle-foreground">{ago(locale, r.time, now)}</p>
                    {r.reason && <blockquote className="mt-3 rounded-lg border-s-2 border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-surface px-3.5 py-2.5 text-pretty text-[13.5px] leading-relaxed text-foreground">{r.reason}</blockquote>}
                  </div>
                </div>
                <div className="mt-4 flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
                  <div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
                    <span className="text-[12.5px] font-medium text-muted-foreground">{t.for}</span>
                    <Segmented
                      label={t.for}
                      value={s}
                      onChange={(v) => setSpan((x) => ({ ...x, [r.id]: v }))}
                      className="flex w-full sm:w-auto"
                      options={[
                        { value: '24h', label: t.hours24 },
                        { value: '7d', label: t.days7 },
                        { value: 'forever', label: t.permanent },
                      ]}
                    />
                  </div>
                  <div className="grid grid-cols-2 gap-2 sm:flex">
                    <Button size="sm" variant="outline" onClick={() => decide(r, false)}>
                      {t.deny}
                    </Button>
                    <Button size="sm" iconLeft="check" onClick={() => decide(r, true)}>
                      {t.approve}
                    </Button>
                  </div>
                </div>
              </article>
            );
          })}
          {!open.length && (
            <div className="kitm-in grid place-items-center rounded-xl border border-dashed border-border px-4 py-14 text-center">
              <span className="grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground">
                <Icon name="check" size={20} strokeWidth={2.2} />
              </span>
              <p className="mt-4 text-[14px] text-muted-foreground">{t.noRequests}</p>
            </div>
          )}
        </div>
        <Card title={t.history} className="kitm-up [--kitm-d:120ms] lg:sticky lg:top-6" bodyClassName="p-0 sm:p-0">
          {decided.length ? (
            <ul className="divide-y divide-border">
              {decided.map((d) => (
                <li key={d.id} className="kitm-fly flex items-center gap-3 px-4 py-3 sm:px-5">
                  <span className={cn('grid size-7 shrink-0 place-items-center rounded-full', d.ok ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-muted text-muted-foreground')}>
                    <Icon name={d.ok ? 'check' : 'x'} size={14} strokeWidth={2.2} />
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="block truncate text-[13px] font-medium text-foreground">{person(d.person)?.name}</span>
                    <span className="block truncate text-[12px] text-muted-foreground">{d.ok ? fill(t.approvedUntil, { d: label[d.span as '7d'] }) : t.denied}</span>
                  </span>
                  <button type="button" onClick={() => undo(d)} className="h-11 shrink-0 rounded-md px-2 text-[12.5px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8">
                    {t.undo}
                  </button>
                </li>
              ))}
            </ul>
          ) : (
            <p className="px-4 py-6 text-center text-[13px] text-muted-foreground sm:px-5">{t.noDecisions}</p>
          )}
        </Card>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* groups                                                              */
/* ------------------------------------------------------------------ */

function Groups({ t, title, subtitle, groups = [], people = [], roles = [], onAction }: P) {
  const id = useId();
  const [items, setItems] = useState(groups);
  const [sel, setSel] = useState(groups[0]?.id ?? '');
  const [q, setQ] = useState('');
  const [adding, setAdding] = useState(false);
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const listRef = useGlide<HTMLDivElement>(sel);
  const [fadeRef, fade] = useScrollFade<HTMLDivElement>();
  const g = items.find((x) => x.id === sel);
  const person = (pid: string) => people.find((p) => p.id === pid);
  const candidates = g ? people.filter((p) => !g.members.includes(p.id) && (!q || `${p.name} ${p.email}`.toLowerCase().includes(q.toLowerCase()))) : [];
  const update = (fn: (x: Group) => Group) => setItems((l) => l.map((x) => (x.id === sel ? fn(x) : x)));
  const roleName = (rid?: string) => roles.find((r) => r.id === rid)?.name;
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[16rem_minmax(0,1fr)] lg:grid-cols-[19rem_minmax(0,1fr)] md:items-start">
        {/* Tablets and desktop: groups as a list. Phones: a row of chips that scrolls sideways. */}
        <div
          ref={(el) => {
            listRef.current = el;
            fadeRef.current = el;
          }}
          role="tablist"
          aria-label={t.groups}
          className={cn('kitm-up kit-no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 md:mx-0 md:grid md:gap-1 md:overflow-visible md:rounded-xl md:border md:border-border md:bg-card md:p-1.5 md:shadow-[var(--highlight),var(--shadow-sm)] md:[mask-image:none]', fade)}
        >
          {items.map((x) => (
            <button
              key={x.id}
              type="button"
              role="tab"
              aria-selected={x.id === sel}
              aria-controls={`${id}-p`}
              onClick={() => {
                setSel(x.id);
                setAdding(false);
                setQ('');
              }}
              className={cn('relative flex h-11 shrink-0 items-center gap-2.5 rounded-lg border px-3 text-left text-[13.5px] transition-colors md:h-auto md:border-0 md:py-2.5', x.id === sel ? 'border-transparent font-medium text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}
            >
              {x.id === sel && <span data-glide aria-hidden className="absolute inset-0 rounded-lg bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] ring-1 ring-[color-mix(in_oklab,var(--primary)_25%,transparent)] md:bg-[color-mix(in_oklab,var(--primary)_8%,transparent)] md:ring-0" />}
              <span className="relative min-w-0 flex-1 truncate whitespace-nowrap">{x.name}</span>
              <span className="relative text-[12px] tabular-nums text-subtle-foreground">{x.members.length}</span>
            </button>
          ))}
        </div>
        {g && (
          <section id={`${id}-p`} role="tabpanel" aria-label={g.name} className="kitm-up [--kitm-d:100ms] min-w-0 rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
            <div key={g.id} className="kitm-in">
              <div className="flex flex-col gap-4 border-b border-border p-4 sm:flex-row sm:items-start sm:justify-between sm:p-5">
                <div className="min-w-0">
                  <h2 className="text-[17px] font-semibold tracking-[-0.015em] text-foreground">{g.name}</h2>
                  {g.description && <p className="mt-1 text-pretty text-[13px] text-muted-foreground">{g.description}</p>}
                </div>
                <div className="w-full shrink-0 sm:w-56">
                  <NativeSelect
                    label={t.groupRole}
                    size="sm"
                    value={g.role ?? ''}
                    onChange={(e) => {
                      update((x) => ({ ...x, role: e.target.value || undefined }));
                      void run(onAction, 'group-role', { group: g.id, role: e.target.value });
                    }}
                    options={[{ value: '', label: t.noRole }, ...roles.filter((r) => !r.locked).map((r) => ({ value: r.id, label: r.name }))]}
                  />
                </div>
              </div>
              <div className="flex items-center justify-between gap-3 px-4 pt-4 sm:px-5">
                <p className="text-[12.5px] font-medium text-muted-foreground">
                  {t.members} <span className="tabular-nums">· {g.members.length}</span>
                </p>
                <Button size="sm" variant="outline" iconLeft={adding ? 'x' : 'user-plus'} aria-expanded={adding} aria-controls={`${id}-add`} onClick={() => setAdding(!adding)}>
                  {t.addMember}
                </Button>
              </div>
              <div id={`${id}-add`} className={cn('grid px-4 transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none sm:px-5', adding ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')} inert={!adding}>
                <div className="min-h-0 overflow-hidden">
                  <div className="mt-3 rounded-lg border border-border bg-surface p-2">
                    <Input aria-label={fill(t.addTo, { group: g.name })} placeholder={t.search} icon="search" size="sm" value={q} onChange={(e) => setQ(e.target.value)} />
                    <ul className="mt-1.5 max-h-48 overflow-y-auto">
                      {candidates.map((p) => (
                        <li key={p.id}>
                          <button
                            type="button"
                            onClick={() => {
                              update((x) => ({ ...x, members: [...x.members, p.id] }));
                              setMsg(fill(t.added, { name: p.name, group: g.name }));
                              void run(onAction, 'add-member', { group: g.id, person: p.id });
                            }}
                            className="flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-1.5 text-left hover:bg-accent"
                          >
                            <Initials name={p.name} index={people.indexOf(p)} className="size-7 text-[10px]" />
                            <span className="min-w-0 flex-1">
                              <span className="block truncate text-[13.5px] text-foreground">{p.name}</span>
                              {p.email && <span className="block truncate text-[12px] text-muted-foreground">{p.email}</span>}
                            </span>
                            <Icon name="plus" size={15} className="shrink-0 text-muted-foreground" />
                          </button>
                        </li>
                      ))}
                      {!candidates.length && <li className="px-2 py-3 text-[13px] text-muted-foreground">{q ? fill(t.noMatch, { q }) : t.nobody}</li>}
                    </ul>
                  </div>
                </div>
              </div>
              <ul className="mt-2 divide-y divide-border px-4 pb-2 sm:px-5">
                {g.members.map((pid, i) => {
                  const p = person(pid);
                  if (!p) return null;
                  return (
                    <li key={pid} className={cn('flex items-center gap-3 py-3', !groups.find((x) => x.id === g.id)?.members.includes(pid) && 'kitm-fly')}>
                      <Initials name={p.name} index={i} />
                      <span className="min-w-0 flex-1">
                        <span className="block truncate text-[14px] font-medium text-foreground">{p.name}</span>
                        <span className="block truncate text-[12.5px] text-muted-foreground">
                          {roleName(p.role)}
                          {g.role && roleName(g.role) !== roleName(p.role) && (
                            <span className="text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]"> + {roleName(g.role)}</span>
                          )}
                        </span>
                      </span>
                      <button
                        type="button"
                        aria-label={fill(t.removeFrom, { name: p.name, group: g.name })}
                        onClick={() => {
                          update((x) => ({ ...x, members: x.members.filter((m) => m !== pid) }));
                          setMsg(fill(t.removed, { name: p.name, group: g.name }));
                          void run(onAction, 'remove-member', { group: g.id, person: pid });
                        }}
                        className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:size-9"
                      >
                        <Icon name="user-minus" size={16} />
                      </button>
                    </li>
                  );
                })}
              </ul>
            </div>
          </section>
        )}
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* access-check                                                        */
/* ------------------------------------------------------------------ */

function AccessCheck({ t, title, subtitle, people = [], roles = [], permissions = [], grants = {}, groups = [], defaultPerson, defaultPermission }: P) {
  const [who, setWho] = useState(defaultPerson ?? people[0]?.id ?? '');
  const [what, setWhat] = useState(defaultPermission ?? permissions[0]?.id ?? '');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const p = people.find((x) => x.id === who);
  const perm = permissions.find((x) => x.id === what);
  const roleName = (rid?: string) => roles.find((r) => r.id === rid)?.name ?? '';
  const sources: { icon: IconName; label: string; ok: boolean }[] = p
    ? [
        { icon: 'id-card', label: fill(t.viaRole, { role: roleName(p.role) }), ok: (grants[p.role] ?? []).includes(what) },
        ...groups
          .filter((g) => g.members.includes(p.id) && g.role)
          .map((g) => ({ icon: 'users' as IconName, label: `${fill(t.viaGroup, { group: g.name })} · ${roleName(g.role)}`, ok: (grants[g.role!] ?? []).includes(what) })),
      ]
    : [];
  const ok = sources.some((s) => s.ok);
  const sentence = p && perm ? fill(ok ? t.yes : t.no, { name: p.name.split(' ')[0], perm: perm.label.toLowerCase() }) : '';
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <p className="mt-2 max-w-[44rem] text-pretty text-[13.5px] text-muted-foreground">{t.checkText}</p>
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[22rem_minmax(0,1fr)] lg:items-start">
        <Card className="kitm-up">
          <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
            <NativeSelect label={t.who} value={who} onChange={(e) => setWho(e.target.value)} options={people.map((x) => ({ value: x.id, label: `${x.name} · ${roleName(x.role)}` }))} />
            <NativeSelect label={t.what} value={what} onChange={(e) => setWhat(e.target.value)} options={permissions.map((x) => ({ value: x.id, label: `${x.group} · ${x.label}` }))} />
          </div>
        </Card>
        <section aria-live="polite" className="kitm-up [--kitm-d:100ms] min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div key={`${who}-${what}`} className="kitm-in">
            <div className={cn('flex items-center gap-4 border-b border-border p-4 sm:p-6', ok ? 'bg-[color-mix(in_oklab,var(--success)_8%,var(--card))]' : 'bg-[color-mix(in_oklab,var(--destructive)_6%,var(--card))]')}>
              <span className={cn('grid size-12 shrink-0 place-items-center rounded-full', ok ? 'bg-[color-mix(in_oklab,var(--success)_18%,transparent)] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)] text-destructive')}>
                <Icon name={ok ? 'check' : 'ban'} size={22} strokeWidth={2.2} className="kitm-pop" />
              </span>
              <div className="min-w-0">
                <p className="text-balance text-[18px] font-semibold tracking-[-0.015em] text-foreground">{sentence}</p>
                {perm?.sensitive && <p className="mt-1 text-[12.5px] font-medium text-warning-foreground">{t.sensitive}</p>}
              </div>
            </div>
            <div className="p-4 sm:p-6">
              <p className="text-[12.5px] font-medium text-muted-foreground">{t.because}</p>
              {/* The path from person to answer: each source on its own line, the line lights where it grants */}
              <ol className="relative mt-3 grid gap-3 ps-6">
                <span aria-hidden className="absolute bottom-3 start-[7px] top-3 w-px bg-border" />
                {sources.map((s, i) => (
                  <li key={s.label} className="relative flex items-center gap-3 rounded-lg border border-border bg-surface p-3" style={{ '--kitm-d': `${i * 80}ms` } as React.CSSProperties}>
                    <span aria-hidden className={cn('absolute -start-6 top-1/2 size-[15px] -translate-y-1/2 rounded-full border-2 border-card', s.ok ? 'bg-success' : 'bg-border-strong')} />
                    <Icon name={s.icon} size={16} className="shrink-0 text-muted-foreground" />
                    <span className="min-w-0 flex-1 text-[13.5px] text-foreground">{s.label}</span>
                    <span className={cn('shrink-0 text-[12.5px] font-medium', s.ok ? 'text-success-foreground' : 'text-subtle-foreground')}>{s.ok ? t.grants : t.lacks}</span>
                  </li>
                ))}
                {sources.length === 1 && <li className="text-[12.5px] text-subtle-foreground">{t.noGroups}</li>}
              </ol>
              {!ok && p && <p className="mt-5 text-pretty text-[13px] leading-relaxed text-muted-foreground">{fill(t.fix, { name: p.name.split(' ')[0] })}</p>}
            </div>
          </div>
        </section>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* Permissions                                                         */
/* ------------------------------------------------------------------ */

/**
 * Roles and permissions for apps: a matrix of roles and rights, an editor
 * for custom roles with a live summary, access requests with a time limit,
 * groups that add roles, and a check that explains who may do what.
 *   <Permissions variant="role-matrix" title="Roles" roles={…} permissions={…} grants={…} />
 */
export function Permissions({ variant = 'role-matrix', sidebar, labels, className, now = '2026-10-19T14:30', ...props }: PermissionsProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const p: P = { ...props, t, now, locale };
  const body = (() => {
    switch (variant) {
      case 'role-editor':
        return <RoleEditor {...p} />;
      case 'access-requests':
        return <AccessRequests {...p} />;
      case 'groups':
        return <Groups {...p} />;
      case 'access-check':
        return <AccessCheck {...p} />;
      default:
        return <RoleMatrix {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <KitmStyle />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <KitmStyle />
      {body}
    </Sidebar>
  );
}

Frequent questions

How do I install Roles and permissions?

With aiw add @ai-website/permissions. The command puts components/ui/permissions.tsx into your project, the code is yours from then on. It brings along button, icon, input, select, switch and sidebar. It needs no further npm packages.

Which variants does Roles and permissions have?

5 variants, Role matrix, Role editor, Access requests, Groups and Access check. You pick one with the variant prop, for example variant="role-editor".

Can I translate the texts of Roles and permissions?

Yes. Roles and permissions carries 76 texts of its own in English and German, such as “Permission”, “Roles” and “Role”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Roles and permissions?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Roles and permissions with aiw_components and install it with aiw_add @ai-website/permissions. Name the variant in your prompt, for example variant="role-matrix", and the assistant writes your content straight into the props.

Can I adjust Roles and permissions without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Permissions code to copy.

Often searched

Jump to

Copied to the clipboard