Zum Inhalt springen
UI Kit
Accent

Rollen und Rechte

Zugriffs-Screens für Apps. Rollenmatrix, Rolleneditor mit Live-Zusammenfassung, Zugriffsanfragen mit Frist, Gruppen mit Rollen und eine Zugriffsprüfung, die sich erklärt.

Eine von 2 Components in Security und Zugriff

5 Variants

Variants

Rollenmatrix

Rechte nach Bereich gegen Rollen, feste Rollen gesperrt, geänderte Zellen markiert und Speicherleiste. Telefone zeigen eine Rolle mit Schaltern.

Im Studio öffnen

Rolleneditor

Name, eine Ausgangsrolle, Rechte je Bereich mit Alle-Auswahl und eine Zusammenfassung, die heikle Rechte beim Wählen zählt.

Im Studio öffnen

Zugriffsanfragen

Anfragen mit Begründung und wählbarer Frist, freigeben oder ablehnen, dazu eine Liste der Entscheidungen zum Rückgängigmachen.

Im Studio öffnen

Gruppen

Gruppen mit Mitgliedern und der Rolle, die sie geben, Personen per Suche hinzufügen und direkt entfernen.

Im Studio öffnen

Zugriffsprüfung

Person und Recht wählen und die Antwort mit ihrem Weg über Rolle und Gruppen sehen.

Im Studio öffnen

Details

Was Rollen und Rechte mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/permissions.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 76 eigene Texte

Props

Die Schnittstelle PermissionsProps aus components/ui/permissions.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    PermissionsVariant

    role-matrixrole-editoraccess-requestsgroupsaccess-check

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

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

  • titlePflicht
    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.

Eingebaute Texte

Diese Texte schreibt Rollen und Rechte selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 76 Texte anzeigen
permissionEnglisch PermissionDeutsch Recht
rolesEnglisch RolesDeutsch Rollen
roleEnglisch RoleDeutsch Rolle
peopleEnglisch {n} peopleDeutsch {n} Personen
personEnglisch 1 personDeutsch 1 Person
lockedEnglisch Fixed, cannot be changedDeutsch Fest, nicht änderbar
sensitiveEnglisch SensitiveDeutsch Heikel
grantEnglisch {perm} for {role}Deutsch {perm} für {role}
unsavedEnglisch {n} unsaved changesDeutsch {n} ungespeicherte Änderungen
oneEnglisch 1 unsaved changeDeutsch 1 ungespeicherte Änderung
discardEnglisch DiscardDeutsch Verwerfen
saveEnglisch Save changesDeutsch Änderungen speichern
savingEnglisch Saving…Deutsch Wird gespeichert…
savedEnglisch Roles saved. Changes apply on the next page load.Deutsch Rollen gespeichert. Änderungen gelten beim nächsten Laden der Seite.
showRoleEnglisch Show permissions ofDeutsch Rechte zeigen von
newRoleEnglisch New roleDeutsch Neue Rolle
editRoleEnglisch Edit roleDeutsch Rolle bearbeiten
nameEnglisch NameDeutsch Name
nameErrorEnglisch Give the role a name.Deutsch Gib der Rolle einen Namen.
descriptionEnglisch DescriptionDeutsch Beschreibung
descHintEnglisch One line about who gets this role.Deutsch Eine Zeile dazu, wer diese Rolle bekommt.
startFromEnglisch Start fromDeutsch Ausgehend von
blankEnglisch NothingDeutsch Nichts
selectAllEnglisch All in {group}Deutsch Alle in {group}
clearGroupEnglisch None in {group}Deutsch Keine in {group}
pickedEnglisch {n} of {total}Deutsch {n} von {total}
canDoEnglisch This role canDeutsch Diese Rolle kann
nothingYetEnglisch Nothing yet. Pick permissions on the left.Deutsch Noch nichts. Wähle links die Rechte.
sensitiveNEnglisch {n} sensitiveDeutsch {n} heikel
createRoleEnglisch Create roleDeutsch Rolle erstellen
saveRoleEnglisch Save roleDeutsch Rolle speichern
createdEnglisch Role {name} created.Deutsch Rolle {name} erstellt.
requestsEnglisch RequestsDeutsch Anfragen
historyEnglisch DecidedDeutsch Entschieden
noDecisionsEnglisch Your decisions show up here, with a way to undo them.Deutsch Deine Entscheidungen erscheinen hier, mit Rückgängig.
noRequestsEnglisch No open requests. New ones show up here.Deutsch Keine offenen Anfragen. Neue erscheinen hier.
asksEnglisch asks for {role}Deutsch bittet um {role}
onEnglisch on {resource}Deutsch für {resource}
forEnglisch Access forDeutsch Zugriff für
hours24Englisch 24 hoursDeutsch 24 Stunden
days7Englisch 7 daysDeutsch 7 Tage
permanentEnglisch Until removedDeutsch Bis auf Widerruf
approveEnglisch ApproveDeutsch Freigeben
denyEnglisch DeclineDeutsch Ablehnen
deniedEnglisch DeclinedDeutsch Abgelehnt
approvedUntilEnglisch Approved for {d}Deutsch Freigegeben für {d}
approvedForEnglisch Approved {role} for {name}.Deutsch {role} für {name} freigegeben.
deniedForEnglisch Declined {name}.Deutsch {name} abgelehnt.
undoEnglisch UndoDeutsch Rückgängig
groupsEnglisch GroupsDeutsch Gruppen
membersEnglisch MembersDeutsch Mitglieder
noRoleEnglisch No extra roleDeutsch Keine zusätzliche Rolle
addMemberEnglisch Add memberDeutsch Mitglied hinzufügen
addToEnglisch Add someone to {group}Deutsch Jemanden zu {group} hinzufügen
removeFromEnglisch Remove {name} from {group}Deutsch {name} aus {group} entfernen
searchEnglisch Search peopleDeutsch Personen suchen
nobodyEnglisch Everyone is in this group already.Deutsch Alle sind schon in dieser Gruppe.
noMatchEnglisch No one outside the group matches “{q}”.Deutsch Niemand außerhalb der Gruppe passt zu „{q}“.
openLabelEnglisch openDeutsch offen
addedEnglisch {name} added to {group}.Deutsch {name} zu {group} hinzugefügt.
removedEnglisch {name} removed from {group}.Deutsch {name} aus {group} entfernt.
groupRoleEnglisch Role of the groupDeutsch Rolle der Gruppe
checkEnglisch Check accessDeutsch Zugriff prüfen
checkTextEnglisch Pick a person and a permission. We show whether they have it and why.Deutsch Wähle Person und Recht. Wir zeigen, ob sie es hat und warum.
whoEnglisch PersonDeutsch Person
whatEnglisch PermissionDeutsch Recht
yesEnglisch {name} can {perm}Deutsch {name} darf {perm}
noEnglisch {name} cannot {perm}Deutsch {name} darf nicht {perm}
becauseEnglisch WhyDeutsch Warum
viaRoleEnglisch Role {role}Deutsch Rolle {role}
viaGroupEnglisch Group {group}Deutsch Gruppe {group}
grantsEnglisch grants itDeutsch gibt es
lacksEnglisch does not grant itDeutsch gibt es nicht
noGroupsEnglisch In no groupDeutsch In keiner Gruppe
fixEnglisch To give access, change the role or add {name} to a group with a role that has it.Deutsch Für Zugriff die Rolle ändern oder {name} in eine Gruppe mit passender Rolle aufnehmen.
ownerEnglisch OwnerDeutsch Inhaber

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Rollen und Rechte reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-controls, aria-disabled, aria-expanded, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen checkbox, radio, radiogroup, status, tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.171 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Rollen und Rechte?

Mit aiw add @ai-website/permissions. Der Befehl legt die Datei components/ui/permissions.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, input, select, switch und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Rollen und Rechte?

5 Varianten, Rollenmatrix, Rolleneditor, Zugriffsanfragen, Gruppen und Zugriffsprüfung. Du wählst sie mit der Prop variant, zum Beispiel variant="role-editor".

Kann ich die Texte von Rollen und Rechte übersetzen?

Ja. Rollen und Rechte bringt 76 eigene Texte auf Deutsch und Englisch mit, etwa „Recht“, „Rollen“ und „Rolle“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Rollen und Rechte einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Rollen und Rechte mit aiw_components und installieren sie mit aiw_add @ai-website/permissions. Nenn im Prompt die Variante, etwa variant="role-matrix", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Rollen und Rechte ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Permissions-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert