@7365admin1/layer-common 4.132.0 → 4.133.0

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,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.133.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2cfd359: Security roles: new permission boxes - See Blocklist, Block / Unblock Vehicles, Invite Visitor / Contractor, See Offline Data (needs core with the same keys). A security role gets a hidden `catalogue:security-v2` marker the first time it is saved in the editor; roles never saved keep today's access (the new boxes are pre-ticked from the old keys when opened). The marker is not counted in permission totals. `VehicleManagement` / `VehicleAddSelection` take optional `canSeeBlocklist` / `canManageBlocklist` props (default true = unchanged). Helper: `utils/security-catalogue`.
8
+
3
9
  ## 4.132.0
4
10
 
5
11
  ### Minor Changes
@@ -3346,11 +3352,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3346
3352
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3347
3353
  own rule from two endpoints the console already calls, unprojected:
3348
3354
 
3349
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3350
- GET /api/roles/id/:role that membership's role document
3355
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3356
+ GET /api/roles/id/:role that membership's role document
3351
3357
 
3352
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3353
- staff = member.type === "admin" && role.type === "admin"
3358
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3359
+ staff = member.type === "admin" && role.type === "admin"
3354
3360
 
3355
3361
  `role.default` is the marker because it is the only property of a platform
3356
3362
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -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
- permissions: permState.value.selected,
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.length} / ${props.catalogueSize}`
352
- : String(held.length);
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
- const requiredValue = requireListRule(definedModel.value);
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(props.id, definedModel.value);
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
- return offered > 0 ? `${held.length} / ${offered}` : String(held.length);
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
- { label: "Blocklist", value: "blocklist" },
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: { name: string; value: TVehicleTab }[] = [
584
+ const vehicleTabs = computed<{ name: string; value: TVehicleTab }[]>(() => [
582
585
  { name: "Residents", value: "residents" },
583
586
  { name: "Season Passes", value: "season-passes" },
584
- { name: "Block Listed", value: "block-listed" },
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.",
@@ -814,6 +826,13 @@ export default function useSecurityPermission() {
814
826
 
815
827
  },
816
828
 
829
+ "offline-data": {
830
+ "see-offline-data": {
831
+ check: true,
832
+ description: "See Offline Data",
833
+ },
834
+ },
835
+
817
836
  };
818
837
 
819
838
  return {
@@ -391,7 +391,12 @@ export const PERMISSIONS = {
391
391
  ],
392
392
 
393
393
  // ---- security ----
394
+ // Security catalogue v2 (2026-10-20). `catalogue:security-v2` is the marker the
395
+ // role editor stamps on save; it grants nothing (see utils/security-catalogue.ts).
396
+ "offline-data": ["see-offline-data"],
397
+ catalogue: ["security-v2"],
394
398
  "visitor-mgmt": [
399
+ "invite-visitor",
395
400
  "see-all-visitor",
396
401
  "add-visitor",
397
402
  "update-visitor",
@@ -399,6 +404,8 @@ export const PERMISSIONS = {
399
404
  "delete-visitor",
400
405
  ],
401
406
  "vehicle-mgmt": [
407
+ "see-blocklist",
408
+ "manage-blocklist",
402
409
  "see-all-vehicles",
403
410
  "add-vehicle",
404
411
  "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.132.0",
5
+ "version": "4.133.0",
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.",
@@ -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.includes(`${resource}:${action}`),
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
+ }
@@ -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 (canUpdate && (isAllowed || isUnblocked || (isRestorable && !restoresToBlocked))) {
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 (canUpdate && isBlocked) {
138
+ if (canBlock && isBlocked) {
131
139
  actions.push({
132
140
  key: "unblock",
133
141
  title: "Unblock",