@7365admin1/layer-common 4.2.11 → 4.2.13

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.2.13
4
+
5
+ ### Patch Changes
6
+
7
+ - 396bae2: Accept every shipped spelling of a permission when deciding a gate.
8
+
9
+ `hasPermission` tested one exact string, so a role holding a minority spelling
10
+ (`work-orders:see-all-work-orders`, `feedbacks:see-all-feedbacks`,
11
+ `visitor-mgmt:see-all-visitor`) was refused while the role editor showed the
12
+ grant. camelCase stays canonical and the role editor is unchanged; nothing
13
+ stored is rewritten. Widening only.
14
+
15
+ The role editor's ticks and its read-only preview are alias-aware too, so a
16
+ grant stored under an alias is shown and can actually be cleared.
17
+
18
+ ## 4.2.12
19
+
20
+ ### Patch Changes
21
+
22
+ - 15e187e: Send the site with the role list request, so a site's roles-and-permissions screen shows its own roles instead of every other estate's.
23
+
3
24
  ## 4.2.11
4
25
 
5
26
  ### Patch Changes
@@ -574,11 +595,11 @@
574
595
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
575
596
  own rule from two endpoints the console already calls, unprojected:
576
597
 
577
- GET /api/members/user/:user/app/admin the Seven365 staff membership
578
- GET /api/roles/id/:role that membership's role document
598
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
599
+ GET /api/roles/id/:role that membership's role document
579
600
 
580
- owner = member.type === "admin" && role.type === "admin" && role.default === true
581
- staff = member.type === "admin" && role.type === "admin"
601
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
602
+ staff = member.type === "admin" && role.type === "admin"
582
603
 
583
604
  `role.default` is the marker because it is the only property of a platform
584
605
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -66,8 +66,9 @@
66
66
  <template #prepend>
67
67
  <v-checkbox-btn
68
68
  :model-value="
69
- definedModel.includes(
70
- `${String(resourceKey)}:${String(actionKey)}`
69
+ isActionSelected(
70
+ String(resourceKey),
71
+ String(actionKey)
71
72
  )
72
73
  "
73
74
  :disabled="!edit"
@@ -130,6 +131,10 @@
130
131
  <script setup lang="ts">
131
132
  import useRole from "../composables/useRole";
132
133
  import useUtils from "../composables/useUtils";
134
+ import {
135
+ holdsPermission,
136
+ withoutPermission,
137
+ } from "../utils/permission-spellings";
133
138
 
134
139
  const definedModel = defineModel<Array<string>>({
135
140
  default: () => [],
@@ -185,7 +190,26 @@ function isWebOnly(resource: string) {
185
190
  return props.webOnlyResources.includes(resource);
186
191
  }
187
192
 
193
+ /**
194
+ * Is this box ticked?
195
+ *
196
+ * The same grant is stored under more than one spelling. An exact `includes()`
197
+ * showed a role holding `work-orders:see-all-work-orders` as NOT having
198
+ * `workOrder:see-all-work-orders`, so the dialog reported a grant the role
199
+ * really holds as absent -- and unticking a box then cleared nothing. Ticks are
200
+ * alias-aware, and so is the untick below.
201
+ */
202
+ function isActionSelected(resource: string, action: string) {
203
+ return holdsPermission(definedModel.value, `${resource}:${action}`);
204
+ }
205
+
188
206
  function selectedActionCount(resource: string) {
207
+ const actions = safePermissions.value[resource];
208
+ if (actions) {
209
+ return Object.keys(actions).filter((action) =>
210
+ isActionSelected(resource, action)
211
+ ).length;
212
+ }
189
213
  return definedModel.value.filter((permission) =>
190
214
  permission.startsWith(`${resource}:`)
191
215
  ).length;
@@ -208,20 +232,22 @@ function toggleCategory(resource: string, value: boolean | null) {
208
232
  expandedResources.value = expandedResources.value.filter(
209
233
  (item) => item !== resource
210
234
  );
211
- definedModel.value = definedModel.value.filter(
212
- (permission) => !permission.startsWith(`${resource}:`)
235
+ const actions = Object.keys(safePermissions.value[resource] ?? {});
236
+ definedModel.value = actions.reduce(
237
+ (kept, action) => withoutPermission(kept, `${resource}:${action}`),
238
+ definedModel.value.filter(
239
+ (permission) => !permission.startsWith(`${resource}:`)
240
+ )
213
241
  );
214
242
  }
215
243
 
216
244
  function toggleAction(resource: string, action: string, value: boolean | null) {
217
245
  if (!edit.value || value === null) return;
218
246
  const permission = `${resource}:${action}`;
219
- if (value && !definedModel.value.includes(permission)) {
247
+ if (value && !isActionSelected(resource, action)) {
220
248
  definedModel.value = [...definedModel.value, permission];
221
249
  } else if (!value) {
222
- definedModel.value = definedModel.value.filter(
223
- (item) => item !== permission
224
- );
250
+ definedModel.value = withoutPermission(definedModel.value, permission);
225
251
  }
226
252
  }
227
253
 
@@ -233,7 +259,8 @@ const isAllSelected = computed(() => {
233
259
  Object.keys(actions || {}).map((action) => `${resource}:${action}`)
234
260
  );
235
261
  return (
236
- keys.length > 0 && keys.every((key) => definedModel.value.includes(key))
262
+ keys.length > 0 &&
263
+ keys.every((key) => holdsPermission(definedModel.value, key))
237
264
  );
238
265
  });
239
266
 
@@ -189,6 +189,7 @@
189
189
  <script setup lang="ts">
190
190
  // Relative import: this layer's `utils/` is not auto-imported into consumers.
191
191
  import { showSiteField } from "../utils/role";
192
+ import { holdsPermission } from "../utils/permission-spellings";
192
193
 
193
194
  const props = defineProps({
194
195
  orgId: {
@@ -297,6 +298,7 @@ const {
297
298
  search: headerSearch.value,
298
299
  type: props.type,
299
300
  org: props.orgId,
301
+ site: props.siteId,
300
302
  }),
301
303
  {
302
304
  watch: [page, headerSearch],
@@ -413,7 +415,12 @@ function filterPermissions(
413
415
  Object.entries(allPermissions).forEach(([resource, actions]) => {
414
416
  if (!actions || typeof actions !== "object") return;
415
417
  const filteredActions = Object.entries(actions)
416
- .filter(([action]) => storedPermissions.includes(`${resource}:${action}`))
418
+ // Alias-aware: the read-only preview must list a grant the role holds
419
+ // under any shipped spelling, or the same string is invisible here and
420
+ // ticked in the editor.
421
+ .filter(([action]) =>
422
+ holdsPermission(storedPermissions, `${resource}:${action}`)
423
+ )
417
424
  .reduce((acc: Record<string, any>, [action, data]) => {
418
425
  acc[action] = data;
419
426
  return acc;
@@ -1,3 +1,5 @@
1
+ import { holdsPermission } from "../utils/permission-spellings";
2
+
1
3
  export default function usePermission() {
2
4
  // Permission-Based Access Control with Dynamic Role Creation
3
5
 
@@ -25,8 +27,16 @@ export default function usePermission() {
25
27
  ): boolean {
26
28
  const permissionKey = `${resource}:${action}`;
27
29
 
28
- // Check if the permission exists in the user's permission array
29
- if (!user.permissions?.includes(permissionKey)) {
30
+ // Check if the permission exists in the user's permission array.
31
+ //
32
+ // The same grant is stored under more than one spelling -- the role editor
33
+ // writes `workOrder:see-all-work-orders` (81 roles) but a minority of roles
34
+ // hold `work-orders:see-all-work-orders` (6). An exact test answered "no"
35
+ // for those roles while the console showed the grant. `holdsPermission`
36
+ // accepts any shipped spelling of the same grant; camelCase stays canonical
37
+ // and nothing stored is rewritten. Widening only -- no role that passed
38
+ // before can fail now.
39
+ if (!holdsPermission(user.permissions, permissionKey)) {
30
40
  return false;
31
41
  }
32
42
 
@@ -23,10 +23,11 @@ export default function useRole() {
23
23
  limit = 20,
24
24
  type = "",
25
25
  org = "",
26
+ site = "",
26
27
  } = {}) {
27
28
  return useNuxtApp().$api<Record<string, any>>("/api/roles", {
28
29
  method: "GET",
29
- query: { search, page, limit, type, org },
30
+ query: { search, page, limit, type, org, site },
30
31
  });
31
32
  }
32
33
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.2.11",
5
+ "version": "4.2.13",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -0,0 +1,87 @@
1
+ import {
2
+ LEGACY_PERMISSION_ALIASES,
3
+ PERMISSION_STRINGS,
4
+ } from "../constants/permissions.ts";
5
+
6
+ /**
7
+ * The same grant is stored under more than one spelling.
8
+ *
9
+ * `constants/permissions.ts` already records every shipped spelling as
10
+ * `legacy -> canonical`. That map was written for the drift test; this file is
11
+ * the ENFORCEMENT side of it. It turns the one-way map into equivalence
12
+ * classes, so a check for one spelling is answered by any spelling of the same
13
+ * grant.
14
+ *
15
+ * Which spelling is CANONICAL is settled and unchanged by this file: the role
16
+ * editor keeps writing what it has always written (`workOrder:…`,
17
+ * `feedbacks:see-all-feedback`, `visitorManagement:…`), which is the majority
18
+ * spelling in live role data by an order of magnitude (81 roles vs 6, 95 vs 8).
19
+ * Nothing stored is rewritten. This is a WIDENING only: a role that passed a
20
+ * gate before still passes it, and a role holding a minority spelling stops
21
+ * being silently unenforced.
22
+ */
23
+ /**
24
+ * Spellings that are recorded in the alias map but must NOT be accepted.
25
+ *
26
+ * `service-provider:*` (no `-mgmt`) is the 4.0.3 regression: 0 of 428 roles
27
+ * hold it, and `test/service-provider-gate.test.mjs` pins it shut so nobody
28
+ * reintroduces a gate that reads a string which can never arrive. Accepting it
29
+ * would grant nobody anything and would quietly remove that tripwire. The map
30
+ * keeps the entry because the drift test needs to know the string exists.
31
+ */
32
+ const NOT_ACCEPTED: ReadonlySet<string> = new Set([
33
+ "service-provider:see-all-service-providers",
34
+ "service-provider:add-service-provider",
35
+ "service-provider:invite-service-provider",
36
+ ]);
37
+
38
+ const SPELLINGS: ReadonlyMap<string, readonly string[]> = (() => {
39
+ const classes = new Map<string, Set<string>>();
40
+ const join = (canonical: string, spelling: string) => {
41
+ const members = classes.get(canonical) ?? new Set<string>([canonical]);
42
+ members.add(spelling);
43
+ classes.set(canonical, members);
44
+ };
45
+
46
+ for (const canonical of PERMISSION_STRINGS) join(canonical, canonical);
47
+ for (const [legacy, canonical] of Object.entries(LEGACY_PERMISSION_ALIASES)) {
48
+ if (NOT_ACCEPTED.has(legacy)) continue;
49
+ join(canonical, legacy);
50
+ }
51
+
52
+ const bySpelling = new Map<string, readonly string[]>();
53
+ for (const members of classes.values()) {
54
+ const all = Object.freeze([...members]);
55
+ for (const spelling of all) bySpelling.set(spelling, all);
56
+ }
57
+ return bySpelling;
58
+ })();
59
+
60
+ /** Every shipped spelling of the same grant, `value` included. */
61
+ export function permissionSpellings(value: string): readonly string[] {
62
+ return SPELLINGS.get(value) ?? [value];
63
+ }
64
+
65
+ /** Does this stored permission list carry `value` under ANY shipped spelling? */
66
+ export function holdsPermission(
67
+ held: readonly string[] | undefined | null,
68
+ value: string,
69
+ ): boolean {
70
+ if (!held?.length) return false;
71
+ return permissionSpellings(value).some((spelling) => held.includes(spelling));
72
+ }
73
+
74
+ /**
75
+ * Drop `value` under EVERY spelling.
76
+ *
77
+ * The counterpart to `holdsPermission`. A tick that is alias-aware must have an
78
+ * untick that is too, or clearing the box leaves the grant in place and the
79
+ * screen lies in the other direction.
80
+ */
81
+ export function withoutPermission(
82
+ held: readonly string[],
83
+ value: string,
84
+ ): string[] {
85
+ const spellings = permissionSpellings(value);
86
+ return held.filter((item) => !spellings.includes(item));
87
+ }