@7365admin1/layer-common 4.73.0 → 4.73.1-staging.452
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.
- package/CHANGELOG.md +4 -4
- package/assets/css/primitives.css +43 -0
- package/components/AccessCardPreviewDialog.vue +286 -50
- package/components/AppSelect.vue +77 -3
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +424 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +845 -155
- package/components/InvitationClientForm.vue +19 -1
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +267 -5
- package/composables/useDocument.ts +7 -0
- package/composables/useFacility.ts +11 -0
- package/composables/useHidAmico.ts +165 -0
- package/composables/useHidNavigation.ts +25 -1
- package/composables/useHidReaderSelection.ts +50 -0
- package/composables/useMember.ts +12 -0
- package/composables/usePeople.ts +19 -0
- package/composables/useSecurityPermission.ts +20 -0
- package/composables/useServiceProvider.ts +33 -2
- package/composables/useSiteCategory.ts +45 -0
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/utils/hid-permission-assignments.ts +190 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/occupancy-role.ts +109 -0
- package/utils/service-type.ts +30 -0
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
<AppField
|
|
68
68
|
v-model="search"
|
|
69
69
|
search
|
|
70
|
-
placeholder="Search"
|
|
71
|
-
@keyup.enter="
|
|
70
|
+
placeholder="Search name, UID or registration no."
|
|
71
|
+
@keyup.enter="onSearchSubmit"
|
|
72
72
|
/>
|
|
73
73
|
<AppSelect
|
|
74
74
|
v-if="!props.cardManagement"
|
|
@@ -96,6 +96,12 @@
|
|
|
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
|
+
|
|
99
105
|
<!-- The shared chip. `utils/status.ts` now carries Mapped/Unmapped with
|
|
100
106
|
the same tones this screen's private `getMappingStatusClass` gave
|
|
101
107
|
them, so the map is one list rather than a fourth private copy. -->
|
|
@@ -166,12 +172,12 @@
|
|
|
166
172
|
</TableMain>
|
|
167
173
|
|
|
168
174
|
<v-dialog v-model="formDialog" max-width="420" persistent>
|
|
169
|
-
<v-card
|
|
170
|
-
<v-card-title
|
|
175
|
+
<v-card class="screen-modal">
|
|
176
|
+
<v-card-title>
|
|
171
177
|
{{ selectedUser ? "Edit User Information" : "Enroll" }}
|
|
172
178
|
</v-card-title>
|
|
173
179
|
|
|
174
|
-
<v-card-text>
|
|
180
|
+
<v-card-text class="screen-modal__body">
|
|
175
181
|
<div class="photo-wrap">
|
|
176
182
|
<v-menu>
|
|
177
183
|
<template #activator="{ props: menuProps }">
|
|
@@ -213,6 +219,15 @@
|
|
|
213
219
|
/>
|
|
214
220
|
</v-list>
|
|
215
221
|
</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
|
+
|
|
216
231
|
<input
|
|
217
232
|
ref="photoInput"
|
|
218
233
|
type="file"
|
|
@@ -222,29 +237,96 @@
|
|
|
222
237
|
/>
|
|
223
238
|
</div>
|
|
224
239
|
|
|
225
|
-
|
|
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>
|
|
226
325
|
<AppField
|
|
227
326
|
v-model="form.name"
|
|
228
327
|
aria-label="Name"
|
|
229
|
-
placeholder="
|
|
230
|
-
|
|
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"
|
|
328
|
+
placeholder="Select a resident above"
|
|
329
|
+
readonly
|
|
248
330
|
klass="mb-3"
|
|
249
331
|
/>
|
|
250
332
|
|
|
@@ -256,11 +338,11 @@
|
|
|
256
338
|
v-model="form.registration"
|
|
257
339
|
aria-label="Registration No."
|
|
258
340
|
placeholder="Enter registration no."
|
|
259
|
-
|
|
341
|
+
readonly
|
|
260
342
|
klass="mb-3"
|
|
261
343
|
/>
|
|
262
344
|
|
|
263
|
-
<div class="field-label">
|
|
345
|
+
<div class="field-label">PIN (optional)</div>
|
|
264
346
|
<!--
|
|
265
347
|
The handoff draws no password field anywhere (the sign-in screens
|
|
266
348
|
are the owner's own), so the reveal toggle is this product's, not
|
|
@@ -270,8 +352,8 @@
|
|
|
270
352
|
-->
|
|
271
353
|
<AppField
|
|
272
354
|
v-model="form.accessPin"
|
|
273
|
-
aria-label="
|
|
274
|
-
placeholder="Enter
|
|
355
|
+
aria-label="PIN"
|
|
356
|
+
placeholder="Enter pin"
|
|
275
357
|
inputmode="numeric"
|
|
276
358
|
autocomplete="new-password"
|
|
277
359
|
:type="showPin ? 'text' : 'password'"
|
|
@@ -280,7 +362,7 @@
|
|
|
280
362
|
<button
|
|
281
363
|
type="button"
|
|
282
364
|
class="app-field__trailing"
|
|
283
|
-
:aria-label="showPin ? 'Hide
|
|
365
|
+
:aria-label="showPin ? 'Hide PIN' : 'Show PIN'"
|
|
284
366
|
@click="showPin = !showPin"
|
|
285
367
|
>
|
|
286
368
|
<v-icon :icon="showPin ? 'mdi-eye-off' : 'mdi-eye'" size="18" />
|
|
@@ -295,28 +377,62 @@
|
|
|
295
377
|
<v-checkbox
|
|
296
378
|
v-if="selectedUser && pinEnrolled"
|
|
297
379
|
v-model="removePinRequested"
|
|
298
|
-
label="Remove current
|
|
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"
|
|
299
414
|
density="compact"
|
|
300
415
|
hide-details
|
|
301
416
|
/>
|
|
302
417
|
</v-card-text>
|
|
303
418
|
|
|
304
|
-
<!-- The
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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">
|
|
308
424
|
<v-btn
|
|
309
|
-
class="text-none
|
|
425
|
+
class="text-none screen-btn-ghost"
|
|
310
426
|
variant="flat"
|
|
311
|
-
height="44"
|
|
312
427
|
@click="formDialog = false"
|
|
313
|
-
>Cancel</v-btn
|
|
314
428
|
>
|
|
429
|
+
Cancel
|
|
430
|
+
</v-btn>
|
|
315
431
|
<v-btn
|
|
316
|
-
class="text-none
|
|
432
|
+
class="text-none screen-btn-primary"
|
|
317
433
|
variant="flat"
|
|
318
|
-
height="44"
|
|
319
434
|
:loading="saving"
|
|
435
|
+
:disabled="Boolean(residentConflict) || checkingResident"
|
|
320
436
|
@click="saveUser"
|
|
321
437
|
>
|
|
322
438
|
Save
|
|
@@ -328,9 +444,9 @@
|
|
|
328
444
|
<HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
|
|
329
445
|
|
|
330
446
|
<v-dialog v-model="viewDialog" max-width="430">
|
|
331
|
-
<v-card
|
|
332
|
-
<v-card-title
|
|
333
|
-
<v-card-text>
|
|
447
|
+
<v-card class="screen-modal">
|
|
448
|
+
<v-card-title>User Information</v-card-title>
|
|
449
|
+
<v-card-text class="screen-modal__body">
|
|
334
450
|
<div class="detail-grid">
|
|
335
451
|
<span>Name</span><strong>{{ getName(selectedUser) }}</strong>
|
|
336
452
|
<span>User ID</span
|
|
@@ -347,21 +463,22 @@
|
|
|
347
463
|
><strong>{{
|
|
348
464
|
selectedUser ? getMappingStatus(selectedUser) : "N/A"
|
|
349
465
|
}}</strong>
|
|
350
|
-
<span>
|
|
466
|
+
<span>PIN</span
|
|
351
467
|
><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
|
|
468
|
+
<span>Password</span
|
|
469
|
+
><strong>{{ selectedUser?.metadata?.passwordSet ? "Configured" : "N/A" }}</strong>
|
|
352
470
|
</div>
|
|
353
471
|
</v-card-text>
|
|
354
|
-
<v-card-actions>
|
|
355
|
-
<v-spacer />
|
|
472
|
+
<v-card-actions class="screen-modal__footer">
|
|
356
473
|
<AppButton variant="ghost" @click="viewDialog = false">Close</AppButton>
|
|
357
474
|
</v-card-actions>
|
|
358
475
|
</v-card>
|
|
359
476
|
</v-dialog>
|
|
360
477
|
|
|
361
478
|
<v-dialog v-model="cardDialog" max-width="560" persistent>
|
|
362
|
-
<v-card
|
|
363
|
-
<v-card-title
|
|
364
|
-
<v-card-text>
|
|
479
|
+
<v-card class="screen-modal">
|
|
480
|
+
<v-card-title>Physical HID Cards</v-card-title>
|
|
481
|
+
<v-card-text class="screen-modal__body">
|
|
365
482
|
<p class="hid-card-user">
|
|
366
483
|
{{ getName(cardUser) }}
|
|
367
484
|
<span>{{ cardUser ? formatHidUid(cardUser.hidUserId) : "" }}</span>
|
|
@@ -452,6 +569,11 @@
|
|
|
452
569
|
Present the physical card to the selected Amico reader. The request will time out after 30 seconds.
|
|
453
570
|
</v-alert>
|
|
454
571
|
|
|
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. -->
|
|
455
577
|
<div class="hid-card-actions mt-4">
|
|
456
578
|
<AppButton
|
|
457
579
|
variant="secondary"
|
|
@@ -470,14 +592,16 @@
|
|
|
470
592
|
<AppButton
|
|
471
593
|
v-if="enrollingCard"
|
|
472
594
|
variant="ghost"
|
|
595
|
+
:disabled="cancellingCard"
|
|
473
596
|
@click="cancelPhysicalCardEnrollment"
|
|
474
597
|
>
|
|
475
|
-
Cancel Reading
|
|
598
|
+
{{ cancellingCard ? "Cancelling…" : "Cancel Reading" }}
|
|
476
599
|
</AppButton>
|
|
477
600
|
</div>
|
|
478
601
|
</v-card-text>
|
|
479
|
-
<v-card-actions>
|
|
480
|
-
|
|
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. -->
|
|
481
605
|
<AppButton variant="ghost" :disabled="enrollingCard" @click="cardDialog = false">
|
|
482
606
|
Close
|
|
483
607
|
</AppButton>
|
|
@@ -486,31 +610,31 @@
|
|
|
486
610
|
</v-dialog>
|
|
487
611
|
|
|
488
612
|
<v-dialog v-model="deleteDialog" max-width="360" persistent>
|
|
489
|
-
<v-card
|
|
490
|
-
<v-card-title
|
|
491
|
-
<v-card-text class="text-body-2">
|
|
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">
|
|
492
616
|
Are you sure you want to permanently delete this HID user?
|
|
493
617
|
<div class="text-caption text-medium-emphasis mt-2">
|
|
494
618
|
This action will remove the user from the HID reader and mark the
|
|
495
619
|
identity as deleted.
|
|
496
620
|
</div>
|
|
497
621
|
</v-card-text>
|
|
498
|
-
<v-card-actions class="
|
|
622
|
+
<v-card-actions class="screen-modal__footer">
|
|
499
623
|
<v-btn
|
|
500
|
-
class="text-none
|
|
624
|
+
class="text-none screen-btn-ghost"
|
|
501
625
|
variant="flat"
|
|
502
|
-
height="44"
|
|
503
626
|
@click="deleteDialog = false"
|
|
504
|
-
>Cancel</v-btn
|
|
505
627
|
>
|
|
628
|
+
Cancel
|
|
629
|
+
</v-btn>
|
|
506
630
|
<v-btn
|
|
507
|
-
class="text-none
|
|
631
|
+
class="text-none screen-btn-primary"
|
|
508
632
|
variant="flat"
|
|
509
|
-
height="44"
|
|
510
633
|
:loading="deleting"
|
|
511
634
|
@click="deleteUser"
|
|
512
|
-
>Delete</v-btn
|
|
513
635
|
>
|
|
636
|
+
Delete
|
|
637
|
+
</v-btn>
|
|
514
638
|
</v-card-actions>
|
|
515
639
|
</v-card>
|
|
516
640
|
</v-dialog>
|
|
@@ -527,6 +651,13 @@ const props = defineProps({
|
|
|
527
651
|
type: String,
|
|
528
652
|
required: true,
|
|
529
653
|
},
|
|
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
|
+
},
|
|
530
661
|
cardManagement: {
|
|
531
662
|
type: Boolean,
|
|
532
663
|
default: false,
|
|
@@ -547,11 +678,13 @@ const {
|
|
|
547
678
|
getUserPinStatus,
|
|
548
679
|
setUserPin,
|
|
549
680
|
deleteUserPin,
|
|
681
|
+
getUserPasswordStatus,
|
|
682
|
+
setUserPassword,
|
|
683
|
+
deleteUserPassword,
|
|
550
684
|
runObjectOperation,
|
|
551
685
|
createIdentity,
|
|
552
686
|
updateIdentity,
|
|
553
687
|
deleteIdentity,
|
|
554
|
-
getPermissionCandidates,
|
|
555
688
|
} = useHidAmico();
|
|
556
689
|
|
|
557
690
|
type HidMetadata = Record<string, unknown> & {
|
|
@@ -566,6 +699,7 @@ type HidMetadata = Record<string, unknown> & {
|
|
|
566
699
|
lastAccessAt?: string;
|
|
567
700
|
facialScores?: Record<string, unknown>;
|
|
568
701
|
pinEnrolled?: boolean;
|
|
702
|
+
passwordSet?: boolean;
|
|
569
703
|
hidPhysicalCards?: HidCard[];
|
|
570
704
|
};
|
|
571
705
|
|
|
@@ -616,6 +750,7 @@ type HidIdentityPayload = Record<string, unknown> & {
|
|
|
616
750
|
const readers = ref<HidReader[]>([]);
|
|
617
751
|
const users = ref<HidUser[]>([]);
|
|
618
752
|
const administratorUserIds = ref<Set<number>>(new Set());
|
|
753
|
+
const { selectHidReader, resolveForSite } = useHidReaderSelection();
|
|
619
754
|
const selectedReaderId = ref("");
|
|
620
755
|
const search = ref("");
|
|
621
756
|
const status = ref("");
|
|
@@ -639,7 +774,14 @@ const physicalCardErrorsByUserId = ref<Record<string, string>>({});
|
|
|
639
774
|
const loadingCards = ref(false);
|
|
640
775
|
const savingCard = ref(false);
|
|
641
776
|
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
|
+
*/
|
|
642
782
|
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);
|
|
643
785
|
const deletingCardId = ref("");
|
|
644
786
|
const showPin = ref(false);
|
|
645
787
|
const pinEnrolled = ref(false);
|
|
@@ -661,13 +803,61 @@ function hasFacialEnrollment(user?: HidUser | null) {
|
|
|
661
803
|
user?.metadata?.imageTimestamp
|
|
662
804
|
);
|
|
663
805
|
}
|
|
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
|
+
});
|
|
664
821
|
const removePinRequested = ref(false);
|
|
822
|
+
const passwordSet = ref(false);
|
|
823
|
+
const showPassword = ref(false);
|
|
824
|
+
const removePasswordRequested = ref(false);
|
|
665
825
|
const snackbar = reactive({
|
|
666
826
|
show: false,
|
|
667
827
|
color: "success",
|
|
668
828
|
message: "",
|
|
669
829
|
});
|
|
670
830
|
|
|
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
|
+
|
|
671
861
|
const form = reactive({
|
|
672
862
|
reader: "",
|
|
673
863
|
block: "",
|
|
@@ -678,6 +868,7 @@ const form = reactive({
|
|
|
678
868
|
registration: "",
|
|
679
869
|
isAdministrator: false,
|
|
680
870
|
accessPin: "",
|
|
871
|
+
password: "",
|
|
681
872
|
photoPreview: "",
|
|
682
873
|
subjectCategory: "resident" as THidPermissionCategory,
|
|
683
874
|
subjectId: "",
|
|
@@ -685,18 +876,131 @@ const form = reactive({
|
|
|
685
876
|
|
|
686
877
|
const route = useRoute();
|
|
687
878
|
const orgId = computed(() => String(route.params.org || ""));
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
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 ?? ""),
|
|
698
923
|
})));
|
|
699
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 ?? ""),
|
|
959
|
+
})));
|
|
960
|
+
|
|
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
|
+
|
|
700
1004
|
const cardForm = reactive({
|
|
701
1005
|
cardType: "pacs" as "pacs" | "csn",
|
|
702
1006
|
facilityCode: "",
|
|
@@ -738,6 +1042,8 @@ const userHeaders = [
|
|
|
738
1042
|
{ title: "Name", value: "name", sortable: false },
|
|
739
1043
|
{ title: "Facial Data", value: "facialData", sortable: false },
|
|
740
1044
|
{ 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 },
|
|
741
1047
|
{ title: "Status", value: "status", sortable: false },
|
|
742
1048
|
{ title: "", value: "action-table", sortable: false },
|
|
743
1049
|
];
|
|
@@ -767,6 +1073,32 @@ function reloadUsersFromFirstPage() {
|
|
|
767
1073
|
loadUsers();
|
|
768
1074
|
}
|
|
769
1075
|
|
|
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
|
+
|
|
770
1102
|
const readerOptions = computed(() =>
|
|
771
1103
|
readers.value.map((reader) => ({
|
|
772
1104
|
title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
|
|
@@ -805,11 +1137,23 @@ async function loadReaders() {
|
|
|
805
1137
|
readers.value = (Array.isArray(items) ? items : [])
|
|
806
1138
|
.map(toHidReader)
|
|
807
1139
|
.filter((reader): reader is HidReader => reader !== null);
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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);
|
|
811
1145
|
}
|
|
812
1146
|
|
|
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
|
+
|
|
813
1157
|
async function loadUsers() {
|
|
814
1158
|
if (!selectedReaderId.value) {
|
|
815
1159
|
users.value = [];
|
|
@@ -819,18 +1163,54 @@ async function loadUsers() {
|
|
|
819
1163
|
return;
|
|
820
1164
|
}
|
|
821
1165
|
|
|
1166
|
+
const sequence = loadUsersSequence.value + 1;
|
|
1167
|
+
loadUsersSequence.value = sequence;
|
|
822
1168
|
loading.value = true;
|
|
823
1169
|
try {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
+
}
|
|
830
1202
|
const responseRecord = toRecord(response);
|
|
831
1203
|
const responseData = toRecord(responseRecord.data);
|
|
832
1204
|
const responseItems = responseRecord.items ?? responseData.items ?? [];
|
|
833
|
-
const
|
|
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;
|
|
834
1214
|
administratorUserIds.value = new Set(
|
|
835
1215
|
pageItems
|
|
836
1216
|
.filter((user) => user.isAdministrator === true)
|
|
@@ -838,13 +1218,22 @@ async function loadUsers() {
|
|
|
838
1218
|
.filter((userId: number | undefined): userId is number => Boolean(userId)),
|
|
839
1219
|
);
|
|
840
1220
|
users.value = pageItems;
|
|
841
|
-
total.value =
|
|
842
|
-
|
|
843
|
-
|
|
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 ?? "");
|
|
844
1232
|
if (props.cardManagement) {
|
|
845
1233
|
await loadVisibleUserCards(users.value);
|
|
846
1234
|
}
|
|
847
1235
|
} catch (error) {
|
|
1236
|
+
if (sequence !== loadUsersSequence.value) return;
|
|
848
1237
|
console.error("Unable to load HID reader users:", error);
|
|
849
1238
|
showToast("Unable to load HID users from reader.", "error");
|
|
850
1239
|
users.value = [];
|
|
@@ -855,7 +1244,9 @@ async function loadUsers() {
|
|
|
855
1244
|
pages.value = 1;
|
|
856
1245
|
serverPageRange.value = "";
|
|
857
1246
|
} finally {
|
|
858
|
-
|
|
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;
|
|
859
1250
|
}
|
|
860
1251
|
}
|
|
861
1252
|
|
|
@@ -870,6 +1261,10 @@ function resetForm() {
|
|
|
870
1261
|
form.registration = "";
|
|
871
1262
|
form.isAdministrator = false;
|
|
872
1263
|
form.accessPin = "";
|
|
1264
|
+
form.password = "";
|
|
1265
|
+
passwordSet.value = false;
|
|
1266
|
+
showPassword.value = false;
|
|
1267
|
+
removePasswordRequested.value = false;
|
|
873
1268
|
pinEnrolled.value = false;
|
|
874
1269
|
showPin.value = false;
|
|
875
1270
|
removePinRequested.value = false;
|
|
@@ -879,32 +1274,165 @@ function resetForm() {
|
|
|
879
1274
|
facialEnrolled.value = false;
|
|
880
1275
|
form.subjectCategory = "resident";
|
|
881
1276
|
form.subjectId = "";
|
|
1277
|
+
residentConflict.value = null;
|
|
1278
|
+
checkingResident.value = false;
|
|
882
1279
|
}
|
|
883
1280
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
+
}
|
|
1289
|
+
|
|
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;
|
|
889
1313
|
}
|
|
890
|
-
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function loadUnitPeople() {
|
|
1317
|
+
unitPeople.value = [];
|
|
1318
|
+
if (!form.unit) return;
|
|
1319
|
+
loadingUnitPeople.value = true;
|
|
891
1320
|
try {
|
|
892
|
-
const response = await
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
category: form.subjectCategory,
|
|
896
|
-
page: 1,
|
|
897
|
-
limit: 500,
|
|
1321
|
+
const response = await getPeopleByUnit(form.unit, {
|
|
1322
|
+
status: "active",
|
|
1323
|
+
type: OCCUPANT_TYPES,
|
|
898
1324
|
});
|
|
899
|
-
|
|
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
|
+
);
|
|
900
1337
|
} finally {
|
|
901
|
-
|
|
1338
|
+
loadingUnitPeople.value = false;
|
|
902
1339
|
}
|
|
903
1340
|
}
|
|
904
1341
|
|
|
905
|
-
|
|
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() {
|
|
906
1366
|
form.subjectId = "";
|
|
907
|
-
|
|
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
|
+
}
|
|
908
1436
|
}
|
|
909
1437
|
|
|
910
1438
|
async function openEnroll() {
|
|
@@ -916,7 +1444,6 @@ async function openEnroll() {
|
|
|
916
1444
|
]);
|
|
917
1445
|
form.hidUserId = hidUserId;
|
|
918
1446
|
form.registration = registration;
|
|
919
|
-
await loadSubjectCandidates();
|
|
920
1447
|
formDialog.value = true;
|
|
921
1448
|
}
|
|
922
1449
|
|
|
@@ -931,6 +1458,10 @@ async function openEdit(user: HidUser) {
|
|
|
931
1458
|
form.registration = user.registration ?? "";
|
|
932
1459
|
form.isAdministrator = hasAdministratorRule(user);
|
|
933
1460
|
form.accessPin = "";
|
|
1461
|
+
form.password = "";
|
|
1462
|
+
showPassword.value = false;
|
|
1463
|
+
removePasswordRequested.value = false;
|
|
1464
|
+
passwordSet.value = Boolean(user.metadata?.passwordSet);
|
|
934
1465
|
pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
|
|
935
1466
|
removePinRequested.value = false;
|
|
936
1467
|
form.photoPreview = "";
|
|
@@ -941,17 +1472,31 @@ async function openEdit(user: HidUser) {
|
|
|
941
1472
|
? "service_provider"
|
|
942
1473
|
: "property_management";
|
|
943
1474
|
form.subjectId = String(user.person || user.member || user.serviceProvider || "");
|
|
944
|
-
|
|
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();
|
|
945
1488
|
selectedPhotoFile.value = null;
|
|
946
1489
|
removePhotoRequested.value = false;
|
|
947
1490
|
formDialog.value = true;
|
|
948
1491
|
|
|
949
1492
|
try {
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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);
|
|
955
1500
|
} catch {
|
|
956
1501
|
// Preserve the last known status if the reader is temporarily offline.
|
|
957
1502
|
}
|
|
@@ -1030,12 +1575,28 @@ function onPhotoChange(event: Event) {
|
|
|
1030
1575
|
async function saveUser() {
|
|
1031
1576
|
const readerId =
|
|
1032
1577
|
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
1033
|
-
|
|
1034
|
-
|
|
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");
|
|
1035
1596
|
return;
|
|
1036
1597
|
}
|
|
1037
1598
|
if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
|
|
1038
|
-
showToast("
|
|
1599
|
+
showToast("PIN must contain numbers only.", "error");
|
|
1039
1600
|
return;
|
|
1040
1601
|
}
|
|
1041
1602
|
if (selectedPhotoFile.value && (!selectedReader.value?.portalId || !selectedReader.value?.portalName)) {
|
|
@@ -1043,6 +1604,16 @@ async function saveUser() {
|
|
|
1043
1604
|
return;
|
|
1044
1605
|
}
|
|
1045
1606
|
|
|
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
|
+
|
|
1046
1617
|
saving.value = true;
|
|
1047
1618
|
let newlyCreatedHidUserId: number | undefined;
|
|
1048
1619
|
try {
|
|
@@ -1056,22 +1627,33 @@ async function saveUser() {
|
|
|
1056
1627
|
return;
|
|
1057
1628
|
}
|
|
1058
1629
|
|
|
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
|
+
|
|
1059
1653
|
const payload = {
|
|
1060
1654
|
hidUserId: String(hidUser.id),
|
|
1061
1655
|
registration: form.registration,
|
|
1062
|
-
|
|
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",
|
|
1656
|
+
...subjectLink,
|
|
1075
1657
|
status: "active" as const,
|
|
1076
1658
|
metadata: {
|
|
1077
1659
|
name: form.name,
|
|
@@ -1088,6 +1670,7 @@ async function saveUser() {
|
|
|
1088
1670
|
imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
|
|
1089
1671
|
facialScores: selectedUser.value?.metadata?.facialScores || {},
|
|
1090
1672
|
pinEnrolled: pinEnrolled.value,
|
|
1673
|
+
passwordSet: passwordSet.value,
|
|
1091
1674
|
hidPhysicalCards: selectedUser.value?.metadata?.hidPhysicalCards || [],
|
|
1092
1675
|
},
|
|
1093
1676
|
};
|
|
@@ -1097,17 +1680,22 @@ async function saveUser() {
|
|
|
1097
1680
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
1098
1681
|
applyFacialResult(payload.metadata, facialResult);
|
|
1099
1682
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
1683
|
+
payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
|
|
1100
1684
|
if (selectedUser.value._id) {
|
|
1101
1685
|
await updateIdentity(selectedUser.value._id, payload);
|
|
1102
|
-
} else {
|
|
1686
|
+
} else if (form.subjectId) {
|
|
1103
1687
|
await saveIdentityOnReader(readerId, payload);
|
|
1104
1688
|
}
|
|
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.
|
|
1105
1692
|
} else {
|
|
1106
1693
|
const created = await createHidUserOnReader(readerId, hidUser);
|
|
1107
1694
|
if (created) newlyCreatedHidUserId = hidUser.id;
|
|
1108
1695
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
1109
1696
|
applyFacialResult(payload.metadata, facialResult);
|
|
1110
1697
|
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
1698
|
+
payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
|
|
1111
1699
|
await saveIdentityOnReader(readerId, payload);
|
|
1112
1700
|
selectedReaderId.value = readerId;
|
|
1113
1701
|
}
|
|
@@ -1143,6 +1731,25 @@ async function saveUser() {
|
|
|
1143
1731
|
}
|
|
1144
1732
|
}
|
|
1145
1733
|
|
|
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
|
+
|
|
1146
1753
|
async function syncAccessPin(readerId: string, hidUserId: number) {
|
|
1147
1754
|
if (removePinRequested.value) {
|
|
1148
1755
|
await deleteUserPin(readerId, hidUserId);
|
|
@@ -1253,7 +1860,7 @@ async function saveIdentityOnReader(
|
|
|
1253
1860
|
}
|
|
1254
1861
|
|
|
1255
1862
|
try {
|
|
1256
|
-
await createIdentity(readerId, { ...payload, site: props.site });
|
|
1863
|
+
await createIdentity(readerId, { ...payload, site: props.site, ...(props.org ? { orgId: props.org } : {}) });
|
|
1257
1864
|
} catch (error: unknown) {
|
|
1258
1865
|
const message = getHidErrorMessage(error);
|
|
1259
1866
|
if (!message.toLowerCase().includes("already exists")) throw error;
|
|
@@ -1447,6 +2054,21 @@ function toHidUserObject(identity: Pick<HidUser, "hidUserId" | "name" | "registr
|
|
|
1447
2054
|
};
|
|
1448
2055
|
}
|
|
1449
2056
|
|
|
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
|
+
|
|
1450
2072
|
function toHidNumericId(value: unknown) {
|
|
1451
2073
|
const raw = String(value ?? "").trim();
|
|
1452
2074
|
if (!raw) return undefined;
|
|
@@ -1585,6 +2207,14 @@ async function assignPhysicalCard() {
|
|
|
1585
2207
|
}
|
|
1586
2208
|
}
|
|
1587
2209
|
|
|
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
|
+
*/
|
|
1588
2218
|
async function enrollPhysicalCard() {
|
|
1589
2219
|
const { readerId, hidUserId } = getCardReaderAndUser();
|
|
1590
2220
|
if (!readerId || !hidUserId) return;
|
|
@@ -1597,6 +2227,8 @@ async function enrollPhysicalCard() {
|
|
|
1597
2227
|
await loadUsers();
|
|
1598
2228
|
showToast("Physical HID card read and enrolled on the reader.");
|
|
1599
2229
|
} 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.
|
|
1600
2232
|
if (!cardEnrollmentCancelled.value) {
|
|
1601
2233
|
showToast(getHidErrorMessage(error), "error");
|
|
1602
2234
|
}
|
|
@@ -1605,15 +2237,49 @@ async function enrollPhysicalCard() {
|
|
|
1605
2237
|
}
|
|
1606
2238
|
}
|
|
1607
2239
|
|
|
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
|
+
*/
|
|
1608
2264
|
async function cancelPhysicalCardEnrollment() {
|
|
1609
2265
|
const { readerId } = getCardReaderAndUser();
|
|
1610
2266
|
if (!readerId) return;
|
|
2267
|
+
// Set first, awaited second: the enrol can reject at any point from here.
|
|
1611
2268
|
cardEnrollmentCancelled.value = true;
|
|
2269
|
+
cancellingCard.value = true;
|
|
1612
2270
|
try {
|
|
1613
2271
|
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;
|
|
1614
2276
|
showToast("Physical HID card reading cancelled.");
|
|
1615
2277
|
} 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.
|
|
1616
2280
|
showToast(getHidErrorMessage(error), "error");
|
|
2281
|
+
} finally {
|
|
2282
|
+
cancellingCard.value = false;
|
|
1617
2283
|
}
|
|
1618
2284
|
}
|
|
1619
2285
|
|
|
@@ -1649,8 +2315,21 @@ function getPhysicalCardSubtitle(card: HidCard) {
|
|
|
1649
2315
|
}
|
|
1650
2316
|
|
|
1651
2317
|
function buildUnitLabel() {
|
|
1652
|
-
|
|
1653
|
-
|
|
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(" / ");
|
|
1654
2333
|
}
|
|
1655
2334
|
|
|
1656
2335
|
function getName(user?: HidUser | null) {
|
|
@@ -1901,6 +2580,12 @@ function toHidCard(value: unknown): HidCard {
|
|
|
1901
2580
|
source: card.source === "reader" || card.source === "manual" || card.source === "device" ? card.source : undefined,
|
|
1902
2581
|
};
|
|
1903
2582
|
}
|
|
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
|
+
});
|
|
1904
2589
|
</script>
|
|
1905
2590
|
|
|
1906
2591
|
<style scoped>
|
|
@@ -1937,12 +2622,6 @@ function toHidCard(value: unknown): HidCard {
|
|
|
1937
2622
|
max-width: 320px;
|
|
1938
2623
|
}
|
|
1939
2624
|
|
|
1940
|
-
.small-title {
|
|
1941
|
-
font-size: 14px;
|
|
1942
|
-
font-weight: 700;
|
|
1943
|
-
color: var(--text);
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
2625
|
/* Was `#4f5a66` / `#e53935` - the same two greys on both themes. */
|
|
1947
2626
|
.field-label {
|
|
1948
2627
|
color: var(--text2);
|
|
@@ -1964,12 +2643,42 @@ function toHidCard(value: unknown): HidCard {
|
|
|
1964
2643
|
line-height: 1.45;
|
|
1965
2644
|
}
|
|
1966
2645
|
|
|
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
|
+
|
|
1967
2652
|
.photo-wrap {
|
|
1968
2653
|
display: grid;
|
|
1969
2654
|
place-items: center;
|
|
1970
2655
|
margin-bottom: 14px;
|
|
1971
2656
|
}
|
|
1972
2657
|
|
|
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
|
+
|
|
1973
2682
|
.photo-button {
|
|
1974
2683
|
width: 120px;
|
|
1975
2684
|
height: 120px;
|
|
@@ -2143,25 +2852,6 @@ function toHidCard(value: unknown): HidCard {
|
|
|
2143
2852
|
color: var(--muted);
|
|
2144
2853
|
}
|
|
2145
2854
|
|
|
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
|
-
|
|
2165
2855
|
@media (max-width: 960px) {
|
|
2166
2856
|
.hid-user-filters :deep(.app-field),
|
|
2167
2857
|
.hid-user-filters :deep(.app-select) {
|