@7365admin1/layer-common 4.149.2 → 4.151.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 (42) 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/OvernightParkingAvailability.vue +123 -11
  14. package/components/Robot/Detail.vue +23 -4
  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
  40. package/utils/robot-credential-form.ts +4 -2
  41. package/utils/robot-display.ts +28 -6
  42. package/utils/robot-setup.ts +1 -0
@@ -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;
@@ -204,6 +204,22 @@ export default function useHidAmico() {
204
204
  });
205
205
  }
206
206
 
207
+ /**
208
+ * The readers a member may enrol THEMSELVES on: the site's enabled readers
209
+ * with a portal, each with its credential capabilities, and nothing else.
210
+ *
211
+ * The self service screens (own QR, face, PIN, card) load this instead of
212
+ * `getReaders`: that one is an admin list behind the HID view right, and a
213
+ * member holding only `hid-mgmt:use-hid-access` is refused on it. This route
214
+ * asks for site membership and the use right. Response: `{ data: { items } }`.
215
+ */
216
+ function getEnrollmentReaders(siteId: string) {
217
+ return useNuxtApp().$api<{ data: { items: HidApiRecord[] } }>(
218
+ `${basePath}/sites/${siteId}/enrollment-readers`,
219
+ { method: "GET" },
220
+ );
221
+ }
222
+
207
223
  /**
208
224
  * The signed-in member's own QR code on one reader.
209
225
  *
@@ -398,6 +414,25 @@ export default function useHidAmico() {
398
414
  }
399
415
 
400
416
 
417
+ /**
418
+ * Put a person's STORED enrolment photo on a reader.
419
+ *
420
+ * No body: the image is the one already on file from when they were first
421
+ * captured. This is what makes a reader that was offline during an enrolment
422
+ * retryable WITHOUT the person coming back - a face template lives on one
423
+ * device and cannot be copied, so the alternative is a fresh capture at every
424
+ * reader.
425
+ *
426
+ * Keyed by the iService account, not a HID user id: the caller does not know
427
+ * which number the person carries on which reader, and the server looks it up.
428
+ */
429
+ function pushStoredFace(readerId: string, userId: string) {
430
+ return useNuxtApp().$api<{ data: HidFacialEnrollmentResult; message?: string }>(
431
+ `${basePath}/readers/${readerId}/accounts/${userId}/stored-face`,
432
+ { method: "POST" },
433
+ );
434
+ }
435
+
401
436
  function setUserImage(
402
437
  readerId: string,
403
438
  hidUserId: string | number,
@@ -660,6 +695,7 @@ export default function useHidAmico() {
660
695
 
661
696
  return {
662
697
  getReaders,
698
+ getEnrollmentReaders,
663
699
  getProfileQr,
664
700
  issueProfileQr,
665
701
  deleteProfileQr,
@@ -681,6 +717,7 @@ export default function useHidAmico() {
681
717
  reconcileReaderIdentificationMethods,
682
718
  getLogs,
683
719
  setUserImage,
720
+ pushStoredFace,
684
721
  deleteUserImage,
685
722
  getUserCards,
686
723
  assignUserCard,
@@ -3,23 +3,23 @@ import type { ComputedRef, Ref } from "vue";
3
3
  /**
4
4
  * THE HID FACE READER MENU IS A PERMISSION, NOT A SERVICE FLAG.
5
5
  *
6
- * This composable used to draw the five-item "HID Face Reader" group whenever
7
- * `getHidServiceEnabled(site)` was true. That is a SITE SERVICE FLAG — it says
8
- * the estate bought HID, not that this member may use it. Since the six
9
- * `access-mgmt` screens were gated (`HidEnabledGate.vue`, layer-common #1363 /
10
- * 4.2.14) a member without an access-card right still saw all five items and
11
- * only met the refusal after clicking. A module a member cannot use should not
12
- * be in the menu at all.
6
+ * This composable used to draw the six item "HID Reader" group whenever
7
+ * `getHidServiceEnabled(site)` was true. That is a SITE SERVICE FLAG: it says
8
+ * the estate bought HID, not that this member may use it. A module a member
9
+ * cannot use should not be in the menu at all, so the menu asks the same
10
+ * questions the pages ask (`useHidAccessPermission`), ANDed with the service
11
+ * flag: no HID service still means no menu.
13
12
  *
14
- * The menu now asks the SAME question the pages ask —
15
- * `useHidAccessPermission().canAccessHidScreens` — ANDed with the service flag,
16
- * so no HID service still means no menu.
13
+ * Decision of 2026-10-16: the admin entries (Face Reader, HID Reader Users, HID
14
+ * Cards, Intercom, Access Logs) follow `canViewHid`; the member's own "HID QR
15
+ * Code" entry follows `canUseHid`. The group is drawn when at least one entry
16
+ * is, so a member holding only `use-hid-access` still finds their QR page, and
17
+ * a member holding only the admin rights does not see a page the server would
18
+ * refuse them. `site-settings:manage-entry-pass` no longer draws anything here.
17
19
  *
18
- * `Layout/NavigationDrawer.vue` used to keep its own hand-written copy of the
19
- * permission list, and that copy was NARROWER than the page gate: it omitted
20
- * `replace-access-card` and `site-settings:manage-entry-pass`. Folding it in
21
- * here therefore WIDENS — nobody who could see the menu can lose it — and
22
- * leaves one list in one place.
20
+ * `Layout/NavigationDrawer.vue` keeps no permission list of its own: it takes
21
+ * `menuItem` from here and runs it through `withoutDeniedRoutes`, where the
22
+ * client's module list (`hid-mgmt`) can take the whole group away again.
23
23
  */
24
24
 
25
25
  type HidNavigationParams = {
@@ -32,14 +32,21 @@ type HidNavigationSite = Record<string, unknown>;
32
32
  export default function useHidNavigation() {
33
33
  const { getSiteById } = useSiteSettings();
34
34
  const { getHidServiceEnabled } = useOptionalServices();
35
- const { canAccessHidScreens } = useHidAccessPermission();
35
+ const { canViewHid, canUseHid } = useHidAccessPermission();
36
36
 
37
+ /**
38
+ * The whole group, unfiltered. Exported for callers that compose their own
39
+ * menus; `useHidFaceReaderMenu` below is the one that applies the rights.
40
+ * Each entry says which right draws it so the filter cannot drift from the
41
+ * list.
42
+ */
37
43
  function getHidFaceReaderMenu({ org, site }: HidNavigationParams) {
38
44
  return {
39
- title: "HID Face Reader",
45
+ title: "HID Reader",
40
46
  children: [
41
47
  {
42
48
  title: "Face Reader",
49
+ hidRight: "view" as const,
43
50
  route: {
44
51
  name: "org-site-access-mgmt-hid-readers",
45
52
  params: { org, site },
@@ -61,6 +68,7 @@ export default function useHidNavigation() {
61
68
  // `hid-users` is left in place and simply unlisted - the same state
62
69
  // `administrator` was in - so any existing link to it still resolves.
63
70
  title: "HID Reader Users",
71
+ hidRight: "view" as const,
64
72
  route: {
65
73
  name: "org-site-access-mgmt-administrator",
66
74
  params: { org, site },
@@ -71,6 +79,7 @@ export default function useHidNavigation() {
71
79
  // entry in this group acts on a resident or a visitor; this one has
72
80
  // no subject but the caller.
73
81
  title: "HID QR Code",
82
+ hidRight: "use" as const,
74
83
  route: {
75
84
  name: "org-site-access-mgmt-hid-qr-code",
76
85
  params: { org, site },
@@ -78,6 +87,7 @@ export default function useHidNavigation() {
78
87
  },
79
88
  {
80
89
  title: "HID Cards",
90
+ hidRight: "view" as const,
81
91
  route: {
82
92
  name: "org-site-access-mgmt-hid-cards",
83
93
  params: { org, site },
@@ -85,6 +95,7 @@ export default function useHidNavigation() {
85
95
  },
86
96
  {
87
97
  title: "Intercom",
98
+ hidRight: "view" as const,
88
99
  route: {
89
100
  name: "org-site-access-mgmt-intercom",
90
101
  params: { org, site },
@@ -92,6 +103,7 @@ export default function useHidNavigation() {
92
103
  },
93
104
  {
94
105
  title: "Access Logs",
106
+ hidRight: "view" as const,
95
107
  route: {
96
108
  name: "org-site-access-mgmt-access-logs",
97
109
  params: { org, site },
@@ -138,14 +150,23 @@ export default function useHidNavigation() {
138
150
  getHidServiceEnabled(site.value, siteDetails.value || fallbackSite?.value),
139
151
  );
140
152
 
141
- // ...AND this member may open the screens behind it. `enabled` keeps its
142
- // name and its meaning for callers: "should the HID menu be drawn".
143
- const enabled = computed(() => serviceEnabled.value && canAccessHidScreens.value);
144
-
145
- const menuItem = computed(() =>
146
- enabled.value ? getHidFaceReaderMenu({ org: org.value, site: site.value }) : null,
153
+ // ...AND this member may open at least one screen behind it. `enabled`
154
+ // keeps its name and its meaning for callers: "should the HID menu be drawn".
155
+ const enabled = computed(
156
+ () => serviceEnabled.value && (canViewHid.value || canUseHid.value),
147
157
  );
148
158
 
159
+ // Only the entries this member's rights draw. The admin entries need
160
+ // `canViewHid`; the member's own QR page needs `canUseHid`.
161
+ const menuItem = computed(() => {
162
+ if (!enabled.value) return null;
163
+ const group = getHidFaceReaderMenu({ org: org.value, site: site.value });
164
+ const children = group.children.filter((child) =>
165
+ child.hidRight === "use" ? canUseHid.value : canViewHid.value,
166
+ );
167
+ return children.length ? { ...group, children } : null;
168
+ });
169
+
149
170
  return {
150
171
  enabled,
151
172
  serviceEnabled,
@@ -285,6 +285,17 @@ export default function useLandscapingPermission() {
285
285
  description: "Allows the user to manage site settings.",
286
286
  },
287
287
  },
288
+ // HID access, user side only (decision of 2026-10-16). The service apps
289
+ // have no HID admin screens yet, so a role here can be prepared with the
290
+ // one string that lets a member use their own QR code, face, PIN, card and
291
+ // intercom. Mirrored from core `src/utils/hid-access.util.ts`.
292
+ "hid-mgmt": {
293
+ "use-hid-access": {
294
+ check: true,
295
+ description:
296
+ "Allows the user to see HID Access in the app and use their own QR code, face, PIN, card and intercom at this site.",
297
+ },
298
+ },
288
299
  };
289
300
 
290
301
  return {
@@ -52,6 +52,11 @@ export default function useMechanicalElectricalPermission() {
52
52
  "area-mgmt": schedulePermissions["area-mgmt"],
53
53
  "unit-mgmt": schedulePermissions["unit-mgmt"],
54
54
  "site-settings": schedulePermissions["site-settings"],
55
+ // HID access, user side only (2026-10-19): the M&E mobile app gains the
56
+ // HID user side, so an M&E role must be able to hold `use-hid-access`.
57
+ // Taken from the cleaning catalogue like the keys above (one action, same
58
+ // description), so the two cannot drift. Catalogue-only: grants nothing.
59
+ "hid-mgmt": schedulePermissions["hid-mgmt"],
55
60
  "cleaning-schedule-mgmt": schedulePermissions["cleaning-schedule-mgmt"],
56
61
  "schedule-task-mgmt": schedulePermissions["schedule-task-mgmt"],
57
62
  "attendance-mgmt": schedulePermissions["attendance-mgmt"],
@@ -93,6 +93,17 @@ export default function usePestControlPermission() {
93
93
  description: "Allows the user to manage and update site settings.",
94
94
  },
95
95
  },
96
+ // HID access, user side only (decision of 2026-10-16). The service apps
97
+ // have no HID admin screens yet, so a role here can be prepared with the
98
+ // one string that lets a member use their own QR code, face, PIN, card and
99
+ // intercom. Mirrored from core `src/utils/hid-access.util.ts`.
100
+ "hid-mgmt": {
101
+ "use-hid-access": {
102
+ check: true,
103
+ description:
104
+ "Allows the user to see HID Access in the app and use their own QR code, face, PIN, card and intercom at this site.",
105
+ },
106
+ },
96
107
  // area management permissions
97
108
  "area-mgmt": {
98
109
  "see-all-areas": {
@@ -93,6 +93,17 @@ export default function usePoolMaintenancePermission() {
93
93
  description: "Allows the user to manage and update site settings.",
94
94
  },
95
95
  },
96
+ // HID access, user side only (decision of 2026-10-16). The service apps
97
+ // have no HID admin screens yet, so a role here can be prepared with the
98
+ // one string that lets a member use their own QR code, face, PIN, card and
99
+ // intercom. Mirrored from core `src/utils/hid-access.util.ts`.
100
+ "hid-mgmt": {
101
+ "use-hid-access": {
102
+ check: true,
103
+ description:
104
+ "Allows the user to see HID Access in the app and use their own QR code, face, PIN, card and intercom at this site.",
105
+ },
106
+ },
96
107
  // area management permissions
97
108
  "area-mgmt": {
98
109
  "see-all-areas": {
@@ -14,7 +14,8 @@ import { useCommonPermissions } from "./useCommonPermission";
14
14
  * Two copies is how they drift. On 2026-09-05 the `-org` copy was found to be a
15
15
  * strict SUBSET of `-property-management`'s — **32 permissions short**, nine
16
16
  * whole families plus all six `access-card-mgmt` strings that
17
- * `core/utils/hid-access.util.ts` actually enforces — so a role built through
17
+ * `core/utils/hid-access.util.ts` enforced at the time (HID reads its own
18
+ * `hid-mgmt` family since 2026-10-16) — so a role built through
18
19
  * the onboarding wizard could never be granted them, and the modules were
19
20
  * unreachable for anybody who joined that way. web-app-org #189 closed it by
20
21
  * copying the file across, which left two identical copies and the same trap
@@ -256,10 +257,6 @@ export default function usePropertyPermission() {
256
257
  description:
257
258
  "Allows the user to remove an access card from the system permanently.",
258
259
  },
259
- view: {
260
- check: true,
261
- description: "Allows the user to view an access card's details.",
262
- },
263
260
  "assign-access-card": {
264
261
  check: true,
265
262
  description: "Allows the user to assign an access card to a person.",
@@ -269,10 +266,37 @@ export default function usePropertyPermission() {
269
266
  description:
270
267
  "Allows the user to replace a lost or damaged access card with a new one.",
271
268
  },
272
- "manage-hid": {
269
+ },
270
+ // HID access management (decision of 2026-10-16). Five strings under one
271
+ // resource, mirrored from core `src/utils/hid-access.util.ts`; the HID
272
+ // screens, menu and buttons (`useHidAccessPermission`) and every
273
+ // `/api/access-management/hid` route read them. Wildcard passes all five,
274
+ // an empty list passes none, either manage right implies view.
275
+ "hid-mgmt": {
276
+ "use-hid-access": {
277
+ check: true,
278
+ description:
279
+ "Allows the user to see HID Access in the app and use their own QR code, face, PIN, card and intercom at this site.",
280
+ },
281
+ "see-all-hid": {
282
+ check: true,
283
+ description:
284
+ "Allows the user to view the site's HID readers, enrolled people, cards and access logs.",
285
+ },
286
+ "manage-hid-readers": {
287
+ check: true,
288
+ description:
289
+ "Allows the user to add, configure, test, sync and remove HID readers. Includes viewing.",
290
+ },
291
+ "manage-hid-users": {
292
+ check: true,
293
+ description:
294
+ "Allows the user to enrol and remove people, cards and PINs, and assign HID access groups. Includes viewing.",
295
+ },
296
+ "manage-hid-settings": {
273
297
  check: true,
274
298
  description:
275
- "Allows the user to manage the HID door readers an access card opens.",
299
+ "Allows the user to switch HID on or off for the site and configure the HID QR code.",
276
300
  },
277
301
  },
278
302
  "service-provider-mgmt": {