@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
|
-
|
|
578
|
-
|
|
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
|
-
|
|
581
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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 && !
|
|
247
|
+
if (value && !isActionSelected(resource, action)) {
|
|
220
248
|
definedModel.value = [...definedModel.value, permission];
|
|
221
249
|
} else if (!value) {
|
|
222
|
-
definedModel.value = definedModel.value
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/composables/useRole.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|