@7365admin1/layer-common 4.149.1 → 4.150.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +16 -4
  2. package/components/EntryPassInformation.vue +13 -0
  3. package/components/HidAccessPermissions.vue +202 -40
  4. package/components/HidEnabledGate.vue +29 -10
  5. package/components/HidIntercomManagement.vue +26 -13
  6. package/components/HidProfileQrCode.vue +6 -2
  7. package/components/HidQrCodeConfiguration.vue +2 -2
  8. package/components/HidReaderForm.vue +239 -1
  9. package/components/HidReaderManagement.vue +26 -8
  10. package/components/HidReaderUserRoster.vue +3 -1
  11. package/components/HidServiceSettingsPanel.vue +2 -2
  12. package/components/HidUserEnrollment.vue +1662 -162
  13. package/components/MemberMain.vue +1 -1
  14. package/components/OvernightParkingAvailability.vue +123 -11
  15. package/components/SiteSettings.vue +5 -2
  16. package/components/VisitorForm.vue +39 -11
  17. package/composables/useCleaningPermission.ts +11 -0
  18. package/composables/useHidAccessPermission.ts +176 -81
  19. package/composables/useHidAmico.ts +37 -0
  20. package/composables/useHidNavigation.ts +44 -23
  21. package/composables/useLandscapingPermission.ts +11 -0
  22. package/composables/useMechanicalElectricalPermission.ts +5 -0
  23. package/composables/usePestControlPermission.ts +11 -0
  24. package/composables/usePoolMaintenancePermission.ts +11 -0
  25. package/composables/usePropertyPermission.ts +31 -7
  26. package/composables/useSecurityPermission.ts +33 -13
  27. package/composables/useServiceProvider.ts +9 -4
  28. package/constants/permissions.ts +73 -21
  29. package/package.json +1 -1
  30. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +3 -0
  31. package/types/overnight-parking.d.ts +14 -0
  32. package/types/site.d.ts +7 -1
  33. package/utils/hid-enrolment-subject.ts +226 -4
  34. package/utils/hid-permission-assignments.ts +75 -6
  35. package/utils/hid-qr-validity.ts +25 -0
  36. package/utils/hid-reader-audiences.ts +557 -0
  37. package/utils/hid-registration.ts +24 -0
  38. package/utils/module-routes.ts +15 -2
  39. package/utils/nav-sections.ts +1 -1
@@ -155,7 +155,7 @@
155
155
  :title="r"
156
156
  data-test="member-role-chip"
157
157
  >
158
- {{ r }}
158
+ {{ r ? r.charAt(0).toUpperCase() + r.slice(1) : r }}
159
159
  </StatusChip>
160
160
  <span
161
161
  v-if="memberAccess(item, orgLevel).roles.length > 2"
@@ -6,17 +6,67 @@
6
6
  >
7
7
  <v-row no-gutters>
8
8
  <v-col cols="12">
9
- <v-row no-gutters class="d-flex align-center ga-2 justify-end">
10
- <span class="d-flex ga-2 align-center">
11
- <p>Allow Opening Barrier via Plate Number?</p>
9
+ <!-- Which QR code types a resident's visitor invitation can carry. -->
10
+ <template v-for="option in visitorQrCodes" :key="option.type">
11
+ <div class="d-flex align-center justify-space-between py-3">
12
+ <div>
13
+ <div class="text-body-2 font-weight-medium">{{ visitorQrLabels[option.type].title }}</div>
14
+ <p class="text-caption text-medium-emphasis mt-1 mb-0">
15
+ {{ visitorQrLabels[option.type].description }}
16
+ </p>
17
+ </div>
12
18
  <v-switch
13
- v-model="isAllowPlateNumberOpenBarrier"
14
- hide-details
19
+ v-model="option.isEnabled"
15
20
  color="success"
16
- size="large"
21
+ inset
22
+ density="compact"
23
+ hide-details
24
+ class="flex-grow-0 ml-4"
17
25
  />
18
- </span>
19
- </v-row>
26
+ </div>
27
+ <!-- How long an HID visitor pass lasts. Only asked once HID is on. -->
28
+ <v-radio-group
29
+ v-if="option.type === 'hid' && option.isEnabled"
30
+ v-model="option.usage"
31
+ inline
32
+ hide-details
33
+ density="compact"
34
+ color="success"
35
+ class="pb-3"
36
+ >
37
+ <v-radio
38
+ v-for="usage in hidQrUsageOptions"
39
+ :key="usage.value"
40
+ :value="usage.value"
41
+ class="mr-6"
42
+ >
43
+ <template #label>
44
+ <div>
45
+ <div class="text-body-2">{{ usage.title }}</div>
46
+ <div class="text-caption text-medium-emphasis">{{ usage.description }}</div>
47
+ </div>
48
+ </template>
49
+ </v-radio>
50
+ </v-radio-group>
51
+ <v-divider />
52
+ </template>
53
+ <div class="d-flex align-center justify-space-between py-3">
54
+ <div>
55
+ <div class="text-body-2 font-weight-medium">Allow Opening Barrier via Plate Number?</div>
56
+ <p class="text-caption text-medium-emphasis mt-1 mb-0">
57
+ When enabled, the barrier can be opened for visitors by matching their vehicle plate number.
58
+ </p>
59
+ </div>
60
+ <v-switch
61
+ v-model="isAllowPlateNumberOpenBarrier"
62
+ color="success"
63
+ inset
64
+ density="compact"
65
+ hide-details
66
+ class="flex-grow-0 ml-4"
67
+ />
68
+ </div>
69
+ <v-divider class="mb-4" />
20
70
  <v-row
21
71
  no-gutters
22
72
  class="d-flex align-center ga-2 justify-space-between"
@@ -220,6 +270,41 @@ const isAllowPlateNumberOpenBarrier = ref(false);
220
270
  const autoApproveOvernightParking = ref(false);
221
271
  const isHydrating = ref(true);
222
272
 
273
+ const visitorQrLabels: Record<TVisitorQrCodeType, { title: string; description: string }> = {
274
+ hid: {
275
+ title: "HID QR Code Visitor Invitation",
276
+ description: "When enabled, HID QR codes will be generated for visitor invitations instead of standard entry pass QR codes.",
277
+ },
278
+ entryPass: {
279
+ title: "Entry Pass QR Code Visitor Invitation",
280
+ description: "When enabled, entry pass QR codes will be generated for visitor invitations.",
281
+ },
282
+ security: {
283
+ title: "Security QR Code Visitor Invitation",
284
+ description: "When enabled, security QR codes will be generated for visitor invitations.",
285
+ },
286
+ };
287
+ // Whole day until the end of the visit day, or one entry and then it expires.
288
+ // Sites that saved before this existed have none, and read as whole day.
289
+ const DEFAULT_HID_QR_USAGE: TVisitorQrUsage = "wholeDay";
290
+ const hidQrUsageOptions: { value: TVisitorQrUsage; title: string; description: string }[] = [
291
+ {
292
+ value: "wholeDay",
293
+ title: "Whole day usage",
294
+ description: "The visitor can use the QR code as many times as needed until the end of the visit day.",
295
+ },
296
+ {
297
+ value: "singleUse",
298
+ title: "Single usage",
299
+ description: "The QR code expires after the visitor's first entry.",
300
+ },
301
+ ];
302
+ const visitorQrCodes = ref<TVisitorQrCode[]>([
303
+ { type: "hid", isEnabled: false, usage: DEFAULT_HID_QR_USAGE },
304
+ { type: "security", isEnabled: false },
305
+ { type: "entryPass", isEnabled: false },
306
+ ]);
307
+
223
308
  const { requiredRule } = useUtils();
224
309
  const { generateTimeSlots, generateTimeSlotsFromStart } = useFacilityUtils();
225
310
  const { currentUser } = useLocalAuth();
@@ -290,6 +375,16 @@ watch(
290
375
  autoApproveOvernightParking.value =
291
376
  data?.autoApproveOvernightParking === true ||
292
377
  data?.isAutoApproved === true;
378
+ // Set in place so the toggle watcher only fires on a real change.
379
+ visitorQrCodes.value.forEach((option) => {
380
+ const saved = (data?.visitorQrCodes as TVisitorQrCode[] | undefined)?.find(
381
+ (q) => q.type === option.type
382
+ );
383
+ option.isEnabled = saved?.isEnabled === true;
384
+ if (option.type === "hid") {
385
+ option.usage = saved?.usage ?? DEFAULT_HID_QR_USAGE;
386
+ }
387
+ });
293
388
  setTimeout(() => {
294
389
  isHydrating.value = false;
295
390
  }, 500);
@@ -374,7 +469,7 @@ watch(
374
469
  isAllowPlateNumberOpenBarrier,
375
470
  (newValue, oldValue) => {
376
471
  if (oldValue === newValue || isHydrating.value) return;
377
- handleSave("toggle");
472
+ handleSave("toggle", "Plate number barrier setting updated successfully.");
378
473
  },
379
474
  { immediate: false }
380
475
  );
@@ -388,7 +483,19 @@ watch(
388
483
  { immediate: false }
389
484
  );
390
485
 
391
- async function handleSave(action: "toggle" | "save") {
486
+ watch(
487
+ visitorQrCodes,
488
+ () => {
489
+ if (isHydrating.value) return;
490
+ handleSave("toggle", "Visitor QR settings updated successfully.");
491
+ },
492
+ { deep: true }
493
+ );
494
+
495
+ async function handleSave(
496
+ action: "toggle" | "save",
497
+ successMessage = "Overnight parking settings updated successfully."
498
+ ) {
392
499
  if (!isValid.value && action === "save") {
393
500
  message.value = "Please fill in all required fields.";
394
501
  messageColor.value = "error";
@@ -403,6 +510,11 @@ async function handleSave(action: "toggle" | "save") {
403
510
  isAllowPlateNumberOpenBarrier: isAllowPlateNumberOpenBarrier.value,
404
511
  autoApproveOvernightParking: autoApproveOvernightParking.value,
405
512
  isAutoApproved: autoApproveOvernightParking.value,
513
+ visitorQrCodes: visitorQrCodes.value.map(({ type, isEnabled, usage }) => ({
514
+ type,
515
+ isEnabled,
516
+ ...(type === "hid" ? { usage: usage ?? DEFAULT_HID_QR_USAGE } : {}),
517
+ })),
406
518
  updatedBy: updatedByValue.value,
407
519
  };
408
520
 
@@ -414,7 +526,7 @@ async function handleSave(action: "toggle" | "save") {
414
526
  userType.value
415
527
  );
416
528
  refreshAvailability();
417
- message.value = "Overnight parking settings updated successfully.";
529
+ message.value = successMessage;
418
530
  messageColor.value = "success";
419
531
  messageSnackbar.value = true;
420
532
  } catch (error) {
@@ -186,10 +186,12 @@
186
186
  </v-expansion-panel-text>
187
187
  </v-expansion-panel>
188
188
 
189
- <v-expansion-panel>
189
+ <!-- HID on or off for the site and the HID QR configuration:
190
+ `hid-mgmt:manage-hid-settings` (`useHidAccessPermission`). -->
191
+ <v-expansion-panel v-if="canManageHidSettings">
190
192
  <v-expansion-panel-title>
191
193
  <v-icon class="mr-2">mdi-face-recognition</v-icon>
192
- HID Face Reader
194
+ HID Reader
193
195
  </v-expansion-panel-title>
194
196
  <v-expansion-panel-text>
195
197
  <HidServiceSettingsPanel
@@ -312,6 +314,7 @@ const { canManageSiteInformation,
312
314
  canManageVehicleQr,
313
315
  canManageSiteSettings: canManageAnySiteSetting,
314
316
  } = useSettingsPermission();
317
+ const { canManageHidSettings } = useHidAccessPermission();
315
318
 
316
319
  const { getSiteById, updateSitebyId, updateSiteInformation } =
317
320
  useSiteSettings();
@@ -350,6 +350,7 @@
350
350
  :hid-qr-printer-configured="hasHidQrPrinterConfig"
351
351
  :hid-reader-name="hidQrReader?.name || ''"
352
352
  :hid-portal-name="hidQrReader?.portalName || ''"
353
+ :hid-qr-usage="hidQrUsage"
353
354
  @update:available-qr-count="(val) => availableQrCount = val" />
354
355
  </v-col>
355
356
 
@@ -838,6 +839,7 @@ import useLocalAuth from "../composables/useLocalAuth";
838
839
  import useBuilding from "../composables/useBuilding";
839
840
  import useVisitor, { getVisitorFieldSwitches, NRIC_LAST4_HINT, VEHICLE_REQUIRED_MESSAGE } from "../composables/useVisitor";
840
841
  import useSiteEntryPassSettings from "../composables/useSiteEntryPassSettings";
842
+ import { hidQrUsageFromSettings, minutesUntilEndOfDay } from "../utils/hid-qr-validity";
841
843
  import useAccessManagement from "../composables/useAccessManagement";
842
844
  import usePeople from "../composables/usePeople";
843
845
  import useHidAmico from "../composables/useHidAmico";
@@ -912,7 +914,7 @@ const currentAutofillSource = ref<AutofillSource>(null);
912
914
 
913
915
  const { requiredRule, debounce, UTCToLocalTIme, maskNRIC, getImage } = useUtils();
914
916
  const { addFile } = useFile();
915
- const { getSiteById, updateSitebyId } = useSiteSettings();
917
+ const { getSiteById, getOvernightParkingAvailabilityV2, updateSitebyId } = useSiteSettings();
916
918
  const { currentUser } = useLocalAuth();
917
919
  const { getBlocksSelection, getLevelsSelection, getUnitSelection } =
918
920
  useBuilding();
@@ -927,7 +929,7 @@ const {
927
929
  const { getBySiteId: getEntryPassSettingsBySiteId } =
928
930
  useSiteEntryPassSettings();
929
931
  const { createVisitorPass, signQr, visitorCheckout } = useAccessManagement();
930
- const { getReaders, issueVisitorQr } = useHidAmico();
932
+ const { getEnrollmentReaders, issueVisitorQr } = useHidAmico();
931
933
  const { testConnection } = useWebUsb();
932
934
  const { findVisitorPeopleAnyType, deletePersonRecords } = usePeople();
933
935
  const { getPassKeysByPageSearch } = usePassKey();
@@ -1230,11 +1232,6 @@ const hidQrTemplate = computed(() => {
1230
1232
  return hidQrCodePassConfig.value?.template || {};
1231
1233
  });
1232
1234
 
1233
- const hidQrValidityMinutes = computed(() => {
1234
- const value = Number(hidQrCodePassConfig.value?.validityMinutes);
1235
- return Number.isFinite(value) && value > 0 ? value : 60;
1236
- });
1237
-
1238
1235
  const hidQrConfiguredReaderId = computed(() => String(hidQrCodePassConfig.value?.readerId || ""));
1239
1236
  type HidQrReaderSummary = {
1240
1237
  _id: string;
@@ -1256,7 +1253,7 @@ const hidQrReaderId = computed(() => (
1256
1253
  * `hidQrCodePassAllowed`, which is why the switch belongs here and not in each
1257
1254
  * of them.
1258
1255
  */
1259
- const HID_VISITOR_QR_ENABLED = false;
1256
+ const HID_VISITOR_QR_ENABLED = true;
1260
1257
 
1261
1258
  const hidQrCodePassAllowed = computed(() => {
1262
1259
  return (
@@ -1266,6 +1263,12 @@ const hidQrCodePassAllowed = computed(() => {
1266
1263
  );
1267
1264
  });
1268
1265
 
1266
+ // The site's Whole day / Single usage choice for HID visitor passes, shown
1267
+ // under the HID QR Code card as the resident app's invite form shows it.
1268
+ // Null until read, and when it cannot be read: then no hint, not a guess.
1269
+ // Loaded by the watcher below `siteData`.
1270
+ const hidQrUsage = ref<"wholeDay" | "singleUse" | null>(null);
1271
+
1269
1272
  // The entry pass is a contractor's, with the site's NFC pass switch on, as
1270
1273
  // the security mobile app (the web-only HID QR pass, switched off above, keeps
1271
1274
  // its own types).
@@ -2298,8 +2301,14 @@ watch(
2298
2301
  hidQrReader.value = null;
2299
2302
  if (!site) return;
2300
2303
  try {
2301
- const response = await getReaders({ site, page: 1, limit: 100 });
2302
- const readers: unknown = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
2304
+ /* The member's list, not the admin one. `POST /readers/:id/visitor-qr`
2305
+ asks for membership plus `hid-mgmt:use-hid-access` (API-core #1772),
2306
+ while `GET /readers` asks for the HID view right, so a member who may
2307
+ issue a visitor pass but holds no admin right got the pass route and
2308
+ not the reader it needs. `enrollment-readers` is the site's enabled
2309
+ readers with a portal, `{ data: { items } }`, for any member. */
2310
+ const response = await getEnrollmentReaders(site);
2311
+ const readers: unknown = response?.data?.items ?? [];
2303
2312
  const availableReaders = Array.isArray(readers)
2304
2313
  ? readers.filter((reader: unknown): reader is HidQrReaderSummary => (
2305
2314
  typeof reader === "object"
@@ -2326,6 +2335,23 @@ watch(
2326
2335
  { immediate: true },
2327
2336
  );
2328
2337
 
2338
+ // Below `siteData` for the same reason as the reader watcher above:
2339
+ // `hidQrCodePassAllowed` reads it. Only fetched where HID QR is offered.
2340
+ watch(
2341
+ [hidQrCodePassAllowed, () => prop.site],
2342
+ async ([allowed, site]) => {
2343
+ hidQrUsage.value = null;
2344
+ if (!allowed || !site) return;
2345
+ try {
2346
+ const settings = await getOvernightParkingAvailabilityV2(String(site), "site");
2347
+ hidQrUsage.value = hidQrUsageFromSettings(settings);
2348
+ } catch {
2349
+ hidQrUsage.value = null;
2350
+ }
2351
+ },
2352
+ { immediate: true },
2353
+ );
2354
+
2329
2355
  const {
2330
2356
  data: blocksListData,
2331
2357
  refresh: refreshBlockListData,
@@ -3388,7 +3414,9 @@ async function prepareHidQrCodePreview(visitorId: string) {
3388
3414
  hidQrVisitorId.value = visitorId;
3389
3415
  const response = await issueVisitorQr(hidQrReaderId.value, {
3390
3416
  visitorId,
3391
- validityMinutes: hidQrValidityMinutes.value,
3417
+ // The whole visit day, as the resident app issues it. Worked out per
3418
+ // call so a regenerate later in the day still ends at midnight.
3419
+ validityMinutes: minutesUntilEndOfDay(),
3392
3420
  });
3393
3421
  const credential = "data" in response ? response.data : response;
3394
3422
  hidQrPrintPayload.value = credential.qrValue;
@@ -84,6 +84,17 @@ export default function useCleaningPermission() {
84
84
  description: "Allows the user to manage and update site settings.",
85
85
  },
86
86
  },
87
+ // HID access, user side only (decision of 2026-10-16). The service apps
88
+ // have no HID admin screens yet, so a role here can be prepared with the
89
+ // one string that lets a member use their own QR code, face, PIN, card and
90
+ // intercom. Mirrored from core `src/utils/hid-access.util.ts`.
91
+ "hid-mgmt": {
92
+ "use-hid-access": {
93
+ check: true,
94
+ description:
95
+ "Allows the user to see HID Access in the app and use their own QR code, face, PIN, card and intercom at this site.",
96
+ },
97
+ },
87
98
  toilet_locations: {
88
99
  "create-toilet-location": {
89
100
  check: true,
@@ -1,124 +1,219 @@
1
+ import { permissionsDenied } from "../utils/module-routes";
2
+
1
3
  /**
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.
4
+ * WHO MAY DO WHAT ON HID: the five flags every HID screen, menu and button asks.
13
5
  *
14
- * ## Why adding this gate cannot lock anybody out
6
+ * Decision of 2026-10-16. HID has its own permission resource, `hid-mgmt`, with
7
+ * five actions. Core defines the lists in `src/utils/hid-access.util.ts` and
8
+ * every `/api/access-management/hid` route reads them; this package does not
9
+ * depend on `@7365admin1/core`, so the strings are copied here and
10
+ * `test/hid-access-gate.test.mjs` pins the copy to the five.
15
11
  *
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.
12
+ * canUseHid hid-mgmt:use-hid-access own QR, face, PIN, card,
13
+ * intercom; the HID QR Code page
14
+ * canViewHid hid-mgmt:see-all-hid open any HID admin screen
15
+ * canManageHidReaders hid-mgmt:manage-hid-readers add, configure, test, sync,
16
+ * remove readers
17
+ * canManageHidUsers hid-mgmt:manage-hid-users enrol and remove people,
18
+ * cards, PINs, access groups
19
+ * canManageHidSettings hid-mgmt:manage-hid-settings HID on or off, QR configuration
21
20
  *
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.
21
+ * ## The rules, in the order each flag applies them
28
22
  *
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.
23
+ * 1. No role loaded: false. `userAppRole` is null on the first tick for
24
+ * everyone, the `*` owner included; `HidEnabledGate` waits on
25
+ * `isAppRoleResolved` before it draws a refusal.
26
+ * 2. The client's module list (RP-5): `hid-mgmt` denied closes every flag,
27
+ * including for a wildcard role. A wildcard role is exactly the role a
28
+ * module list exists to narrow, so this is asked BEFORE the `*` check.
29
+ * 3. `*` passes every flag.
30
+ * 4. An EMPTY permission list passes nothing. This is deliberately not the
31
+ * estate rule `hasPermission` applies (`grantsPermission`: empty means
32
+ * everything). The owner's decision for HID is strict on empty, everywhere,
33
+ * so the empty list is refused here before `hasPermission` can widen it.
34
+ * 5. Either manage right implies view: a role holding only
35
+ * `manage-hid-readers` could otherwise create a reader and not list them.
36
+ * Settings does NOT imply view, and use implies nothing.
35
37
  *
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.
38
+ * ## The alias period
39
39
  *
40
- * ## Intercom is gated with the other five
40
+ * Until the live roles are backfilled onto `hid-mgmt`, core still accepts the
41
+ * Access Cards strings HID borrowed before, at the levels
42
+ * `HID_LEGACY_VIEW_PERMISSIONS` and `HID_LEGACY_MANAGE_PERMISSIONS` give them:
43
+ * `see-all-card`, `see-all-qr-tagging` and `view` open the admin screens; the
44
+ * five card write strings and `manage-hid` manage readers and users; only
45
+ * `manage-hid` reaches settings. The same strings pass here at the same levels,
46
+ * so nothing that works today stops working before the backfill. The follow-up
47
+ * that drops the aliases deletes the `legacy*` branches below and the two
48
+ * `RETIRED_PERMISSIONS` in `constants/permissions.ts` together.
41
49
  *
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
+ * `site-settings:manage-entry-pass` is Entry Pass only again. It opened the HID
51
+ * screens from 4.2.14 to this release because a core utility of the same name
52
+ * once listed it; it never opened a HID endpoint.
50
53
  *
51
54
  * ## Drawing, not securing
52
55
  *
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.
56
+ * The server re-decides every HID call on its own. These flags hide screens and
57
+ * buttons that would be refused; they add no protection the API lacks.
55
58
  */
56
59
 
57
60
  /**
58
- * The nine strings `core/utils/hid-access.util.ts` enforces, view + manage.
59
- *
60
- * Written out in full `resource:action` form on purpose. `permission-vocabulary`
61
- * harvests what this package checks by reading literals out of the source, and a
62
- * bare action fed through a loop into `hasPermission(role, cat, "access-card-mgmt",
63
- * action)` is invisible to it. Spelling the strings out keeps all nine visible.
61
+ * Written out in full `resource:action` form on purpose: `permission-vocabulary`
62
+ * harvests what this package checks by reading literals out of the source, and
63
+ * a string built from a loop is invisible to it.
64
64
  */
65
- const HID_PERMISSIONS = [
65
+ export const HID_RESOURCE = "hid-mgmt";
66
+
67
+ export const HID_PERMISSION_STRINGS = [
68
+ "hid-mgmt:use-hid-access",
69
+ "hid-mgmt:see-all-hid",
70
+ "hid-mgmt:manage-hid-readers",
71
+ "hid-mgmt:manage-hid-users",
72
+ "hid-mgmt:manage-hid-settings",
73
+ ] as const;
74
+
75
+ /** Core `HID_LEGACY_VIEW_PERMISSIONS`: open the admin screens during the alias period. */
76
+ export const HID_LEGACY_VIEW_PERMISSIONS = [
66
77
  "access-card-mgmt:see-all-card",
67
78
  "access-card-mgmt:see-all-qr-tagging",
68
79
  "access-card-mgmt:view",
80
+ ] as const;
81
+
82
+ /** Core `HID_LEGACY_MANAGE_PERMISSIONS`: manage readers and users during the alias period. */
83
+ export const HID_LEGACY_MANAGE_PERMISSIONS = [
69
84
  "access-card-mgmt:add-access-card",
70
85
  "access-card-mgmt:update-access-card",
71
- "access-card-mgmt:replace-access-card",
72
86
  "access-card-mgmt:delete-access-card",
73
87
  "access-card-mgmt:assign-access-card",
88
+ "access-card-mgmt:replace-access-card",
74
89
  "access-card-mgmt:manage-hid",
75
90
  ] as const;
76
91
 
77
- const HID_ACTIONS = HID_PERMISSIONS.map((p) => p.split(":")[1]);
92
+ const HID_ACTIONS = HID_PERMISSION_STRINGS.map((p) => p.split(":")[1]);
93
+ const LEGACY_ACTIONS = [...HID_LEGACY_VIEW_PERMISSIONS, ...HID_LEGACY_MANAGE_PERMISSIONS].map(
94
+ (p) => p.split(":")[1],
95
+ );
78
96
 
79
97
  export function useHidAccessPermission() {
80
98
  const { hasPermission } = usePermission();
81
- const { siteSettingsPermissions } = useCommonPermissions();
82
- const { userAppRole } = useLocalSetup();
99
+ const { userAppRole, userAppModulesDenied } = useLocalSetup();
83
100
 
84
- // `hasPermission` needs BOTH the role's string AND a catalogue entry for it:
85
- // a resource missing from the catalogue is a dead gate however the role is
86
- // granted. `access-card-mgmt` exists in no shared catalogue composable, so it
87
- // is built here, exactly as `useServiceProviderPermission` builds its own.
101
+ // `hasPermission` needs BOTH the role's string AND a catalogue entry for it.
102
+ // The property and security catalogues carry `hid-mgmt`, the five service
103
+ // catalogues (cleaning, landscaping, pest control, pool maintenance and,
104
+ // since 2026-10-19, M&E) carry only `use-hid-access`, and no catalogue
105
+ // offers the retired `view` and `manage-hid` any more, so the lookup table
106
+ // is built here: these flags must answer the same in every app that mounts
107
+ // a HID screen.
88
108
  const permissions: TPermissions = {
109
+ "hid-mgmt": Object.fromEntries(
110
+ HID_ACTIONS.map((action) => [action, { check: true, description: `HID: ${action}.` }]),
111
+ ),
89
112
  "access-card-mgmt": Object.fromEntries(
90
- HID_ACTIONS.map((action) => [
113
+ LEGACY_ACTIONS.map((action) => [
91
114
  action,
92
- {
93
- check: true,
94
- description: `Allows the user to reach the HID access management screens (${action}).`,
95
- },
115
+ { check: true, description: `HID alias period: access-card-mgmt:${action}.` },
96
116
  ]),
97
117
  ),
98
- "site-settings": siteSettingsPermissions,
99
118
  };
100
119
 
120
+ // `?.` on purpose: an app pinned to an older layer-common, or a caller that
121
+ // stubs `useLocalSetup` without this field, hands back `undefined` here. That
122
+ // must mean "nothing denied", never a TypeError inside every gate.
123
+ const moduleDenied = (...modules: string[]) =>
124
+ permissionsDenied(modules, userAppModulesDenied?.value);
125
+
101
126
  /**
102
- * Can this member open a HID `access-mgmt` screen at all? Any single string
103
- * the server accepts is enough -- somebody granted only `delete-access-card`
104
- * still has to open the screen to use it.
127
+ * Rules 1 to 4. `null` means "decide from the strings"; a boolean is final.
128
+ * The empty list is refused HERE because `hasPermission` would admit it.
105
129
  */
106
- const canAccessHidScreens = computed(() => {
130
+ function decided(): boolean | null {
107
131
  const role = userAppRole.value;
108
132
  if (!role) return false;
109
- if (role.permissions.includes("*")) return true;
110
- if (hasPermission(role, permissions, "site-settings", "manage-entry-pass")) return true;
111
- return HID_ACTIONS.some((action) =>
112
- hasPermission(role, permissions, "access-card-mgmt", action),
113
- );
114
- });
115
-
116
- // Only the gate is returned. `isAppRoleResolved` is deliberately NOT
117
- // re-exported: `gate-defaults.test.mjs` sweeps every ref a permission
118
- // composable returns and requires it to read false for an unresolved role,
119
- // which a "has the lookup finished" flag is not. Callers that need it take it
120
- // from `useLocalSetup()`, which is where it lives.
121
- return { canAccessHidScreens };
133
+ if (moduleDenied("hid-mgmt")) return false;
134
+ const held: unknown = role.permissions;
135
+ if (!Array.isArray(held) || !held.length) return false;
136
+ if (held.includes("*")) return true;
137
+ return null;
138
+ }
139
+
140
+ // Only reached once `decided()` has answered `null`, so the role is loaded.
141
+ const has = (resource: string, action: string) => {
142
+ const role = userAppRole.value;
143
+ return Boolean(role) && hasPermission(role, permissions, resource, action);
144
+ };
145
+
146
+ // The alias period, string by string so both source sweeps can see them.
147
+ const legacyView = () =>
148
+ has("access-card-mgmt", "see-all-card") ||
149
+ has("access-card-mgmt", "see-all-qr-tagging") ||
150
+ has("access-card-mgmt", "view");
151
+ const legacyManage = () =>
152
+ has("access-card-mgmt", "add-access-card") ||
153
+ has("access-card-mgmt", "update-access-card") ||
154
+ has("access-card-mgmt", "delete-access-card") ||
155
+ has("access-card-mgmt", "assign-access-card") ||
156
+ has("access-card-mgmt", "replace-access-card") ||
157
+ has("access-card-mgmt", "manage-hid");
158
+ const legacySettings = () => has("access-card-mgmt", "manage-hid");
159
+
160
+ // The five, each as a literal `hasPermission` call so the gate sweep
161
+ // (`gate-defaults.test.mjs`) and the vocabulary sweep both read them.
162
+ const useHid = () => {
163
+ const role = userAppRole.value;
164
+ return Boolean(role) && hasPermission(role, permissions, "hid-mgmt", "use-hid-access");
165
+ };
166
+ const viewHid = () => {
167
+ const role = userAppRole.value;
168
+ return Boolean(role) && hasPermission(role, permissions, "hid-mgmt", "see-all-hid");
169
+ };
170
+ const manageReaders = () => {
171
+ const role = userAppRole.value;
172
+ return Boolean(role) && hasPermission(role, permissions, "hid-mgmt", "manage-hid-readers");
173
+ };
174
+ const manageUsers = () => {
175
+ const role = userAppRole.value;
176
+ return Boolean(role) && hasPermission(role, permissions, "hid-mgmt", "manage-hid-users");
177
+ };
178
+ const manageSettings = () => {
179
+ const role = userAppRole.value;
180
+ return Boolean(role) && hasPermission(role, permissions, "hid-mgmt", "manage-hid-settings");
181
+ };
182
+
183
+ /** The user side: HID Access in the app, own QR, face, PIN, card, intercom. */
184
+ const canUseHid = computed(() => decided() ?? useHid());
185
+
186
+ /** Any manage right implies view (rule 5). */
187
+ const canManageHidReaders = computed(() => decided() ?? (manageReaders() || legacyManage()));
188
+ const canManageHidUsers = computed(() => decided() ?? (manageUsers() || legacyManage()));
189
+ const canViewHid = computed(
190
+ () =>
191
+ decided() ??
192
+ (viewHid() || legacyView() || manageReaders() || manageUsers() || legacyManage()),
193
+ );
194
+
195
+ /** HID on or off for the site, and the HID QR code configuration. */
196
+ const canManageHidSettings = computed(() => decided() ?? (manageSettings() || legacySettings()));
197
+
198
+ /**
199
+ * @deprecated The pre 2026-10 single gate: "may this member open a HID admin
200
+ * screen". Kept for any app on this layer that still reads it; it is
201
+ * `canViewHid` and nothing else. Use the five flags above.
202
+ */
203
+ const canAccessHidScreens = canViewHid;
204
+
205
+ // `isAppRoleResolved` is deliberately NOT re-exported: `gate-defaults.test.mjs`
206
+ // sweeps every ref a permission composable returns and requires it to read
207
+ // false for an unresolved role, which a "has the lookup finished" flag is not.
208
+ // Callers that need it take it from `useLocalSetup()`, which is where it lives.
209
+ return {
210
+ canUseHid,
211
+ canViewHid,
212
+ canManageHidReaders,
213
+ canManageHidUsers,
214
+ canManageHidSettings,
215
+ canAccessHidScreens,
216
+ };
122
217
  }
123
218
 
124
219
  export default useHidAccessPermission;