@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.
- package/CHANGELOG.md +21 -4
- package/assets/css/primitives.css +0 -43
- package/components/AccessCardPreviewDialog.vue +50 -286
- package/components/AppSelect.vue +3 -77
- package/components/BuildingUnitFormEdit.vue +0 -6
- package/components/Dialog/UpdateMoreAction.vue +1 -11
- package/components/DocumentForm.vue +112 -407
- package/components/DocumentManagement.vue +12 -213
- package/components/Facility/BookingSetup.vue +0 -63
- package/components/HidAccessLogDashboard.vue +17 -74
- package/components/HidIntercomManagement.vue +14 -183
- package/components/HidQrCodeConfiguration.vue +195 -17
- package/components/HidReaderManagement.vue +0 -398
- package/components/HidReaderUserRoster.vue +3 -44
- package/components/HidUserEnrollment.vue +155 -845
- package/components/InvitationClientForm.vue +1 -19
- package/components/MemberMain.vue +3 -6
- package/components/VisitorForm.vue +0 -12
- package/components/VisitorManagement.vue +5 -267
- package/composables/useDocument.ts +0 -7
- package/composables/useFacility.ts +0 -11
- package/composables/useHidAmico.ts +0 -165
- package/composables/useHidNavigation.ts +1 -25
- package/composables/useMember.ts +0 -12
- package/composables/usePeople.ts +0 -19
- package/composables/useServiceProvider.ts +2 -33
- 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-readers/index.vue +1 -1
- package/types/document.d.ts +0 -14
- package/types/facility.d.ts +0 -4
- package/types/member.d.ts +0 -4
- package/types/people.d.ts +1 -28
- package/types/service-provider.d.ts +0 -5
- package/utils/role.ts +1 -6
- package/components/BuildingUnitManagement.vue +0 -298
- package/components/HidAccessPermissions.vue +0 -424
- package/components/HidProfileQrCode.vue +0 -332
- package/composables/useHidReaderSelection.ts +0 -50
- package/composables/useSiteCategory.ts +0 -45
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
- package/utils/hid-permission-assignments.ts +0 -190
- package/utils/hid-reader-selection.ts +0 -52
- package/utils/occupancy-role.ts +0 -109
- 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
|
|
71
|
-
@keyup.enter="
|
|
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
|
|
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
|
|
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
|
-
|
|
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="
|
|
329
|
-
|
|
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
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
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
|
|
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
|
|
448
|
-
<v-card-title>User Information</v-card-title>
|
|
449
|
-
<v-card-text
|
|
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
|
|
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
|
|
480
|
-
<v-card-title>Physical HID Cards</v-card-title>
|
|
481
|
-
<v-card-text
|
|
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
|
-
|
|
475
|
+
Cancel Reading
|
|
599
476
|
</AppButton>
|
|
600
477
|
</div>
|
|
601
478
|
</v-card-text>
|
|
602
|
-
<v-card-actions
|
|
603
|
-
|
|
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
|
|
614
|
-
<v-card-title>Delete</v-card-title>
|
|
615
|
-
<v-card-text class="
|
|
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="
|
|
498
|
+
<v-card-actions class="pa-0">
|
|
623
499
|
<v-btn
|
|
624
|
-
class="text-none
|
|
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
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
const
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
|
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 =
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1291
|
-
|
|
1292
|
-
if (!
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
|
1322
|
-
|
|
1323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
901
|
+
loadingSubjects.value = false;
|
|
1339
902
|
}
|
|
1340
903
|
}
|
|
1341
904
|
|
|
1342
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
-
|
|
1579
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2319
|
-
|
|
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) {
|