@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
@@ -49,6 +49,60 @@
49
49
  Disabling keeps the reader in Standalone mode and uses manual log pulls instead.
50
50
  </p>
51
51
 
52
+ <!-- WHO THIS READER IS FOR.
53
+ A site with several doors rarely wants every door open to everybody:
54
+ a service entrance for contractors, a lobby for residents. Switching
55
+ an audience off stops new grants in that category - the server
56
+ refuses them, so this is not only a hint - and takes the matching tab
57
+ off the Access Permissions screen.
58
+ Each switch says what it DOES rather than only what it is called: a
59
+ row of four labels with no sentences is a guess about what they
60
+ control. -->
61
+ <InputLabel title="Who may use this reader" required class="mb-1" />
62
+ <div class="reader-audiences mb-1">
63
+ <div
64
+ v-for="field in audienceFields"
65
+ :key="field.value"
66
+ class="reader-audience"
67
+ >
68
+ <v-switch
69
+ v-model="audiences[field.value]"
70
+ :label="field.label"
71
+ :aria-label="field.label"
72
+ hide-details
73
+ inset
74
+ density="compact"
75
+ :color="audiences[field.value] ? 'success' : 'muted'"
76
+ />
77
+ <p class="reader-audience__hint">{{ field.hint }}</p>
78
+ </div>
79
+ </div>
80
+ <!-- WHAT THIS SAVE TAKES AWAY.
81
+ Switching an account type off revokes the people already granted
82
+ this reader - their credentials stay on the device, so they go from
83
+ authorised to recognised-and-refused and back again if it is switched
84
+ on later. Said with a number, because "removes access" and "removes
85
+ access for 40 people" are different decisions. -->
86
+ <p v-if="revocationWarning" class="reader-discovery-hint reader-audiences__revoking mb-1">
87
+ {{ revocationWarning }}
88
+ </p>
89
+ <!-- And whether anybody is left with a door at all. -->
90
+ <p v-if="strandedWarning" class="reader-discovery-hint reader-audiences__empty mb-1">
91
+ {{ strandedWarning }}
92
+ </p>
93
+ <p v-if="needsConfirmation && revocationConfirmed" class="reader-discovery-hint reader-audiences__revoking mb-3">
94
+ Press Save again to apply this.
95
+ </p>
96
+
97
+ <p v-if="noAudienceSelected" class="reader-discovery-hint reader-audiences__empty mb-3">
98
+ Nobody can be enrolled on this reader while all four are off. Existing
99
+ people keep the access they already have.
100
+ </p>
101
+ <p v-else class="reader-discovery-hint mt-1 mb-3">
102
+ Switching one off stops new people of that kind being given this reader.
103
+ It does not remove anybody who already has it.
104
+ </p>
105
+
52
106
  <InputLabel title="Username" required class="mb-1" />
53
107
  <v-text-field
54
108
  v-model="draft.username"
@@ -206,7 +260,7 @@
206
260
  :disabled="mode === 'add' && (!draft.deviceId || !draft.portalId || draft.cardReadingMode === 'unconfigured')"
207
261
  @click="submit"
208
262
  >
209
- Submit
263
+ {{ submitLabel }}
210
264
  </v-btn>
211
265
  </v-card-actions>
212
266
  </v-card>
@@ -214,6 +268,18 @@
214
268
  </template>
215
269
 
216
270
  <script setup lang="ts">
271
+ /* Mirrors core's `hid-reader-audience.util.ts`, which is what enforces this —
272
+ kept in a util with tests because the absent-means-all default is the part
273
+ that could close a door by accident. */
274
+ import {
275
+ audienceRevocations,
276
+ audiencesLeftWithNoReader,
277
+ HID_READER_AUDIENCE_FIELDS,
278
+ readerAudiences,
279
+ type THidPermissionCounts,
280
+ type THidReaderAudiences,
281
+ } from "../utils/hid-reader-audiences";
282
+
217
283
  type HidReaderFormRecord = {
218
284
  name?: string;
219
285
  baseUrl?: string;
@@ -248,6 +314,7 @@ type HidReaderFormPayload = {
248
314
  pacsFormat: "raw" | "wiegand";
249
315
  operatingMode: "standalone" | "online";
250
316
  monitorEnabled: boolean;
317
+ audiences: THidReaderAudiences;
251
318
  };
252
319
 
253
320
  const props = defineProps({
@@ -278,6 +345,23 @@ const props = defineProps({
278
345
  type: String,
279
346
  default: "",
280
347
  },
348
+ // Which organisation's permission document to read the granted counts from.
349
+ // The reader's audiences are the reader's, but who is granted under them is
350
+ // per organisation - see `getSitePermissions`.
351
+ org: {
352
+ type: String,
353
+ default: "",
354
+ },
355
+ /*
356
+ * Every reader at this site, so switching the LAST one serving an account type
357
+ * can be warned about. Passed in rather than fetched: the parent already holds
358
+ * the list it draws the table from, and a second request would be a second
359
+ * answer that could disagree with what is on screen.
360
+ */
361
+ siteReaders: {
362
+ type: Array as () => Array<Record<string, unknown>>,
363
+ default: () => [],
364
+ },
281
365
  });
282
366
 
283
367
  const emit = defineEmits<{
@@ -314,6 +398,112 @@ const draft = reactive({
314
398
  operatingMode: "standalone" as "standalone" | "online",
315
399
  });
316
400
 
401
+ /*
402
+ * WHO THIS READER IS FOR. All four on by default, which is the same reading core
403
+ * gives an absent field - a reader being added should not quietly be closed to
404
+ * somebody because a switch defaulted off.
405
+ */
406
+ const audiences = reactive<THidReaderAudiences>({
407
+ resident: true,
408
+ residentVisitor: true,
409
+ member: true,
410
+ serviceProvider: true,
411
+ });
412
+ const audienceFields = HID_READER_AUDIENCE_FIELDS;
413
+
414
+ /*
415
+ * WHAT THE READER LOOKED LIKE WHEN THE DIALOG OPENED.
416
+ *
417
+ * Only an on-to-off change costs anybody their access, so the warnings compare
418
+ * against this rather than against the stored record - which the switches are
419
+ * already editing.
420
+ */
421
+ const audiencesOnOpen = ref<THidReaderAudiences>({ ...audiences });
422
+
423
+ /** Who is granted on this reader, per category. Read once per opening. */
424
+ const grantedCounts = ref<THidPermissionCounts | null>(null);
425
+ const { getSitePermissions } = useHidAmico();
426
+
427
+ /**
428
+ * NEVER THROWS, and a failure means the counts are simply unknown.
429
+ *
430
+ * The warning below says how many people a switch-off costs; without the counts
431
+ * it says that it will remove access without a number, which is still the thing
432
+ * the operator needs to know. Failing the dialog over a count would be worse.
433
+ */
434
+ async function loadGrantedCounts() {
435
+ grantedCounts.value = null;
436
+ const readerId = String(props.reader?._id ?? "");
437
+ if (!readerId || !props.site || !props.org) return;
438
+ try {
439
+ const response = await getSitePermissions(props.site, props.org, readerId);
440
+ grantedCounts.value = (response?.data?.counts ?? null) as THidPermissionCounts | null;
441
+ } catch (error: unknown) {
442
+ console.error("Unable to read who is granted on this reader:", error);
443
+ }
444
+ }
445
+
446
+ /** The account types this edit switches off, and what each costs. */
447
+ const revocations = computed(() => audienceRevocations(
448
+ audiencesOnOpen.value,
449
+ audiences,
450
+ grantedCounts.value,
451
+ ));
452
+
453
+ /** Account types this edit would leave with no reader anywhere at the site. */
454
+ const strandedAudiences = computed(() => audiencesLeftWithNoReader(
455
+ props.reader?._id,
456
+ audiences,
457
+ props.siteReaders,
458
+ ));
459
+
460
+ const revocationWarning = computed(() => {
461
+ if (!revocations.value.length) return "";
462
+ return revocations.value
463
+ .map((item) => {
464
+ if (item.audience === "residentVisitor") {
465
+ // Not a revocation: a visitor pass is bound through `user_access_rules`
466
+ // with its own expiry, so issued passes run out rather than stopping.
467
+ return `${item.label}: no new passes will be issued. Passes already issued run out on their own.`;
468
+ }
469
+ if (!item.people) {
470
+ return `${item.label}: nobody is currently granted this reader under it.`;
471
+ }
472
+ return `${item.label}: removes access for ${item.people} ${item.people === 1 ? "person" : "people"}.`;
473
+ })
474
+ .join(" ");
475
+ });
476
+
477
+ const strandedWarning = computed(() => {
478
+ if (!strandedAudiences.value.length) return "";
479
+ const labels = strandedAudiences.value.map((item) => item.label);
480
+ const subject = labels.length === 1 ? labels[0] : labels.join(", ");
481
+ return `No other reader at this site serves ${subject}.`
482
+ + ` Saving this leaves ${labels.length === 1 ? "that account type" : "those account types"} with no way in.`;
483
+ });
484
+
485
+ /*
486
+ * A SECOND PRESS when something is being taken away.
487
+ *
488
+ * Revoking forty people is not an edit to make on one click, and a dialog over
489
+ * a dialog for a switch is heavier than it needs to be. Save states what it is
490
+ * about to do and then does it.
491
+ */
492
+ const revocationConfirmed = ref(false);
493
+ const needsConfirmation = computed(
494
+ () => Boolean(revocations.value.length || strandedAudiences.value.length)
495
+ );
496
+ const submitLabel = computed(() => (
497
+ needsConfirmation.value && !revocationConfirmed.value
498
+ ? "Review and save"
499
+ : props.mode === "edit" ? "Save Changes" : "Add Reader"
500
+ ));
501
+ /* Switching every audience off leaves a reader nobody can be enrolled on, which
502
+ is legal but almost never meant - so it is said out loud rather than refused. */
503
+ const noAudienceSelected = computed(() =>
504
+ HID_READER_AUDIENCE_FIELDS.every((field) => audiences[field.value] === false)
505
+ );
506
+
317
507
  const cardModeOptions = [
318
508
  { title: "PACS (facility code + card number)", value: "pacs" },
319
509
  { title: "CSN", value: "csn" },
@@ -352,6 +542,14 @@ watch(
352
542
  discoveryError.value = "";
353
543
  discoveredConnection.value = "";
354
544
  draft.location = props.reader?.location ?? "";
545
+ // Read through `readerAudiences`, so a reader saved before this field
546
+ // existed opens with all four on rather than all four off - and a save from
547
+ // this form therefore cannot close it by accident.
548
+ Object.assign(audiences, readerAudiences(props.reader as Record<string, unknown> | null));
549
+ // The baseline the warnings compare against, and the counts behind them.
550
+ audiencesOnOpen.value = { ...audiences };
551
+ revocationConfirmed.value = false;
552
+ void loadGrantedCounts();
355
553
  draft.username = props.reader?.username ?? "";
356
554
  draft.password = props.reader?.password ?? "";
357
555
  monitorEnabled.value = props.reader?.monitorStatus !== "disabled";
@@ -446,11 +644,22 @@ function submit() {
446
644
  discoveryError.value = "Select the model printed on the physical HID reader.";
447
645
  return;
448
646
  }
647
+ /*
648
+ * The first press on a save that takes access away only acknowledges it. The
649
+ * warnings are already on screen; this makes the operator read them.
650
+ */
651
+ if (needsConfirmation.value && !revocationConfirmed.value) {
652
+ revocationConfirmed.value = true;
653
+ return;
654
+ }
449
655
  const portal = portals.value.find((item) => item.id === Number(draft.portalId));
450
656
  const payload: HidReaderFormPayload = {
451
657
  ...draft,
452
658
  portalName: portal?.name || draft.portalName,
453
659
  monitorEnabled: monitorEnabled.value,
660
+ // Spread, not passed by reference: the payload leaves this dialog and the
661
+ // reactive object keeps being edited behind it.
662
+ audiences: { ...audiences },
454
663
  };
455
664
 
456
665
  emit("submit", payload);
@@ -462,6 +671,35 @@ function submit() {
462
671
  overflow: hidden;
463
672
  }
464
673
 
674
+ /* Four rows rather than a row of four: each switch carries a sentence, and at
675
+ phone width a horizontal strip would either wrap badly or clip the hints. */
676
+ .reader-audiences {
677
+ display: flex;
678
+ flex-direction: column;
679
+ gap: 2px;
680
+ }
681
+
682
+ .reader-audience__hint {
683
+ margin: -4px 0 0;
684
+ padding-left: 2px;
685
+ color: var(--muted);
686
+ font-size: 12px;
687
+ line-height: 1.4;
688
+ }
689
+
690
+ /* Said in the warning colour, not refused: a reader nobody may use is legal and
691
+ occasionally deliberate - a door being decommissioned. */
692
+ .reader-audiences__empty {
693
+ color: var(--err);
694
+ }
695
+
696
+ /* A revocation is a warning rather than an error: it is a legitimate thing to
697
+ do, deliberately, and the colour should not imply the save is refused. */
698
+ .reader-audiences__revoking {
699
+ color: var(--warn, var(--err));
700
+ font-weight: 600;
701
+ }
702
+
465
703
  /* Type comes from `.screen-modal .v-card-title`; only the header strip's own
466
704
  padding and rule stay here. `#ececec` was a fixed near-white line, i.e.
467
705
  invisible on a light card and a bright scratch across a dark one. */
@@ -29,7 +29,10 @@
29
29
  show-header
30
30
  @refresh="loadReaders"
31
31
  >
32
- <template #actions>
32
+ <!-- Hidden, not disabled, for a member without the right: the server
33
+ refuses the call anyway, and a button that cannot work is noise.
34
+ `hid-mgmt:manage-hid-readers` (`useHidAccessPermission`). -->
35
+ <template v-if="canManageHidReaders" #actions>
33
36
  <AppButton icon="mdi-plus" @click="openAdd">Add HID Reader</AppButton>
34
37
  </template>
35
38
 
@@ -85,14 +88,23 @@
85
88
  <AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" />
86
89
  </template>
87
90
 
91
+ <!-- View is the view right. Access Permissions assigns people to the
92
+ reader's group, so it is the users right; everything else acts on
93
+ the reader itself and is the readers right. -->
88
94
  <v-list density="compact" min-width="180">
89
95
  <v-list-item title="View" @click="openView(item)" />
90
- <v-list-item title="Access Permissions" @click="openPermissions(item)" />
91
- <v-list-item title="Identification Methods" @click="openIdentification(item)" />
92
- <v-list-item title="Sync HID Reader" @click="openSyncConfirm(item)" />
93
- <v-list-item title="Test Connection" @click="openTestConfirm(item)" />
94
- <v-list-item title="Edit" @click="openEdit(item)" />
95
- <v-list-item title="Delete" class="text-error" @click="openDeleteConfirm(item)" />
96
+ <v-list-item
97
+ v-if="canManageHidUsers"
98
+ title="Access Permissions"
99
+ @click="openPermissions(item)"
100
+ />
101
+ <template v-if="canManageHidReaders">
102
+ <v-list-item title="Identification Methods" @click="openIdentification(item)" />
103
+ <v-list-item title="Sync HID Reader" @click="openSyncConfirm(item)" />
104
+ <v-list-item title="Test Connection" @click="openTestConfirm(item)" />
105
+ <v-list-item title="Edit" @click="openEdit(item)" />
106
+ <v-list-item title="Delete" class="text-error" @click="openDeleteConfirm(item)" />
107
+ </template>
96
108
  </v-list>
97
109
  </v-menu>
98
110
  </template>
@@ -111,8 +123,10 @@
111
123
  <HidReaderForm
112
124
  v-model="formDialog"
113
125
  :site="props.site"
126
+ :org="props.org"
114
127
  :mode="selectedReader ? 'edit' : 'add'"
115
128
  :reader="selectedReader"
129
+ :site-readers="readers"
116
130
  :loading="saving"
117
131
  @submit="saveReader"
118
132
  />
@@ -134,7 +148,7 @@
134
148
  <span>Username</span><strong>{{ selectedReader?.username || "N/A" }}</strong>
135
149
  </div>
136
150
 
137
- <div class="dialog-actions-inline">
151
+ <div v-if="canManageHidReaders" class="dialog-actions-inline">
138
152
  <AppButton variant="ghost" @click="openTestConfirm(selectedReader)">Test Connection</AppButton>
139
153
  <AppButton variant="ghost" @click="openSyncConfirm(selectedReader)">Sync Reader</AppButton>
140
154
  </div>
@@ -479,6 +493,10 @@ const props = defineProps({
479
493
  },
480
494
  });
481
495
 
496
+ // Which buttons this member gets. The screen itself is behind `HidEnabledGate`
497
+ // (the view right); these two decide the write actions on it.
498
+ const { canManageHidReaders, canManageHidUsers } = useHidAccessPermission();
499
+
482
500
  const {
483
501
  getReaders,
484
502
  createReader,
@@ -68,7 +68,8 @@
68
68
  </span>
69
69
  </template>
70
70
 
71
- <template #[`item.action-table`]="{ item }">
71
+ <!-- Setting a PIN is the users right; the roster itself is the view right. -->
72
+ <template v-if="canManageHidUsers" #[`item.action-table`]="{ item }">
72
73
  <AppButton
73
74
  variant="row"
74
75
  icon="mdi-form-textbox-password"
@@ -199,6 +200,7 @@ type HidReaderUser = Record<string, unknown> & {
199
200
  metadata?: HidMetadata;
200
201
  };
201
202
 
203
+ const { canManageHidUsers } = useHidAccessPermission();
202
204
  const {
203
205
  getReaders,
204
206
  getReaderUsers,
@@ -115,7 +115,7 @@ function buildHidServices(enabled: boolean) {
115
115
 
116
116
  const service = {
117
117
  key: "hid_amico",
118
- name: "HID Face Reader",
118
+ name: "HID Reader",
119
119
  enabled,
120
120
  status: enabled ? "active" : "inactive",
121
121
  };
@@ -154,7 +154,7 @@ async function saveHidService(value: boolean) {
154
154
  hidEnabled.value = value;
155
155
  emit("refresh");
156
156
  await refreshNuxtData(`layout-selected-site-details-${useRuntimeConfig().public.APP}`);
157
- showMessage(`HID Face Reader ${value ? "enabled" : "disabled"} successfully`, "success");
157
+ showMessage(`HID Reader ${value ? "enabled" : "disabled"} successfully`, "success");
158
158
  } catch (error: unknown) {
159
159
  hidEnabled.value = previousValue;
160
160
  showMessage(errorConverter(error), "error");