@7365admin1/layer-common 4.97.1-staging.574 → 4.97.1-staging.576
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/components/HidUserEnrollment.vue +23 -22
- package/components/RolePermissionFormCreate.vue +5 -1
- package/components/RolePermissionFormPreviewUpdate.vue +34 -4
- package/components/RolePermissionMain.vue +3 -1
- package/components/VehicleAddSelection.vue +5 -1
- package/components/VehicleManagement.vue +10 -4
- package/composables/useSecurityPermission.ts +19 -0
- package/constants/permissions.ts +7 -0
- package/package.json +1 -1
- package/utils/hid-registration.ts +24 -0
- package/utils/org-modules.ts +17 -1
- package/utils/security-catalogue.ts +59 -0
- package/utils/vehicle-actions.ts +10 -2
|
@@ -1214,6 +1214,7 @@ import {
|
|
|
1214
1214
|
subjectLinkOf,
|
|
1215
1215
|
type HidEnrolmentSubject,
|
|
1216
1216
|
} from "../utils/hid-enrolment-subject";
|
|
1217
|
+
import { nextRegistration } from "../utils/hid-registration";
|
|
1217
1218
|
/* Mirrors core's audience rules — core is what enforces them; this only decides
|
|
1218
1219
|
what is worth offering and where an enrolment lands. */
|
|
1219
1220
|
import {
|
|
@@ -2954,8 +2955,8 @@ async function openEnroll() {
|
|
|
2954
2955
|
* reader chosen on the screen behind it.
|
|
2955
2956
|
*/
|
|
2956
2957
|
const [hidUserId, registration] = await Promise.all([
|
|
2957
|
-
getNextUid(),
|
|
2958
|
-
getNextRegistration(),
|
|
2958
|
+
getNextUid(selectedReaderId.value),
|
|
2959
|
+
getNextRegistration(selectedReaderId.value),
|
|
2959
2960
|
loadReaders({ resolveSelection: false }),
|
|
2960
2961
|
]);
|
|
2961
2962
|
resetForm();
|
|
@@ -4377,26 +4378,26 @@ async function getNextUid(readerId: string) {
|
|
|
4377
4378
|
return `UID${String(nextNumber).padStart(6, "0")}`;
|
|
4378
4379
|
}
|
|
4379
4380
|
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4381
|
+
/**
|
|
4382
|
+
* The next registration number ON THIS READER.
|
|
4383
|
+
*
|
|
4384
|
+
* Every reader numbers its own users (see `enrolOnReader`), so the list has to
|
|
4385
|
+
* come from the reader being allocated for. This used to read it with no
|
|
4386
|
+
* reader at all, which `getExistingHidReaderUsers` answers with an empty list,
|
|
4387
|
+
* so every enrolment proposed 000001 and the second person on a reader was
|
|
4388
|
+
* refused with "HID Amico identity already exists for this reader"
|
|
4389
|
+
* (2026-10-20, Landscape Management on HID 1 after Pest Management took
|
|
4390
|
+
* 000001). The roster on screen is the second opinion for the selected reader,
|
|
4391
|
+
* exactly as `getNextUid` consults it: it catches a number the device holds
|
|
4392
|
+
* but has not reported.
|
|
4393
|
+
*/
|
|
4394
|
+
async function getNextRegistration(readerId: string) {
|
|
4395
|
+
const existingUsers = await getExistingHidReaderUsers(readerId, ["id", "registration"]);
|
|
4396
|
+
const onScreen = readerId === selectedReaderId.value ? users.value : [];
|
|
4397
|
+
return nextRegistration([
|
|
4398
|
+
...existingUsers.map((user) => user.registration),
|
|
4399
|
+
...onScreen.map((user) => user.registration),
|
|
4400
|
+
]);
|
|
4400
4401
|
}
|
|
4401
4402
|
|
|
4402
4403
|
async function getExistingHidReaderIds(readerId: string) {
|
|
@@ -283,6 +283,7 @@ import useRole from "../composables/useRole";
|
|
|
283
283
|
import useUtils from "../composables/useUtils";
|
|
284
284
|
import useSite from "../composables/useSite";
|
|
285
285
|
import { readApiError } from "../utils/subscription-form";
|
|
286
|
+
import { withSecurityMarker } from "../utils/security-catalogue";
|
|
286
287
|
import { applyRolePreset, ROLE_PRESETS, type TRolePreset } from "../utils/inventory-presets";
|
|
287
288
|
|
|
288
289
|
/** Shown after "Couldn't save:" when the server gave no reason (network, timeout). */
|
|
@@ -533,7 +534,10 @@ async function submit() {
|
|
|
533
534
|
try {
|
|
534
535
|
await createRole({
|
|
535
536
|
name: name.value,
|
|
536
|
-
|
|
537
|
+
// Security catalogue only (it carries `offline-data`): stamp the v2 marker.
|
|
538
|
+
permissions: "offline-data" in (props.permissions || {})
|
|
539
|
+
? withSecurityMarker(permState.value.selected)
|
|
540
|
+
: permState.value.selected,
|
|
537
541
|
type: props.type,
|
|
538
542
|
org: props.org,
|
|
539
543
|
site: props.siteId,
|
|
@@ -272,6 +272,11 @@ import {
|
|
|
272
272
|
import useRole from "../composables/useRole";
|
|
273
273
|
import useUtils from "../composables/useUtils";
|
|
274
274
|
import { readApiError } from "../utils/subscription-form";
|
|
275
|
+
import {
|
|
276
|
+
countRealPermissions,
|
|
277
|
+
securityV2PreTicks,
|
|
278
|
+
withSecurityMarker,
|
|
279
|
+
} from "../utils/security-catalogue";
|
|
275
280
|
import {
|
|
276
281
|
holdsPermission,
|
|
277
282
|
withoutPermission,
|
|
@@ -348,8 +353,8 @@ const totalLabel = computed(() => {
|
|
|
348
353
|
const held = definedModel.value ?? [];
|
|
349
354
|
if (holdsWildcard.value) return "All";
|
|
350
355
|
return props.catalogueSize > 0
|
|
351
|
-
? `${held
|
|
352
|
-
: String(held
|
|
356
|
+
? `${countRealPermissions(held)} / ${props.catalogueSize}`
|
|
357
|
+
: String(countRealPermissions(held));
|
|
353
358
|
});
|
|
354
359
|
|
|
355
360
|
/**
|
|
@@ -638,6 +643,23 @@ const edit = defineModel<boolean>("edit", {
|
|
|
638
643
|
default: false,
|
|
639
644
|
});
|
|
640
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Security catalogue only: the Security tree carries the `offline-data`
|
|
648
|
+
* module. Other apps' editors never match, so they are untouched.
|
|
649
|
+
*/
|
|
650
|
+
const isSecurityCatalogue = computed(
|
|
651
|
+
() => "offline-data" in (props.permissions as Record<string, unknown>)
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
// Opening an UNMARKED security role for edit: pre-tick the new boxes the role
|
|
655
|
+
// already effectively has (legacy fallback), so an unrelated save never strips
|
|
656
|
+
// access. Cancel restores the original list.
|
|
657
|
+
watch(edit, (on) => {
|
|
658
|
+
if (!on || !isSecurityCatalogue.value) return;
|
|
659
|
+
const add = securityV2PreTicks(definedModel.value ?? []);
|
|
660
|
+
if (add.length) definedModel.value = [...definedModel.value, ...add];
|
|
661
|
+
});
|
|
662
|
+
|
|
641
663
|
/**
|
|
642
664
|
* CANCEL THROWS THE DRAFT AWAY.
|
|
643
665
|
*
|
|
@@ -688,7 +710,10 @@ const errorMessages = computed(() => {
|
|
|
688
710
|
return equalValue;
|
|
689
711
|
}
|
|
690
712
|
|
|
691
|
-
|
|
713
|
+
// The marker is not a permission: a list holding only it is still empty.
|
|
714
|
+
const requiredValue = requireListRule(
|
|
715
|
+
(definedModel.value ?? []).filter((p) => countRealPermissions([p]) > 0)
|
|
716
|
+
);
|
|
692
717
|
if (requiredValue) {
|
|
693
718
|
return requiredValue;
|
|
694
719
|
}
|
|
@@ -706,7 +731,12 @@ async function submit() {
|
|
|
706
731
|
disable.value = true;
|
|
707
732
|
message.value = "";
|
|
708
733
|
try {
|
|
709
|
-
await updatePermissionById(
|
|
734
|
+
await updatePermissionById(
|
|
735
|
+
props.id,
|
|
736
|
+
isSecurityCatalogue.value
|
|
737
|
+
? withSecurityMarker(definedModel.value)
|
|
738
|
+
: definedModel.value
|
|
739
|
+
);
|
|
710
740
|
emit("success");
|
|
711
741
|
} catch (error: any) {
|
|
712
742
|
// `error.response` is undefined on a network or timeout failure, so the old
|
|
@@ -208,6 +208,7 @@
|
|
|
208
208
|
// Relative import: this layer's `utils/` is not auto-imported into consumers.
|
|
209
209
|
import { roleLabel, safeReturnTo, showSiteField, sitePickerRoleLabel } from "../utils/role";
|
|
210
210
|
import { holdsPermission } from "../utils/permission-spellings";
|
|
211
|
+
import { countRealPermissions } from "../utils/security-catalogue";
|
|
211
212
|
import {
|
|
212
213
|
catalogueForOrg,
|
|
213
214
|
catalogueForOwnerGrant,
|
|
@@ -570,7 +571,8 @@ function getPermissionCount(permissions: unknown): string {
|
|
|
570
571
|
if (held.includes("*")) return "All";
|
|
571
572
|
|
|
572
573
|
const offered = permissionCatalogueSize.value;
|
|
573
|
-
|
|
574
|
+
const count = countRealPermissions(held);
|
|
575
|
+
return offered > 0 ? `${count} / ${offered}` : String(count);
|
|
574
576
|
}
|
|
575
577
|
|
|
576
578
|
function updateRolePermissionsInList(
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
type TVehicleAddChoice = TVehicleType | "seasonpass" | "whitelist-bulk";
|
|
28
28
|
|
|
29
29
|
const prop = defineProps({
|
|
30
|
+
// Security catalogue v2: false hides the Blocklist choice. Default = today.
|
|
31
|
+
canManageBlocklist: { type: Boolean, default: true },
|
|
30
32
|
});
|
|
31
33
|
|
|
32
34
|
const emit = defineEmits(['cancel', 'select']);
|
|
@@ -37,7 +39,9 @@ const selection = computed<{label: string, value: TVehicleAddChoice}[]>(() => {
|
|
|
37
39
|
// Many whitelist vehicles at once, typed or pasted into a sheet.
|
|
38
40
|
{ label: "Whitelist (Bulk)", value: "whitelist-bulk" },
|
|
39
41
|
{ label: "Season Pass", value: "seasonpass" },
|
|
40
|
-
|
|
42
|
+
...(prop.canManageBlocklist
|
|
43
|
+
? [{ label: "Blocklist", value: "blocklist" as TVehicleAddChoice }]
|
|
44
|
+
: []),
|
|
41
45
|
];
|
|
42
46
|
})
|
|
43
47
|
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
<!-- <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" /> -->
|
|
138
138
|
|
|
139
139
|
<v-dialog v-model="dialog.showSelection" width="450" persistent>
|
|
140
|
-
<VehicleAddSelection @cancel="dialog.showSelection = false" @select="handleSelectVehicleStatus" />
|
|
140
|
+
<VehicleAddSelection :can-manage-blocklist="canManageBlocklist" @cancel="dialog.showSelection = false" @select="handleSelectVehicleStatus" />
|
|
141
141
|
</v-dialog>
|
|
142
142
|
|
|
143
143
|
<!-- Many whitelist vehicles at once. Wider than the one-vehicle form: it
|
|
@@ -495,6 +495,9 @@ const props = defineProps({
|
|
|
495
495
|
canDeleteVehicle: { type: Boolean, default: false },
|
|
496
496
|
canViewVehicleDetails: { type: Boolean, default: false },
|
|
497
497
|
canApproveVehicle: { type: Boolean, default: false },
|
|
498
|
+
// Security catalogue v2. Defaults keep today's behaviour for every other app.
|
|
499
|
+
canSeeBlocklist: { type: Boolean, default: true },
|
|
500
|
+
canManageBlocklist: { type: Boolean, default: undefined },
|
|
498
501
|
site: { type: String, required: true },
|
|
499
502
|
org: { type: String, required: true },
|
|
500
503
|
app: { type: String, required: true },
|
|
@@ -578,11 +581,13 @@ const historyHeaders = [
|
|
|
578
581
|
* resident / season pass that was blocked
|
|
579
582
|
*/
|
|
580
583
|
type TVehicleTab = "residents" | "season-passes" | "block-listed";
|
|
581
|
-
const vehicleTabs
|
|
584
|
+
const vehicleTabs = computed<{ name: string; value: TVehicleTab }[]>(() => [
|
|
582
585
|
{ name: "Residents", value: "residents" },
|
|
583
586
|
{ name: "Season Passes", value: "season-passes" },
|
|
584
|
-
|
|
585
|
-
]
|
|
587
|
+
...(props.canSeeBlocklist
|
|
588
|
+
? [{ name: "Block Listed", value: "block-listed" as TVehicleTab }]
|
|
589
|
+
: []),
|
|
590
|
+
])
|
|
586
591
|
const activeTab = ref<TVehicleTab>("residents");
|
|
587
592
|
|
|
588
593
|
|
|
@@ -1134,6 +1139,7 @@ const vehiclePermissions = computed(() => ({
|
|
|
1134
1139
|
canUpdateVehicle: props.canUpdateVehicle && !revoked.update,
|
|
1135
1140
|
canDeleteVehicle: props.canDeleteVehicle && !revoked.delete,
|
|
1136
1141
|
canApproveVehicle: props.canApproveVehicle && !revoked.approve,
|
|
1142
|
+
canManageBlocklist: props.canManageBlocklist,
|
|
1137
1143
|
}));
|
|
1138
1144
|
|
|
1139
1145
|
/**
|
|
@@ -115,6 +115,14 @@ export default function useSecurityPermission() {
|
|
|
115
115
|
},
|
|
116
116
|
|
|
117
117
|
"vehicle-mgmt": {
|
|
118
|
+
"see-blocklist": {
|
|
119
|
+
check: true,
|
|
120
|
+
description: "See Blocklist",
|
|
121
|
+
},
|
|
122
|
+
"manage-blocklist": {
|
|
123
|
+
check: true,
|
|
124
|
+
description: "Block / Unblock Vehicles",
|
|
125
|
+
},
|
|
118
126
|
"add-vehicle": {
|
|
119
127
|
check: true,
|
|
120
128
|
description: "Allows the user to add a new vehicle to the system.",
|
|
@@ -291,6 +299,10 @@ export default function useSecurityPermission() {
|
|
|
291
299
|
},
|
|
292
300
|
|
|
293
301
|
"visitor-mgmt": {
|
|
302
|
+
"invite-visitor": {
|
|
303
|
+
check: true,
|
|
304
|
+
description: "Invite Visitor / Contractor",
|
|
305
|
+
},
|
|
294
306
|
"add-visitor": {
|
|
295
307
|
check: true,
|
|
296
308
|
description: "Allows the user to add a new visitor to the system.",
|
|
@@ -834,6 +846,13 @@ export default function useSecurityPermission() {
|
|
|
834
846
|
|
|
835
847
|
},
|
|
836
848
|
|
|
849
|
+
"offline-data": {
|
|
850
|
+
"see-offline-data": {
|
|
851
|
+
check: true,
|
|
852
|
+
description: "See Offline Data",
|
|
853
|
+
},
|
|
854
|
+
},
|
|
855
|
+
|
|
837
856
|
};
|
|
838
857
|
|
|
839
858
|
return {
|
package/constants/permissions.ts
CHANGED
|
@@ -413,7 +413,12 @@ export const PERMISSIONS = {
|
|
|
413
413
|
],
|
|
414
414
|
|
|
415
415
|
// ---- security ----
|
|
416
|
+
// Security catalogue v2 (2026-10-20). `catalogue:security-v2` is the marker the
|
|
417
|
+
// role editor stamps on save; it grants nothing (see utils/security-catalogue.ts).
|
|
418
|
+
"offline-data": ["see-offline-data"],
|
|
419
|
+
catalogue: ["security-v2"],
|
|
416
420
|
"visitor-mgmt": [
|
|
421
|
+
"invite-visitor",
|
|
417
422
|
"see-all-visitor",
|
|
418
423
|
"add-visitor",
|
|
419
424
|
"update-visitor",
|
|
@@ -421,6 +426,8 @@ export const PERMISSIONS = {
|
|
|
421
426
|
"delete-visitor",
|
|
422
427
|
],
|
|
423
428
|
"vehicle-mgmt": [
|
|
429
|
+
"see-blocklist",
|
|
430
|
+
"manage-blocklist",
|
|
424
431
|
"see-all-vehicles",
|
|
425
432
|
"add-vehicle",
|
|
426
433
|
"update-vehicle",
|
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.97.1-staging.
|
|
5
|
+
"version": "4.97.1-staging.576",
|
|
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,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE NEXT REGISTRATION NUMBER ON ONE READER.
|
|
3
|
+
*
|
|
4
|
+
* Each reader numbers its own users, so the input is that reader's existing
|
|
5
|
+
* registration numbers (from the device, plus the roster on screen when it is
|
|
6
|
+
* the selected reader). Numeric ones count; the next is the highest plus one,
|
|
7
|
+
* zero padded to six digits or to the widest number already in use, whichever
|
|
8
|
+
* is wider. Anything that is not a plain number (`USR-...`, blank, undefined)
|
|
9
|
+
* is ignored rather than breaking the allocation.
|
|
10
|
+
*
|
|
11
|
+
* Pure. `HidUserEnrollment.vue` reads the lists and calls this.
|
|
12
|
+
*/
|
|
13
|
+
export function nextRegistration(registrations: readonly unknown[]): string {
|
|
14
|
+
let max = 0;
|
|
15
|
+
let width = 6;
|
|
16
|
+
for (const value of registrations) {
|
|
17
|
+
const text = String(value ?? "").trim();
|
|
18
|
+
const match = text.match(/^(\d+)$/);
|
|
19
|
+
if (!match) continue;
|
|
20
|
+
max = Math.max(max, Number(match[1]));
|
|
21
|
+
width = Math.max(width, match[1].length);
|
|
22
|
+
}
|
|
23
|
+
return String(max + 1).padStart(width, "0");
|
|
24
|
+
}
|
package/utils/org-modules.ts
CHANGED
|
@@ -54,6 +54,7 @@ export const ALWAYS_ALLOWED_MODULES: readonly string[] = Object.freeze([
|
|
|
54
54
|
"roles",
|
|
55
55
|
"roles-and-permissions",
|
|
56
56
|
"site-settings",
|
|
57
|
+
"offline-data",
|
|
57
58
|
]);
|
|
58
59
|
|
|
59
60
|
/**
|
|
@@ -246,6 +247,21 @@ export function ownerGrant(
|
|
|
246
247
|
return chosen ? (permissions as unknown[]).map(String) : null;
|
|
247
248
|
}
|
|
248
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Legacy grant keys that also carry a newer key split out of them. Mirrors
|
|
252
|
+
* core `PERMISSION_COVERS` (security catalogue v2 entries only).
|
|
253
|
+
*/
|
|
254
|
+
const GRANT_COVERS: Readonly<Record<string, readonly string[]>> = {
|
|
255
|
+
"vehicle-mgmt:see-all-vehicles": ["vehicle-mgmt:see-blocklist"],
|
|
256
|
+
"vehicle-mgmt:update-vehicle": ["vehicle-mgmt:manage-blocklist"],
|
|
257
|
+
"visitor-mgmt:add-visitor": ["visitor-mgmt:invite-visitor"],
|
|
258
|
+
"visitorManagement:add-visitor": ["visitor-mgmt:invite-visitor"],
|
|
259
|
+
"visitor:create": ["visitor-mgmt:invite-visitor"],
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const grantHolds = (grant: readonly string[], key: string) =>
|
|
263
|
+
grant.includes(key) || grant.some((g) => GRANT_COVERS[g]?.includes(key));
|
|
264
|
+
|
|
249
265
|
/**
|
|
250
266
|
* Narrow a `{ resource: { action } }` catalogue to that grant. Running the
|
|
251
267
|
* organisation (`ALWAYS_ALLOWED_MODULES`) is never narrowed, matching the
|
|
@@ -266,7 +282,7 @@ export function catalogueForOwnerGrant<T extends Record<string, unknown>>(
|
|
|
266
282
|
if (!actions || typeof actions !== "object") continue;
|
|
267
283
|
const kept = Object.fromEntries(
|
|
268
284
|
Object.entries(actions as Record<string, unknown>).filter(([action]) =>
|
|
269
|
-
grant
|
|
285
|
+
grantHolds(grant, `${resource}:${action}`),
|
|
270
286
|
),
|
|
271
287
|
);
|
|
272
288
|
if (Object.keys(kept).length) narrowed[resource] = kept;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { holdsPermission } from "./permission-spellings.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Security catalogue v2 marker. The role editor adds it on save (security roles
|
|
5
|
+
* only). It grants nothing -- it only says "this role was saved with the new
|
|
6
|
+
* boxes, so check them exactly". A role without it keeps today's behaviour.
|
|
7
|
+
* Mirrors core/API-core; keep the three in step.
|
|
8
|
+
*/
|
|
9
|
+
export const SECURITY_CATALOGUE_MARKER = "catalogue:security-v2";
|
|
10
|
+
|
|
11
|
+
/** new key -> legacy keys that stand in for it on an UNMARKED role. null = open to all. */
|
|
12
|
+
export const SECURITY_V2_FALLBACKS: Readonly<Record<string, readonly string[] | null>> = {
|
|
13
|
+
"vehicle-mgmt:see-blocklist": ["vehicle-mgmt:see-all-vehicles"],
|
|
14
|
+
"vehicle-mgmt:manage-blocklist": ["vehicle-mgmt:update-vehicle"],
|
|
15
|
+
"visitor-mgmt:invite-visitor": [
|
|
16
|
+
"visitor-mgmt:add-visitor",
|
|
17
|
+
"visitorManagement:add-visitor",
|
|
18
|
+
"visitor:create",
|
|
19
|
+
],
|
|
20
|
+
"offline-data:see-offline-data": null,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const isSecurityV2Marked = (perms?: readonly string[] | null) =>
|
|
24
|
+
!!perms?.includes(SECURITY_CATALOGUE_MARKER);
|
|
25
|
+
|
|
26
|
+
/** Real keys = anything that is not the marker or the wildcard. */
|
|
27
|
+
export const countRealPermissions = (perms?: readonly string[] | null) =>
|
|
28
|
+
(perms ?? []).filter((p) => p !== SECURITY_CATALOGUE_MARKER).length;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Effective grant for a v2 key. Empty list / "*" = allowed (same as
|
|
32
|
+
* usePermission). Unmarked role -> legacy fallback; marked role -> exact key.
|
|
33
|
+
*/
|
|
34
|
+
export function effectiveSecurityGrant(
|
|
35
|
+
perms: readonly string[] | undefined | null,
|
|
36
|
+
key: string,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (!perms?.length || perms.includes("*")) return true;
|
|
39
|
+
if (isSecurityV2Marked(perms)) return holdsPermission(perms, key);
|
|
40
|
+
const legacy = SECURITY_V2_FALLBACKS[key];
|
|
41
|
+
if (legacy === undefined) return holdsPermission(perms, key);
|
|
42
|
+
if (legacy === null) return true;
|
|
43
|
+
return legacy.some((k) => holdsPermission(perms, k));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Marker to append on save: only when the list has a real key and no "*". */
|
|
47
|
+
export function withSecurityMarker(perms: readonly string[]): string[] {
|
|
48
|
+
const keys = perms.filter((p) => p !== SECURITY_CATALOGUE_MARKER);
|
|
49
|
+
if (!keys.length || keys.includes("*")) return keys;
|
|
50
|
+
return [...keys, SECURITY_CATALOGUE_MARKER];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** New keys to pre-tick when opening an UNMARKED role in the editor. */
|
|
54
|
+
export function securityV2PreTicks(perms: readonly string[]): string[] {
|
|
55
|
+
if (!perms.length || perms.includes("*") || isSecurityV2Marked(perms)) return [];
|
|
56
|
+
return Object.keys(SECURITY_V2_FALLBACKS).filter(
|
|
57
|
+
(k) => !perms.includes(k) && effectiveSecurityGrant(perms, k),
|
|
58
|
+
);
|
|
59
|
+
}
|
package/utils/vehicle-actions.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type VehicleActionPermissions = {
|
|
|
27
27
|
canUpdateVehicle?: boolean;
|
|
28
28
|
canDeleteVehicle?: boolean;
|
|
29
29
|
canApproveVehicle?: boolean;
|
|
30
|
+
/** Block / Unblock. Omitted = allowed (today's behaviour). */
|
|
31
|
+
canManageBlocklist?: boolean;
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
export type VehicleAction = {
|
|
@@ -81,6 +83,12 @@ export function vehiclePlateActions(
|
|
|
81
83
|
const isRestorable = RESTORABLE.has(status);
|
|
82
84
|
|
|
83
85
|
const canUpdate = !!permissions.canUpdateVehicle;
|
|
86
|
+
// Server accepts manage-blocklist OR update-vehicle. When the caller passes the
|
|
87
|
+
// flag it decides alone; absent, block follows update (today's rule).
|
|
88
|
+
const canBlock =
|
|
89
|
+
permissions.canManageBlocklist === undefined
|
|
90
|
+
? canUpdate
|
|
91
|
+
: !!permissions.canManageBlocklist;
|
|
84
92
|
const actions: VehicleAction[] = [];
|
|
85
93
|
|
|
86
94
|
if (canUpdate && (isAllowed || isBlocked)) {
|
|
@@ -118,7 +126,7 @@ export function vehiclePlateActions(
|
|
|
118
126
|
// same thing. A deleted whitelist plate keeps Block - that turns it into a
|
|
119
127
|
// blocked plate, which Restore (back to allowed) does not.
|
|
120
128
|
const restoresToBlocked = isRestorable && type === "blocklist";
|
|
121
|
-
if (
|
|
129
|
+
if (canBlock && (isAllowed || isUnblocked || (isRestorable && !restoresToBlocked))) {
|
|
122
130
|
actions.push({
|
|
123
131
|
key: "block",
|
|
124
132
|
title: "Block",
|
|
@@ -127,7 +135,7 @@ export function vehiclePlateActions(
|
|
|
127
135
|
});
|
|
128
136
|
}
|
|
129
137
|
|
|
130
|
-
if (
|
|
138
|
+
if (canBlock && isBlocked) {
|
|
131
139
|
actions.push({
|
|
132
140
|
key: "unblock",
|
|
133
141
|
title: "Unblock",
|