@7365admin1/layer-common 4.2.12 → 4.2.14
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 +35 -4
- package/components/HidEnabledGate.vue +24 -1
- package/components/RolePermissionFormPreviewUpdate.vue +36 -9
- package/components/RolePermissionMain.vue +7 -1
- package/composables/useHidAccessPermission.ts +115 -0
- package/composables/usePermission.ts +12 -2
- package/package.json +1 -1
- package/utils/permission-spellings.ts +87 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.2.14
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 1d22a74: Gate the six HID access-mgmt screens on the access-card-mgmt permissions the API already enforces
|
|
8
|
+
|
|
9
|
+
The access-logs, administrator, hid-cards, hid-readers, hid-users and intercom
|
|
10
|
+
pages carried no permission check, so any member of any organisation could type
|
|
11
|
+
the URL and reach door access, biometric enrolment, physical cards and the
|
|
12
|
+
access log. HidEnabledGate now also requires the caller to hold one of the nine
|
|
13
|
+
access-card-mgmt strings, site-settings:manage-entry-pass, or the wildcard.
|
|
14
|
+
|
|
15
|
+
Those are the same strings /api/access-management/hid already enforces, so the
|
|
16
|
+
set now refused is a strict subset of the set the API refuses today: nobody with
|
|
17
|
+
a working screen loses it.
|
|
18
|
+
|
|
19
|
+
## 4.2.13
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 396bae2: Accept every shipped spelling of a permission when deciding a gate.
|
|
24
|
+
|
|
25
|
+
`hasPermission` tested one exact string, so a role holding a minority spelling
|
|
26
|
+
(`work-orders:see-all-work-orders`, `feedbacks:see-all-feedbacks`,
|
|
27
|
+
`visitor-mgmt:see-all-visitor`) was refused while the role editor showed the
|
|
28
|
+
grant. camelCase stays canonical and the role editor is unchanged; nothing
|
|
29
|
+
stored is rewritten. Widening only.
|
|
30
|
+
|
|
31
|
+
The role editor's ticks and its read-only preview are alias-aware too, so a
|
|
32
|
+
grant stored under an alias is shown and can actually be cleared.
|
|
33
|
+
|
|
3
34
|
## 4.2.12
|
|
4
35
|
|
|
5
36
|
### Patch Changes
|
|
@@ -580,11 +611,11 @@
|
|
|
580
611
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
581
612
|
own rule from two endpoints the console already calls, unprojected:
|
|
582
613
|
|
|
583
|
-
|
|
584
|
-
|
|
614
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
615
|
+
GET /api/roles/id/:role that membership's role document
|
|
585
616
|
|
|
586
|
-
|
|
587
|
-
|
|
617
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
618
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
588
619
|
|
|
589
620
|
`role.default` is the marker because it is the only property of a platform
|
|
590
621
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -17,7 +17,22 @@
|
|
|
17
17
|
a coloured slab. Icons are translated centrally — no hand-mapping here.
|
|
18
18
|
-->
|
|
19
19
|
<template>
|
|
20
|
-
|
|
20
|
+
<!-- Nothing at all while the role is still resolving. `userAppRole` is null
|
|
21
|
+
on the first tick for EVERYONE including the `*` owner, so drawing the
|
|
22
|
+
denial here would flash "no permission" at the owner on every load. -->
|
|
23
|
+
<template v-if="!isAppRoleResolved" />
|
|
24
|
+
|
|
25
|
+
<slot v-else-if="hidEnabled && canAccessHidScreens" />
|
|
26
|
+
|
|
27
|
+
<div v-else-if="hidEnabled" class="screen-empty hid-gate">
|
|
28
|
+
<span class="hid-gate__icon tone-info">
|
|
29
|
+
<v-icon icon="mdi-lock-outline" size="24" />
|
|
30
|
+
</span>
|
|
31
|
+
<h2 class="hid-gate__title">You do not have permission for HID access management</h2>
|
|
32
|
+
<p class="hid-gate__body">
|
|
33
|
+
Ask an administrator to grant your role access card management for this site.
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
21
36
|
|
|
22
37
|
<div v-else class="screen-empty hid-gate">
|
|
23
38
|
<span class="hid-gate__icon tone-info">
|
|
@@ -45,6 +60,14 @@ const props = withDefaults(
|
|
|
45
60
|
const { getSiteById } = useSiteSettings();
|
|
46
61
|
const { getHidServiceEnabled } = useOptionalServices();
|
|
47
62
|
|
|
63
|
+
// The permission half of the same gate. This component is the only thing all
|
|
64
|
+
// six `access-mgmt` pages have in common, so putting the check here governs all
|
|
65
|
+
// six -- and all 66 inherited routes across the eleven apps -- without a page
|
|
66
|
+
// guard per screen. See `composables/useHidAccessPermission.ts` for why the
|
|
67
|
+
// gate cannot remove access that currently works.
|
|
68
|
+
const { canAccessHidScreens } = useHidAccessPermission();
|
|
69
|
+
const { isAppRoleResolved } = useLocalSetup();
|
|
70
|
+
|
|
48
71
|
// Do not await this request during route setup. Every HID page uses this gate;
|
|
49
72
|
// awaiting it makes Nuxt Suspense keep rendering the previous page until the
|
|
50
73
|
// site request finishes, so the URL changes while the visible module appears
|
|
@@ -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: {
|
|
@@ -414,7 +415,12 @@ function filterPermissions(
|
|
|
414
415
|
Object.entries(allPermissions).forEach(([resource, actions]) => {
|
|
415
416
|
if (!actions || typeof actions !== "object") return;
|
|
416
417
|
const filteredActions = Object.entries(actions)
|
|
417
|
-
|
|
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
|
+
)
|
|
418
424
|
.reduce((acc: Record<string, any>, [action, data]) => {
|
|
419
425
|
acc[action] = data;
|
|
420
426
|
return acc;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHO MAY REACH THE SIX HID `access-mgmt` SCREENS.
|
|
3
|
+
*
|
|
4
|
+
* `pages/[org]/[site]/access-mgmt/{access-logs, administrator, hid-cards,
|
|
5
|
+
* hid-readers, hid-users, intercom}/index.vue` shipped with
|
|
6
|
+
* `middleware: ["01-auth","02-org"], memberOnly: true` and no permission check
|
|
7
|
+
* at all. Every one of the eleven Nuxt apps inherits all six, so that was 66
|
|
8
|
+
* routes on which any member of any organisation could type the URL and reach
|
|
9
|
+
* door access, biometric enrolment, physical cards and the access log. The menu
|
|
10
|
+
* that links five of them (`useHidNavigation`) is gated on
|
|
11
|
+
* `getHidServiceEnabled(site)` -- a SITE SERVICE FLAG, not a permission -- so
|
|
12
|
+
* the menu was never a boundary either.
|
|
13
|
+
*
|
|
14
|
+
* ## Why adding this gate cannot lock anybody out
|
|
15
|
+
*
|
|
16
|
+
* Every data call these screens make goes through `useHidAmico` to
|
|
17
|
+
* `/api/access-management/hid`, and every route there carries `requireAuth`
|
|
18
|
+
* plus `authorizeView` or `authorizeManage`. Those resolve through
|
|
19
|
+
* `authorizeHidAccess` (`core/services/hid-amico.service.ts`) to the lists in
|
|
20
|
+
* `core/utils/hid-access.util.ts` -- the nine `access-card-mgmt` strings below.
|
|
21
|
+
*
|
|
22
|
+
* WARNING: there are TWO different `HID_MANAGE_PERMISSIONS` in `core` and they
|
|
23
|
+
* are NOT the same list. `hid-amico-authz.util.ts` exports
|
|
24
|
+
* `["site-settings:manage-entry-pass"]`; `hid-access.util.ts` exports the
|
|
25
|
+
* `access-card-mgmt` family. `hid-amico.service.ts` imports the one from
|
|
26
|
+
* `hid-access.util.ts` -- and `authorizeSiteForCaller`'s own doc comment is
|
|
27
|
+
* stale and still claims the other. Follow the import, not the name.
|
|
28
|
+
*
|
|
29
|
+
* So a member holding none of these strings already gets a 401 on every list
|
|
30
|
+
* call on all six screens: the screens are non-functional for them today, and
|
|
31
|
+
* they see chrome plus an error toast. The set this gate refuses is a strict
|
|
32
|
+
* SUBSET of the set the server already refuses in production, which is a
|
|
33
|
+
* stronger guarantee than the usual widen-never-swap argument because it is
|
|
34
|
+
* anchored on live server behaviour rather than on a role census.
|
|
35
|
+
*
|
|
36
|
+
* `site-settings:manage-entry-pass` is unioned in -- it is what
|
|
37
|
+
* `hid-amico-authz.util.ts` enforces on the entry-pass paths and the string the
|
|
38
|
+
* HID menu has always been associated with -- and `*` keeps its short-circuit.
|
|
39
|
+
*
|
|
40
|
+
* ## Intercom is gated with the other five
|
|
41
|
+
*
|
|
42
|
+
* `intercom` was the one screen that might have lived entirely on
|
|
43
|
+
* `authorizeMember` endpoints (`/intercom/status`, `/intercom/call`,
|
|
44
|
+
* `/intercom/hangup`). It does not. `HidIntercomManagement.reload()` opens with
|
|
45
|
+
* `getReaders()` (`GET /readers`, `authorizeView`) and filters its rows out of
|
|
46
|
+
* that response; the contacts tab, the dial-code dialog, the make-call dialog
|
|
47
|
+
* and the web phone are all reached from a row, and `loadSipAccount()` is only
|
|
48
|
+
* ever called from one of those dialogs. Without `authorizeView` the screen is
|
|
49
|
+
* an empty table and an error, so gating it removes nothing that works.
|
|
50
|
+
*
|
|
51
|
+
* ## Drawing, not securing
|
|
52
|
+
*
|
|
53
|
+
* The server re-decides every HID call on its own. This hides screens that do
|
|
54
|
+
* not work; it does not add protection the API does not already have.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** The nine strings `core/utils/hid-access.util.ts` enforces, view + manage. */
|
|
58
|
+
const HID_ACTIONS = [
|
|
59
|
+
"see-all-card",
|
|
60
|
+
"see-all-qr-tagging",
|
|
61
|
+
"view",
|
|
62
|
+
"add-access-card",
|
|
63
|
+
"update-access-card",
|
|
64
|
+
"replace-access-card",
|
|
65
|
+
"delete-access-card",
|
|
66
|
+
"assign-access-card",
|
|
67
|
+
"manage-hid",
|
|
68
|
+
] as const;
|
|
69
|
+
|
|
70
|
+
export function useHidAccessPermission() {
|
|
71
|
+
const { hasPermission } = usePermission();
|
|
72
|
+
const { siteSettingsPermissions } = useCommonPermissions();
|
|
73
|
+
const { userAppRole } = useLocalSetup();
|
|
74
|
+
|
|
75
|
+
// `hasPermission` needs BOTH the role's string AND a catalogue entry for it:
|
|
76
|
+
// a resource missing from the catalogue is a dead gate however the role is
|
|
77
|
+
// granted. `access-card-mgmt` exists in no shared catalogue composable, so it
|
|
78
|
+
// is built here, exactly as `useServiceProviderPermission` builds its own.
|
|
79
|
+
const permissions: TPermissions = {
|
|
80
|
+
"access-card-mgmt": Object.fromEntries(
|
|
81
|
+
HID_ACTIONS.map((action) => [
|
|
82
|
+
action,
|
|
83
|
+
{
|
|
84
|
+
check: true,
|
|
85
|
+
description: `Allows the user to reach the HID access management screens (${action}).`,
|
|
86
|
+
},
|
|
87
|
+
]),
|
|
88
|
+
),
|
|
89
|
+
"site-settings": siteSettingsPermissions,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Can this member open a HID `access-mgmt` screen at all? Any single string
|
|
94
|
+
* the server accepts is enough -- somebody granted only `delete-access-card`
|
|
95
|
+
* still has to open the screen to use it.
|
|
96
|
+
*/
|
|
97
|
+
const canAccessHidScreens = computed(() => {
|
|
98
|
+
const role = userAppRole.value;
|
|
99
|
+
if (!role) return false;
|
|
100
|
+
if (role.permissions.includes("*")) return true;
|
|
101
|
+
if (hasPermission(role, permissions, "site-settings", "manage-entry-pass")) return true;
|
|
102
|
+
return HID_ACTIONS.some((action) =>
|
|
103
|
+
hasPermission(role, permissions, "access-card-mgmt", action),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Only the gate is returned. `isAppRoleResolved` is deliberately NOT
|
|
108
|
+
// re-exported: `gate-defaults.test.mjs` sweeps every ref a permission
|
|
109
|
+
// composable returns and requires it to read false for an unresolved role,
|
|
110
|
+
// which a "has the lookup finished" flag is not. Callers that need it take it
|
|
111
|
+
// from `useLocalSetup()`, which is where it lives.
|
|
112
|
+
return { canAccessHidScreens };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default useHidAccessPermission;
|
|
@@ -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/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.14",
|
|
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
|
+
}
|