@byline/admin 2.4.0 → 2.4.1

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.
Files changed (177) hide show
  1. package/dist/abilities.js +5 -24
  2. package/dist/index.js +8 -30
  3. package/dist/lib/assert-admin-actor.js +13 -74
  4. package/dist/lib/create-command.js +6 -16
  5. package/dist/modules/admin-account/commands.js +35 -24
  6. package/dist/modules/admin-account/components/change-password.d.ts +8 -0
  7. package/dist/modules/admin-account/components/change-password.js +192 -0
  8. package/dist/modules/admin-account/components/change-password.module.js +8 -0
  9. package/dist/modules/admin-account/components/change-password_module.css +27 -0
  10. package/dist/modules/admin-account/components/container.d.ts +29 -0
  11. package/dist/modules/admin-account/components/container.js +298 -0
  12. package/dist/modules/admin-account/components/container.module.js +28 -0
  13. package/dist/modules/admin-account/components/container_module.css +106 -0
  14. package/dist/modules/admin-account/components/update.d.ts +8 -0
  15. package/dist/modules/admin-account/components/update.js +207 -0
  16. package/dist/modules/admin-account/components/update.module.js +8 -0
  17. package/dist/modules/admin-account/components/update_module.css +27 -0
  18. package/dist/modules/admin-account/errors.js +14 -45
  19. package/dist/modules/admin-account/index.js +4 -34
  20. package/dist/modules/admin-account/schemas.js +25 -59
  21. package/dist/modules/admin-account/service.js +56 -61
  22. package/dist/modules/admin-permissions/abilities.js +6 -24
  23. package/dist/modules/admin-permissions/commands.js +42 -28
  24. package/dist/modules/admin-permissions/components/inspector.d.ts +4 -0
  25. package/dist/modules/admin-permissions/components/inspector.js +284 -0
  26. package/dist/modules/admin-permissions/components/inspector.module.js +56 -0
  27. package/dist/modules/admin-permissions/components/inspector_module.css +238 -0
  28. package/dist/modules/admin-permissions/dto.js +3 -16
  29. package/dist/modules/admin-permissions/errors.js +14 -27
  30. package/dist/modules/admin-permissions/index.js +6 -26
  31. package/dist/modules/admin-permissions/repository.js +1 -8
  32. package/dist/modules/admin-permissions/schemas.js +33 -70
  33. package/dist/modules/admin-permissions/service.js +88 -92
  34. package/dist/modules/admin-roles/abilities.js +8 -30
  35. package/dist/modules/admin-roles/commands.js +89 -55
  36. package/dist/modules/admin-roles/components/create.d.ts +7 -0
  37. package/dist/modules/admin-roles/components/create.js +177 -0
  38. package/dist/modules/admin-roles/components/create.module.js +8 -0
  39. package/dist/modules/admin-roles/components/create_module.css +27 -0
  40. package/dist/modules/admin-roles/components/permissions.d.ts +10 -0
  41. package/dist/modules/admin-roles/components/permissions.js +303 -0
  42. package/dist/modules/admin-roles/components/permissions.module.js +44 -0
  43. package/dist/modules/admin-roles/components/permissions_module.css +192 -0
  44. package/dist/modules/admin-roles/components/update.d.ts +8 -0
  45. package/dist/modules/admin-roles/components/update.js +166 -0
  46. package/dist/modules/admin-roles/components/update.module.js +8 -0
  47. package/dist/modules/admin-roles/components/update_module.css +27 -0
  48. package/dist/modules/admin-roles/dto.js +3 -16
  49. package/dist/modules/admin-roles/errors.js +16 -40
  50. package/dist/modules/admin-roles/index.js +6 -26
  51. package/dist/modules/admin-roles/repository.js +1 -8
  52. package/dist/modules/admin-roles/schemas.js +41 -71
  53. package/dist/modules/admin-roles/service.js +79 -82
  54. package/dist/modules/admin-users/abilities.js +9 -38
  55. package/dist/modules/admin-users/commands.js +92 -50
  56. package/dist/modules/admin-users/components/create.d.ts +8 -0
  57. package/dist/modules/admin-users/components/create.js +268 -0
  58. package/dist/modules/admin-users/components/create.module.js +10 -0
  59. package/dist/modules/admin-users/components/create_module.css +45 -0
  60. package/dist/modules/admin-users/components/roles.d.ts +11 -0
  61. package/dist/modules/admin-users/components/roles.js +148 -0
  62. package/dist/modules/admin-users/components/roles.module.js +18 -0
  63. package/dist/modules/admin-users/components/roles_module.css +75 -0
  64. package/dist/modules/admin-users/components/set-password.d.ts +8 -0
  65. package/dist/modules/admin-users/components/set-password.js +170 -0
  66. package/dist/modules/admin-users/components/set-password.module.js +9 -0
  67. package/dist/modules/admin-users/components/set-password_module.css +31 -0
  68. package/dist/modules/admin-users/components/update.d.ts +8 -0
  69. package/dist/modules/admin-users/components/update.js +254 -0
  70. package/dist/modules/admin-users/components/update.module.js +9 -0
  71. package/dist/modules/admin-users/components/update_module.css +34 -0
  72. package/dist/modules/admin-users/dto.js +3 -18
  73. package/dist/modules/admin-users/errors.js +17 -43
  74. package/dist/modules/admin-users/index.js +7 -27
  75. package/dist/modules/admin-users/repository.js +1 -8
  76. package/dist/modules/admin-users/schemas.js +44 -75
  77. package/dist/modules/admin-users/seed-super-admin.js +9 -34
  78. package/dist/modules/admin-users/service.js +76 -91
  79. package/dist/modules/auth/components/sign-in-form.d.ts +12 -0
  80. package/dist/modules/auth/components/sign-in-form.js +115 -0
  81. package/dist/modules/auth/components/sign-in-form.module.js +12 -0
  82. package/dist/modules/auth/components/sign-in-form_module.css +41 -0
  83. package/dist/modules/auth/index.js +3 -24
  84. package/dist/modules/auth/jwt-session-provider.js +179 -149
  85. package/dist/modules/auth/password.js +11 -53
  86. package/dist/modules/auth/phc.js +21 -54
  87. package/dist/modules/auth/refresh-tokens-repository.js +1 -8
  88. package/dist/modules/auth/resolve-actor.js +6 -28
  89. package/dist/services/admin-services-context.d.ts +16 -0
  90. package/dist/services/admin-services-context.js +13 -0
  91. package/dist/services/admin-services-types.d.ts +129 -0
  92. package/dist/services/admin-services-types.js +1 -0
  93. package/dist/store.js +1 -8
  94. package/dist/vendor/noble-argon2/_blake.js +277 -45
  95. package/dist/vendor/noble-argon2/_md.js +81 -136
  96. package/dist/vendor/noble-argon2/_u64.js +65 -67
  97. package/dist/vendor/noble-argon2/argon2.js +181 -342
  98. package/dist/vendor/noble-argon2/blake2.js +252 -327
  99. package/dist/vendor/noble-argon2/utils.js +110 -490
  100. package/dist/vendor/noble-argon2/utils.js.LICENSE.txt +1 -0
  101. package/package.json +89 -10
  102. package/src/abilities.ts +32 -0
  103. package/src/declarations.d.ts +4 -0
  104. package/src/index.ts +39 -0
  105. package/src/lib/assert-admin-actor.ts +90 -0
  106. package/src/lib/create-command.ts +109 -0
  107. package/src/modules/admin-account/commands.ts +76 -0
  108. package/src/modules/admin-account/components/change-password.module.css +40 -0
  109. package/src/modules/admin-account/components/change-password.tsx +232 -0
  110. package/src/modules/admin-account/components/container.module.css +158 -0
  111. package/src/modules/admin-account/components/container.tsx +229 -0
  112. package/src/modules/admin-account/components/update.module.css +40 -0
  113. package/src/modules/admin-account/components/update.tsx +263 -0
  114. package/src/modules/admin-account/errors.ts +75 -0
  115. package/src/modules/admin-account/index.ts +60 -0
  116. package/src/modules/admin-account/schemas.ts +84 -0
  117. package/src/modules/admin-account/service.ts +92 -0
  118. package/src/modules/admin-permissions/abilities.ts +46 -0
  119. package/src/modules/admin-permissions/commands.ts +103 -0
  120. package/src/modules/admin-permissions/components/inspector.module.css +326 -0
  121. package/src/modules/admin-permissions/components/inspector.tsx +298 -0
  122. package/src/modules/admin-permissions/dto.ts +28 -0
  123. package/src/modules/admin-permissions/errors.ts +57 -0
  124. package/src/modules/admin-permissions/index.ts +72 -0
  125. package/src/modules/admin-permissions/repository.ts +49 -0
  126. package/src/modules/admin-permissions/schemas.ts +128 -0
  127. package/src/modules/admin-permissions/service.ts +137 -0
  128. package/src/modules/admin-roles/abilities.ts +62 -0
  129. package/src/modules/admin-roles/commands.ts +161 -0
  130. package/src/modules/admin-roles/components/create.module.css +40 -0
  131. package/src/modules/admin-roles/components/create.tsx +218 -0
  132. package/src/modules/admin-roles/components/permissions.module.css +279 -0
  133. package/src/modules/admin-roles/components/permissions.tsx +396 -0
  134. package/src/modules/admin-roles/components/update.module.css +40 -0
  135. package/src/modules/admin-roles/components/update.tsx +218 -0
  136. package/src/modules/admin-roles/dto.ts +30 -0
  137. package/src/modules/admin-roles/errors.ts +76 -0
  138. package/src/modules/admin-roles/index.ts +81 -0
  139. package/src/modules/admin-roles/repository.ts +96 -0
  140. package/src/modules/admin-roles/schemas.ts +139 -0
  141. package/src/modules/admin-roles/service.ts +136 -0
  142. package/src/modules/admin-users/abilities.ts +76 -0
  143. package/src/modules/admin-users/commands.ts +157 -0
  144. package/src/modules/admin-users/components/create.module.css +63 -0
  145. package/src/modules/admin-users/components/create.tsx +323 -0
  146. package/src/modules/admin-users/components/roles.module.css +119 -0
  147. package/src/modules/admin-users/components/roles.tsx +172 -0
  148. package/src/modules/admin-users/components/set-password.module.css +46 -0
  149. package/src/modules/admin-users/components/set-password.tsx +199 -0
  150. package/src/modules/admin-users/components/update.module.css +49 -0
  151. package/src/modules/admin-users/components/update.tsx +328 -0
  152. package/src/modules/admin-users/dto.ts +39 -0
  153. package/src/modules/admin-users/errors.ts +84 -0
  154. package/src/modules/admin-users/index.ts +91 -0
  155. package/src/modules/admin-users/repository.ts +161 -0
  156. package/src/modules/admin-users/schemas.ts +168 -0
  157. package/src/modules/admin-users/seed-super-admin.ts +102 -0
  158. package/src/modules/admin-users/service.ts +166 -0
  159. package/src/modules/auth/components/sign-in-form.module.css +62 -0
  160. package/src/modules/auth/components/sign-in-form.tsx +132 -0
  161. package/src/modules/auth/index.ts +31 -0
  162. package/src/modules/auth/jwt-session-provider.ts +301 -0
  163. package/src/modules/auth/password.ts +94 -0
  164. package/src/modules/auth/phc.ts +121 -0
  165. package/src/modules/auth/refresh-tokens-repository.ts +74 -0
  166. package/src/modules/auth/resolve-actor.ts +42 -0
  167. package/src/services/admin-services-context.tsx +52 -0
  168. package/src/services/admin-services-types.ts +177 -0
  169. package/src/store.ts +32 -0
  170. package/src/vendor/noble-argon2/LICENSE +21 -0
  171. package/src/vendor/noble-argon2/README.md +87 -0
  172. package/src/vendor/noble-argon2/_blake.ts +58 -0
  173. package/src/vendor/noble-argon2/_md.ts +223 -0
  174. package/src/vendor/noble-argon2/_u64.ts +118 -0
  175. package/src/vendor/noble-argon2/argon2.ts +668 -0
  176. package/src/vendor/noble-argon2/blake2.ts +583 -0
  177. package/src/vendor/noble-argon2/utils.ts +849 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * RolePermissions — inline per-role ability editor.
3
+ *
4
+ * Override handles:
5
+ * .byline-role-permissions-wrap — outer column container
6
+ * .byline-role-permissions-toolbar — top toolbar (mode toggle + count + actions)
7
+ * .byline-role-permissions-counter — "n of m selected/granted" line
8
+ * .byline-role-permissions-counter-num — bold number span
9
+ * .byline-role-permissions-actions — Cancel/Save cluster (right-aligned)
10
+ * .byline-role-permissions-action — Cancel/Save buttons
11
+ * .byline-role-permissions-groups — vertical group stack
12
+ * .byline-role-permissions-group — group card
13
+ * .byline-role-permissions-group-head — group header row
14
+ * .byline-role-permissions-group-title — group name + count line
15
+ * .byline-role-permissions-group-name — group display name
16
+ * .byline-role-permissions-group-count — n-of-m count text
17
+ * .byline-role-permissions-group-buttons — Select-all/Clear cluster
18
+ * .byline-role-permissions-grid — ability checkbox grid
19
+ * .byline-role-permissions-row — single ability row
20
+ * .byline-role-permissions-label — clickable label
21
+ * .byline-role-permissions-label-edit — edit-mode cursor variant
22
+ * .byline-role-permissions-label-head — name + key pill row
23
+ * .byline-role-permissions-label-name — ability display name
24
+ * .byline-role-permissions-key — code pill (ability key)
25
+ * .byline-role-permissions-description — ability description
26
+ * .byline-role-permissions-mode-toggle — segmented View/Edit toggle
27
+ * .byline-role-permissions-mode-button — toggle button base
28
+ * .byline-role-permissions-mode-button-active — active toggle button
29
+ * .byline-role-permissions-mode-button-divider — left-border separator on the second button
30
+ * .byline-role-permissions-mode-button-disabled — disabled-cursor modifier
31
+ */
32
+
33
+ .wrap,
34
+ :global(.byline-role-permissions-wrap) {
35
+ display: flex;
36
+ flex-direction: column;
37
+ gap: var(--spacing-12);
38
+ margin-bottom: var(--spacing-48);
39
+ }
40
+
41
+ .toolbar,
42
+ :global(.byline-role-permissions-toolbar) {
43
+ display: flex;
44
+ flex-wrap: wrap;
45
+ align-items: center;
46
+ gap: var(--spacing-12);
47
+ padding: var(--spacing-12);
48
+ border: var(--border-width-thin) var(--border-style-solid) var(--gray-100);
49
+ background-color: var(--canvas-25);
50
+ border-radius: var(--border-radius-sm);
51
+ }
52
+
53
+ .counter,
54
+ :global(.byline-role-permissions-counter) {
55
+ margin: 0;
56
+ font-size: var(--font-size-sm);
57
+ }
58
+
59
+ .counter-num,
60
+ :global(.byline-role-permissions-counter-num) {
61
+ font-weight: var(--font-weight-medium);
62
+ }
63
+
64
+ .actions,
65
+ :global(.byline-role-permissions-actions) {
66
+ margin-left: auto;
67
+ display: flex;
68
+ align-items: center;
69
+ gap: var(--spacing-8);
70
+ }
71
+
72
+ .action,
73
+ :global(.byline-role-permissions-action) {
74
+ min-width: 4rem;
75
+ }
76
+
77
+ /*
78
+ * Checkbox shrink-fit — overrides the uikit Checkbox's `width: 100%`
79
+ * default container so the external label sits flush against the
80
+ * checkbox button. Replaces the `!w-auto` Tailwind utility we used
81
+ * pre-lift; with the component now in a published package, Tailwind's
82
+ * source scanner no longer sees that class string in the host so the
83
+ * rule was never generated and the label was being pushed to the right.
84
+ *
85
+ * `!important` is required — the uikit Checkbox sets `width: 100%`
86
+ * at the same specificity, so a plain rule loses to source-order. The
87
+ * pre-lift Tailwind class `!w-auto` carried `!important` via Tailwind's
88
+ * `!` prefix; this is the same effect, expressed in a CSS module.
89
+ */
90
+ .checkbox-auto,
91
+ :global(.byline-role-permissions-checkbox-auto) {
92
+ /* biome-ignore lint/complexity/noImportantStyles: see comment above */
93
+ width: auto !important;
94
+ }
95
+
96
+ .groups,
97
+ :global(.byline-role-permissions-groups) {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: var(--spacing-8);
101
+ }
102
+
103
+ .group,
104
+ :global(.byline-role-permissions-group) {
105
+ border: var(--border-width-thin) var(--border-style-solid) var(--gray-100);
106
+ border-radius: var(--border-radius-sm);
107
+ }
108
+
109
+ .group-head,
110
+ :global(.byline-role-permissions-group-head) {
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: space-between;
114
+ padding: var(--spacing-12);
115
+ border-bottom: var(--border-width-thin) var(--border-style-solid) var(--gray-100);
116
+ }
117
+
118
+ .group-name,
119
+ :global(.byline-role-permissions-group-name) {
120
+ font-weight: var(--font-weight-medium);
121
+ }
122
+
123
+ .group-count,
124
+ :global(.byline-role-permissions-group-count) {
125
+ margin-left: var(--spacing-8);
126
+ font-size: var(--font-size-xs);
127
+ }
128
+
129
+ .group-buttons,
130
+ :global(.byline-role-permissions-group-buttons) {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: var(--spacing-4);
134
+ }
135
+
136
+ .grid,
137
+ :global(.byline-role-permissions-grid) {
138
+ display: grid;
139
+ grid-template-columns: 1fr;
140
+ gap: var(--spacing-4);
141
+ padding: var(--spacing-12);
142
+ }
143
+
144
+ @media (min-width: 40rem) {
145
+ .grid,
146
+ :global(.byline-role-permissions-grid) {
147
+ grid-template-columns: 1fr 1fr;
148
+ }
149
+ }
150
+
151
+ .row,
152
+ :global(.byline-role-permissions-row) {
153
+ display: flex;
154
+ align-items: flex-start;
155
+ gap: var(--spacing-8);
156
+ padding: var(--spacing-4) 0;
157
+ }
158
+
159
+ .label,
160
+ :global(.byline-role-permissions-label) {
161
+ min-width: 0;
162
+ flex: 1 1 0;
163
+ cursor: default;
164
+ }
165
+
166
+ .label-edit,
167
+ :global(.byline-role-permissions-label-edit) {
168
+ cursor: pointer;
169
+ }
170
+
171
+ .label-head,
172
+ :global(.byline-role-permissions-label-head) {
173
+ display: flex;
174
+ align-items: center;
175
+ gap: var(--spacing-8);
176
+ }
177
+
178
+ .label-name,
179
+ :global(.byline-role-permissions-label-name) {
180
+ font-size: var(--font-size-sm);
181
+ font-weight: var(--font-weight-medium);
182
+ }
183
+
184
+ .key,
185
+ :global(.byline-role-permissions-key) {
186
+ background-color: var(--gray-100);
187
+ padding: 0.125rem 0.375rem;
188
+ font-size: var(--font-size-xs);
189
+ border-radius: var(--border-radius-sm);
190
+ }
191
+
192
+ .description,
193
+ :global(.byline-role-permissions-description) {
194
+ margin-bottom: 0;
195
+ font-size: var(--font-size-xs);
196
+ }
197
+
198
+ .mode-toggle,
199
+ :global(.byline-role-permissions-mode-toggle) {
200
+ display: inline-flex;
201
+ overflow: hidden;
202
+ border: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
203
+ border-radius: var(--border-radius-sm);
204
+ }
205
+
206
+ .mode-button,
207
+ :global(.byline-role-permissions-mode-button) {
208
+ padding: 0.125rem 0.625rem;
209
+ font-size: var(--font-size-xs);
210
+ background-color: transparent;
211
+ border: 0;
212
+ cursor: pointer;
213
+ transition: background-color 150ms ease;
214
+ }
215
+
216
+ .mode-button:hover,
217
+ :global(.byline-role-permissions-mode-button):hover {
218
+ background-color: var(--gray-50);
219
+ }
220
+
221
+ .mode-button-active,
222
+ :global(.byline-role-permissions-mode-button-active) {
223
+ background-color: var(--gray-100);
224
+ font-weight: var(--font-weight-medium);
225
+ }
226
+
227
+ .mode-button-divider,
228
+ :global(.byline-role-permissions-mode-button-divider) {
229
+ border-left: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
230
+ }
231
+
232
+ .mode-button-disabled,
233
+ :global(.byline-role-permissions-mode-button-disabled) {
234
+ opacity: 0.5;
235
+ cursor: not-allowed;
236
+ }
237
+
238
+ :is([data-theme="dark"], :global(.dark)) {
239
+ .toolbar,
240
+ :global(.byline-role-permissions-toolbar) {
241
+ border-color: var(--gray-700);
242
+ background-color: var(--canvas-800);
243
+ }
244
+
245
+ .group,
246
+ :global(.byline-role-permissions-group) {
247
+ border-color: var(--gray-700);
248
+ }
249
+
250
+ .group-head,
251
+ :global(.byline-role-permissions-group-head) {
252
+ border-bottom-color: var(--gray-700);
253
+ }
254
+
255
+ .key,
256
+ :global(.byline-role-permissions-key) {
257
+ background-color: var(--canvas-800);
258
+ }
259
+
260
+ .mode-toggle,
261
+ :global(.byline-role-permissions-mode-toggle) {
262
+ border-color: var(--gray-700);
263
+ }
264
+
265
+ .mode-button:hover,
266
+ :global(.byline-role-permissions-mode-button):hover {
267
+ background-color: var(--canvas-700);
268
+ }
269
+
270
+ .mode-button-active,
271
+ :global(.byline-role-permissions-mode-button-active) {
272
+ background-color: var(--canvas-700);
273
+ }
274
+
275
+ .mode-button-divider,
276
+ :global(.byline-role-permissions-mode-button-divider) {
277
+ border-left-color: var(--gray-700);
278
+ }
279
+ }
@@ -0,0 +1,396 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ /**
12
+ * Inline per-role ability editor. Renders below the role-details
13
+ * header on the role detail page — full-width, no drawer.
14
+ *
15
+ * Mode-switched: defaults to **view** (checkboxes disabled, no edit
16
+ * affordances). Click Edit to switch to **edit** mode — checkboxes
17
+ * become interactive, per-group "Select all / Clear" buttons appear,
18
+ * and a Save button materialises once the selection diverges from the
19
+ * stored set. Cancel reverts the local set and returns to view mode.
20
+ *
21
+ * The View toggle is disabled while there are unsaved changes — this
22
+ * is the explicit-intent equivalent of a confirm dialog and keeps the
23
+ * UX honest about whether changes have been persisted.
24
+ *
25
+ * Vid-less by design (see Phase B notes): abilities live in
26
+ * `byline_admin_permissions`, not on the role row, so editing them
27
+ * does not bump the role's `vid`. Last-writer-wins on a per-role basis.
28
+ *
29
+ * Stable override handles: see `permissions.module.css`.
30
+ */
31
+
32
+ import { useMemo, useState } from 'react'
33
+
34
+ import { Alert, Button, Checkbox, LoaderEllipsis } from '@byline/ui/react'
35
+ import cx from 'classnames'
36
+
37
+ import { useBylineAdminServices } from '../../../services/admin-services-context.js'
38
+ import styles from './permissions.module.css'
39
+ import type {
40
+ AbilityDescriptorResponse,
41
+ AbilityGroupResponse,
42
+ ListRegisteredAbilitiesResponse,
43
+ SetRoleAbilitiesResponse,
44
+ } from '../../admin-permissions/index.js'
45
+ import type { AdminRoleResponse } from '../index.js'
46
+
47
+ type Mode = 'view' | 'edit'
48
+
49
+ interface RolePermissionsProps {
50
+ role: AdminRoleResponse
51
+ registered: ListRegisteredAbilitiesResponse
52
+ initialAbilities: string[]
53
+ onSaved?: (response: SetRoleAbilitiesResponse) => void
54
+ }
55
+
56
+ function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
57
+ if (a.size !== b.size) return false
58
+ for (const item of a) if (!b.has(item)) return false
59
+ return true
60
+ }
61
+
62
+ interface GroupSectionProps {
63
+ group: AbilityGroupResponse
64
+ selected: ReadonlySet<string>
65
+ mode: Mode
66
+ saving: boolean
67
+ onToggle: (key: string, checked: boolean) => void
68
+ onSelectAll: (groupKeys: readonly string[]) => void
69
+ onClearAll: (groupKeys: readonly string[]) => void
70
+ }
71
+
72
+ function GroupSection({
73
+ group,
74
+ selected,
75
+ mode,
76
+ saving,
77
+ onToggle,
78
+ onSelectAll,
79
+ onClearAll,
80
+ }: GroupSectionProps) {
81
+ const groupKeys = useMemo(() => group.abilities.map((a) => a.key), [group.abilities])
82
+ const selectedInGroup = groupKeys.filter((key) => selected.has(key)).length
83
+ const isEdit = mode === 'edit'
84
+
85
+ return (
86
+ <div className={cx('byline-role-permissions-group', styles.group)}>
87
+ <div className={cx('byline-role-permissions-group-head', styles['group-head'])}>
88
+ <div>
89
+ <span className={cx('byline-role-permissions-group-name', styles['group-name'])}>
90
+ {group.group}
91
+ </span>
92
+ <span
93
+ className={cx('muted', 'byline-role-permissions-group-count', styles['group-count'])}
94
+ >
95
+ {selectedInGroup} of {group.abilities.length} {isEdit ? 'selected' : 'granted'}
96
+ </span>
97
+ </div>
98
+ {isEdit ? (
99
+ <div className={cx('byline-role-permissions-group-buttons', styles['group-buttons'])}>
100
+ <Button
101
+ size="xs"
102
+ intent="secondary"
103
+ type="button"
104
+ disabled={saving || selectedInGroup === group.abilities.length}
105
+ onClick={() => onSelectAll(groupKeys)}
106
+ >
107
+ Select all
108
+ </Button>
109
+ <Button
110
+ size="xs"
111
+ intent="secondary"
112
+ type="button"
113
+ disabled={saving || selectedInGroup === 0}
114
+ onClick={() => onClearAll(groupKeys)}
115
+ >
116
+ Clear
117
+ </Button>
118
+ </div>
119
+ ) : null}
120
+ </div>
121
+ <div className={cx('byline-role-permissions-grid', styles.grid)}>
122
+ {group.abilities.map((ability: AbilityDescriptorResponse) => (
123
+ <div key={ability.key} className={cx('byline-role-permissions-row', styles.row)}>
124
+ <Checkbox
125
+ id={`ability-${ability.key}`}
126
+ name={`ability-${ability.key}`}
127
+ checked={selected.has(ability.key)}
128
+ disabled={!isEdit || saving}
129
+ onCheckedChange={(checked) => onToggle(ability.key, checked === true)}
130
+ // Override the uikit Checkbox container's `width: 100%` so it
131
+ // shrinks to its button width — otherwise the external label
132
+ // is pushed away by an empty 100%-wide container.
133
+ containerClasses={cx(
134
+ 'byline-role-permissions-checkbox-auto',
135
+ styles['checkbox-auto']
136
+ )}
137
+ componentClasses={cx(
138
+ 'byline-role-permissions-checkbox-auto',
139
+ styles['checkbox-auto']
140
+ )}
141
+ />
142
+ <label
143
+ htmlFor={`ability-${ability.key}`}
144
+ className={cx(
145
+ 'byline-role-permissions-label',
146
+ styles.label,
147
+ isEdit && ['byline-role-permissions-label-edit', styles['label-edit']]
148
+ )}
149
+ >
150
+ <div className={cx('byline-role-permissions-label-head', styles['label-head'])}>
151
+ <span className={cx('byline-role-permissions-label-name', styles['label-name'])}>
152
+ {ability.label}
153
+ </span>
154
+ <code className={cx('byline-role-permissions-key', styles.key)}>{ability.key}</code>
155
+ </div>
156
+ {ability.description ? (
157
+ <p
158
+ className={cx('muted', 'byline-role-permissions-description', styles.description)}
159
+ >
160
+ {ability.description}
161
+ </p>
162
+ ) : null}
163
+ </label>
164
+ </div>
165
+ ))}
166
+ </div>
167
+ </div>
168
+ )
169
+ }
170
+
171
+ export function RolePermissions({
172
+ role,
173
+ registered,
174
+ initialAbilities,
175
+ onSaved,
176
+ }: RolePermissionsProps) {
177
+ const { setRoleAbilities } = useBylineAdminServices()
178
+ const [mode, setMode] = useState<Mode>('view')
179
+ const [initialSet, setInitialSet] = useState<ReadonlySet<string>>(() => new Set(initialAbilities))
180
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(initialAbilities))
181
+ const [saving, setSaving] = useState(false)
182
+ const [error, setError] = useState<string | null>(null)
183
+
184
+ const isDirty = !setsEqual(selected, initialSet)
185
+ const totalSelected = selected.size
186
+
187
+ function handleToggle(key: string, checked: boolean): void {
188
+ if (mode !== 'edit') return
189
+ setSelected((current) => {
190
+ const next = new Set(current)
191
+ if (checked) next.add(key)
192
+ else next.delete(key)
193
+ return next
194
+ })
195
+ }
196
+
197
+ function handleSelectAll(groupKeys: readonly string[]): void {
198
+ setSelected((current) => {
199
+ const next = new Set(current)
200
+ for (const key of groupKeys) next.add(key)
201
+ return next
202
+ })
203
+ }
204
+
205
+ function handleClearAll(groupKeys: readonly string[]): void {
206
+ setSelected((current) => {
207
+ const next = new Set(current)
208
+ for (const key of groupKeys) next.delete(key)
209
+ return next
210
+ })
211
+ }
212
+
213
+ function handleCancel(): void {
214
+ setSelected(new Set(initialSet))
215
+ setError(null)
216
+ setMode('view')
217
+ }
218
+
219
+ function handleEnterEdit(): void {
220
+ setError(null)
221
+ setMode('edit')
222
+ }
223
+
224
+ function handleEnterView(): void {
225
+ // Disabled while dirty (the toggle's `disabled` prop guards this) —
226
+ // belt-and-suspenders so a stray click can't slip through.
227
+ if (isDirty) return
228
+ setError(null)
229
+ setMode('view')
230
+ }
231
+
232
+ async function handleSave(): Promise<void> {
233
+ if (saving) return
234
+ setSaving(true)
235
+ setError(null)
236
+ try {
237
+ const response = await setRoleAbilities({
238
+ data: { id: role.id, abilities: Array.from(selected) },
239
+ })
240
+ // Reset baseline to the authoritative stored set — guards against
241
+ // any dedupe/normalisation the server might apply.
242
+ const storedSet = new Set(response.abilities)
243
+ setInitialSet(storedSet)
244
+ setSelected(new Set(storedSet))
245
+ onSaved?.(response)
246
+ } catch (err) {
247
+ const code = getErrorCode(err)
248
+ if (code === 'admin.permissions.roleNotFound') {
249
+ setError('This role no longer exists.')
250
+ } else if (code === 'admin.permissions.abilityUnregistered') {
251
+ setError(
252
+ 'One or more selected abilities are no longer registered. Reload the page and try again.'
253
+ )
254
+ } else {
255
+ setError('Could not save permissions. Please try again.')
256
+ }
257
+ } finally {
258
+ setSaving(false)
259
+ }
260
+ }
261
+
262
+ const isEdit = mode === 'edit'
263
+
264
+ return (
265
+ <div className={cx('byline-role-permissions-wrap', styles.wrap)}>
266
+ <div className={cx('byline-role-permissions-toolbar', styles.toolbar)}>
267
+ <ModeToggle
268
+ mode={mode}
269
+ dirty={isDirty}
270
+ saving={saving}
271
+ onView={handleEnterView}
272
+ onEdit={handleEnterEdit}
273
+ />
274
+ <p className={cx('muted', 'byline-role-permissions-counter', styles.counter)}>
275
+ <span className={cx('byline-role-permissions-counter-num', styles['counter-num'])}>
276
+ {totalSelected}
277
+ </span>{' '}
278
+ of{' '}
279
+ <span className={cx('byline-role-permissions-counter-num', styles['counter-num'])}>
280
+ {registered.total}
281
+ </span>{' '}
282
+ {isEdit ? 'selected' : 'granted'} for {role.name}
283
+ </p>
284
+ {isEdit && isDirty ? (
285
+ <div className={cx('byline-role-permissions-actions', styles.actions)}>
286
+ <Button
287
+ type="button"
288
+ intent="secondary"
289
+ size="xs"
290
+ onClick={handleCancel}
291
+ disabled={saving}
292
+ className={cx('byline-role-permissions-action', styles.action)}
293
+ >
294
+ Cancel
295
+ </Button>
296
+ <Button
297
+ type="button"
298
+ intent="primary"
299
+ size="xs"
300
+ onClick={() => void handleSave()}
301
+ disabled={saving}
302
+ className={cx('byline-role-permissions-action', styles.action)}
303
+ >
304
+ {saving ? <LoaderEllipsis size={30} /> : 'Save'}
305
+ </Button>
306
+ </div>
307
+ ) : null}
308
+ </div>
309
+
310
+ {error ? <Alert intent="danger">{error}</Alert> : null}
311
+
312
+ <div className={cx('byline-role-permissions-groups', styles.groups)}>
313
+ {registered.groups.map((group) => (
314
+ <GroupSection
315
+ key={group.group}
316
+ group={group}
317
+ selected={selected}
318
+ mode={mode}
319
+ saving={saving}
320
+ onToggle={handleToggle}
321
+ onSelectAll={handleSelectAll}
322
+ onClearAll={handleClearAll}
323
+ />
324
+ ))}
325
+ </div>
326
+ </div>
327
+ )
328
+ }
329
+
330
+ interface ModeToggleProps {
331
+ mode: Mode
332
+ dirty: boolean
333
+ saving: boolean
334
+ onView: () => void
335
+ onEdit: () => void
336
+ }
337
+
338
+ function ModeToggle({ mode, dirty, saving, onView, onEdit }: ModeToggleProps) {
339
+ // Segmented two-state toggle. View is disabled while dirty so the
340
+ // user has to commit to Save or Cancel — avoids accidentally
341
+ // discarding a draft selection.
342
+ const isView = mode === 'view'
343
+ const isEdit = mode === 'edit'
344
+ const viewDisabled = dirty || saving
345
+ const editDisabled = saving
346
+ return (
347
+ <div
348
+ role="group"
349
+ aria-label="Permissions mode"
350
+ className={cx('byline-role-permissions-mode-toggle', styles['mode-toggle'])}
351
+ >
352
+ <button
353
+ type="button"
354
+ onClick={onView}
355
+ disabled={viewDisabled}
356
+ className={cx(
357
+ 'byline-role-permissions-mode-button',
358
+ styles['mode-button'],
359
+ isView && ['byline-role-permissions-mode-button-active', styles['mode-button-active']],
360
+ viewDisabled &&
361
+ !isView && [
362
+ 'byline-role-permissions-mode-button-disabled',
363
+ styles['mode-button-disabled'],
364
+ ]
365
+ )}
366
+ >
367
+ View
368
+ </button>
369
+ <button
370
+ type="button"
371
+ onClick={onEdit}
372
+ disabled={editDisabled}
373
+ className={cx(
374
+ 'byline-role-permissions-mode-button',
375
+ 'byline-role-permissions-mode-button-divider',
376
+ styles['mode-button'],
377
+ styles['mode-button-divider'],
378
+ isEdit && ['byline-role-permissions-mode-button-active', styles['mode-button-active']],
379
+ editDisabled &&
380
+ !isEdit && [
381
+ 'byline-role-permissions-mode-button-disabled',
382
+ styles['mode-button-disabled'],
383
+ ]
384
+ )}
385
+ >
386
+ Edit
387
+ </button>
388
+ </div>
389
+ )
390
+ }
391
+
392
+ function getErrorCode(err: unknown): string | null {
393
+ return typeof (err as { code?: unknown })?.code === 'string'
394
+ ? (err as { code: string }).code
395
+ : null
396
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * UpdateAdminRole — drawer form for editing an existing role.
3
+ *
4
+ * Override handles:
5
+ * .byline-role-update-wrap — outer container
6
+ * .byline-role-update-form — vertical-stack form element
7
+ * .byline-role-update-actions — Cancel/Save row
8
+ * .byline-role-update-action — buttons in the actions row
9
+ */
10
+
11
+ .wrap,
12
+ :global(.byline-role-update-wrap) {
13
+ display: flex;
14
+ flex-direction: column;
15
+ gap: var(--spacing-8);
16
+ padding: var(--spacing-4);
17
+ margin-top: var(--spacing-4);
18
+ }
19
+
20
+ .form,
21
+ :global(.byline-role-update-form) {
22
+ display: flex;
23
+ flex-direction: column;
24
+ gap: var(--spacing-16);
25
+ padding-top: var(--spacing-8);
26
+ }
27
+
28
+ .actions,
29
+ :global(.byline-role-update-actions) {
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: flex-end;
33
+ gap: var(--spacing-8);
34
+ margin-top: var(--spacing-16);
35
+ }
36
+
37
+ .action,
38
+ :global(.byline-role-update-action) {
39
+ min-width: 4rem;
40
+ }