@7365admin1/layer-common 4.97.1-staging.575 → 4.97.1-staging.577

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.
@@ -28,6 +28,14 @@
28
28
  class="ml-2"
29
29
  style="max-width: 250px"
30
30
  />
31
+ <v-autocomplete
32
+ v-model="filterByCategory"
33
+ :items="categoryFilterItems"
34
+ label="Filter by category"
35
+ hide-details
36
+ class="ml-2"
37
+ style="max-width: 250px"
38
+ />
31
39
  <v-text-field
32
40
  v-model="startDate"
33
41
  :value="standardFormatDate(startDate)"
@@ -684,6 +692,7 @@ const pageRange = ref("-- - -- of --");
684
692
  const items = ref<Array<Record<string, any>>>([]);
685
693
 
686
694
  const filterByStatus = ref("All");
695
+ const filterByCategory = ref("All");
687
696
  const getPast30DaysDate = (): Date => {
688
697
  const today = new Date();
689
698
  const past30Days = new Date(today);
@@ -715,9 +724,10 @@ const {
715
724
  dateTo: moment(endDate.value, "DD/MM/YYYY").endOf("day"),
716
725
  search: searchText.value,
717
726
  status: filterByStatus.value == "All" ? "" : filterByStatus.value,
727
+ category: filterByCategory.value == "All" ? "" : filterByCategory.value,
718
728
  }),
719
729
  {
720
- watch: [filterByStatus, searchText, page, startDate, endDate],
730
+ watch: [filterByStatus, filterByCategory, searchText, page, startDate, endDate],
721
731
  }
722
732
  );
723
733
 
@@ -1068,6 +1078,12 @@ const subjectOptions = computed(() => {
1068
1078
  });
1069
1079
  });
1070
1080
 
1081
+ // Same categories as the create form, plus "All" (default: nothing hidden).
1082
+ const categoryFilterItems = computed(() => [
1083
+ "All",
1084
+ ...subjectOptions.value.map((item) => item.title),
1085
+ ]);
1086
+
1071
1087
  const isFileUploading = ref<boolean>(false);
1072
1088
  type TFile = {
1073
1089
  name: 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
- 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
  /**
@@ -73,6 +73,7 @@ export default function useFeedback() {
73
73
  provider = "",
74
74
  service = "",
75
75
  userId = "",
76
+ category = "",
76
77
  }: {
77
78
  page?: number;
78
79
  limit?: number;
@@ -85,6 +86,8 @@ export default function useFeedback() {
85
86
  provider?: string | any;
86
87
  service?: string;
87
88
  userId?: string;
89
+ /** Optional: one category (the feedback subject) or a comma list. Empty = all. */
90
+ category?: string;
88
91
  } = {}) {
89
92
  try {
90
93
  return useNuxtApp().$api<Record<string, any>>("/api/feedbacks2", {
@@ -100,6 +103,7 @@ export default function useFeedback() {
100
103
  status,
101
104
  provider,
102
105
  service,
106
+ ...(category && { category }),
103
107
  ...(currentUser.value?.type != "site" && {
104
108
  userId,
105
109
  }),
@@ -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 {
@@ -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.575",
5
+ "version": "4.97.1-staging.577",
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",