@7365admin1/layer-common 4.73.1-staging.456 → 4.75.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 (46) hide show
  1. package/CHANGELOG.md +21 -4
  2. package/assets/css/primitives.css +0 -43
  3. package/components/AccessCardPreviewDialog.vue +50 -286
  4. package/components/AppSelect.vue +3 -77
  5. package/components/BuildingUnitFormEdit.vue +0 -6
  6. package/components/Dialog/UpdateMoreAction.vue +1 -11
  7. package/components/DocumentForm.vue +112 -407
  8. package/components/DocumentManagement.vue +12 -213
  9. package/components/Facility/BookingSetup.vue +0 -63
  10. package/components/HidAccessLogDashboard.vue +17 -74
  11. package/components/HidIntercomManagement.vue +14 -183
  12. package/components/HidQrCodeConfiguration.vue +195 -17
  13. package/components/HidReaderManagement.vue +0 -398
  14. package/components/HidReaderUserRoster.vue +3 -44
  15. package/components/HidUserEnrollment.vue +155 -845
  16. package/components/InvitationClientForm.vue +1 -19
  17. package/components/MemberMain.vue +3 -6
  18. package/components/VisitorForm.vue +0 -12
  19. package/components/VisitorManagement.vue +5 -267
  20. package/composables/useDocument.ts +0 -7
  21. package/composables/useFacility.ts +0 -11
  22. package/composables/useHidAmico.ts +0 -165
  23. package/composables/useHidNavigation.ts +1 -25
  24. package/composables/useMember.ts +0 -12
  25. package/composables/usePeople.ts +0 -19
  26. package/composables/useServiceProvider.ts +2 -33
  27. package/package.json +1 -1
  28. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  29. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  30. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  31. package/types/document.d.ts +0 -14
  32. package/types/facility.d.ts +0 -4
  33. package/types/member.d.ts +0 -4
  34. package/types/people.d.ts +1 -28
  35. package/types/service-provider.d.ts +0 -5
  36. package/utils/role.ts +1 -6
  37. package/components/BuildingUnitManagement.vue +0 -298
  38. package/components/HidAccessPermissions.vue +0 -424
  39. package/components/HidProfileQrCode.vue +0 -332
  40. package/composables/useHidReaderSelection.ts +0 -50
  41. package/composables/useSiteCategory.ts +0 -45
  42. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
  43. package/utils/hid-permission-assignments.ts +0 -190
  44. package/utils/hid-reader-selection.ts +0 -52
  45. package/utils/occupancy-role.ts +0 -109
  46. package/utils/service-type.ts +0 -30
@@ -67,8 +67,8 @@
67
67
  <AppField
68
68
  v-model="search"
69
69
  search
70
- placeholder="Search name, UID or registration no."
71
- @keyup.enter="onSearchSubmit"
70
+ placeholder="Search"
71
+ @keyup.enter="reloadUsersFromFirstPage"
72
72
  />
73
73
  <AppSelect
74
74
  v-if="!props.cardManagement"
@@ -96,12 +96,6 @@
96
96
  <span class="app-cell--num">{{ formatHidUid(item.hidUserId) || "N/A" }}</span>
97
97
  </template>
98
98
 
99
- <!-- The reader allocates this alongside the UID. A row the reader holds
100
- that we never enrolled has none, so it is not always present. -->
101
- <template #[`item.registration`]="{ item }">
102
- <span class="app-cell--num">{{ item.registration || "N/A" }}</span>
103
- </template>
104
-
105
99
  <!-- The shared chip. `utils/status.ts` now carries Mapped/Unmapped with
106
100
  the same tones this screen's private `getMappingStatusClass` gave
107
101
  them, so the map is one list rather than a fourth private copy. -->
@@ -172,12 +166,12 @@
172
166
  </TableMain>
173
167
 
174
168
  <v-dialog v-model="formDialog" max-width="420" persistent>
175
- <v-card class="screen-modal">
176
- <v-card-title>
169
+ <v-card rounded="lg">
170
+ <v-card-title class="small-title">
177
171
  {{ selectedUser ? "Edit User Information" : "Enroll" }}
178
172
  </v-card-title>
179
173
 
180
- <v-card-text class="screen-modal__body">
174
+ <v-card-text>
181
175
  <div class="photo-wrap">
182
176
  <v-menu>
183
177
  <template #activator="{ props: menuProps }">
@@ -219,15 +213,6 @@
219
213
  />
220
214
  </v-list>
221
215
  </v-menu>
222
- <!-- Sits under the button because it describes what the button
223
- is reporting: the reader holds a face, and this is its
224
- reference. Nothing to show while enrolling - the value does not
225
- exist until the reader has accepted a photo. -->
226
- <p v-if="editFacialData" class="photo-facial">
227
- <span class="photo-facial__label">Facial Data</span>
228
- <span class="photo-facial__value">{{ editFacialData }}</span>
229
- </p>
230
-
231
216
  <input
232
217
  ref="photoInput"
233
218
  type="file"
@@ -237,96 +222,29 @@
237
222
  />
238
223
  </div>
239
224
 
240
- <!-- Block / Level / Unit / Resident. Enrolment only.
241
- Enrollment is resident-only and the resident must already exist
242
- in iService365 - you pick a person, you do not type one. Name is
243
- therefore a mirror of that record, never an input, so the reader
244
- can never end up holding a name the app does not know.
245
- Every option is derived from the residents themselves, so a
246
- block, level or unit only appears when somebody lives there.
247
- The list loads on first touch of Block, not on dialog open.
248
- Enrolment only, with no exception. Editing does not move
249
- anybody: the record already carries its block, level and unit,
250
- `openEdit` loads them into the form and `saveUser` writes them
251
- back untouched, so the location survives an edit without being
252
- asked for. A row that never had one keeps not having one; that
253
- is not a reason to make somebody fill in a location before they
254
- can change a PIN. -->
255
- <template v-if="!selectedUser">
256
- <div class="field-label">Block <span>*</span></div>
257
- <!-- The listener sits on a wrapper, NOT on AppSelect: AppSelect
258
- spreads `$attrs` over its v-menu activator AFTER the activator's
259
- own bindings, so an `@click` here would replace the handler that
260
- opens the dropdown. `focusin` covers keyboard users, and both
261
- are idempotent - `ensureOccupancy()` fetches once.
262
- AppSelect has no `loading` prop either; the placeholder carries
263
- the loading and empty states instead. -->
264
- <div @click="onBlockOpened" @focusin="onBlockOpened">
265
- <AppSelect
266
- v-model="form.block"
267
- :items="blockOptions"
268
- :placeholder="blockPlaceholder"
269
- klass="mb-3"
270
- @update:model-value="onBlockChanged"
271
- />
272
- </div>
273
-
274
- <div class="field-label">Level <span>*</span></div>
275
- <AppSelect
276
- v-model="form.level"
277
- :items="levelOptions"
278
- :disabled="!form.block"
279
- placeholder="Select level"
280
- klass="mb-3"
281
- @update:model-value="onLevelChanged"
282
- />
283
-
284
- <div class="field-label">Unit <span>*</span></div>
285
- <AppSelect
286
- v-model="form.unit"
287
- :items="unitOptions"
288
- :disabled="!form.level"
289
- placeholder="Select unit"
290
- klass="mb-3"
291
- @update:model-value="onUnitChanged"
292
- />
293
- </template>
294
-
295
- <!-- Drawn only where it can offer something: while enrolling, or
296
- while editing a record whose stored unit gives it a list. A row
297
- with no unit has no candidates to show, and the dialog is then
298
- purely the reader's own credentials - photo, PIN, password -
299
- which is exactly the edit that must not be blocked. -->
300
- <template v-if="!selectedUser || form.unit">
301
- <div class="field-label">Resident <span>*</span></div>
302
- <AppSelect
303
- v-model="form.subjectId"
304
- :items="subjectOptions"
305
- :disabled="!form.unit"
306
- :placeholder="unitResidentPlaceholder"
307
- klass="mb-3"
308
- @update:model-value="onResidentChanged"
309
- />
310
- </template>
311
- <!-- The duplicate check. It is reader-scoped: this resident holding a
312
- HID user on another gate is normal and says nothing here. -->
313
- <p v-if="checkingResident" class="field-hint">Checking enrollment...</p>
314
- <p v-else-if="residentConflict" class="field-hint field-hint--error mb-3">
315
- {{ residentConflictMessage }}
316
- </p>
317
- <p v-else-if="unitResidentNote" class="field-hint mb-3">
318
- {{ unitResidentNote }}
319
- </p>
320
-
321
- <!-- Read-only: it restates the chosen resident, it does not collect
322
- anything. `selectedUser` is the edit case, where the name is
323
- whatever the reader already holds. -->
324
- <div class="field-label">Name</div>
225
+ <div class="field-label">Name <span>*</span></div>
325
226
  <AppField
326
227
  v-model="form.name"
327
228
  aria-label="Name"
328
- placeholder="Select a resident above"
329
- readonly
229
+ placeholder="Enter name"
230
+ klass="mb-3"
231
+ />
232
+
233
+ <div class="field-label">Link To <span>*</span></div>
234
+ <AppSelect
235
+ v-model="form.subjectCategory"
236
+ :items="subjectCategoryOptions"
237
+ placeholder="Select person type"
238
+ klass="mb-3"
239
+ @update:model-value="onSubjectCategoryChanged"
240
+ />
241
+
242
+ <div class="field-label">Resident / Staff / Provider <span>*</span></div>
243
+ <AppSelect
244
+ v-model="form.subjectId"
245
+ :items="subjectOptions"
246
+ :loading="loadingSubjects"
247
+ placeholder="Select an existing site record"
330
248
  klass="mb-3"
331
249
  />
332
250
 
@@ -338,11 +256,11 @@
338
256
  v-model="form.registration"
339
257
  aria-label="Registration No."
340
258
  placeholder="Enter registration no."
341
- readonly
259
+ :readonly="!selectedUser"
342
260
  klass="mb-3"
343
261
  />
344
262
 
345
- <div class="field-label">PIN (optional)</div>
263
+ <div class="field-label">Access PIN (optional)</div>
346
264
  <!--
347
265
  The handoff draws no password field anywhere (the sign-in screens
348
266
  are the owner's own), so the reveal toggle is this product's, not
@@ -352,8 +270,8 @@
352
270
  -->
353
271
  <AppField
354
272
  v-model="form.accessPin"
355
- aria-label="PIN"
356
- placeholder="Enter pin"
273
+ aria-label="Access PIN"
274
+ placeholder="Enter numeric access PIN"
357
275
  inputmode="numeric"
358
276
  autocomplete="new-password"
359
277
  :type="showPin ? 'text' : 'password'"
@@ -362,7 +280,7 @@
362
280
  <button
363
281
  type="button"
364
282
  class="app-field__trailing"
365
- :aria-label="showPin ? 'Hide PIN' : 'Show PIN'"
283
+ :aria-label="showPin ? 'Hide access PIN' : 'Show access PIN'"
366
284
  @click="showPin = !showPin"
367
285
  >
368
286
  <v-icon :icon="showPin ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
@@ -377,62 +295,28 @@
377
295
  <v-checkbox
378
296
  v-if="selectedUser && pinEnrolled"
379
297
  v-model="removePinRequested"
380
- label="Remove current PIN"
381
- density="compact"
382
- hide-details
383
- />
384
-
385
- <!-- Password: the reader stores only a hash plus its salt, so there
386
- is nothing to read back and the field is always empty on open.
387
- Same reveal toggle as the PIN above. -->
388
- <div class="field-label mt-3">Password (optional)</div>
389
- <AppField
390
- v-model="form.password"
391
- aria-label="Password"
392
- :placeholder="selectedUser && passwordSet ? 'Enter a new password' : 'Enter password'"
393
- autocomplete="new-password"
394
- :type="showPassword ? 'text' : 'password'"
395
- >
396
- <template #trailing>
397
- <button
398
- type="button"
399
- class="app-field__trailing"
400
- :aria-label="showPassword ? 'Hide password' : 'Show password'"
401
- @click="showPassword = !showPassword"
402
- >
403
- <v-icon :icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
404
- </button>
405
- </template>
406
- </AppField>
407
- <p class="field-hint">
408
- {{ selectedUser && passwordSet ? "A password is set. Leave blank to keep it." : "Used with the user ID on the reader keypad." }}
409
- </p>
410
- <v-checkbox
411
- v-if="selectedUser && passwordSet"
412
- v-model="removePasswordRequested"
413
- label="Remove current password"
298
+ label="Remove current access PIN"
414
299
  density="compact"
415
300
  hide-details
416
301
  />
417
302
  </v-card-text>
418
303
 
419
- <!-- The shared modal footer: `screen-btn-ghost` + `screen-btn-primary`
420
- on `v-btn`, the same pair every other module's dialog uses
421
- (`assets/css/screens.css`). `v-btn` stays because the submit needs
422
- `:loading`, which `AppButton` does not carry. -->
423
- <v-card-actions class="screen-modal__footer">
304
+ <!-- The split footer bar, as panel 2 keeps it: `v-btn` because it needs
305
+ `:loading` and a full-bleed shape the handoff does not draw. Only
306
+ the colours change - they were fixed hexes on both themes. -->
307
+ <v-card-actions class="pa-0">
424
308
  <v-btn
425
- class="text-none screen-btn-ghost"
309
+ class="text-none action-cancel"
426
310
  variant="flat"
311
+ height="44"
427
312
  @click="formDialog = false"
313
+ >Cancel</v-btn
428
314
  >
429
- Cancel
430
- </v-btn>
431
315
  <v-btn
432
- class="text-none screen-btn-primary"
316
+ class="text-none action-submit"
433
317
  variant="flat"
318
+ height="44"
434
319
  :loading="saving"
435
- :disabled="Boolean(residentConflict) || checkingResident"
436
320
  @click="saveUser"
437
321
  >
438
322
  Save
@@ -444,9 +328,9 @@
444
328
  <HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
445
329
 
446
330
  <v-dialog v-model="viewDialog" max-width="430">
447
- <v-card class="screen-modal">
448
- <v-card-title>User Information</v-card-title>
449
- <v-card-text class="screen-modal__body">
331
+ <v-card rounded="lg">
332
+ <v-card-title class="small-title">User Information</v-card-title>
333
+ <v-card-text>
450
334
  <div class="detail-grid">
451
335
  <span>Name</span><strong>{{ getName(selectedUser) }}</strong>
452
336
  <span>User ID</span
@@ -463,22 +347,21 @@
463
347
  ><strong>{{
464
348
  selectedUser ? getMappingStatus(selectedUser) : "N/A"
465
349
  }}</strong>
466
- <span>PIN</span
350
+ <span>Access PIN</span
467
351
  ><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
468
- <span>Password</span
469
- ><strong>{{ selectedUser?.metadata?.passwordSet ? "Configured" : "N/A" }}</strong>
470
352
  </div>
471
353
  </v-card-text>
472
- <v-card-actions class="screen-modal__footer">
354
+ <v-card-actions>
355
+ <v-spacer />
473
356
  <AppButton variant="ghost" @click="viewDialog = false">Close</AppButton>
474
357
  </v-card-actions>
475
358
  </v-card>
476
359
  </v-dialog>
477
360
 
478
361
  <v-dialog v-model="cardDialog" max-width="560" persistent>
479
- <v-card class="screen-modal">
480
- <v-card-title>Physical HID Cards</v-card-title>
481
- <v-card-text class="screen-modal__body">
362
+ <v-card rounded="lg">
363
+ <v-card-title class="small-title">Physical HID Cards</v-card-title>
364
+ <v-card-text>
482
365
  <p class="hid-card-user">
483
366
  {{ getName(cardUser) }}
484
367
  <span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
@@ -569,11 +452,6 @@
569
452
  Present the physical card to the selected Amico reader. The request will time out after 30 seconds.
570
453
  </v-alert>
571
454
 
572
- <!-- Reading the card at the reader is the primary way in: it is the
573
- only one that does not require knowing the facility code and card
574
- number up front, which are printed on nothing the operator holds.
575
- Assigning by hand stays for the cards whose values are already
576
- known. -->
577
455
  <div class="hid-card-actions mt-4">
578
456
  <AppButton
579
457
  variant="secondary"
@@ -592,16 +470,14 @@
592
470
  <AppButton
593
471
  v-if="enrollingCard"
594
472
  variant="ghost"
595
- :disabled="cancellingCard"
596
473
  @click="cancelPhysicalCardEnrollment"
597
474
  >
598
- {{ cancellingCard ? "Cancelling…" : "Cancel Reading" }}
475
+ Cancel Reading
599
476
  </AppButton>
600
477
  </div>
601
478
  </v-card-text>
602
- <v-card-actions class="screen-modal__footer">
603
- <!-- Closing mid-read would leave the reader listening with nothing
604
- to receive the card it reads. -->
479
+ <v-card-actions>
480
+ <v-spacer />
605
481
  <AppButton variant="ghost" :disabled="enrollingCard" @click="cardDialog = false">
606
482
  Close
607
483
  </AppButton>
@@ -610,31 +486,31 @@
610
486
  </v-dialog>
611
487
 
612
488
  <v-dialog v-model="deleteDialog" max-width="360" persistent>
613
- <v-card class="screen-modal">
614
- <v-card-title>Delete</v-card-title>
615
- <v-card-text class="screen-modal__body text-body-2">
489
+ <v-card rounded="lg">
490
+ <v-card-title class="small-title">Delete</v-card-title>
491
+ <v-card-text class="text-body-2">
616
492
  Are you sure you want to permanently delete this HID user?
617
493
  <div class="text-caption text-medium-emphasis mt-2">
618
494
  This action will remove the user from the HID reader and mark the
619
495
  identity as deleted.
620
496
  </div>
621
497
  </v-card-text>
622
- <v-card-actions class="screen-modal__footer">
498
+ <v-card-actions class="pa-0">
623
499
  <v-btn
624
- class="text-none screen-btn-ghost"
500
+ class="text-none action-cancel"
625
501
  variant="flat"
502
+ height="44"
626
503
  @click="deleteDialog = false"
504
+ >Cancel</v-btn
627
505
  >
628
- Cancel
629
- </v-btn>
630
506
  <v-btn
631
- class="text-none screen-btn-primary"
507
+ class="text-none action-submit"
632
508
  variant="flat"
509
+ height="44"
633
510
  :loading="deleting"
634
511
  @click="deleteUser"
512
+ >Delete</v-btn
635
513
  >
636
- Delete
637
- </v-btn>
638
514
  </v-card-actions>
639
515
  </v-card>
640
516
  </v-dialog>
@@ -651,13 +527,6 @@ const props = defineProps({
651
527
  type: String,
652
528
  required: true,
653
529
  },
654
- // Which organisation the access this enrolment grants belongs to. Saving now
655
- // assigns the person to the reader, and the assignment is stored per
656
- // organisation - see `schemaCreateHidAmicoIdentity`.
657
- org: {
658
- type: String,
659
- default: "",
660
- },
661
530
  cardManagement: {
662
531
  type: Boolean,
663
532
  default: false,
@@ -678,13 +547,11 @@ const {
678
547
  getUserPinStatus,
679
548
  setUserPin,
680
549
  deleteUserPin,
681
- getUserPasswordStatus,
682
- setUserPassword,
683
- deleteUserPassword,
684
550
  runObjectOperation,
685
551
  createIdentity,
686
552
  updateIdentity,
687
553
  deleteIdentity,
554
+ getPermissionCandidates,
688
555
  } = useHidAmico();
689
556
 
690
557
  type HidMetadata = Record<string, unknown> & {
@@ -699,7 +566,6 @@ type HidMetadata = Record<string, unknown> & {
699
566
  lastAccessAt?: string;
700
567
  facialScores?: Record<string, unknown>;
701
568
  pinEnrolled?: boolean;
702
- passwordSet?: boolean;
703
569
  hidPhysicalCards?: HidCard[];
704
570
  };
705
571
 
@@ -750,7 +616,6 @@ type HidIdentityPayload = Record<string, unknown> & {
750
616
  const readers = ref<HidReader[]>([]);
751
617
  const users = ref<HidUser[]>([]);
752
618
  const administratorUserIds = ref<Set<number>>(new Set());
753
- const { selectHidReader, resolveForSite } = useHidReaderSelection();
754
619
  const selectedReaderId = ref("");
755
620
  const search = ref("");
756
621
  const status = ref("");
@@ -774,14 +639,7 @@ const physicalCardErrorsByUserId = ref<Record<string, string>>({});
774
639
  const loadingCards = ref(false);
775
640
  const savingCard = ref(false);
776
641
  const enrollingCard = ref(false);
777
- /*
778
- * Cancelling makes the in-flight enrol REJECT, and that rejection is expected
779
- * rather than a failure. Without this flag the cancel raises an error toast on
780
- * top of the one confirming it, which reads as the cancel having gone wrong.
781
- */
782
642
  const cardEnrollmentCancelled = ref(false);
783
- /** The cancel's own round trip to the reader — a second or two, not the enrol's. */
784
- const cancellingCard = ref(false);
785
643
  const deletingCardId = ref("");
786
644
  const showPin = ref(false);
787
645
  const pinEnrolled = ref(false);
@@ -803,61 +661,13 @@ function hasFacialEnrollment(user?: HidUser | null) {
803
661
  user?.metadata?.imageTimestamp
804
662
  );
805
663
  }
806
-
807
- /**
808
- * The reader's own reference for the enrolled face, shown while editing.
809
- *
810
- * We hold neither the image nor the biometric template - the reader does - so
811
- * this value is the only handle on the enrollment there is. `getFacialData` is
812
- * the roster's Facial Data column, reused here so the dialog and the table
813
- * cannot disagree; it answers "N/A" when there is nothing, which is the case
814
- * this must not draw a row for.
815
- */
816
- const editFacialData = computed(() => {
817
- if (!selectedUser.value) return "";
818
- const value = String(getFacialData(selectedUser.value) ?? "").trim();
819
- return value && value !== "N/A" ? value : "";
820
- });
821
664
  const removePinRequested = ref(false);
822
- const passwordSet = ref(false);
823
- const showPassword = ref(false);
824
- const removePasswordRequested = ref(false);
825
665
  const snackbar = reactive({
826
666
  show: false,
827
667
  color: "success",
828
668
  message: "",
829
669
  });
830
670
 
831
- type OccupiedUnit = { _id: string; name: string; people: number };
832
- type OccupiedLevel = { _id: string; name: string; units?: OccupiedUnit[] };
833
- type OccupiedBlock = {
834
- _id: string;
835
- name: string;
836
- block: number | null;
837
- levels?: OccupiedLevel[];
838
- };
839
- type UnitPerson = {
840
- _id: string;
841
- name?: string;
842
- unitName?: string;
843
- /**
844
- * The resident's app account, and the reason this field is read here.
845
- *
846
- * A HID identity is refused unless its subject is a valid permission subject
847
- * at the site, and for a resident that means a `site.people` row WITH a
848
- * linked user - `assertPermissionSubject` in `iservice365-core` filters on
849
- * exactly this field. The permission model has no way to represent anyone
850
- * else: `resolvePermissionUserBindings` binds reader access by user id and
851
- * drops every row without one, so a resident enrolled without an account
852
- * would end up on the reader holding no access rules at all.
853
- *
854
- * The picker this replaced was fed by that same server-side function, so it
855
- * could never offer an unenrollable resident. The unit cascade asks People
856
- * Management instead, which has no such filter - hence this one.
857
- */
858
- user?: string | null;
859
- };
860
-
861
671
  const form = reactive({
862
672
  reader: "",
863
673
  block: "",
@@ -868,7 +678,6 @@ const form = reactive({
868
678
  registration: "",
869
679
  isAdministrator: false,
870
680
  accessPin: "",
871
- password: "",
872
681
  photoPreview: "",
873
682
  subjectCategory: "resident" as THidPermissionCategory,
874
683
  subjectId: "",
@@ -876,131 +685,18 @@ const form = reactive({
876
685
 
877
686
  const route = useRoute();
878
687
  const orgId = computed(() => String(route.params.org || ""));
879
-
880
- // The cascade runs on People Management's own endpoints, the same pair the
881
- // resident forms use:
882
- // GET /api/people/site/:site/occupied-structure - blocks/levels/units that
883
- // actually have an occupant, so the picker cannot offer an empty unit
884
- // GET /api/people/unit/:unit - who is in the chosen one
885
- // Both are fetched on FIRST USE, never when the dialog opens.
886
- const { getOccupiedStructure, getPeopleByUnit } = usePeople();
887
-
888
- // `resident,tenant` matches the occupancy endpoint's own default, so the units
889
- // it offers and the people this returns are the same set. Asking the two
890
- // halves different questions is how a unit ends up offered but empty.
891
- const OCCUPANT_TYPES = "resident,tenant";
892
-
893
- const occupiedBlocks = ref<OccupiedBlock[]>([]);
894
- const occupancyLoaded = ref(false);
895
- const loadingOccupancy = ref(false);
896
- const unitPeople = ref<UnitPerson[]>([]);
897
- const loadingUnitPeople = ref(false);
898
-
899
- // "Is this resident already enrolled on THIS reader?" - the reader keeps its own
900
- // user table, so the same person at a different gate is normal and does not
901
- // count. Held here rather than derived, because it is the answer to a request.
902
- //
903
- // The server refuses the duplicate as well (`createIdentity` in
904
- // `iservice365-core`), but by the time it answers, `saveUser` has already
905
- // created a user on the physical device and has to roll it back. Asking here
906
- // keeps that off the reader entirely.
907
- const residentConflict = ref<HidUser | null>(null);
908
- const checkingResident = ref(false);
909
-
910
- // One spelling of a block, used by the picker and by the label stamped onto the
911
- // record. Two copies of this expression is how the dropdown and the Unit column
912
- // drift apart.
913
- function formatBlockTitle(block?: OccupiedBlock | null) {
914
- if (!block) return "";
915
- return block.name
916
- ? `Block ${block.block} (${block.name})`
917
- : `Block ${block.block ?? ""}`.trim();
918
- }
919
-
920
- const blockOptions = computed(() => occupiedBlocks.value.map((block) => ({
921
- title: formatBlockTitle(block),
922
- value: String(block._id ?? ""),
923
- })));
924
-
925
- const levelOptions = computed(() => {
926
- const block = occupiedBlocks.value.find((item) => String(item._id ?? "") === form.block);
927
- return (block?.levels ?? []).map((level) => ({
928
- title: String(level.name ?? ""),
929
- value: String(level._id ?? ""),
930
- }));
931
- });
932
-
933
- const unitOptions = computed(() => {
934
- const block = occupiedBlocks.value.find((item) => String(item._id ?? "") === form.block);
935
- const level = (block?.levels ?? []).find((item) => String(item._id ?? "") === form.level);
936
- return (level?.units ?? []).map((unit) => ({
937
- title: String(unit.name ?? ""),
938
- value: String(unit._id ?? ""),
939
- }));
940
- });
941
-
942
- // Only the residents the server will actually accept - see `UnitPerson.user`.
943
- // Offering the rest produced "The linked HID identity subject is not active at
944
- // this site." at the very end of enrollment, after a user had already been
945
- // written to the reader.
946
- const enrollableUnitPeople = computed(() =>
947
- unitPeople.value.filter((person) => Boolean(person.user)),
948
- );
949
-
950
- const unenrollableUnitPeopleCount = computed(
951
- () => unitPeople.value.length - enrollableUnitPeople.value.length,
952
- );
953
-
954
- // `_id` here is `site.people._id`, which is exactly the `person` that
955
- // `createIdentity` maps a HID user to.
956
- const subjectOptions = computed(() => enrollableUnitPeople.value.map((person) => ({
957
- title: String(person.name ?? "Resident"),
958
- value: String(person._id ?? ""),
688
+ const permissionCandidates = ref<THidPermissionCandidate[]>([]);
689
+ const loadingSubjects = ref(false);
690
+ const subjectCategoryOptions = [
691
+ { title: "Resident", value: "resident" },
692
+ { title: "Property management", value: "property_management" },
693
+ { title: "Service provider", value: "service_provider" },
694
+ ];
695
+ const subjectOptions = computed(() => permissionCandidates.value.map((candidate) => ({
696
+ title: candidate.subtitle ? `${candidate.name} · ${candidate.subtitle}` : candidate.name,
697
+ value: candidate.subjectId,
959
698
  })));
960
699
 
961
- // Says why somebody the operator can see in People Management is missing here.
962
- // Without it the unit just looks empty and the screen looks broken.
963
- const unitResidentNote = computed(() => {
964
- if (!form.unit || loadingUnitPeople.value || !unenrollableUnitPeopleCount.value) return "";
965
- const count = unenrollableUnitPeopleCount.value;
966
- const subject = count === 1 ? "1 resident" : `${count} residents`;
967
- const verb = count === 1 ? "is" : "are";
968
- return `${subject} in this unit ${verb} not listed: HID enrollment needs a resident app account, and ${count === 1 ? "that person has" : "they have"} none yet.`;
969
- });
970
-
971
- const blockPlaceholder = computed(() => {
972
- if (loadingOccupancy.value) return "Loading...";
973
- if (occupancyLoaded.value && !blockOptions.value.length) {
974
- return "No occupied units at this site";
975
- }
976
- return "Select block";
977
- });
978
-
979
- const unitResidentPlaceholder = computed(() => {
980
- if (!form.unit) return "Select a unit first";
981
- if (loadingUnitPeople.value) return "Loading residents...";
982
- if (enrollableUnitPeople.value.length) return "Select a resident";
983
- // The two empty cases are different problems with different fixes, so they
984
- // do not share a sentence: nobody lives here, versus nobody here can be
985
- // enrolled yet. `unitResidentNote` carries the detail for the second.
986
- return unitPeople.value.length
987
- ? "No resident here can be enrolled yet"
988
- : "No residents in this unit";
989
- });
990
-
991
- // Names the HID user that is in the way, so the operator can go and edit it
992
- // rather than being told only that something is wrong.
993
- const residentConflictMessage = computed(() => {
994
- const conflict = residentConflict.value;
995
- if (!conflict) return "";
996
- const label = [getName(conflict), formatHidUid(conflict.hidUserId)]
997
- .filter(Boolean)
998
- .join(" · ");
999
- return label
1000
- ? `Already enrolled on this reader as ${label}. Edit that HID user instead of enrolling again.`
1001
- : "Already enrolled on this reader. Edit that HID user instead of enrolling again.";
1002
- });
1003
-
1004
700
  const cardForm = reactive({
1005
701
  cardType: "pacs" as "pacs" | "csn",
1006
702
  facilityCode: "",
@@ -1042,8 +738,6 @@ const userHeaders = [
1042
738
  { title: "Name", value: "name", sortable: false },
1043
739
  { title: "Facial Data", value: "facialData", sortable: false },
1044
740
  { title: "UID", value: "hidUserId", sortable: false },
1045
- // Reads in the same order the dialog asks for them: UID then registration.
1046
- { title: "Registration No.", value: "registration", sortable: false },
1047
741
  { title: "Status", value: "status", sortable: false },
1048
742
  { title: "", value: "action-table", sortable: false },
1049
743
  ];
@@ -1073,32 +767,6 @@ function reloadUsersFromFirstPage() {
1073
767
  loadUsers();
1074
768
  }
1075
769
 
1076
- /**
1077
- * Typing is the search.
1078
- *
1079
- * The field used to reload on `@keyup.enter` and on nothing else, so a screen
1080
- * that looks like every other search box did nothing at all unless you guessed
1081
- * that Enter was required - no request left the browser.
1082
- *
1083
- * Debounced, because each reload is a login/load/logout round trip to a reader
1084
- * over a VPN, not a database query. Enter still works and skips the wait.
1085
- */
1086
- const SEARCH_DEBOUNCE_MS = 400;
1087
- let searchDebounce: ReturnType<typeof setTimeout> | undefined;
1088
-
1089
- function onSearchInput() {
1090
- clearTimeout(searchDebounce);
1091
- searchDebounce = setTimeout(reloadUsersFromFirstPage, SEARCH_DEBOUNCE_MS);
1092
- }
1093
-
1094
- function onSearchSubmit() {
1095
- clearTimeout(searchDebounce);
1096
- reloadUsersFromFirstPage();
1097
- }
1098
-
1099
- watch(search, onSearchInput);
1100
- onBeforeUnmount(() => clearTimeout(searchDebounce));
1101
-
1102
770
  const readerOptions = computed(() =>
1103
771
  readers.value.map((reader) => ({
1104
772
  title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
@@ -1137,23 +805,11 @@ async function loadReaders() {
1137
805
  readers.value = (Array.isArray(items) ? items : [])
1138
806
  .map(toHidReader)
1139
807
  .filter((reader): reader is HidReader => reader !== null);
1140
- /* Shared with every other HID screen — see `useHidReaderSelection`. The
1141
- resolver refuses a remembered reader that is not this site's, so a site
1142
- switch or a deleted reader falls back to the first rather than querying
1143
- an id the site does not own. */
1144
- selectedReaderId.value = resolveForSite(props.site, readers.value);
808
+ if (!selectedReaderId.value && readers.value.length) {
809
+ selectedReaderId.value = readers.value[0]._id;
810
+ }
1145
811
  }
1146
812
 
1147
- /**
1148
- * Discards a load whose answer arrives after a newer one started.
1149
- *
1150
- * Each reload is a slow round trip to a reader over a VPN, and typing now fires
1151
- * them, so "abc" can have three in flight at once. Without this the reply to
1152
- * "a" can land last and repopulate the table with the wrong rows. Same monotonic
1153
- * counter `HidAccessLogDashboard` uses.
1154
- */
1155
- const loadUsersSequence = ref(0);
1156
-
1157
813
  async function loadUsers() {
1158
814
  if (!selectedReaderId.value) {
1159
815
  users.value = [];
@@ -1163,54 +819,18 @@ async function loadUsers() {
1163
819
  return;
1164
820
  }
1165
821
 
1166
- const sequence = loadUsersSequence.value + 1;
1167
- loadUsersSequence.value = sequence;
1168
822
  loading.value = true;
1169
823
  try {
1170
- // `userType: "user"` is REQUIRED here, not a nicety. The reader keeps
1171
- // visitors in the same `users` table, marked `user_type_id = 1`, and the
1172
- // API's default is `all`. Visitor rows are written by our own visitor-QR
1173
- // issuance and are never removed - revoke only pushes `end_time` into the
1174
- // past - so without this filter the enrollment roster counts every visitor
1175
- // pass ever issued and drifts further from the reader's own Users screen
1176
- // with each one. This screen enrolls and edits real people; visitors are
1177
- // owned by the visitor module.
1178
- let clientFiltersVisitors = false;
1179
- let response: unknown;
1180
- try {
1181
- response = await getReaderUsers(selectedReaderId.value, {
1182
- page: page.value,
1183
- limit: limit.value,
1184
- search: search.value.trim(),
1185
- status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
1186
- userType: "user",
1187
- });
1188
- } catch (error: unknown) {
1189
- if (!isUnsupportedUserTypeFilter(error)) throw error;
1190
-
1191
- // A deployed API can lag behind this shared layer. Keep the roster
1192
- // correct on one that has not added `userType` yet, the same way
1193
- // HidReaderUserRoster does, rather than failing the whole screen.
1194
- clientFiltersVisitors = true;
1195
- response = await getReaderUsers(selectedReaderId.value, {
1196
- page: 1,
1197
- limit: 100,
1198
- search: search.value.trim(),
1199
- status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
1200
- });
1201
- }
824
+ const response = await getReaderUsers(selectedReaderId.value, {
825
+ page: page.value,
826
+ limit: limit.value,
827
+ search: search.value.trim(),
828
+ status: status.value.toLowerCase() as "mapped" | "unmapped" | "",
829
+ });
1202
830
  const responseRecord = toRecord(response);
1203
831
  const responseData = toRecord(responseRecord.data);
1204
832
  const responseItems = responseRecord.items ?? responseData.items ?? [];
1205
- const allItems = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
1206
- const matchingItems = clientFiltersVisitors
1207
- ? allItems.filter((user) => !isVisitorUser(user))
1208
- : allItems;
1209
- const pageStart = (page.value - 1) * limit.value;
1210
- const pageItems = clientFiltersVisitors
1211
- ? matchingItems.slice(pageStart, pageStart + limit.value)
1212
- : matchingItems;
1213
- if (sequence !== loadUsersSequence.value) return;
833
+ const pageItems = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
1214
834
  administratorUserIds.value = new Set(
1215
835
  pageItems
1216
836
  .filter((user) => user.isAdministrator === true)
@@ -1218,22 +838,13 @@ async function loadUsers() {
1218
838
  .filter((userId: number | undefined): userId is number => Boolean(userId)),
1219
839
  );
1220
840
  users.value = pageItems;
1221
- total.value = clientFiltersVisitors
1222
- ? matchingItems.length
1223
- : Number(responseRecord.total ?? responseData.total ?? users.value.length);
1224
- pages.value = clientFiltersVisitors
1225
- ? Math.max(1, Math.ceil(total.value / limit.value))
1226
- : Number(responseRecord.pages ?? responseData.pages ?? 1);
1227
- // The server's `pageRange` counts the unfiltered page, so it cannot be
1228
- // trusted once this screen has done the filtering itself.
1229
- serverPageRange.value = clientFiltersVisitors
1230
- ? ""
1231
- : String(responseRecord.pageRange ?? responseData.pageRange ?? "");
841
+ total.value = Number(responseRecord.total ?? responseData.total ?? users.value.length);
842
+ pages.value = Number(responseRecord.pages ?? responseData.pages ?? 1);
843
+ serverPageRange.value = String(responseRecord.pageRange ?? responseData.pageRange ?? "");
1232
844
  if (props.cardManagement) {
1233
845
  await loadVisibleUserCards(users.value);
1234
846
  }
1235
847
  } catch (error) {
1236
- if (sequence !== loadUsersSequence.value) return;
1237
848
  console.error("Unable to load HID reader users:", error);
1238
849
  showToast("Unable to load HID users from reader.", "error");
1239
850
  users.value = [];
@@ -1244,9 +855,7 @@ async function loadUsers() {
1244
855
  pages.value = 1;
1245
856
  serverPageRange.value = "";
1246
857
  } finally {
1247
- // Only the newest load owns the spinner; an older one finishing must not
1248
- // clear it while that newer one is still running.
1249
- if (sequence === loadUsersSequence.value) loading.value = false;
858
+ loading.value = false;
1250
859
  }
1251
860
  }
1252
861
 
@@ -1261,10 +870,6 @@ function resetForm() {
1261
870
  form.registration = "";
1262
871
  form.isAdministrator = false;
1263
872
  form.accessPin = "";
1264
- form.password = "";
1265
- passwordSet.value = false;
1266
- showPassword.value = false;
1267
- removePasswordRequested.value = false;
1268
873
  pinEnrolled.value = false;
1269
874
  showPin.value = false;
1270
875
  removePinRequested.value = false;
@@ -1274,165 +879,32 @@ function resetForm() {
1274
879
  facialEnrolled.value = false;
1275
880
  form.subjectCategory = "resident";
1276
881
  form.subjectId = "";
1277
- residentConflict.value = null;
1278
- checkingResident.value = false;
1279
- }
1280
-
1281
-
1282
- /** Surface what the API actually said - a bare "unable to load" hides the
1283
- * difference between a permission refusal, a bad id and a server fault. */
1284
- function apiReason(error: unknown) {
1285
- const record = toRecord(error);
1286
- const data = toRecord(record.data);
1287
- return toText(data.message) || toText(record.message) || toText(record.statusMessage);
1288
882
  }
1289
883
 
1290
- // Called when the Block select is first touched, not when the dialog opens.
1291
- async function ensureOccupancy() {
1292
- if (!props.site || occupancyLoaded.value || loadingOccupancy.value) return;
1293
- loadingOccupancy.value = true;
1294
- try {
1295
- const response = toRecord(await getOccupiedStructure(props.site, {
1296
- type: OCCUPANT_TYPES,
1297
- status: "active",
1298
- }));
1299
- const data = toRecord(response.data);
1300
- const blocks = response.blocks ?? data.blocks ?? [];
1301
- occupiedBlocks.value = (Array.isArray(blocks) ? blocks : []) as OccupiedBlock[];
1302
- occupancyLoaded.value = true;
1303
- } catch (error: unknown) {
1304
- console.error("Unable to load occupied units:", error);
1305
- occupiedBlocks.value = [];
1306
- const reason = apiReason(error);
1307
- showToast(
1308
- reason ? `Unable to load units: ${reason}` : "Unable to load units for this site.",
1309
- "error",
1310
- );
1311
- } finally {
1312
- loadingOccupancy.value = false;
884
+ async function loadSubjectCandidates() {
885
+ const readerId = form.reader || selectedReaderId.value;
886
+ if (!readerId || !orgId.value) {
887
+ permissionCandidates.value = [];
888
+ return;
1313
889
  }
1314
- }
1315
-
1316
- async function loadUnitPeople() {
1317
- unitPeople.value = [];
1318
- if (!form.unit) return;
1319
- loadingUnitPeople.value = true;
890
+ loadingSubjects.value = true;
1320
891
  try {
1321
- const response = await getPeopleByUnit(form.unit, {
1322
- status: "active",
1323
- type: OCCUPANT_TYPES,
892
+ const response = await getPermissionCandidates(props.site, {
893
+ orgId: orgId.value,
894
+ readerId,
895
+ category: form.subjectCategory,
896
+ page: 1,
897
+ limit: 500,
1324
898
  });
1325
- const record = toRecord(response);
1326
- const rows = Array.isArray(response)
1327
- ? response
1328
- : record.data ?? record.items ?? [];
1329
- unitPeople.value = (Array.isArray(rows) ? rows : []) as UnitPerson[];
1330
- } catch (error: unknown) {
1331
- console.error("Unable to load residents for the unit:", error);
1332
- const reason = apiReason(error);
1333
- showToast(
1334
- reason ? `Unable to load residents: ${reason}` : "Unable to load residents for this unit.",
1335
- "error",
1336
- );
899
+ permissionCandidates.value = response.items ?? response.data?.items ?? [];
1337
900
  } finally {
1338
- loadingUnitPeople.value = false;
901
+ loadingSubjects.value = false;
1339
902
  }
1340
903
  }
1341
904
 
1342
- function onBlockChanged() {
1343
- form.level = "";
1344
- form.unit = "";
1345
- unitPeople.value = [];
1346
- clearResidentSelection();
1347
- }
1348
-
1349
- // Wrapper `@click`/`@focusin` on the Block select: load once, on first touch.
1350
- async function onBlockOpened() {
1351
- await ensureOccupancy();
1352
- }
1353
-
1354
- function onLevelChanged() {
1355
- form.unit = "";
1356
- unitPeople.value = [];
1357
- clearResidentSelection();
1358
- }
1359
-
1360
- async function onUnitChanged() {
1361
- clearResidentSelection();
1362
- await loadUnitPeople();
1363
- }
1364
-
1365
- function clearResidentSelection() {
905
+ async function onSubjectCategoryChanged() {
1366
906
  form.subjectId = "";
1367
- residentConflict.value = null;
1368
- // Name only ever mirrors a chosen resident, so it clears with the choice.
1369
- if (!selectedUser.value) form.name = "";
1370
- }
1371
-
1372
- // The auto-fill the whole cascade exists for.
1373
- async function onResidentChanged() {
1374
- const person = unitPeople.value.find(
1375
- (item) => String(item._id) === String(form.subjectId),
1376
- );
1377
- form.name = String(person?.name ?? "");
1378
- await checkResidentEnrollment();
1379
- }
1380
-
1381
- /**
1382
- * The HID user this resident already holds on the selected reader, or null.
1383
- *
1384
- * `subject`, not `person`. The same resident is stored under different ids
1385
- * depending on which screen wrote the row - the permissions screen keys on
1386
- * their user account, this screen on the record the picker offered - so a
1387
- * filter on one field alone reports "not enrolled" for somebody who plainly is.
1388
- * `subject` follows the record to its account and matches every link field,
1389
- * which is exactly what the server does before it accepts an enrollment.
1390
- *
1391
- * Reader-scoped by the endpoint itself.
1392
- */
1393
- async function findResidentIdentity(readerId: string, personId: string) {
1394
- const response = await getIdentities(readerId, {
1395
- page: 1,
1396
- // A resident should hold at most one, but ask for a few: on the edit screen
1397
- // the first row back can be the very identity being edited, and skipping it
1398
- // must not hide a real second one behind it.
1399
- limit: 10,
1400
- subject: personId,
1401
- });
1402
- const responseRecord = toRecord(response);
1403
- const responseData = toRecord(responseRecord.data);
1404
- const responseItems =
1405
- responseRecord.items ?? responseData.items ?? responseData.identities ?? [];
1406
- const identities = (Array.isArray(responseItems) ? responseItems : []).map(toHidUser);
1407
- const editingId = String(selectedUser.value?._id ?? "");
1408
-
1409
- return (
1410
- identities.find((identity) => !editingId || String(identity._id ?? "") !== editingId) ?? null
1411
- );
1412
- }
1413
-
1414
- /**
1415
- * Sets `residentConflict` for the currently selected resident.
1416
- *
1417
- * Fails OPEN: if the lookup itself errors the operator is not blocked, because
1418
- * the server refuses the duplicate anyway. This is a courtesy check that keeps
1419
- * a doomed enrollment off the physical reader, not the guard itself.
1420
- */
1421
- async function checkResidentEnrollment() {
1422
- residentConflict.value = null;
1423
- const readerId = selectedReaderId.value || form.reader || readers.value[0]?._id;
1424
- if (!readerId || !form.subjectId || form.subjectCategory !== "resident") return null;
1425
-
1426
- checkingResident.value = true;
1427
- try {
1428
- residentConflict.value = await findResidentIdentity(readerId, form.subjectId);
1429
- return residentConflict.value;
1430
- } catch (error: unknown) {
1431
- console.error("Unable to check whether this resident is already enrolled:", error);
1432
- return null;
1433
- } finally {
1434
- checkingResident.value = false;
1435
- }
907
+ await loadSubjectCandidates();
1436
908
  }
1437
909
 
1438
910
  async function openEnroll() {
@@ -1444,6 +916,7 @@ async function openEnroll() {
1444
916
  ]);
1445
917
  form.hidUserId = hidUserId;
1446
918
  form.registration = registration;
919
+ await loadSubjectCandidates();
1447
920
  formDialog.value = true;
1448
921
  }
1449
922
 
@@ -1458,10 +931,6 @@ async function openEdit(user: HidUser) {
1458
931
  form.registration = user.registration ?? "";
1459
932
  form.isAdministrator = hasAdministratorRule(user);
1460
933
  form.accessPin = "";
1461
- form.password = "";
1462
- showPassword.value = false;
1463
- removePasswordRequested.value = false;
1464
- passwordSet.value = Boolean(user.metadata?.passwordSet);
1465
934
  pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
1466
935
  removePinRequested.value = false;
1467
936
  form.photoPreview = "";
@@ -1472,31 +941,17 @@ async function openEdit(user: HidUser) {
1472
941
  ? "service_provider"
1473
942
  : "property_management";
1474
943
  form.subjectId = String(user.person || user.member || user.serviceProvider || "");
1475
- // The resident this user already holds is not a conflict with itself. A real
1476
- // one only appears if the operator reassigns the row to somebody else.
1477
- residentConflict.value = null;
1478
- // The location is not drawn when editing, but it is still carried: these
1479
- // three form fields are what `saveUser` writes back and what `buildUnitLabel`
1480
- // resolves into the Unit column, so a save must not blank them.
1481
- //
1482
- // `ensureOccupancy` is what lets `buildUnitLabel` turn them back into names.
1483
- // The unit's residents are loaded too, because the Resident select IS drawn
1484
- // here and its options come from that list - without it the select shows the
1485
- // stored id instead of a name.
1486
- await ensureOccupancy();
1487
- if (form.unit) await loadUnitPeople();
944
+ await loadSubjectCandidates();
1488
945
  selectedPhotoFile.value = null;
1489
946
  removePhotoRequested.value = false;
1490
947
  formDialog.value = true;
1491
948
 
1492
949
  try {
1493
- const hidUserId = toHidNumericId(user.hidUserId);
1494
- const [pinStatus, passwordStatus] = await Promise.all([
1495
- getUserPinStatus(form.reader, hidUserId),
1496
- getUserPasswordStatus(form.reader, hidUserId),
1497
- ]);
1498
- pinEnrolled.value = Boolean(pinStatus?.data?.pinEnrolled);
1499
- passwordSet.value = Boolean(passwordStatus?.data?.passwordSet);
950
+ const status = await getUserPinStatus(
951
+ form.reader,
952
+ toHidNumericId(user.hidUserId),
953
+ );
954
+ pinEnrolled.value = Boolean(status?.data?.pinEnrolled);
1500
955
  } catch {
1501
956
  // Preserve the last known status if the reader is temporarily offline.
1502
957
  }
@@ -1575,28 +1030,12 @@ function onPhotoChange(event: Event) {
1575
1030
  async function saveUser() {
1576
1031
  const readerId =
1577
1032
  selectedReaderId.value || form.reader || readers.value[0]?._id;
1578
- // A resident is required to ENROL, because an identity has to be linked to
1579
- // somebody. It is not required to edit: the reader holds users we never
1580
- // enrolled, and changing a photo, a PIN or a password on one of those is a
1581
- // reader-side operation that has nothing to say about who they are. Demanding
1582
- // a resident there refuses a save for a field the dialog does not even draw.
1583
- const missing = !readerId
1584
- ? "a reader"
1585
- : !selectedUser.value && !form.subjectId
1586
- ? "a resident"
1587
- : !form.name
1588
- ? "a name"
1589
- : !form.hidUserId
1590
- ? "an ID"
1591
- : !form.registration
1592
- ? "a registration number"
1593
- : "";
1594
- if (missing) {
1595
- showToast(`This HID user needs ${missing} before it can be saved.`, "error");
1033
+ if (!readerId || !form.name || !form.hidUserId || !form.registration || !form.subjectId) {
1034
+ showToast("Please complete the required HID user fields.", "error");
1596
1035
  return;
1597
1036
  }
1598
1037
  if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
1599
- showToast("PIN must contain numbers only.", "error");
1038
+ showToast("Access PIN must contain numbers only.", "error");
1600
1039
  return;
1601
1040
  }
1602
1041
  if (selectedPhotoFile.value && (!selectedReader.value?.portalId || !selectedReader.value?.portalName)) {
@@ -1604,16 +1043,6 @@ async function saveUser() {
1604
1043
  return;
1605
1044
  }
1606
1045
 
1607
- // Re-asked here, not trusted from the select: the dialog can sit open while
1608
- // somebody else enrolls the same resident, and the reader is only ever
1609
- // written to below this line. A duplicate caught here costs one request; the
1610
- // same duplicate caught by the server costs a created HID user and a rollback.
1611
- const alreadyEnrolled = await checkResidentEnrollment();
1612
- if (alreadyEnrolled) {
1613
- showToast(residentConflictMessage.value, "error");
1614
- return;
1615
- }
1616
-
1617
1046
  saving.value = true;
1618
1047
  let newlyCreatedHidUserId: number | undefined;
1619
1048
  try {
@@ -1627,33 +1056,22 @@ async function saveUser() {
1627
1056
  return;
1628
1057
  }
1629
1058
 
1630
- // The link is sent only when the form actually holds one. Sending
1631
- // `person: ""` is not "leave it alone", it is "clear it": the server reads
1632
- // an empty string as a link being removed and then refuses the write for
1633
- // having no subject at all. Omitting the keys is what makes it keep the
1634
- // subject the record already has.
1635
- const subjectLink = form.subjectId
1636
- ? {
1637
- person: form.subjectCategory === "resident" ? form.subjectId : "",
1638
- member: form.subjectCategory === "property_management" ? form.subjectId : "",
1639
- serviceProvider: form.subjectCategory === "service_provider" ? form.subjectId : "",
1640
- type: (form.subjectCategory === "resident"
1641
- ? "resident"
1642
- : form.subjectCategory === "service_provider"
1643
- ? "contractor"
1644
- : "staff") as
1645
- | "resident"
1646
- | "staff"
1647
- | "contractor"
1648
- | "visitor"
1649
- | "unknown",
1650
- }
1651
- : {};
1652
-
1653
1059
  const payload = {
1654
1060
  hidUserId: String(hidUser.id),
1655
1061
  registration: form.registration,
1656
- ...subjectLink,
1062
+ person: form.subjectCategory === "resident" ? form.subjectId : "",
1063
+ member: form.subjectCategory === "property_management" ? form.subjectId : "",
1064
+ serviceProvider: form.subjectCategory === "service_provider" ? form.subjectId : "",
1065
+ type: (form.subjectCategory === "resident"
1066
+ ? "resident"
1067
+ : form.subjectCategory === "service_provider"
1068
+ ? "contractor"
1069
+ : "staff") as
1070
+ | "resident"
1071
+ | "staff"
1072
+ | "contractor"
1073
+ | "visitor"
1074
+ | "unknown",
1657
1075
  status: "active" as const,
1658
1076
  metadata: {
1659
1077
  name: form.name,
@@ -1670,7 +1088,6 @@ async function saveUser() {
1670
1088
  imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
1671
1089
  facialScores: selectedUser.value?.metadata?.facialScores || {},
1672
1090
  pinEnrolled: pinEnrolled.value,
1673
- passwordSet: passwordSet.value,
1674
1091
  hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
1675
1092
  },
1676
1093
  };
@@ -1680,22 +1097,17 @@ async function saveUser() {
1680
1097
  const facialResult = await syncFacialImage(readerId, hidUser.id);
1681
1098
  applyFacialResult(payload.metadata, facialResult);
1682
1099
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
1683
- payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
1684
1100
  if (selectedUser.value._id) {
1685
1101
  await updateIdentity(selectedUser.value._id, payload);
1686
- } else if (form.subjectId) {
1102
+ } else {
1687
1103
  await saveIdentityOnReader(readerId, payload);
1688
1104
  }
1689
- // else: a user the reader holds and we have never linked. The photo, PIN
1690
- // and password above are already on the device; creating an identity for
1691
- // it would need a subject we deliberately no longer ask for here.
1692
1105
  } else {
1693
1106
  const created = await createHidUserOnReader(readerId, hidUser);
1694
1107
  if (created) newlyCreatedHidUserId = hidUser.id;
1695
1108
  const facialResult = await syncFacialImage(readerId, hidUser.id);
1696
1109
  applyFacialResult(payload.metadata, facialResult);
1697
1110
  payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
1698
- payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
1699
1111
  await saveIdentityOnReader(readerId, payload);
1700
1112
  selectedReaderId.value = readerId;
1701
1113
  }
@@ -1731,25 +1143,6 @@ async function saveUser() {
1731
1143
  }
1732
1144
  }
1733
1145
 
1734
- async function syncPassword(readerId: string, hidUserId: number) {
1735
- // Mirrors syncAccessPin. Removal wins over a typed value, and a blank field
1736
- // means "leave whatever is on the reader alone" - the hash cannot be read
1737
- // back, so blank can never mean "clear it".
1738
- if (removePasswordRequested.value) {
1739
- await deleteUserPassword(readerId, hidUserId);
1740
- passwordSet.value = false;
1741
- form.password = "";
1742
- return false;
1743
- }
1744
- if (form.password) {
1745
- await setUserPassword(readerId, hidUserId, form.password);
1746
- form.password = "";
1747
- passwordSet.value = true;
1748
- return true;
1749
- }
1750
- return passwordSet.value;
1751
- }
1752
-
1753
1146
  async function syncAccessPin(readerId: string, hidUserId: number) {
1754
1147
  if (removePinRequested.value) {
1755
1148
  await deleteUserPin(readerId, hidUserId);
@@ -1860,7 +1253,7 @@ async function saveIdentityOnReader(
1860
1253
  }
1861
1254
 
1862
1255
  try {
1863
- await createIdentity(readerId, { ...payload, site: props.site, ...(props.org ? { orgId: props.org } : {}) });
1256
+ await createIdentity(readerId, { ...payload, site: props.site });
1864
1257
  } catch (error: unknown) {
1865
1258
  const message = getHidErrorMessage(error);
1866
1259
  if (!message.toLowerCase().includes("already exists")) throw error;
@@ -2054,21 +1447,6 @@ function toHidUserObject(identity: Pick<HidUser, "hidUserId" | "name" | "registr
2054
1447
  };
2055
1448
  }
2056
1449
 
2057
- function isVisitorUser(user: HidUser) {
2058
- // The reader marks visitors with `user_type_id = 1`; a real user leaves the
2059
- // field null. `metadata.hidUserTypeId` is what our API forwards it as.
2060
- return Number(user.metadata?.hidUserTypeId ?? user.user_type_id) === 1;
2061
- }
2062
-
2063
- function isUnsupportedUserTypeFilter(error: unknown) {
2064
- const record = toRecord(error);
2065
- const data = toRecord(record.data);
2066
- const message = toText(data.message) || toText(record.message);
2067
- const normalized = message.toLowerCase();
2068
- return normalized.includes("usertype")
2069
- && (normalized.includes("not allowed") || normalized.includes("unknown"));
2070
- }
2071
-
2072
1450
  function toHidNumericId(value: unknown) {
2073
1451
  const raw = String(value ?? "").trim();
2074
1452
  if (!raw) return undefined;
@@ -2207,14 +1585,6 @@ async function assignPhysicalCard() {
2207
1585
  }
2208
1586
  }
2209
1587
 
2210
- /**
2211
- * Read a physical card by presenting it to the reader.
2212
- *
2213
- * The way most cards get assigned, because it is the only one that does not
2214
- * require knowing the facility code and card number in advance - neither is
2215
- * printed on the card, and an operator holding a blank fob has no way to look
2216
- * them up. The reader listens for 30 seconds and reports what it read.
2217
- */
2218
1588
  async function enrollPhysicalCard() {
2219
1589
  const { readerId, hidUserId } = getCardReaderAndUser();
2220
1590
  if (!readerId || !hidUserId) return;
@@ -2227,8 +1597,6 @@ async function enrollPhysicalCard() {
2227
1597
  await loadUsers();
2228
1598
  showToast("Physical HID card read and enrolled on the reader.");
2229
1599
  } catch (error: unknown) {
2230
- // Cancelling rejects the request above. That is the operator getting what
2231
- // they asked for, so it must not also be reported as a failure.
2232
1600
  if (!cardEnrollmentCancelled.value) {
2233
1601
  showToast(getHidErrorMessage(error), "error");
2234
1602
  }
@@ -2237,49 +1605,15 @@ async function enrollPhysicalCard() {
2237
1605
  }
2238
1606
  }
2239
1607
 
2240
- /**
2241
- * Stop the reader listening.
2242
- *
2243
- * Told to the DEVICE, not just the screen: abandoning the request on this side
2244
- * would leave the reader waiting out its 30 seconds and enrolling whatever card
2245
- * was presented in the meantime.
2246
- */
2247
- /**
2248
- * Stop the reader listening, and hand the dialog straight back.
2249
- *
2250
- * Told to the DEVICE, not just the screen: abandoning the request on this side
2251
- * would leave the reader waiting out its remaining seconds and enrolling
2252
- * whatever card was presented in the meantime.
2253
- *
2254
- * The part worth understanding is what the ENROL request does next. The server
2255
- * cannot tell a cancelled read from a failed one - the reader simply reports no
2256
- * card - so the enrol that is still in flight ends as a 400, seconds after the
2257
- * cancel. Waiting for it was the bug: Cancel appeared to do nothing until that
2258
- * rejection finally arrived.
2259
- *
2260
- * So the dialog is released as soon as the DEVICE confirms it has stopped, and
2261
- * the doomed enrol is left to land on its own. `cardEnrollmentCancelled` is set
2262
- * before anything is awaited, which is what keeps its 400 silent.
2263
- */
2264
1608
  async function cancelPhysicalCardEnrollment() {
2265
1609
  const { readerId } = getCardReaderAndUser();
2266
1610
  if (!readerId) return;
2267
- // Set first, awaited second: the enrol can reject at any point from here.
2268
1611
  cardEnrollmentCancelled.value = true;
2269
- cancellingCard.value = true;
2270
1612
  try {
2271
1613
  await cancelUserCardEnrollment(readerId);
2272
- // Only now, because the enrollment lock is released by the call above.
2273
- // Re-enabling Read at Reader any earlier invites a second attempt that
2274
- // the reader refuses with "already running".
2275
- enrollingCard.value = false;
2276
1614
  showToast("Physical HID card reading cancelled.");
2277
1615
  } catch (error: unknown) {
2278
- // The cancel itself failing is real and worth saying: the reader may still
2279
- // be listening, so the dialog stays in its reading state.
2280
1616
  showToast(getHidErrorMessage(error), "error");
2281
- } finally {
2282
- cancellingCard.value = false;
2283
1617
  }
2284
1618
  }
2285
1619
 
@@ -2315,21 +1649,8 @@ function getPhysicalCardSubtitle(card: HidCard) {
2315
1649
  }
2316
1650
 
2317
1651
  function buildUnitLabel() {
2318
- // `form.block/level/unit` hold ObjectIds, so the label has to resolve them
2319
- // back to names through the occupancy tree. Joining the raw values would
2320
- // stamp "665f.../665f.../665f..." onto the record and into the Unit column.
2321
- const block = occupiedBlocks.value.find((item) => String(item._id ?? "") === form.block);
2322
- const level = (block?.levels ?? []).find((item) => String(item._id ?? "") === form.level);
2323
- const unit = (level?.units ?? []).find((item) => String(item._id ?? "") === form.unit);
2324
- const parts = [
2325
- formatBlockTitle(block),
2326
- String(level?.name ?? ""),
2327
- String(unit?.name ?? ""),
2328
- ].filter(Boolean);
2329
- // Editing a record enrolled before the cascade existed: keep its stored
2330
- // label rather than blanking it.
2331
- if (!parts.length) return String(selectedUser.value?.metadata?.unitLabel ?? "");
2332
- return parts.join(" / ");
1652
+ const parts = [form.block, form.level, form.unit].filter(Boolean);
1653
+ return parts.length ? `BLK ${parts.join("/")}` : "";
2333
1654
  }
2334
1655
 
2335
1656
  function getName(user?: HidUser | null) {
@@ -2580,12 +1901,6 @@ function toHidCard(value: unknown): HidCard {
2580
1901
  source: card.source === "reader" || card.source === "manual" || card.source === "device" ? card.source : undefined,
2581
1902
  };
2582
1903
  }
2583
-
2584
- /* Publish every change, including the seed above: a screen that had to fall
2585
- back to the first reader should leave the others agreeing with it. */
2586
- watch(selectedReaderId, (value) => {
2587
- if (value) selectHidReader(props.site, value);
2588
- });
2589
1904
  </script>
2590
1905
 
2591
1906
  <style scoped>
@@ -2622,6 +1937,12 @@ watch(selectedReaderId, (value) => {
2622
1937
  max-width: 320px;
2623
1938
  }
2624
1939
 
1940
+ .small-title {
1941
+ font-size: 14px;
1942
+ font-weight: 700;
1943
+ color: var(--text);
1944
+ }
1945
+
2625
1946
  /* Was `#4f5a66` / `#e53935` - the same two greys on both themes. */
2626
1947
  .field-label {
2627
1948
  color: var(--text2);
@@ -2643,42 +1964,12 @@ watch(selectedReaderId, (value) => {
2643
1964
  line-height: 1.45;
2644
1965
  }
2645
1966
 
2646
- /* Same line, saying the field is refused rather than explained. `--err` is the
2647
- token the required-field asterisk already uses. */
2648
- .field-hint--error {
2649
- color: var(--err);
2650
- }
2651
-
2652
1967
  .photo-wrap {
2653
1968
  display: grid;
2654
1969
  place-items: center;
2655
1970
  margin-bottom: 14px;
2656
1971
  }
2657
1972
 
2658
- .photo-facial {
2659
- margin: 10px 0 0;
2660
- text-align: center;
2661
- }
2662
-
2663
- /* The same small muted word `.field-label` uses, so the caption reads as a
2664
- field label rather than as a second heading. */
2665
- .photo-facial__label {
2666
- display: block;
2667
- color: var(--muted);
2668
- font-size: 12px;
2669
- font-weight: 600;
2670
- }
2671
-
2672
- .photo-facial__value {
2673
- display: block;
2674
- margin-top: 2px;
2675
- font-size: 13.5px;
2676
- font-weight: 500;
2677
- /* The reader's reference is a long unbroken number; let it wrap rather than
2678
- widen the dialog. */
2679
- word-break: break-all;
2680
- }
2681
-
2682
1973
  .photo-button {
2683
1974
  width: 120px;
2684
1975
  height: 120px;
@@ -2852,6 +2143,25 @@ watch(selectedReaderId, (value) => {
2852
2143
  color: var(--muted);
2853
2144
  }
2854
2145
 
2146
+ /* The split footer bar. The handoff does not draw one, so the SHAPE is the
2147
+ screen's own; only the colours change - `#062d42` was a fixed navy that sat
2148
+ on the dark card unchanged. Same treatment as panel 2. */
2149
+ .action-cancel,
2150
+ .action-submit {
2151
+ flex: 1;
2152
+ border-radius: 0;
2153
+ }
2154
+
2155
+ .action-cancel {
2156
+ background: var(--card);
2157
+ color: var(--text2);
2158
+ }
2159
+
2160
+ .action-submit {
2161
+ background: var(--accent-strong);
2162
+ color: var(--on-accent-strong);
2163
+ }
2164
+
2855
2165
  @media (max-width: 960px) {
2856
2166
  .hid-user-filters :deep(.app-field),
2857
2167
  .hid-user-filters :deep(.app-select) {