@dowel-ui/react 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/dist/components/ai-extraction-review/ai-extraction-review.d.ts +52 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.d.ts.map +1 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.js +249 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.js.map +1 -0
- package/dist/components/ai-extraction-review/extraction-model.d.ts +94 -0
- package/dist/components/ai-extraction-review/extraction-model.d.ts.map +1 -0
- package/dist/components/ai-extraction-review/extraction-model.js +91 -0
- package/dist/components/ai-extraction-review/extraction-model.js.map +1 -0
- package/dist/components/ai-extraction-review/index.d.ts +3 -0
- package/dist/components/ai-extraction-review/index.js +3 -0
- package/dist/components/ai-extraction-review/meta.js +17 -0
- package/dist/components/ai-extraction-review/meta.js.map +1 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.d.ts +72 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.d.ts.map +1 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.js +126 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.js.map +1 -0
- package/dist/components/ai-suggested-value/index.d.ts +2 -0
- package/dist/components/ai-suggested-value/index.js +2 -0
- package/dist/components/ai-suggested-value/meta.js +17 -0
- package/dist/components/ai-suggested-value/meta.js.map +1 -0
- package/dist/components/alert/alert.d.ts +1 -1
- package/dist/components/avatar/avatar.d.ts +1 -1
- package/dist/components/badge/badge.d.ts +1 -1
- package/dist/components/button/button.d.ts +1 -1
- package/dist/components/confirm-typed/confirm-typed.d.ts +47 -0
- package/dist/components/confirm-typed/confirm-typed.d.ts.map +1 -0
- package/dist/components/confirm-typed/confirm-typed.js +106 -0
- package/dist/components/confirm-typed/confirm-typed.js.map +1 -0
- package/dist/components/confirm-typed/index.d.ts +2 -0
- package/dist/components/confirm-typed/index.js +2 -0
- package/dist/components/confirm-typed/meta.js +17 -0
- package/dist/components/confirm-typed/meta.js.map +1 -0
- package/dist/components/cron-editor/cron-editor.d.ts +36 -0
- package/dist/components/cron-editor/cron-editor.d.ts.map +1 -0
- package/dist/components/cron-editor/cron-editor.js +493 -0
- package/dist/components/cron-editor/cron-editor.js.map +1 -0
- package/dist/components/cron-editor/cron-expression.d.ts +82 -0
- package/dist/components/cron-editor/cron-expression.d.ts.map +1 -0
- package/dist/components/cron-editor/cron-expression.js +352 -0
- package/dist/components/cron-editor/cron-expression.js.map +1 -0
- package/dist/components/cron-editor/index.d.ts +3 -0
- package/dist/components/cron-editor/index.js +3 -0
- package/dist/components/cron-editor/meta.js +17 -0
- package/dist/components/cron-editor/meta.js.map +1 -0
- package/dist/components/dns-record/dns-record.d.ts +61 -0
- package/dist/components/dns-record/dns-record.d.ts.map +1 -0
- package/dist/components/dns-record/dns-record.js +227 -0
- package/dist/components/dns-record/dns-record.js.map +1 -0
- package/dist/components/dns-record/index.d.ts +2 -0
- package/dist/components/dns-record/index.js +2 -0
- package/dist/components/dns-record/meta.js +17 -0
- package/dist/components/dns-record/meta.js.map +1 -0
- package/dist/components/permission-matrix/index.d.ts +3 -0
- package/dist/components/permission-matrix/index.js +3 -0
- package/dist/components/permission-matrix/meta.js +17 -0
- package/dist/components/permission-matrix/meta.js.map +1 -0
- package/dist/components/permission-matrix/permission-matrix.d.ts +46 -0
- package/dist/components/permission-matrix/permission-matrix.d.ts.map +1 -0
- package/dist/components/permission-matrix/permission-matrix.js +199 -0
- package/dist/components/permission-matrix/permission-matrix.js.map +1 -0
- package/dist/components/permission-matrix/permission-model.d.ts +53 -0
- package/dist/components/permission-matrix/permission-model.d.ts.map +1 -0
- package/dist/components/permission-matrix/permission-model.js +64 -0
- package/dist/components/permission-matrix/permission-model.js.map +1 -0
- package/dist/components/progress/progress.d.ts +1 -1
- package/dist/components/secret-field/index.d.ts +2 -0
- package/dist/components/secret-field/index.js +2 -0
- package/dist/components/secret-field/meta.js +17 -0
- package/dist/components/secret-field/meta.js.map +1 -0
- package/dist/components/secret-field/secret-field.d.ts +58 -0
- package/dist/components/secret-field/secret-field.d.ts.map +1 -0
- package/dist/components/secret-field/secret-field.js +168 -0
- package/dist/components/secret-field/secret-field.js.map +1 -0
- package/dist/components/session-expiry/index.d.ts +2 -0
- package/dist/components/session-expiry/index.js +2 -0
- package/dist/components/session-expiry/meta.js +17 -0
- package/dist/components/session-expiry/meta.js.map +1 -0
- package/dist/components/session-expiry/session-expiry.d.ts +64 -0
- package/dist/components/session-expiry/session-expiry.d.ts.map +1 -0
- package/dist/components/session-expiry/session-expiry.js +185 -0
- package/dist/components/session-expiry/session-expiry.js.map +1 -0
- package/dist/components/shortcut-recorder/index.d.ts +3 -0
- package/dist/components/shortcut-recorder/index.js +3 -0
- package/dist/components/shortcut-recorder/meta.js +17 -0
- package/dist/components/shortcut-recorder/meta.js.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-model.d.ts +67 -0
- package/dist/components/shortcut-recorder/shortcut-model.d.ts.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-model.js +168 -0
- package/dist/components/shortcut-recorder/shortcut-model.js.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.d.ts +47 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.d.ts.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.js +212 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.js.map +1 -0
- package/dist/components/spinner/spinner.d.ts +1 -1
- package/dist/components/sync-status/index.d.ts +2 -0
- package/dist/components/sync-status/index.js +2 -0
- package/dist/components/sync-status/meta.js +17 -0
- package/dist/components/sync-status/meta.js.map +1 -0
- package/dist/components/sync-status/sync-status.d.ts +65 -0
- package/dist/components/sync-status/sync-status.d.ts.map +1 -0
- package/dist/components/sync-status/sync-status.js +103 -0
- package/dist/components/sync-status/sync-status.js.map +1 -0
- package/dist/components/toast/toast.d.ts +1 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +16 -2
- package/dist/registry/components.d.ts.map +1 -1
- package/dist/registry/components.js +73 -53
- package/dist/registry/components.js.map +1 -1
- package/package.json +4 -4
- package/src/components/ai-extraction-review/ai-extraction-review.tsx +492 -0
- package/src/components/ai-extraction-review/extraction-model.ts +159 -0
- package/src/components/ai-extraction-review/index.ts +13 -0
- package/src/components/ai-extraction-review/meta.ts +24 -0
- package/src/components/ai-suggested-value/ai-suggested-value.tsx +295 -0
- package/src/components/ai-suggested-value/index.ts +6 -0
- package/src/components/ai-suggested-value/meta.ts +24 -0
- package/src/components/confirm-typed/confirm-typed.tsx +191 -0
- package/src/components/confirm-typed/index.ts +1 -0
- package/src/components/confirm-typed/meta.ts +22 -0
- package/src/components/cron-editor/cron-editor.tsx +676 -0
- package/src/components/cron-editor/cron-expression.ts +552 -0
- package/src/components/cron-editor/index.ts +23 -0
- package/src/components/cron-editor/meta.ts +23 -0
- package/src/components/dns-record/dns-record.tsx +315 -0
- package/src/components/dns-record/index.ts +7 -0
- package/src/components/dns-record/meta.ts +21 -0
- package/src/components/permission-matrix/index.ts +14 -0
- package/src/components/permission-matrix/meta.ts +23 -0
- package/src/components/permission-matrix/permission-matrix.tsx +368 -0
- package/src/components/permission-matrix/permission-model.ts +120 -0
- package/src/components/secret-field/index.ts +6 -0
- package/src/components/secret-field/meta.ts +21 -0
- package/src/components/secret-field/secret-field.tsx +284 -0
- package/src/components/session-expiry/index.ts +9 -0
- package/src/components/session-expiry/meta.ts +23 -0
- package/src/components/session-expiry/session-expiry.tsx +321 -0
- package/src/components/shortcut-recorder/index.ts +19 -0
- package/src/components/shortcut-recorder/meta.ts +22 -0
- package/src/components/shortcut-recorder/shortcut-model.ts +204 -0
- package/src/components/shortcut-recorder/shortcut-recorder.tsx +336 -0
- package/src/components/sync-status/index.ts +9 -0
- package/src/components/sync-status/meta.ts +21 -0
- package/src/components/sync-status/sync-status.tsx +217 -0
- package/src/index.ts +10 -0
- package/src/registry/components.ts +20 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useId,
|
|
5
|
+
useMemo,
|
|
6
|
+
useState,
|
|
7
|
+
type ComponentPropsWithRef,
|
|
8
|
+
type KeyboardEvent,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
import { Checkbox } from "@/components/checkbox";
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
cellState,
|
|
16
|
+
countGranted,
|
|
17
|
+
groupPermissions,
|
|
18
|
+
groupState,
|
|
19
|
+
type Grants,
|
|
20
|
+
type Permission,
|
|
21
|
+
type Role,
|
|
22
|
+
} from "./permission-model";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Roles across, permissions down, a checkbox at every crossing.
|
|
26
|
+
*
|
|
27
|
+
* Every admin panel has one and every admin panel builds it, because the
|
|
28
|
+
* hard part is not the checkboxes. It is that a role can inherit from
|
|
29
|
+
* another, so a box can be ticked without anyone having ticked it; that an
|
|
30
|
+
* Owner has everything and none of it can be unticked; that a section of
|
|
31
|
+
* eight permissions wants one control for all of them; and that sixty
|
|
32
|
+
* checkboxes are sixty tab stops unless something is done about it.
|
|
33
|
+
*
|
|
34
|
+
* Something is done about it. This is a grid in the WAI-ARIA sense — one tab
|
|
35
|
+
* stop, arrow keys between cells, Home and End along a row — which is the
|
|
36
|
+
* right call here and was the wrong one for the diff viewer: a diff is read,
|
|
37
|
+
* a matrix is operated. Every checkbox is named by both coordinates, "Delete
|
|
38
|
+
* projects for Editor", so a reader arriving by arrow key knows where they
|
|
39
|
+
* are without re-reading the headers.
|
|
40
|
+
*
|
|
41
|
+
* A grant that arrived by inheritance is shown as a checked box that cannot
|
|
42
|
+
* be unchecked here, with the role it came from said beside it and in the
|
|
43
|
+
* box's description. A disabled control would be the obvious rendering, and
|
|
44
|
+
* it would take the box out of the tab order and the arrow-key path, so a
|
|
45
|
+
* keyboard user would step over the one cell whose state needs explaining.
|
|
46
|
+
*
|
|
47
|
+
* Changes are reported, never applied. A group toggle reports every
|
|
48
|
+
* permission it touched in one call, so an application can save it as one
|
|
49
|
+
* change rather than eight.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export interface PermissionMatrixProps extends Omit<
|
|
53
|
+
ComponentPropsWithRef<"table">,
|
|
54
|
+
"onChange"
|
|
55
|
+
> {
|
|
56
|
+
/** Names the matrix: "Permissions for Acme". */
|
|
57
|
+
label: string;
|
|
58
|
+
roles: Role[];
|
|
59
|
+
permissions: Permission[];
|
|
60
|
+
grants: Grants;
|
|
61
|
+
/**
|
|
62
|
+
* A change to make. Several ids arrive together when a group toggle was
|
|
63
|
+
* used. Omit to render the matrix as a record.
|
|
64
|
+
*/
|
|
65
|
+
onChange?: (roleId: string, permissionIds: string[], granted: boolean) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type MatrixRow =
|
|
69
|
+
| { kind: "group"; label: string; permissions: Permission[] }
|
|
70
|
+
| { kind: "permission"; permission: Permission };
|
|
71
|
+
|
|
72
|
+
const ARROWS: Record<string, [number, number]> = {
|
|
73
|
+
ArrowUp: [-1, 0],
|
|
74
|
+
ArrowDown: [1, 0],
|
|
75
|
+
ArrowLeft: [0, -1],
|
|
76
|
+
ArrowRight: [0, 1],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function PermissionMatrix({
|
|
80
|
+
className,
|
|
81
|
+
label,
|
|
82
|
+
roles,
|
|
83
|
+
permissions,
|
|
84
|
+
grants,
|
|
85
|
+
onChange,
|
|
86
|
+
...props
|
|
87
|
+
}: PermissionMatrixProps) {
|
|
88
|
+
const uid = useId();
|
|
89
|
+
|
|
90
|
+
const rows = useMemo<MatrixRow[]>(
|
|
91
|
+
() =>
|
|
92
|
+
groupPermissions(permissions).flatMap((group): MatrixRow[] => [
|
|
93
|
+
...(group.group !== undefined
|
|
94
|
+
? [{ kind: "group" as const, label: group.group, permissions: group.permissions }]
|
|
95
|
+
: []),
|
|
96
|
+
...group.permissions.map((permission) => ({ kind: "permission" as const, permission })),
|
|
97
|
+
]),
|
|
98
|
+
[permissions],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// One tab stop. The cell that last had focus is the one Tab returns to.
|
|
102
|
+
const [active, setActive] = useState<[number, number]>([0, 0]);
|
|
103
|
+
|
|
104
|
+
const onKeyDown = (event: KeyboardEvent<HTMLTableElement>) => {
|
|
105
|
+
const target = event.target as HTMLElement;
|
|
106
|
+
const row = Number(target.dataset.row);
|
|
107
|
+
const col = Number(target.dataset.col);
|
|
108
|
+
if (Number.isNaN(row) || Number.isNaN(col)) return;
|
|
109
|
+
|
|
110
|
+
let next: [number, number] | null = null;
|
|
111
|
+
const arrow = ARROWS[event.key];
|
|
112
|
+
if (arrow) {
|
|
113
|
+
next = [row + arrow[0], col + arrow[1]];
|
|
114
|
+
} else if (event.key === "Home") {
|
|
115
|
+
next = event.ctrlKey ? [0, 0] : [row, 0];
|
|
116
|
+
} else if (event.key === "End") {
|
|
117
|
+
next = event.ctrlKey ? [rows.length - 1, roles.length - 1] : [row, roles.length - 1];
|
|
118
|
+
}
|
|
119
|
+
if (!next) return;
|
|
120
|
+
|
|
121
|
+
const [nextRow, nextCol] = next;
|
|
122
|
+
if (nextRow < 0 || nextRow >= rows.length || nextCol < 0 || nextCol >= roles.length) {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
const cell = event.currentTarget.querySelector<HTMLElement>(
|
|
128
|
+
`[data-row="${String(nextRow)}"][data-col="${String(nextCol)}"]`,
|
|
129
|
+
);
|
|
130
|
+
cell?.focus();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="relative w-full overflow-x-auto">
|
|
135
|
+
<table
|
|
136
|
+
data-slot="permission-matrix"
|
|
137
|
+
role="grid"
|
|
138
|
+
aria-label={label}
|
|
139
|
+
aria-readonly={onChange ? undefined : true}
|
|
140
|
+
onKeyDown={onKeyDown}
|
|
141
|
+
className={cn("w-full border-collapse text-sm", className)}
|
|
142
|
+
{...props}
|
|
143
|
+
>
|
|
144
|
+
<thead>
|
|
145
|
+
<tr role="row" className="border-b border-border">
|
|
146
|
+
<th
|
|
147
|
+
role="columnheader"
|
|
148
|
+
scope="col"
|
|
149
|
+
className="px-3 py-2 text-left text-xs font-medium"
|
|
150
|
+
>
|
|
151
|
+
Permission
|
|
152
|
+
</th>
|
|
153
|
+
{roles.map((role) => {
|
|
154
|
+
const granted = countGranted(roles, grants, role, permissions);
|
|
155
|
+
return (
|
|
156
|
+
<th
|
|
157
|
+
key={role.id}
|
|
158
|
+
role="columnheader"
|
|
159
|
+
scope="col"
|
|
160
|
+
className="px-3 py-2 text-center text-xs font-medium"
|
|
161
|
+
>
|
|
162
|
+
{role.label}
|
|
163
|
+
<span className="block font-normal text-muted-foreground">
|
|
164
|
+
{role.locked ? (
|
|
165
|
+
"all, locked"
|
|
166
|
+
) : (
|
|
167
|
+
<>
|
|
168
|
+
{String(granted)}/{String(permissions.length)}
|
|
169
|
+
<span className="sr-only"> granted</span>
|
|
170
|
+
</>
|
|
171
|
+
)}
|
|
172
|
+
</span>
|
|
173
|
+
</th>
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
176
|
+
</tr>
|
|
177
|
+
</thead>
|
|
178
|
+
<tbody>
|
|
179
|
+
{rows.map((row, rowIndex) =>
|
|
180
|
+
row.kind === "group" ? (
|
|
181
|
+
<tr
|
|
182
|
+
key={`group-${row.label}`}
|
|
183
|
+
role="row"
|
|
184
|
+
className="border-b border-border bg-muted/40"
|
|
185
|
+
>
|
|
186
|
+
<th
|
|
187
|
+
role="rowheader"
|
|
188
|
+
scope="row"
|
|
189
|
+
className="px-3 py-1.5 text-left text-xs font-medium"
|
|
190
|
+
>
|
|
191
|
+
{row.label}
|
|
192
|
+
<span className="sr-only">, all</span>
|
|
193
|
+
</th>
|
|
194
|
+
{roles.map((role, colIndex) => (
|
|
195
|
+
<GroupCell
|
|
196
|
+
key={role.id}
|
|
197
|
+
role={role}
|
|
198
|
+
roles={roles}
|
|
199
|
+
grants={grants}
|
|
200
|
+
group={row.label}
|
|
201
|
+
permissions={row.permissions}
|
|
202
|
+
position={[rowIndex, colIndex]}
|
|
203
|
+
active={active[0] === rowIndex && active[1] === colIndex}
|
|
204
|
+
onActivate={setActive}
|
|
205
|
+
onChange={onChange}
|
|
206
|
+
/>
|
|
207
|
+
))}
|
|
208
|
+
</tr>
|
|
209
|
+
) : (
|
|
210
|
+
<tr
|
|
211
|
+
key={row.permission.id}
|
|
212
|
+
role="row"
|
|
213
|
+
className="border-b border-border last:border-0 hover:bg-muted/30"
|
|
214
|
+
>
|
|
215
|
+
<th
|
|
216
|
+
role="rowheader"
|
|
217
|
+
scope="row"
|
|
218
|
+
className={cn(
|
|
219
|
+
"px-3 py-2 text-left font-normal",
|
|
220
|
+
row.permission.group !== undefined && "pl-6",
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
{row.permission.label}
|
|
224
|
+
{row.permission.description ? (
|
|
225
|
+
<span className="block text-xs text-muted-foreground">
|
|
226
|
+
{row.permission.description}
|
|
227
|
+
</span>
|
|
228
|
+
) : null}
|
|
229
|
+
</th>
|
|
230
|
+
{roles.map((role, colIndex) => (
|
|
231
|
+
<PermissionCell
|
|
232
|
+
key={role.id}
|
|
233
|
+
id={`${uid}-${role.id}-${row.permission.id}`}
|
|
234
|
+
role={role}
|
|
235
|
+
roles={roles}
|
|
236
|
+
grants={grants}
|
|
237
|
+
permission={row.permission}
|
|
238
|
+
position={[rowIndex, colIndex]}
|
|
239
|
+
active={active[0] === rowIndex && active[1] === colIndex}
|
|
240
|
+
onActivate={setActive}
|
|
241
|
+
onChange={onChange}
|
|
242
|
+
/>
|
|
243
|
+
))}
|
|
244
|
+
</tr>
|
|
245
|
+
),
|
|
246
|
+
)}
|
|
247
|
+
</tbody>
|
|
248
|
+
</table>
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface CellProps {
|
|
254
|
+
role: Role;
|
|
255
|
+
roles: Role[];
|
|
256
|
+
grants: Grants;
|
|
257
|
+
position: [number, number];
|
|
258
|
+
active: boolean;
|
|
259
|
+
onActivate: (position: [number, number]) => void;
|
|
260
|
+
onChange?: (roleId: string, permissionIds: string[], granted: boolean) => void;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function PermissionCell({
|
|
264
|
+
id,
|
|
265
|
+
role,
|
|
266
|
+
roles,
|
|
267
|
+
grants,
|
|
268
|
+
permission,
|
|
269
|
+
position,
|
|
270
|
+
active,
|
|
271
|
+
onActivate,
|
|
272
|
+
onChange,
|
|
273
|
+
}: CellProps & { id: string; permission: Permission }) {
|
|
274
|
+
const state = cellState(roles, grants, role, permission.id);
|
|
275
|
+
const fixed = state.fixed || !onChange;
|
|
276
|
+
const noteId = `${id}-note`;
|
|
277
|
+
|
|
278
|
+
const note = role.locked
|
|
279
|
+
? `${role.label} has every permission; this cannot be changed.`
|
|
280
|
+
: state.inheritedFrom
|
|
281
|
+
? `Inherited from ${state.inheritedFrom.label}.`
|
|
282
|
+
: null;
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<td
|
|
286
|
+
role="gridcell"
|
|
287
|
+
data-slot="permission-cell"
|
|
288
|
+
data-granted={state.granted || undefined}
|
|
289
|
+
data-inherited={state.inheritedFrom ? "" : undefined}
|
|
290
|
+
className="px-3 py-2 text-center align-middle"
|
|
291
|
+
>
|
|
292
|
+
<Checkbox
|
|
293
|
+
id={id}
|
|
294
|
+
checked={state.granted}
|
|
295
|
+
aria-label={`${permission.label} for ${role.label}`}
|
|
296
|
+
aria-describedby={note ? noteId : undefined}
|
|
297
|
+
// Fixed cells stay focusable, so the arrow-key path and the tab order
|
|
298
|
+
// do not step over the one cell whose state needs explaining.
|
|
299
|
+
aria-disabled={fixed || undefined}
|
|
300
|
+
tabIndex={active ? 0 : -1}
|
|
301
|
+
data-row={position[0]}
|
|
302
|
+
data-col={position[1]}
|
|
303
|
+
className={cn(fixed && "opacity-55")}
|
|
304
|
+
onFocus={() => {
|
|
305
|
+
onActivate(position);
|
|
306
|
+
}}
|
|
307
|
+
onCheckedChange={(next) => {
|
|
308
|
+
if (fixed) return;
|
|
309
|
+
onChange?.(role.id, [permission.id], next === true);
|
|
310
|
+
}}
|
|
311
|
+
/>
|
|
312
|
+
{state.inheritedFrom ? (
|
|
313
|
+
<span id={noteId} className="mt-0.5 block text-2xs text-muted-foreground">
|
|
314
|
+
via {state.inheritedFrom.label}
|
|
315
|
+
</span>
|
|
316
|
+
) : note ? (
|
|
317
|
+
<span id={noteId} className="sr-only">
|
|
318
|
+
{note}
|
|
319
|
+
</span>
|
|
320
|
+
) : null}
|
|
321
|
+
</td>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function GroupCell({
|
|
326
|
+
role,
|
|
327
|
+
roles,
|
|
328
|
+
grants,
|
|
329
|
+
group,
|
|
330
|
+
permissions,
|
|
331
|
+
position,
|
|
332
|
+
active,
|
|
333
|
+
onActivate,
|
|
334
|
+
onChange,
|
|
335
|
+
}: CellProps & { group: string; permissions: Permission[] }) {
|
|
336
|
+
const ids = permissions.map((permission) => permission.id);
|
|
337
|
+
const state = groupState(roles, grants, role, ids);
|
|
338
|
+
// Only what can actually change here: inherited grants stay either way.
|
|
339
|
+
const changeable = permissions
|
|
340
|
+
.filter((permission) => !cellState(roles, grants, role, permission.id).fixed)
|
|
341
|
+
.map((permission) => permission.id);
|
|
342
|
+
const fixed = role.locked || !onChange || changeable.length === 0;
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<td
|
|
346
|
+
role="gridcell"
|
|
347
|
+
data-slot="permission-group-cell"
|
|
348
|
+
className="px-3 py-1.5 text-center align-middle"
|
|
349
|
+
>
|
|
350
|
+
<Checkbox
|
|
351
|
+
checked={state === "all" ? true : state === "some" ? "indeterminate" : false}
|
|
352
|
+
aria-label={`All ${group} for ${role.label}`}
|
|
353
|
+
aria-disabled={fixed || undefined}
|
|
354
|
+
tabIndex={active ? 0 : -1}
|
|
355
|
+
data-row={position[0]}
|
|
356
|
+
data-col={position[1]}
|
|
357
|
+
className={cn(fixed && "opacity-55")}
|
|
358
|
+
onFocus={() => {
|
|
359
|
+
onActivate(position);
|
|
360
|
+
}}
|
|
361
|
+
onCheckedChange={(next) => {
|
|
362
|
+
if (fixed) return;
|
|
363
|
+
onChange?.(role.id, changeable, next === true);
|
|
364
|
+
}}
|
|
365
|
+
/>
|
|
366
|
+
</td>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roles, permissions and who has what — resolved, so the grid only draws.
|
|
3
|
+
*
|
|
4
|
+
* Pure, so inheritance and the group states are tested without rendering
|
|
5
|
+
* anything, and so a server can answer "may this role do this" from the same
|
|
6
|
+
* grants the matrix edits, with the same rule for inheritance.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface Permission {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
/** Permissions with the same group share a heading and an all-or-none toggle. */
|
|
14
|
+
group?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Role {
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
/** Roles whose grants this one includes. Resolved transitively. */
|
|
21
|
+
inherits?: string[];
|
|
22
|
+
/** Has every permission, and none of them can be changed. An Owner. */
|
|
23
|
+
locked?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Explicit grants, by role id. Inherited ones are not listed here. */
|
|
27
|
+
export type Grants = Record<string, string[]>;
|
|
28
|
+
|
|
29
|
+
export interface CellState {
|
|
30
|
+
/** Whether the role can do it, by any route. */
|
|
31
|
+
granted: boolean;
|
|
32
|
+
/** The role it comes from, when it is not explicit here. */
|
|
33
|
+
inheritedFrom: Role | null;
|
|
34
|
+
/** Cannot be changed here — locked role, or inherited. */
|
|
35
|
+
fixed: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type GroupState = "all" | "some" | "none";
|
|
39
|
+
|
|
40
|
+
function roleById(roles: Role[], id: string): Role | undefined {
|
|
41
|
+
return roles.find((role) => role.id === id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The role a permission is inherited from, or null. Depth-first through
|
|
46
|
+
* `inherits`, cycle-safe, so two roles that inherit each other resolve rather
|
|
47
|
+
* than recurse forever — a configuration a form can produce by accident.
|
|
48
|
+
*/
|
|
49
|
+
export function inheritedFrom(
|
|
50
|
+
roles: Role[],
|
|
51
|
+
grants: Grants,
|
|
52
|
+
role: Role,
|
|
53
|
+
permissionId: string,
|
|
54
|
+
seen: Set<string> = new Set(),
|
|
55
|
+
): Role | null {
|
|
56
|
+
seen.add(role.id);
|
|
57
|
+
for (const parentId of role.inherits ?? []) {
|
|
58
|
+
if (seen.has(parentId)) continue;
|
|
59
|
+
const parent = roleById(roles, parentId);
|
|
60
|
+
if (!parent) continue;
|
|
61
|
+
if (parent.locked || (grants[parent.id] ?? []).includes(permissionId)) return parent;
|
|
62
|
+
const further = inheritedFrom(roles, grants, parent, permissionId, seen);
|
|
63
|
+
if (further) return further;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function cellState(
|
|
69
|
+
roles: Role[],
|
|
70
|
+
grants: Grants,
|
|
71
|
+
role: Role,
|
|
72
|
+
permissionId: string,
|
|
73
|
+
): CellState {
|
|
74
|
+
if (role.locked) return { granted: true, inheritedFrom: null, fixed: true };
|
|
75
|
+
const explicit = (grants[role.id] ?? []).includes(permissionId);
|
|
76
|
+
if (explicit) return { granted: true, inheritedFrom: null, fixed: false };
|
|
77
|
+
const from = inheritedFrom(roles, grants, role, permissionId);
|
|
78
|
+
return { granted: from !== null, inheritedFrom: from, fixed: from !== null };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function groupState(
|
|
82
|
+
roles: Role[],
|
|
83
|
+
grants: Grants,
|
|
84
|
+
role: Role,
|
|
85
|
+
permissionIds: string[],
|
|
86
|
+
): GroupState {
|
|
87
|
+
const granted = permissionIds.filter(
|
|
88
|
+
(id) => cellState(roles, grants, role, id).granted,
|
|
89
|
+
).length;
|
|
90
|
+
if (granted === 0) return "none";
|
|
91
|
+
return granted === permissionIds.length ? "all" : "some";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function countGranted(
|
|
95
|
+
roles: Role[],
|
|
96
|
+
grants: Grants,
|
|
97
|
+
role: Role,
|
|
98
|
+
permissions: Permission[],
|
|
99
|
+
): number {
|
|
100
|
+
return permissions.filter(
|
|
101
|
+
(permission) => cellState(roles, grants, role, permission.id).granted,
|
|
102
|
+
).length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface PermissionGroup {
|
|
106
|
+
/** Undefined for permissions declared without one. */
|
|
107
|
+
group: string | undefined;
|
|
108
|
+
permissions: Permission[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Permissions by group, in the order the groups first appear. */
|
|
112
|
+
export function groupPermissions(permissions: Permission[]): PermissionGroup[] {
|
|
113
|
+
const groups: PermissionGroup[] = [];
|
|
114
|
+
for (const permission of permissions) {
|
|
115
|
+
const existing = groups.find((candidate) => candidate.group === permission.group);
|
|
116
|
+
if (existing) existing.permissions.push(permission);
|
|
117
|
+
else groups.push({ group: permission.group, permissions: [permission] });
|
|
118
|
+
}
|
|
119
|
+
return groups;
|
|
120
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineMeta } from "@/registry/schema";
|
|
2
|
+
|
|
3
|
+
export const meta = defineMeta({
|
|
4
|
+
name: "secret-field",
|
|
5
|
+
title: "Secret Field",
|
|
6
|
+
description: "An API key in its three states: shown once, hidden but revealable, and gone.",
|
|
7
|
+
category: "form",
|
|
8
|
+
status: "stable",
|
|
9
|
+
dependencies: [],
|
|
10
|
+
registryDependencies: ["button", "input"],
|
|
11
|
+
files: ["secret-field.tsx"],
|
|
12
|
+
a11y:
|
|
13
|
+
"The value sits in a read-only input rather than a disabled one, so it can be focused, " +
|
|
14
|
+
"selected and read, and focusing a shown value selects all of it for copying by hand. The " +
|
|
15
|
+
"field's state — shown once, hidden, revealed, gone — is a sentence attached as its " +
|
|
16
|
+
"description, never a colour or an icon. Copying is announced through a status region that " +
|
|
17
|
+
"exists before the first copy, and failure is announced too, with what to do instead, because " +
|
|
18
|
+
"the clipboard can be refused. Every button carries the field's label in its name, so a " +
|
|
19
|
+
"settings page with several keys has several distinct Copy buttons. Regenerating is confirmed " +
|
|
20
|
+
"inline in a named group that states the consequence, since it revokes the current key.",
|
|
21
|
+
});
|