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
Variants
Details
What Roles and permissions brings along, read from the registry and the source.
- Category
- Security and access
- 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
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PermissionsProps interface from components/ui/permissions.tsx, with the comments from the code.
variantoptionalPermissionsVariantrole-matrixrole-editoraccess-requestsgroupsaccess-check
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
rolesoptionalRole[]permissionsoptionalPermission[]grantsoptionalRecord<string, string[]>Permission ids per role id
peopleoptionalPerson[]groupsoptionalGroup[]requestsoptionalAccessRequest[]editRoleoptionalstringrole-editor: the role that starts open, leave out for a new one
startFromoptionalstringrole-editor: for a new role, the role whose permissions it starts with
defaultPersonoptionalstringaccess-check: who and what start out picked
defaultPermissionoptionalstringonActionoptional(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
| Key | English | German |
|---|---|---|
| permission | English Permission | German Recht |
| roles | English Roles | German Rollen |
| role | English Role | German Rolle |
| people | English {n} people | German {n} Personen |
| person | English 1 person | German 1 Person |
| locked | English Fixed, cannot be changed | German Fest, nicht änderbar |
| sensitive | English Sensitive | German Heikel |
| grant | English {perm} for {role} | German {perm} für {role} |
| unsaved | English {n} unsaved changes | German {n} ungespeicherte Änderungen |
| one | English 1 unsaved change | German 1 ungespeicherte Änderung |
| discard | English Discard | German Verwerfen |
| save | English Save changes | German Änderungen speichern |
| saving | English Saving… | German Wird gespeichert… |
| saved | English Roles saved. Changes apply on the next page load. | German Rollen gespeichert. Änderungen gelten beim nächsten Laden der Seite. |
| showRole | English Show permissions of | German Rechte zeigen von |
| newRole | English New role | German Neue Rolle |
| editRole | English Edit role | German Rolle bearbeiten |
| name | English Name | German Name |
| nameError | English Give the role a name. | German Gib der Rolle einen Namen. |
| description | English Description | German Beschreibung |
| descHint | English One line about who gets this role. | German Eine Zeile dazu, wer diese Rolle bekommt. |
| startFrom | English Start from | German Ausgehend von |
| blank | English Nothing | German Nichts |
| selectAll | English All in {group} | German Alle in {group} |
| clearGroup | English None in {group} | German Keine in {group} |
| picked | English {n} of {total} | German {n} von {total} |
| canDo | English This role can | German Diese Rolle kann |
| nothingYet | English Nothing yet. Pick permissions on the left. | German Noch nichts. Wähle links die Rechte. |
| sensitiveN | English {n} sensitive | German {n} heikel |
| createRole | English Create role | German Rolle erstellen |
| saveRole | English Save role | German Rolle speichern |
| created | English Role {name} created. | German Rolle {name} erstellt. |
| requests | English Requests | German Anfragen |
| history | English Decided | German Entschieden |
| noDecisions | English Your decisions show up here, with a way to undo them. | German Deine Entscheidungen erscheinen hier, mit Rückgängig. |
| noRequests | English No open requests. New ones show up here. | German Keine offenen Anfragen. Neue erscheinen hier. |
| asks | English asks for {role} | German bittet um {role} |
| on | English on {resource} | German für {resource} |
| for | English Access for | German Zugriff für |
| hours24 | English 24 hours | German 24 Stunden |
| days7 | English 7 days | German 7 Tage |
| permanent | English Until removed | German Bis auf Widerruf |
| approve | English Approve | German Freigeben |
| deny | English Decline | German Ablehnen |
| denied | English Declined | German Abgelehnt |
| approvedUntil | English Approved for {d} | German Freigegeben für {d} |
| approvedFor | English Approved {role} for {name}. | German {role} für {name} freigegeben. |
| deniedFor | English Declined {name}. | German {name} abgelehnt. |
| undo | English Undo | German Rückgängig |
| groups | English Groups | German Gruppen |
| members | English Members | German Mitglieder |
| noRole | English No extra role | German Keine zusätzliche Rolle |
| addMember | English Add member | German Mitglied hinzufügen |
| addTo | English Add someone to {group} | German Jemanden zu {group} hinzufügen |
| removeFrom | English Remove {name} from {group} | German {name} aus {group} entfernen |
| search | English Search people | German Personen suchen |
| nobody | English Everyone is in this group already. | German Alle sind schon in dieser Gruppe. |
| noMatch | English No one outside the group matches “{q}”. | German Niemand außerhalb der Gruppe passt zu „{q}“. |
| openLabel | English open | German offen |
| added | English {name} added to {group}. | German {name} zu {group} hinzugefügt. |
| removed | English {name} removed from {group}. | German {name} aus {group} entfernt. |
| groupRole | English Role of the group | German Rolle der Gruppe |
| check | English Check access | German Zugriff prüfen |
| checkText | English 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. |
| who | English Person | German Person |
| what | English Permission | German Recht |
| yes | English {name} can {perm} | German {name} darf {perm} |
| no | English {name} cannot {perm} | German {name} darf nicht {perm} |
| because | English Why | German Warum |
| viaRole | English Role {role} | German Rolle {role} |
| viaGroup | English Group {group} | German Gruppe {group} |
| grants | English grants it | German gibt es |
| lacks | English does not grant it | German gibt es nicht |
| noGroups | English In no group | German In keiner Gruppe |
| fix | English 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. |
| owner | English Owner | German 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
'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.