@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.150.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b43efad: HID Access, the staging work released to main (owner decision 2026-10-28). The HID management permission group with five flags, menus and buttons following them, Use HID access in the M&E role catalogue, and the menu group, settings panel and service named HID Reader; a reader says who it is for and shows only the matching tabs and enrolling types, a person is enrolled on every reader that serves them with a result per reader and updated and deleted on each; a service provider's own people are enrolled one at a time or in bulk, with the registration number taken from the reader being enrolled on; the visitor form offers HID QR Code again, lists the enrollment readers, issues the pass for the whole visit day and says how long it lasts, with the site choosing whole day or single use in its visitor settings.
8
+
9
+ ## 4.149.2
10
+
11
+ ### Patch Changes
12
+
13
+ - d1c8392: Members list: role chips start with a capital letter (a role saved as "owner" reads "Owner").
14
+
3
15
  ## 4.149.1
4
16
 
5
17
  ### Patch Changes
@@ -3542,11 +3554,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3542
3554
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3543
3555
  own rule from two endpoints the console already calls, unprojected:
3544
3556
 
3545
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3546
- GET /api/roles/id/:role that membership's role document
3557
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3558
+ GET /api/roles/id/:role that membership's role document
3547
3559
 
3548
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3549
- staff = member.type === "admin" && role.type === "admin"
3560
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3561
+ staff = member.type === "admin" && role.type === "admin"
3550
3562
 
3551
3563
  `role.default` is the marker because it is the only property of a platform
3552
3564
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -206,6 +206,13 @@
206
206
  </div>
207
207
  </div>
208
208
  </v-card>
209
+ <!-- How long the pass lasts, from the site's setting; the same words
210
+ as the resident app's invite form. -->
211
+ <div v-if="hidQrUsage" class="text-caption text-primary font-weight-medium mt-2">
212
+ {{ hidQrUsage === "singleUse"
213
+ ? "HID: Single use, expires after the first entry"
214
+ : "HID: Valid all day, until 11:59 pm" }}
215
+ </div>
209
216
  <v-alert
210
217
  v-if="!hidPortalName"
211
218
  type="warning"
@@ -341,6 +348,12 @@ const props = defineProps({
341
348
  type: String,
342
349
  default: "",
343
350
  },
351
+ // The site's HID pass usage, for the hint under the HID QR Code card. Null
352
+ // shows no hint (not loaded, or a caller that does not pass it).
353
+ hidQrUsage: {
354
+ type: String as PropType<"wholeDay" | "singleUse" | null>,
355
+ default: null,
356
+ },
344
357
  excludedCardIds: {
345
358
  type: Array as PropType<string[]>,
346
359
  default: () => [],
@@ -20,15 +20,23 @@
20
20
  density="compact"
21
21
  class="ma-4"
22
22
  >
23
- Set a location on this HID Face Reader before managing permissions.
23
+ Set a location on this HID Reader before managing permissions.
24
24
  </v-alert>
25
25
 
26
26
  <template v-else>
27
27
  <div class="hid-access-permissions__intro">
28
28
  <p class="text-body-2 text-medium-emphasis mb-0">
29
- Residents enrolled on this reader. Enrolling already grants access — switch
30
- somebody off to close the door to them without removing their face, card
31
- or PIN.
29
+ Everybody enrolled on this reader, by type. Enrolling already grants
30
+ access — switch somebody off to close the door to them without removing
31
+ their face, card or PIN.
32
+ </p>
33
+ <!-- Why a tab is missing. Left unsaid, an absent tab reads as a broken
34
+ screen rather than as a setting on the reader. -->
35
+ <p
36
+ v-if="audienceNote"
37
+ class="text-body-2 text-medium-emphasis mb-0 hid-access-permissions__audience-note"
38
+ >
39
+ {{ audienceNote }}
32
40
  </p>
33
41
  </div>
34
42
 
@@ -45,12 +53,36 @@
45
53
  @refresh="loadEnrolled"
46
54
  @update:page="goToPage"
47
55
  >
56
+ <!-- WHICH OF THE READER'S PEOPLE TO SHOW.
57
+ The reader holds residents, members and contractors; each tab is
58
+ one `type`, filtered and paged by the server. The strip is
59
+ `TableMain`'s own slot and the shared `.app-tab` markup, the same
60
+ as every other tabbed table here - not a `v-tabs`, whose selected
61
+ state is drawn from Vuetify's `on-surface` rather than the
62
+ design's accent underline.
63
+ The count beside each label is the whole reader's, from the
64
+ draft, so it follows an unsaved tick. -->
65
+ <template #tabs>
66
+ <button
67
+ v-for="tab in visibleTabs"
68
+ :key="tab.value"
69
+ type="button"
70
+ class="app-tab"
71
+ :class="{ 'app-tab--active': activeTab === tab.value }"
72
+ :aria-pressed="activeTab === tab.value"
73
+ @click="selectTab(tab.value)"
74
+ >
75
+ {{ tab.label }}
76
+ <span class="hid-access-permissions__tab-count">{{ tabCount(tab) }}</span>
77
+ </button>
78
+ </template>
79
+
48
80
  <template #extension>
49
81
  <div class="app-filter-row">
50
82
  <AppField
51
83
  v-model="search"
52
84
  search
53
- placeholder="Search name, unit or registration"
85
+ :placeholder="searchPlaceholder"
54
86
  @keyup.enter="searchFromFirstPage"
55
87
  />
56
88
  </div>
@@ -97,7 +129,11 @@
97
129
  </span>
98
130
  <v-spacer />
99
131
  <AppButton variant="ghost" :disabled="saving" @click="open = false">Cancel</AppButton>
132
+ <!-- Reachable only from a menu item the users right draws; the same
133
+ right is asked again here so the dialog stays read only if it is
134
+ ever opened some other way. -->
100
135
  <AppButton
136
+ v-if="canManageHidUsers"
101
137
  icon="mdi-content-save-outline"
102
138
  :loading="saving"
103
139
  :disabled="!readerLocation || !dirty || saving"
@@ -121,9 +157,13 @@ import {
121
157
  readCandidatePage,
122
158
  seedAssignments,
123
159
  setAssignment,
160
+ subjectOfIdentity,
124
161
  toAssignmentPayload,
125
162
  type HidAssignmentState,
126
163
  } from "../utils/hid-permission-assignments";
164
+ /* Mirrors core's audience rules — core is what enforces them; this only decides
165
+ which tabs are worth drawing. */
166
+ import { visibleAudienceTabs } from "../utils/hid-reader-audiences";
127
167
 
128
168
  /**
129
169
  * WHO MAY PASS THIS READER — a review and revoke screen, not a picker.
@@ -149,6 +189,8 @@ const props = defineProps<{
149
189
  reader: Record<string, unknown> | null;
150
190
  }>();
151
191
 
192
+ const { canManageHidUsers } = useHidAccessPermission();
193
+
152
194
  const emit = defineEmits<{
153
195
  (event: "update:modelValue", value: boolean): void;
154
196
  (event: "saved"): void;
@@ -156,11 +198,79 @@ const emit = defineEmits<{
156
198
 
157
199
  const { getSitePermissions, getIdentities, updateSitePermissions } = useHidAmico();
158
200
 
159
- /* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`. */
160
- const headers = [
161
- { title: "Resident", value: "name", sortable: false },
201
+ /**
202
+ * WHO EACH TAB LISTS, AND WHAT A GRANT MADE FROM IT IS FILED UNDER.
203
+ *
204
+ * `type` is the identity's own, filtered by the SERVER (`listIdentities` does an
205
+ * exact match on it), and it is what the enrolment writes and what
206
+ * `permissionIdentityType` re-confirms on every reconcile. `category` is the
207
+ * permission category a NEW grant from this tab belongs to.
208
+ *
209
+ * The two are a pair rather than one derived from the other, because the tab is
210
+ * the only thing on this screen that knows which of them the operator means: a
211
+ * contractor and a property-management member are both a `member` link, and
212
+ * guessing between them from the record is exactly what went wrong before.
213
+ */
214
+ const TABS = [
215
+ { value: "resident", label: "Resident", noun: "Resident", type: "resident", category: "resident" },
216
+ { value: "member", label: "Member", noun: "Member", type: "staff", category: "property_management" },
217
+ {
218
+ value: "provider",
219
+ label: "Service provider",
220
+ noun: "Person",
221
+ type: "contractor",
222
+ category: "service_provider_member",
223
+ },
224
+ ] as const satisfies ReadonlyArray<{
225
+ value: string;
226
+ label: string;
227
+ noun: string;
228
+ type: string;
229
+ category: THidPermissionCategory;
230
+ }>;
231
+
232
+ const activeTab = ref<(typeof TABS)[number]["value"]>("resident");
233
+
234
+ /*
235
+ * ONLY THE TABS THIS READER SERVES.
236
+ *
237
+ * A reader whose audiences exclude a category cannot hold a grant in it - core
238
+ * refuses the save - so a tab for it would be a list the operator can look at
239
+ * and never act on. See `visibleAudienceTabs`, which also keeps one tab when a
240
+ * reader serves nobody, because a dialog with no tabs and no explanation reads
241
+ * as a broken screen.
242
+ *
243
+ * A reader saved before audiences existed has none, and that reads as all four -
244
+ * so this changes nothing for one.
245
+ */
246
+ const audienceTabs = computed(() => visibleAudienceTabs(props.reader, TABS));
247
+ const visibleTabs = computed(() => audienceTabs.value.tabs);
248
+ const audienceNote = computed(() => {
249
+ if (audienceTabs.value.emptied) {
250
+ return "This reader is not set to serve anybody. Switch an audience on in the reader's settings before granting access here.";
251
+ }
252
+ if (audienceTabs.value.narrowed) {
253
+ return "Only the kinds of people this reader serves are shown. The rest are switched off in the reader's settings.";
254
+ }
255
+ return "";
256
+ });
257
+
258
+ const currentTab = computed(
259
+ () => visibleTabs.value.find((tab) => tab.value === activeTab.value)
260
+ ?? visibleTabs.value[0]
261
+ ?? TABS[0],
262
+ );
263
+
264
+ /** Granted counts per category, for the tab strip. One call, all three. */
265
+ const counts = ref<Record<string, number>>({});
266
+ const tabCount = (tab: (typeof TABS)[number]) => counts.value[tab.category] ?? 0;
267
+
268
+ /* Name over unit in one cell rather than a "Unit" column — see `subtitleOf`.
269
+ The first column follows the tab; "Door access" is the same action for all. */
270
+ const headers = computed(() => [
271
+ { title: currentTab.value.noun, value: "name", sortable: false },
162
272
  { title: "Door access", value: "selected", sortable: false, width: 190 },
163
- ];
273
+ ]);
164
274
 
165
275
  const search = ref("");
166
276
  const page = ref(1);
@@ -183,10 +293,17 @@ const open = computed({
183
293
  const readerId = computed(() => String(props.reader?._id ?? ""));
184
294
  const readerLocation = computed(() => String(props.reader?.location ?? "").trim());
185
295
  const readerLabel = computed(() => {
186
- const name = String(props.reader?.name ?? "").trim() || "HID Face Reader";
296
+ const name = String(props.reader?.name ?? "").trim() || "HID Reader";
187
297
  return readerLocation.value ? `${name} · ${readerLocation.value}` : name;
188
298
  });
189
299
 
300
+ /* "unit" only means something for a resident. */
301
+ const searchPlaceholder = computed(() =>
302
+ activeTab.value === "resident"
303
+ ? "Search name, unit or registration"
304
+ : "Search name or registration",
305
+ );
306
+
190
307
  const assignedTotal = computed(() => draft.value.size);
191
308
  const dirty = computed(() => hasChanges(savedState.value, draft.value));
192
309
  /* The draft is the truth once the dialog is open, so `selected` is recomputed
@@ -199,6 +316,10 @@ watch(
199
316
  if (!isOpen || !readerId.value) return;
200
317
  search.value = "";
201
318
  page.value = 1;
319
+ // The first tab this reader actually serves, not always Resident: opening on
320
+ // one it does not serve would draw an empty list under a heading that cannot
321
+ // accept a grant.
322
+ activeTab.value = visibleTabs.value[0]?.value ?? "resident";
202
323
  void loadAll();
203
324
  },
204
325
  { immediate: true },
@@ -221,6 +342,13 @@ async function loadAssignments() {
221
342
  const assignments = response?.data?.assignments ?? [];
222
343
  savedState.value = seedAssignments(assignments);
223
344
  draft.value = seedAssignments(assignments);
345
+ /*
346
+ * Counted from the assignments themselves rather than the server's `counts`
347
+ * block, so the strip follows the DRAFT and a tick updates the number it
348
+ * sits beside. `countByCategory` is the same helper the candidate screens
349
+ * use.
350
+ */
351
+ counts.value = countByCategory(draft.value);
224
352
  } catch (error) {
225
353
  showToast(getErrorMessage(error, "Unable to load this reader's permissions."), "error");
226
354
  }
@@ -230,27 +358,6 @@ function toRecord(value: unknown): Record<string, unknown> {
230
358
  return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
231
359
  }
232
360
 
233
- /**
234
- * The subject an identity names, in the order `createIdentity` validates them.
235
- *
236
- * A visitor returns null and is left out of the list: visitor access comes from
237
- * `user_access_rules`, not from a group, so there is nothing here to switch
238
- * off. An identity linked to nobody — a device user the reader holds that was
239
- * never tied to a person — is dropped for the same reason.
240
- */
241
- function subjectOf(identity: Record<string, unknown>) {
242
- if (identity.person) {
243
- return { subjectId: String(identity.person), category: "resident" as const };
244
- }
245
- if (identity.member) {
246
- return { subjectId: String(identity.member), category: "property_management" as const };
247
- }
248
- if (identity.serviceProvider) {
249
- return { subjectId: String(identity.serviceProvider), category: "service_provider" as const };
250
- }
251
- return null;
252
- }
253
-
254
361
  /**
255
362
  * Why a row cannot be switched, in words the reader of the screen can act on.
256
363
  *
@@ -294,10 +401,10 @@ async function loadEnrolled() {
294
401
  page: page.value,
295
402
  limit: 10,
296
403
  search: search.value.trim(),
297
- // Residents only, and filtered by the SERVER rather than here. The pager's
298
- // count comes back from the same query, so narrowing the list on this side
299
- // would print a total that never matched the rows under it.
300
- type: "resident",
404
+ // THIS TAB's people, and filtered by the SERVER rather than here. The
405
+ // pager's count comes back from the same query, so narrowing the list on
406
+ // this side would print a total that never matched the rows under it.
407
+ type: currentTab.value.type,
301
408
  });
302
409
  const result = readCandidatePage(response);
303
410
  /*
@@ -309,11 +416,13 @@ async function loadEnrolled() {
309
416
  */
310
417
  enrolled.value = result.items.map((row) => {
311
418
  const identity = toRecord(row);
312
- const subject = subjectOf(identity);
419
+ const subject = subjectOfIdentity(identity);
313
420
  const metadata = toRecord(identity.metadata);
314
421
  return {
315
422
  subjectId: subject?.subjectId ?? "",
316
- category: subject?.category ?? "resident",
423
+ // The record's own category where it has one; otherwise the tab's,
424
+ // which is the only thing that knows what the operator means.
425
+ category: subject?.category ?? currentTab.value.category,
317
426
  // The row's own key. `subjectId` is empty for the rows below, and two
318
427
  // empty keys would collapse into one row in the table.
319
428
  rowKey: String(identity._id ?? identity.hidUserId ?? Math.random()),
@@ -342,6 +451,24 @@ async function loadEnrolled() {
342
451
  }
343
452
  }
344
453
 
454
+ /**
455
+ * Switching tabs refetches, because the filter is the SERVER's.
456
+ *
457
+ * The draft is untouched: it is the whole reader's, and the save sends all of it
458
+ * whatever tab is in front of you. Clearing it here - or building the payload
459
+ * from the loaded rows - is what would revoke everybody on the other two tabs.
460
+ *
461
+ * Search is cleared with the tab. It is a server-side search within one type, so
462
+ * carrying it across would land on an empty list and look like the tab was empty.
463
+ */
464
+ function selectTab(value: (typeof TABS)[number]["value"]) {
465
+ if (activeTab.value === value) return;
466
+ activeTab.value = value;
467
+ search.value = "";
468
+ page.value = 1;
469
+ void loadEnrolled();
470
+ }
471
+
345
472
  function goToPage(value: number) {
346
473
  page.value = Number(value) || 1;
347
474
  void loadEnrolled();
@@ -352,15 +479,34 @@ function searchFromFirstPage() {
352
479
  void loadEnrolled();
353
480
  }
354
481
 
482
+ /**
483
+ * Grant or revoke one person.
484
+ *
485
+ * The category comes from the row where the server gave it one, and otherwise
486
+ * from the TAB - never from re-reading the identity's links on this side. A
487
+ * contractor and a property-management member are both a `member` link, and the
488
+ * stale local copy of that reading is what used to file contractors as staff: the
489
+ * reconcile then derived `staff` from that category and rewrote the person's
490
+ * type, moving them into the Member tab.
491
+ *
492
+ * An ALREADY GRANTED person keeps the category their assignment holds, so merely
493
+ * visiting a tab and toggling somebody off and on cannot re-file them.
494
+ */
355
495
  function toggle(item: THidPermissionCandidate, selected: boolean) {
356
- if (!String(item.subjectId ?? "")) return;
496
+ const subjectId = String(item.subjectId ?? "");
497
+ if (!subjectId) return;
498
+ const category = savedState.value.get(subjectId)?.category
499
+ ?? item.category
500
+ ?? currentTab.value.category;
357
501
  draft.value = setAssignment(
358
502
  draft.value,
359
503
  savedState.value,
360
- String(item.subjectId ?? ""),
361
- item.category,
504
+ subjectId,
505
+ category,
362
506
  selected,
363
507
  );
508
+ // The strip sits beside the rows, so it follows the tick rather than the save.
509
+ counts.value = countByCategory(draft.value);
364
510
  }
365
511
 
366
512
  async function save() {
@@ -401,6 +547,22 @@ function getErrorMessage(error: unknown, fallback: string) {
401
547
  </script>
402
548
 
403
549
  <style scoped lang="scss">
550
+ /* The granted count beside a tab label. Quieter than the label and set in
551
+ tabular figures so the three tabs do not shift width as numbers change. */
552
+ /* Quieter than the intro above it, and set apart so it reads as a condition of
553
+ this reader rather than part of the screen's own description. */
554
+ .hid-access-permissions__audience-note {
555
+ margin-top: 6px;
556
+ font-style: italic;
557
+ }
558
+
559
+ .hid-access-permissions__tab-count {
560
+ margin-left: 6px;
561
+ color: var(--muted);
562
+ font-weight: 600;
563
+ font-variant-numeric: tabular-nums;
564
+ }
565
+
404
566
  .hid-access-permissions {
405
567
  &__title { padding-block: 16px; }
406
568
 
@@ -22,16 +22,14 @@
22
22
  denial here would flash "no permission" at the owner on every load. -->
23
23
  <template v-if="!isAppRoleResolved" />
24
24
 
25
- <slot v-else-if="hidEnabled && canAccessHidScreens" />
25
+ <slot v-else-if="hidEnabled && permitted" />
26
26
 
27
27
  <div v-else-if="hidEnabled" class="screen-empty hid-gate">
28
28
  <span class="hid-gate__icon tone-info">
29
29
  <v-icon icon="mdi-lock-outline" size="24" />
30
30
  </span>
31
- <h2 class="hid-gate__title">You do not have permission for HID access management</h2>
32
- <p class="hid-gate__body">
33
- Ask an administrator to grant your role access card management for this site.
34
- </p>
31
+ <h2 class="hid-gate__title">{{ deniedTitle }}</h2>
32
+ <p class="hid-gate__body">{{ deniedBody }}</p>
35
33
  </div>
36
34
 
37
35
  <div v-else class="screen-empty hid-gate">
@@ -50,10 +48,18 @@ const props = withDefaults(
50
48
  org: string;
51
49
  message?: string;
52
50
  redirectToSettings?: boolean;
51
+ /**
52
+ * Which HID right opens the screen behind this gate. `view` is the admin
53
+ * screens (readers, users, cards, intercom, access logs: `canViewHid`);
54
+ * `use` is the member's own HID QR Code page (`canUseHid`). Defaults to the
55
+ * stricter one, so a page that forgets to say is an admin page.
56
+ */
57
+ level?: "view" | "use";
53
58
  }>(),
54
59
  {
55
60
  message: "Enable HID as a service for this site before accessing this screen.",
56
61
  redirectToSettings: true,
62
+ level: "view",
57
63
  },
58
64
  );
59
65
 
@@ -61,13 +67,26 @@ const { getSiteById } = useSiteSettings();
61
67
  const { getHidServiceEnabled } = useOptionalServices();
62
68
 
63
69
  // The permission half of the same gate. This component is the only thing all
64
- // six `access-mgmt` pages have in common, so putting the check here governs all
65
- // six -- and all 66 inherited routes across the eleven apps -- without a page
66
- // guard per screen. See `composables/useHidAccessPermission.ts` for why the
67
- // gate cannot remove access that currently works.
68
- const { canAccessHidScreens } = useHidAccessPermission();
70
+ // seven `access-mgmt` HID pages have in common, so putting the check here
71
+ // governs all of them, and every inherited route across the eleven apps,
72
+ // without a page guard per screen. See `composables/useHidAccessPermission.ts`
73
+ // for the five flags and the alias period.
74
+ const { canViewHid, canUseHid } = useHidAccessPermission();
69
75
  const { isAppRoleResolved } = useLocalSetup();
70
76
 
77
+ const permitted = computed(() => (props.level === "use" ? canUseHid.value : canViewHid.value));
78
+
79
+ const deniedTitle = computed(() =>
80
+ props.level === "use"
81
+ ? "You do not have permission for HID access"
82
+ : "You do not have permission for HID access management",
83
+ );
84
+ const deniedBody = computed(() =>
85
+ props.level === "use"
86
+ ? "Ask an administrator to grant your role HID access for this site."
87
+ : "Ask an administrator to grant your role HID management for this site.",
88
+ );
89
+
71
90
  // Do not await this request during route setup. Every HID page uses this gate;
72
91
  // awaiting it makes Nuxt Suspense keep rendering the previous page until the
73
92
  // site request finishes, so the URL changes while the visible module appears
@@ -46,16 +46,18 @@
46
46
  @update:page="page = $event"
47
47
  >
48
48
  <!-- The action swaps with the tab, as it did in the old top bar. -->
49
+ <!-- Contacts are reader objects, so adding one is the readers right.
50
+ The web call is a member level call and stays for everyone. -->
49
51
  <template #actions>
50
52
  <AppButton
51
- v-if="activeTab === 'contacts'"
53
+ v-if="activeTab === 'contacts' && canManageHidReaders"
52
54
  icon="mdi-plus"
53
55
  @click="openContactDialog()"
54
56
  >
55
57
  Add Contacts
56
58
  </AppButton>
57
59
  <AppButton
58
- v-else
60
+ v-else-if="activeTab !== 'contacts'"
59
61
  icon="mdi-phone-in-talk-outline"
60
62
  class="web-call-button"
61
63
  :class="webPhoneButtonClass"
@@ -151,23 +153,30 @@
151
153
  <template #activator="{ props: menuProps }">
152
154
  <AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
153
155
  </template>
156
+ <!-- The reader's SIP settings, call settings, dial code and contacts
157
+ are reader configuration: the readers right. View and the status
158
+ check stay for the view right. -->
154
159
  <v-list v-if="activeTab === 'intercom'" density="compact" class="screen-menu">
155
160
  <v-list-item class="screen-menu__item" title="View" prepend-icon="mdi-eye" @click="openViewDialog(item)" />
156
- <v-list-item
157
- class="screen-menu__item"
158
- title="Test Connection with SIP"
159
- prepend-icon="mdi-lan-check"
160
- :disabled="testingSipReaderId === item.reader._id"
161
- @click="testSipConnection(item)"
162
- />
163
- <v-list-item class="screen-menu__item" title="Call Settings" prepend-icon="mdi-cog" @click="openCallDialog(item)" />
164
- <v-list-item class="screen-menu__item" title="Dial Code" prepend-icon="mdi-dialpad" @click="openDialCodeDialog(item)" />
161
+ <template v-if="canManageHidReaders">
162
+ <v-list-item
163
+ class="screen-menu__item"
164
+ title="Test Connection with SIP"
165
+ prepend-icon="mdi-lan-check"
166
+ :disabled="testingSipReaderId === item.reader._id"
167
+ @click="testSipConnection(item)"
168
+ />
169
+ <v-list-item class="screen-menu__item" title="Call Settings" prepend-icon="mdi-cog" @click="openCallDialog(item)" />
170
+ <v-list-item class="screen-menu__item" title="Dial Code" prepend-icon="mdi-dialpad" @click="openDialCodeDialog(item)" />
171
+ </template>
165
172
  </v-list>
166
173
  <v-list v-else density="compact" class="screen-menu">
167
174
  <v-list-item class="screen-menu__item" title="Check reader SIP status" prepend-icon="mdi-lan-check" @click="testContactSipStatus(item)" />
168
175
  <v-list-item class="screen-menu__item" title="View" prepend-icon="mdi-eye" @click="openContactView(item)" />
169
- <v-list-item class="screen-menu__item" title="Edit" prepend-icon="mdi-pencil" @click="openContactDialog(item)" />
170
- <v-list-item class="screen-menu__item screen-menu__item--danger" title="Delete" prepend-icon="mdi-delete-outline" @click="openDeleteContact(item)" />
176
+ <template v-if="canManageHidReaders">
177
+ <v-list-item class="screen-menu__item" title="Edit" prepend-icon="mdi-pencil" @click="openContactDialog(item)" />
178
+ <v-list-item class="screen-menu__item screen-menu__item--danger" title="Delete" prepend-icon="mdi-delete-outline" @click="openDeleteContact(item)" />
179
+ </template>
171
180
  </v-list>
172
181
  </v-menu>
173
182
  </template>
@@ -749,6 +758,10 @@ type IntercomRow = {
749
758
  pjsip: Record<string, unknown>;
750
759
  };
751
760
 
761
+ // Reader configuration (SIP, call settings, dial code, contacts) is the readers
762
+ // right. The screen itself is behind `HidEnabledGate` (the view right).
763
+ const { canManageHidReaders } = useHidAccessPermission();
764
+
752
765
  const {
753
766
  getReaders,
754
767
  getReaderConfiguration,
@@ -121,7 +121,7 @@ import type { HidProfileQr } from "../composables/useHidAmico";
121
121
 
122
122
  const props = defineProps<{ site: string }>();
123
123
 
124
- const { getReaders, getProfileQr, issueProfileQr, deleteProfileQr } = useHidAmico();
124
+ const { getEnrollmentReaders, getProfileQr, issueProfileQr, deleteProfileQr } = useHidAmico();
125
125
 
126
126
  type HidReaderOption = { _id: string; name?: string; portalName?: string };
127
127
 
@@ -200,7 +200,11 @@ function downloadQr() {
200
200
 
201
201
  async function loadReaders() {
202
202
  if (!props.site) return;
203
- const response = toRecord(await getReaders({ site: props.site, page: 1, limit: 100 }));
203
+ /* The self service list, not the admin one. `GET /readers` sits behind the
204
+ HID view right, so a member holding only `hid-mgmt:use-hid-access` (the
205
+ right this page is drawn for) was refused before the picker could fill.
206
+ `enrollment-readers` is the same site's enabled readers, for members. */
207
+ const response = toRecord(await getEnrollmentReaders(props.site));
204
208
  const data = toRecord(response.data);
205
209
  const items = response.items ?? data.items ?? data.readers ?? [];
206
210
  readers.value = (Array.isArray(items) ? items : []).filter((reader) => reader?._id);
@@ -1,14 +1,14 @@
1
1
  <!--
2
2
  HID QR CODE CONFIGURATION - the reader's own settings, inside Site Settings.
3
3
 
4
- It is mounted in `SiteSettings.vue`'s "HID Face Reader" expansion panel, next
4
+ It is mounted in `SiteSettings.vue`'s "HID Reader" expansion panel, next
5
5
  to `HidServiceSettingsPanel`, so it takes the shape the handoff gives a
6
6
  settings module BODY: section labels at 13px/700, explanatory lines at
7
7
  12-13px/600 in `--muted`, the 38x22 toggle, 40px fields and 38px buttons.
8
8
 
9
9
  Two pieces of legacy chrome are gone rather than restyled:
10
10
  * the `#082e43` navy title bar. The design puts NO card inside an accordion
11
- body - the accordion head ("HID Face Reader") is the card, and a second
11
+ body - the accordion head ("HID Reader") is the card, and a second
12
12
  titled panel inside it is a header the design does not draw. Its title is
13
13
  kept as the body's first section label.
14
14
  * the chevron button that sat on that bar. It had no `@click` and no model: