@7365admin1/layer-common 4.149.2 → 4.150.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -4
- package/components/EntryPassInformation.vue +13 -0
- package/components/HidAccessPermissions.vue +202 -40
- package/components/HidEnabledGate.vue +29 -10
- package/components/HidIntercomManagement.vue +26 -13
- package/components/HidProfileQrCode.vue +6 -2
- package/components/HidQrCodeConfiguration.vue +2 -2
- package/components/HidReaderForm.vue +239 -1
- package/components/HidReaderManagement.vue +26 -8
- package/components/HidReaderUserRoster.vue +3 -1
- package/components/HidServiceSettingsPanel.vue +2 -2
- package/components/HidUserEnrollment.vue +1662 -162
- package/components/OvernightParkingAvailability.vue +123 -11
- package/components/SiteSettings.vue +5 -2
- package/components/VisitorForm.vue +39 -11
- package/composables/useCleaningPermission.ts +11 -0
- package/composables/useHidAccessPermission.ts +176 -81
- package/composables/useHidAmico.ts +37 -0
- package/composables/useHidNavigation.ts +44 -23
- package/composables/useLandscapingPermission.ts +11 -0
- package/composables/useMechanicalElectricalPermission.ts +5 -0
- package/composables/usePestControlPermission.ts +11 -0
- package/composables/usePoolMaintenancePermission.ts +11 -0
- package/composables/usePropertyPermission.ts +31 -7
- package/composables/useSecurityPermission.ts +33 -13
- package/composables/useServiceProvider.ts +9 -4
- package/constants/permissions.ts +73 -21
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +3 -0
- package/types/overnight-parking.d.ts +14 -0
- package/types/site.d.ts +7 -1
- package/utils/hid-enrolment-subject.ts +226 -4
- package/utils/hid-permission-assignments.ts +75 -6
- package/utils/hid-qr-validity.ts +25 -0
- package/utils/hid-reader-audiences.ts +557 -0
- package/utils/hid-registration.ts +24 -0
- package/utils/module-routes.ts +15 -2
- package/utils/nav-sections.ts +1 -1
|
@@ -53,7 +53,9 @@
|
|
|
53
53
|
@refresh="loadUsers"
|
|
54
54
|
@update:page="goToPage"
|
|
55
55
|
>
|
|
56
|
-
|
|
56
|
+
<!-- Enrolling is the users right (`hid-mgmt:manage-hid-users`). Hidden,
|
|
57
|
+
not disabled: the server refuses the call for a member without it. -->
|
|
58
|
+
<template v-if="!props.cardManagement && canManageHidUsers" #actions>
|
|
57
59
|
<AppButton icon="mdi-account-plus" @click="openEnroll"
|
|
58
60
|
>Enroll</AppButton
|
|
59
61
|
>
|
|
@@ -141,14 +143,14 @@
|
|
|
141
143
|
|
|
142
144
|
<template #[`item.action-table`]="{ item }">
|
|
143
145
|
<AppButton
|
|
144
|
-
v-if="props.cardManagement"
|
|
146
|
+
v-if="props.cardManagement && canManageHidUsers"
|
|
145
147
|
variant="secondary"
|
|
146
148
|
icon="mdi-card-account-details-outline"
|
|
147
149
|
@click="openCardManager(item)"
|
|
148
150
|
>
|
|
149
151
|
Manage
|
|
150
152
|
</AppButton>
|
|
151
|
-
<v-menu v-else>
|
|
153
|
+
<v-menu v-else-if="!props.cardManagement">
|
|
152
154
|
<template #activator="{ props: menuProps }">
|
|
153
155
|
<AppButton
|
|
154
156
|
v-bind="menuProps"
|
|
@@ -159,16 +161,18 @@
|
|
|
159
161
|
|
|
160
162
|
<v-list density="compact" min-width="150">
|
|
161
163
|
<v-list-item title="View" @click="openView(item)" />
|
|
162
|
-
<v-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
164
|
+
<template v-if="canManageHidUsers">
|
|
165
|
+
<v-list-item title="Edit" @click="openEdit(item)" />
|
|
166
|
+
<v-list-item
|
|
167
|
+
title="Manage HID Cards"
|
|
168
|
+
@click="openCardManager(item)"
|
|
169
|
+
/>
|
|
170
|
+
<v-list-item
|
|
171
|
+
title="Delete"
|
|
172
|
+
class="text-error"
|
|
173
|
+
@click="openDelete(item)"
|
|
174
|
+
/>
|
|
175
|
+
</template>
|
|
172
176
|
</v-list>
|
|
173
177
|
</v-menu>
|
|
174
178
|
</template>
|
|
@@ -274,13 +278,26 @@
|
|
|
274
278
|
it. -->
|
|
275
279
|
<div class="field-label">Enrolling <span>*</span></div>
|
|
276
280
|
<AppSelect
|
|
277
|
-
v-if="!selectedUser"
|
|
281
|
+
v-if="!selectedUser && subjectCategoryOptions.length"
|
|
278
282
|
v-model="form.subjectCategory"
|
|
279
283
|
:items="subjectCategoryOptions"
|
|
280
284
|
klass="mb-3"
|
|
281
285
|
@update:model-value="onSubjectCategoryChanged"
|
|
282
286
|
/>
|
|
283
|
-
<p v-else class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
|
|
287
|
+
<p v-else-if="selectedUser" class="field-hint mb-3">{{ subjectCategoryLabel }}</p>
|
|
288
|
+
<!-- WHY THERE IS NOTHING TO PICK. Drawn in place of the dropdown when
|
|
289
|
+
the reader serves nobody, and beneath it when only some types were
|
|
290
|
+
removed. Without it, a missing option reads as a screen that failed
|
|
291
|
+
to load rather than as a setting on the reader.
|
|
292
|
+
Nothing extra is needed to stop the save: with no type there is no
|
|
293
|
+
subject, and the save already refuses that. -->
|
|
294
|
+
<p
|
|
295
|
+
v-if="!selectedUser && subjectCategoryNote"
|
|
296
|
+
class="field-hint mb-3"
|
|
297
|
+
:class="{ 'field-hint--error': enrolmentSubjects.emptied }"
|
|
298
|
+
>
|
|
299
|
+
{{ subjectCategoryNote }}
|
|
300
|
+
</p>
|
|
284
301
|
|
|
285
302
|
<!-- Block / Level / Unit / Resident. Enrolment only.
|
|
286
303
|
Enrollment is resident-only and the resident must already exist
|
|
@@ -363,7 +380,7 @@
|
|
|
363
380
|
access is bound by user id, so there is nothing to bind. The note
|
|
364
381
|
below says how many were left out rather than letting a missing
|
|
365
382
|
colleague read as a bug. -->
|
|
366
|
-
<template v-if="!selectedUser &&
|
|
383
|
+
<template v-if="!selectedUser && isMemberSubject">
|
|
367
384
|
<div class="field-label">Member <span>*</span></div>
|
|
368
385
|
<AppSelect
|
|
369
386
|
v-model="form.subjectId"
|
|
@@ -389,6 +406,59 @@
|
|
|
389
406
|
</p>
|
|
390
407
|
</template>
|
|
391
408
|
|
|
409
|
+
<!-- SERVICE PROVIDER STAFF. One person from a provider company, and
|
|
410
|
+
never the company: granting a company puts every employee of the
|
|
411
|
+
firm on the door, including the ones who do not attend this site.
|
|
412
|
+
The list, the service filter and the filter's "All services"
|
|
413
|
+
default are the Members > Service Providers screen's own, from the
|
|
414
|
+
same endpoint and through the same `filterProviderMembers` rule,
|
|
415
|
+
so the two screens cannot disagree about who works here.
|
|
416
|
+
Unpaged - the endpoint returns the site's people in one response -
|
|
417
|
+
so searching and filtering cost no request. -->
|
|
418
|
+
<template v-if="!selectedUser && isProviderSubject">
|
|
419
|
+
<div class="field-label">Service</div>
|
|
420
|
+
<AppSelect
|
|
421
|
+
v-model="providerService"
|
|
422
|
+
:items="providerServiceOptions"
|
|
423
|
+
klass="mb-3"
|
|
424
|
+
@update:model-value="onProviderServiceChanged"
|
|
425
|
+
/>
|
|
426
|
+
|
|
427
|
+
<div class="field-label">Person <span>*</span></div>
|
|
428
|
+
<AppSelect
|
|
429
|
+
v-model="form.subjectId"
|
|
430
|
+
:items="providerOptions"
|
|
431
|
+
searchable
|
|
432
|
+
search-placeholder="Search by name, company or role"
|
|
433
|
+
:placeholder="providerPlaceholder"
|
|
434
|
+
klass="mb-3"
|
|
435
|
+
@update:model-value="onProviderChanged"
|
|
436
|
+
/>
|
|
437
|
+
|
|
438
|
+
<!-- ACCESS ONLY, and the button says so. Shown here and nowhere
|
|
439
|
+
else: it is the one subject type that arrives a shift at a time,
|
|
440
|
+
and the people it adds fill in their own credentials from their
|
|
441
|
+
app afterwards. -->
|
|
442
|
+
<div class="hid-bulk-cta mb-3">
|
|
443
|
+
<AppButton
|
|
444
|
+
variant="ghost"
|
|
445
|
+
icon="mdi-account-multiple-plus-outline"
|
|
446
|
+
:disabled="loadingProviders"
|
|
447
|
+
@click="openBulkAdd"
|
|
448
|
+
>
|
|
449
|
+
Add Users in Bulk
|
|
450
|
+
</AppButton>
|
|
451
|
+
<span class="field-hint">
|
|
452
|
+
Gives access to many people at once. They add their own face, QR
|
|
453
|
+
or PIN from their app.
|
|
454
|
+
</span>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<p v-if="hiddenProviderNote" class="field-hint mb-3">
|
|
458
|
+
{{ hiddenProviderNote }}
|
|
459
|
+
</p>
|
|
460
|
+
</template>
|
|
461
|
+
|
|
392
462
|
<!-- The duplicate check. It is reader-scoped: this resident holding a
|
|
393
463
|
HID user on another gate is normal and says nothing here. -->
|
|
394
464
|
<p v-if="checkingResident" class="field-hint">
|
|
@@ -412,17 +482,176 @@
|
|
|
412
482
|
whatever the reader already holds. -->
|
|
413
483
|
<!-- A mirror of the chosen member's role, on the same rule as Name:
|
|
414
484
|
restated from the record, never collected. -->
|
|
415
|
-
<template v-if="!selectedUser &&
|
|
416
|
-
<div class="field-label">
|
|
485
|
+
<template v-if="!selectedUser && isPersonSubject">
|
|
486
|
+
<div class="field-label">{{ subjectMirrorLabel }}</div>
|
|
417
487
|
<AppField
|
|
418
488
|
v-model="form.subjectRole"
|
|
419
|
-
aria-label="
|
|
420
|
-
placeholder="
|
|
489
|
+
:aria-label="subjectMirrorLabel"
|
|
490
|
+
:placeholder="subjectMirrorPlaceholder"
|
|
421
491
|
readonly
|
|
422
492
|
klass="mb-3"
|
|
423
493
|
/>
|
|
424
494
|
</template>
|
|
425
495
|
|
|
496
|
+
<!-- WHERE THIS PERSON WILL END UP.
|
|
497
|
+
Enrolling is no longer "pick a reader" - they land on every reader
|
|
498
|
+
at the site that serves their account type - so the dialog states
|
|
499
|
+
it rather than asking. The excluded readers are drawn too, with the
|
|
500
|
+
reason: an absent reader reads as a screen that failed to load,
|
|
501
|
+
while "does not serve residents" reads as a setting somebody chose.
|
|
502
|
+
Replaced while a save is running by the per-reader progress below,
|
|
503
|
+
which is the same list with each row's own state. -->
|
|
504
|
+
<template v-if="!selectedUser">
|
|
505
|
+
<div class="field-label">
|
|
506
|
+
{{ readerOutcomes.length ? "Enrolling on" : "Will be enrolled on" }}
|
|
507
|
+
</div>
|
|
508
|
+
|
|
509
|
+
<div v-if="readerOutcomes.length" class="hid-fanout mb-1">
|
|
510
|
+
<div
|
|
511
|
+
v-for="outcome in readerOutcomes"
|
|
512
|
+
:key="outcome.readerId"
|
|
513
|
+
class="hid-fanout__row"
|
|
514
|
+
:class="`hid-fanout__row--${outcome.step}`"
|
|
515
|
+
>
|
|
516
|
+
<v-icon
|
|
517
|
+
:icon="
|
|
518
|
+
outcome.step === 'done'
|
|
519
|
+
? 'mdi-check-circle-outline'
|
|
520
|
+
: outcome.step === 'failed'
|
|
521
|
+
? 'mdi-alert-circle-outline'
|
|
522
|
+
: outcome.step === 'working'
|
|
523
|
+
? 'mdi-progress-upload'
|
|
524
|
+
: 'mdi-clock-outline'
|
|
525
|
+
"
|
|
526
|
+
size="17"
|
|
527
|
+
/>
|
|
528
|
+
<div class="hid-fanout__body">
|
|
529
|
+
<strong>{{ outcome.name }}</strong>
|
|
530
|
+
<span v-if="outcome.message">{{ outcome.message }}</span>
|
|
531
|
+
</div>
|
|
532
|
+
<!-- Retry is per reader, because the failure was. Enabled only
|
|
533
|
+
when nothing else is running, so two writes cannot race at
|
|
534
|
+
one device. -->
|
|
535
|
+
<AppButton
|
|
536
|
+
v-if="outcome.step === 'failed'"
|
|
537
|
+
variant="row"
|
|
538
|
+
icon="mdi-refresh"
|
|
539
|
+
:disabled="fanOutRunning"
|
|
540
|
+
@click="retryReader(outcome)"
|
|
541
|
+
/>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
<div v-else class="hid-fanout mb-1">
|
|
546
|
+
<div
|
|
547
|
+
v-for="target in enrolmentTargets.targets"
|
|
548
|
+
:key="target.readerId"
|
|
549
|
+
class="hid-fanout__row hid-fanout__row--target"
|
|
550
|
+
>
|
|
551
|
+
<v-icon icon="mdi-check" size="17" />
|
|
552
|
+
<div class="hid-fanout__body">
|
|
553
|
+
<strong>{{ target.name }}</strong>
|
|
554
|
+
<span v-if="target.location">{{ target.location }}</span>
|
|
555
|
+
<span v-if="target.warning">{{ target.warning }}</span>
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
<div
|
|
559
|
+
v-for="skipped in enrolmentTargets.excluded"
|
|
560
|
+
:key="skipped.readerId"
|
|
561
|
+
class="hid-fanout__row hid-fanout__row--excluded"
|
|
562
|
+
>
|
|
563
|
+
<v-icon icon="mdi-minus" size="17" />
|
|
564
|
+
<div class="hid-fanout__body">
|
|
565
|
+
<strong>{{ skipped.name }}</strong>
|
|
566
|
+
<span>{{ skipped.reason }}</span>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
|
|
571
|
+
<p v-if="fanOutProgress && fanOutRunning" class="field-hint mb-3">
|
|
572
|
+
{{ fanOutProgress }}
|
|
573
|
+
</p>
|
|
574
|
+
<p
|
|
575
|
+
v-else-if="fanOutResult"
|
|
576
|
+
class="field-hint mb-3"
|
|
577
|
+
:class="{ 'field-hint--error': fanOutResult.tone !== 'success' }"
|
|
578
|
+
>
|
|
579
|
+
{{ fanOutResult.message }}
|
|
580
|
+
</p>
|
|
581
|
+
<p v-else-if="!enrolmentTargets.targets.length" class="field-hint field-hint--error mb-3">
|
|
582
|
+
No reader at this site serves this kind of person yet.
|
|
583
|
+
</p>
|
|
584
|
+
<!-- Each reader numbers its own users, so the ID and registration
|
|
585
|
+
below describe the reader on screen - not the others. -->
|
|
586
|
+
<p v-else-if="enrolmentTargets.targets.length > 1" class="field-hint mb-3">
|
|
587
|
+
Each reader assigns its own ID and registration number.
|
|
588
|
+
</p>
|
|
589
|
+
</template>
|
|
590
|
+
|
|
591
|
+
<!-- EDITING SOMEBODY WHO IS ON SEVERAL READERS.
|
|
592
|
+
The same list, for the same reason, except the readers are the
|
|
593
|
+
ones that already hold this person rather than the ones that would
|
|
594
|
+
take them. Said before the save because of the PIN: a changed PIN
|
|
595
|
+
that reaches two readers of three leaves this person holding two
|
|
596
|
+
PINs, so the operator needs to know the change travels. -->
|
|
597
|
+
<template v-else-if="editPlaces.length > 1 || readerOutcomes.length">
|
|
598
|
+
<div class="field-label">
|
|
599
|
+
{{ readerOutcomes.length ? "Updating on" : "Also on" }}
|
|
600
|
+
</div>
|
|
601
|
+
|
|
602
|
+
<div class="hid-fanout mb-1">
|
|
603
|
+
<div
|
|
604
|
+
v-for="outcome in editRows"
|
|
605
|
+
:key="outcome.readerId"
|
|
606
|
+
class="hid-fanout__row"
|
|
607
|
+
:class="`hid-fanout__row--${outcome.step}`"
|
|
608
|
+
>
|
|
609
|
+
<v-icon
|
|
610
|
+
:icon="
|
|
611
|
+
outcome.step === 'done'
|
|
612
|
+
? 'mdi-check-circle-outline'
|
|
613
|
+
: outcome.step === 'failed'
|
|
614
|
+
? 'mdi-alert-circle-outline'
|
|
615
|
+
: outcome.step === 'working'
|
|
616
|
+
? 'mdi-progress-upload'
|
|
617
|
+
: outcome.step === 'target'
|
|
618
|
+
? 'mdi-check'
|
|
619
|
+
: 'mdi-clock-outline'
|
|
620
|
+
"
|
|
621
|
+
size="17"
|
|
622
|
+
/>
|
|
623
|
+
<div class="hid-fanout__body">
|
|
624
|
+
<strong>{{ outcome.name }}</strong>
|
|
625
|
+
<span v-if="outcome.message">{{ outcome.message }}</span>
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
628
|
+
</div>
|
|
629
|
+
|
|
630
|
+
<p v-if="fanOutProgress && fanOutRunning" class="field-hint mb-3">
|
|
631
|
+
{{ fanOutProgress }}
|
|
632
|
+
</p>
|
|
633
|
+
<p
|
|
634
|
+
v-else-if="editResult"
|
|
635
|
+
class="field-hint mb-3"
|
|
636
|
+
:class="{ 'field-hint--error': editResult.tone !== 'success' }"
|
|
637
|
+
>
|
|
638
|
+
{{ editResult.message }}
|
|
639
|
+
</p>
|
|
640
|
+
<!-- Registration is the one field that does NOT travel: each reader
|
|
641
|
+
allocates its own, and one number written to all would collide. -->
|
|
642
|
+
<p v-else class="field-hint mb-3">
|
|
643
|
+
Name, photo, PIN and password are updated on every reader. The
|
|
644
|
+
registration number is each reader's own.
|
|
645
|
+
</p>
|
|
646
|
+
</template>
|
|
647
|
+
|
|
648
|
+
<p
|
|
649
|
+
v-else-if="editPlacesLoading"
|
|
650
|
+
class="field-hint mb-3"
|
|
651
|
+
>
|
|
652
|
+
Checking which readers hold this person...
|
|
653
|
+
</p>
|
|
654
|
+
|
|
426
655
|
<div class="field-label">Name</div>
|
|
427
656
|
<AppField
|
|
428
657
|
v-model="form.name"
|
|
@@ -430,6 +659,8 @@
|
|
|
430
659
|
:placeholder="
|
|
431
660
|
isResidentSubject
|
|
432
661
|
? 'Select a resident above'
|
|
662
|
+
: isProviderSubject
|
|
663
|
+
? 'Select a person above'
|
|
433
664
|
: 'Select a member above'
|
|
434
665
|
"
|
|
435
666
|
readonly
|
|
@@ -567,6 +798,115 @@
|
|
|
567
798
|
</v-card>
|
|
568
799
|
</v-dialog>
|
|
569
800
|
|
|
801
|
+
<!-- ADD USERS IN BULK.
|
|
802
|
+
Over the enrolment form, not instead of it: the operator came here to
|
|
803
|
+
enrol, and this is the shortcut for the case where a whole shift arrives
|
|
804
|
+
at once. It grants ACCESS only - the people it adds are recognised by the
|
|
805
|
+
reader once they add a face, QR or PIN from their own app - which is why
|
|
806
|
+
it collects no photo, PIN or password.
|
|
807
|
+
The table is the same list as the picker behind it, under the same
|
|
808
|
+
service filter, with Company / Name / Service and nothing else: the
|
|
809
|
+
Members screen's other columns say nothing about whether to open a door
|
|
810
|
+
for somebody. -->
|
|
811
|
+
<v-dialog v-model="bulkDialog" max-width="640" persistent>
|
|
812
|
+
<v-card class="screen-modal">
|
|
813
|
+
<v-card-title>Add Users in Bulk</v-card-title>
|
|
814
|
+
<v-card-text class="screen-modal__body">
|
|
815
|
+
<p class="field-hint mb-3">
|
|
816
|
+
Gives these people access to
|
|
817
|
+
<strong>{{ bulkReaderLabel }}</strong>. They add their own face, QR
|
|
818
|
+
or PIN from their app - nothing is enrolled on the reader here.
|
|
819
|
+
</p>
|
|
820
|
+
|
|
821
|
+
<AppField
|
|
822
|
+
v-model="bulkSearch"
|
|
823
|
+
aria-label="Search people"
|
|
824
|
+
placeholder="Search by name, company or role"
|
|
825
|
+
klass="mb-3"
|
|
826
|
+
/>
|
|
827
|
+
|
|
828
|
+
<v-progress-linear v-if="bulkLoading" indeterminate class="mb-3" />
|
|
829
|
+
|
|
830
|
+
<template v-else-if="bulkRows.length">
|
|
831
|
+
<v-table density="compact" class="hid-bulk-table mb-2">
|
|
832
|
+
<thead>
|
|
833
|
+
<tr>
|
|
834
|
+
<th class="hid-bulk-table__check">
|
|
835
|
+
<!-- Acts on the rows in front of you - what the service
|
|
836
|
+
filter and the search have left - never on the whole
|
|
837
|
+
estate. -->
|
|
838
|
+
<v-checkbox
|
|
839
|
+
:model-value="bulkAllSelected"
|
|
840
|
+
:disabled="!bulkSelectableRows.length"
|
|
841
|
+
hide-details
|
|
842
|
+
density="compact"
|
|
843
|
+
aria-label="Select everyone listed"
|
|
844
|
+
@update:model-value="toggleBulkAll"
|
|
845
|
+
/>
|
|
846
|
+
</th>
|
|
847
|
+
<th>Company</th>
|
|
848
|
+
<th>Name</th>
|
|
849
|
+
<th>Service</th>
|
|
850
|
+
</tr>
|
|
851
|
+
</thead>
|
|
852
|
+
<tbody>
|
|
853
|
+
<tr v-for="row in bulkRows" :key="row.subjectId">
|
|
854
|
+
<td class="hid-bulk-table__check">
|
|
855
|
+
<!-- Already on the door: ticked and locked. Unticking here
|
|
856
|
+
would revoke access, and this dialog does not revoke. -->
|
|
857
|
+
<v-checkbox
|
|
858
|
+
:model-value="row.granted || isBulkSelected(row.subjectId)"
|
|
859
|
+
:disabled="row.granted"
|
|
860
|
+
hide-details
|
|
861
|
+
density="compact"
|
|
862
|
+
:aria-label="`Select ${row.name}`"
|
|
863
|
+
@update:model-value="toggleBulkRow(row.subjectId, $event)"
|
|
864
|
+
/>
|
|
865
|
+
</td>
|
|
866
|
+
<td>{{ row.company || "—" }}</td>
|
|
867
|
+
<td>
|
|
868
|
+
{{ row.name }}
|
|
869
|
+
<span v-if="row.granted" class="hid-bulk-granted">
|
|
870
|
+
Already has access
|
|
871
|
+
</span>
|
|
872
|
+
</td>
|
|
873
|
+
<td>{{ row.service || "—" }}</td>
|
|
874
|
+
</tr>
|
|
875
|
+
</tbody>
|
|
876
|
+
</v-table>
|
|
877
|
+
<p class="field-hint">
|
|
878
|
+
{{ bulkSelectedCount }} selected<template v-if="bulkGrantedCount">
|
|
879
|
+
· {{ bulkGrantedCount }} already have access</template
|
|
880
|
+
>
|
|
881
|
+
</p>
|
|
882
|
+
</template>
|
|
883
|
+
|
|
884
|
+
<v-alert v-else type="info" variant="tonal" density="compact">
|
|
885
|
+
{{ bulkEmptyText }}
|
|
886
|
+
</v-alert>
|
|
887
|
+
</v-card-text>
|
|
888
|
+
<v-card-actions class="screen-modal__footer">
|
|
889
|
+
<v-btn
|
|
890
|
+
class="text-none screen-btn-ghost"
|
|
891
|
+
variant="flat"
|
|
892
|
+
:disabled="bulkSaving"
|
|
893
|
+
@click="bulkDialog = false"
|
|
894
|
+
>
|
|
895
|
+
Cancel
|
|
896
|
+
</v-btn>
|
|
897
|
+
<v-btn
|
|
898
|
+
class="text-none screen-btn-primary"
|
|
899
|
+
variant="flat"
|
|
900
|
+
:loading="bulkSaving"
|
|
901
|
+
:disabled="!bulkSelectedCount"
|
|
902
|
+
@click="saveBulkAdd"
|
|
903
|
+
>
|
|
904
|
+
Give access
|
|
905
|
+
</v-btn>
|
|
906
|
+
</v-card-actions>
|
|
907
|
+
</v-card>
|
|
908
|
+
</v-dialog>
|
|
909
|
+
|
|
570
910
|
<HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
|
|
571
911
|
|
|
572
912
|
<v-dialog v-model="viewDialog" max-width="430">
|
|
@@ -759,20 +1099,76 @@
|
|
|
759
1099
|
</v-card>
|
|
760
1100
|
</v-dialog>
|
|
761
1101
|
|
|
762
|
-
<v-dialog v-model="deleteDialog" max-width="
|
|
1102
|
+
<v-dialog v-model="deleteDialog" max-width="420" persistent>
|
|
763
1103
|
<v-card class="screen-modal">
|
|
764
1104
|
<v-card-title>Delete</v-card-title>
|
|
765
1105
|
<v-card-text class="screen-modal__body text-body-2">
|
|
766
1106
|
Are you sure you want to permanently delete this HID user?
|
|
1107
|
+
|
|
1108
|
+
<div v-if="deletePlacesLoading" class="text-caption text-medium-emphasis mt-2">
|
|
1109
|
+
Checking which readers hold this person...
|
|
1110
|
+
</div>
|
|
1111
|
+
<div v-else class="text-caption text-medium-emphasis mt-2">
|
|
1112
|
+
{{ deleteReach }}
|
|
1113
|
+
</div>
|
|
1114
|
+
|
|
1115
|
+
<!-- Only a choice when there is one to make. On one reader the radios
|
|
1116
|
+
would ask the operator to confirm the only possible answer. -->
|
|
1117
|
+
<v-radio-group
|
|
1118
|
+
v-if="deleteScopeOptions.length > 1"
|
|
1119
|
+
v-model="deleteScope"
|
|
1120
|
+
class="mt-2"
|
|
1121
|
+
density="compact"
|
|
1122
|
+
hide-details
|
|
1123
|
+
:disabled="deleting"
|
|
1124
|
+
>
|
|
1125
|
+
<v-radio
|
|
1126
|
+
v-for="choice in deleteScopeOptions"
|
|
1127
|
+
:key="choice.value"
|
|
1128
|
+
:label="choice.label"
|
|
1129
|
+
:value="choice.value"
|
|
1130
|
+
density="compact"
|
|
1131
|
+
/>
|
|
1132
|
+
</v-radio-group>
|
|
1133
|
+
|
|
1134
|
+
<!-- Per-reader, so a reader that refused is named rather than hidden
|
|
1135
|
+
behind a single failure message. -->
|
|
1136
|
+
<div v-if="deleteOutcomes.length > 1" class="mt-3">
|
|
1137
|
+
<div
|
|
1138
|
+
v-for="outcome in deleteOutcomes"
|
|
1139
|
+
:key="outcome.readerId"
|
|
1140
|
+
class="d-flex align-center justify-space-between text-caption"
|
|
1141
|
+
>
|
|
1142
|
+
<span>{{ outcome.name }}</span>
|
|
1143
|
+
<span
|
|
1144
|
+
:class="outcome.step === 'failed'
|
|
1145
|
+
? 'text-error'
|
|
1146
|
+
: 'text-medium-emphasis'"
|
|
1147
|
+
>
|
|
1148
|
+
{{ outcome.step === "done" ? "Deleted"
|
|
1149
|
+
: outcome.step === "failed" ? (outcome.message || "Failed")
|
|
1150
|
+
: outcome.step === "working" ? "Deleting..." : "Waiting" }}
|
|
1151
|
+
</span>
|
|
1152
|
+
</div>
|
|
1153
|
+
</div>
|
|
1154
|
+
|
|
1155
|
+
<div
|
|
1156
|
+
v-if="deleteSummary && deleteSummary.tone !== 'success'"
|
|
1157
|
+
class="text-caption text-error mt-2"
|
|
1158
|
+
>
|
|
1159
|
+
{{ deleteSummary.message }}
|
|
1160
|
+
</div>
|
|
1161
|
+
|
|
767
1162
|
<div class="text-caption text-medium-emphasis mt-2">
|
|
768
|
-
This
|
|
769
|
-
|
|
1163
|
+
This removes the user from the reader and marks the identity as
|
|
1164
|
+
deleted, which also takes away the access that let them through.
|
|
770
1165
|
</div>
|
|
771
1166
|
</v-card-text>
|
|
772
1167
|
<v-card-actions class="screen-modal__footer">
|
|
773
1168
|
<v-btn
|
|
774
1169
|
class="text-none screen-btn-ghost"
|
|
775
1170
|
variant="flat"
|
|
1171
|
+
:disabled="deleting"
|
|
776
1172
|
@click="deleteDialog = false"
|
|
777
1173
|
>
|
|
778
1174
|
Cancel
|
|
@@ -780,10 +1176,12 @@
|
|
|
780
1176
|
<v-btn
|
|
781
1177
|
class="text-none screen-btn-primary"
|
|
782
1178
|
variant="flat"
|
|
783
|
-
:loading="deleting"
|
|
1179
|
+
:loading="deleting || deletePlacesLoading"
|
|
784
1180
|
@click="deleteUser"
|
|
785
1181
|
>
|
|
786
|
-
|
|
1182
|
+
{{ deleteSummary && deleteSummary.tone === "partial"
|
|
1183
|
+
? "Try again"
|
|
1184
|
+
: "Delete" }}
|
|
787
1185
|
</v-btn>
|
|
788
1186
|
</v-card-actions>
|
|
789
1187
|
</v-card>
|
|
@@ -804,9 +1202,51 @@ import {
|
|
|
804
1202
|
hiddenSubjectNote,
|
|
805
1203
|
memberCandidateOf,
|
|
806
1204
|
memberHasAccount,
|
|
1205
|
+
HID_ALL_SERVICES,
|
|
1206
|
+
enrolmentSubjectOfCategory,
|
|
1207
|
+
permissionCategoryOf,
|
|
1208
|
+
providerAccountsKnown,
|
|
1209
|
+
providerCandidateOf,
|
|
1210
|
+
providerEnrollableRows,
|
|
807
1211
|
readMemberPage,
|
|
1212
|
+
serviceFilterOf,
|
|
808
1213
|
subjectCategoryLabel as labelForSubjectCategory,
|
|
1214
|
+
subjectLinkOf,
|
|
1215
|
+
type HidEnrolmentSubject,
|
|
809
1216
|
} from "../utils/hid-enrolment-subject";
|
|
1217
|
+
import { nextRegistration } from "../utils/hid-registration";
|
|
1218
|
+
/* Mirrors core's audience rules — core is what enforces them; this only decides
|
|
1219
|
+
what is worth offering and where an enrolment lands. */
|
|
1220
|
+
import {
|
|
1221
|
+
availableEnrolmentSubjects,
|
|
1222
|
+
deleteFanOutSummary,
|
|
1223
|
+
deleteReachText,
|
|
1224
|
+
deleteScopeChoices,
|
|
1225
|
+
editFanOutSummary,
|
|
1226
|
+
fanOutProgressText,
|
|
1227
|
+
fanOutSummary,
|
|
1228
|
+
partitionEnrolmentTargets,
|
|
1229
|
+
type THidReaderOutcome,
|
|
1230
|
+
type THidReaderStep,
|
|
1231
|
+
} from "../utils/hid-reader-audiences";
|
|
1232
|
+
/* The SAME filter the Members > Service Providers screen applies, not a second
|
|
1233
|
+
copy of the rule: a picker that disagrees with the screen the operator was
|
|
1234
|
+
just looking at is a picker they cannot trust. Core applies it to the Excel
|
|
1235
|
+
export too (`filterSiteProviderMembers`). */
|
|
1236
|
+
import {
|
|
1237
|
+
filterProviderMembers,
|
|
1238
|
+
serviceTypeOptions,
|
|
1239
|
+
} from "../utils/site-provider-members";
|
|
1240
|
+
/* Bulk access is granted through the permissions document, and the PUT REPLACES
|
|
1241
|
+
this reader's whole assignment set. These three are what keep a bulk add from
|
|
1242
|
+
revoking everybody who was already on the door - see the file's own header. */
|
|
1243
|
+
import {
|
|
1244
|
+
seedAssignments,
|
|
1245
|
+
setAssignment,
|
|
1246
|
+
subjectOfIdentity,
|
|
1247
|
+
toAssignmentPayload,
|
|
1248
|
+
type HidAssignmentState,
|
|
1249
|
+
} from "../utils/hid-permission-assignments";
|
|
810
1250
|
|
|
811
1251
|
const props = defineProps({
|
|
812
1252
|
site: {
|
|
@@ -826,6 +1266,10 @@ const props = defineProps({
|
|
|
826
1266
|
},
|
|
827
1267
|
});
|
|
828
1268
|
|
|
1269
|
+
// Enrol, edit, delete, cards and PINs are all the users right. The screen
|
|
1270
|
+
// itself is behind `HidEnabledGate` (the view right).
|
|
1271
|
+
const { canManageHidUsers } = useHidAccessPermission();
|
|
1272
|
+
|
|
829
1273
|
const {
|
|
830
1274
|
getReaders,
|
|
831
1275
|
getIdentities,
|
|
@@ -847,8 +1291,14 @@ const {
|
|
|
847
1291
|
createIdentity,
|
|
848
1292
|
updateIdentity,
|
|
849
1293
|
deleteIdentity,
|
|
1294
|
+
pushStoredFace,
|
|
1295
|
+
getSitePermissions,
|
|
1296
|
+
updateSitePermissions,
|
|
850
1297
|
} = useHidAmico();
|
|
851
1298
|
const { getAll: getAllMembers } = useMember();
|
|
1299
|
+
/* Members > Service Providers reads this same call, so the enrolment picker
|
|
1300
|
+
offers exactly the people that screen lists. Site-scoped by the server. */
|
|
1301
|
+
const { getSiteMembers } = useServiceProvider();
|
|
852
1302
|
const runtimeConfig = useRuntimeConfig();
|
|
853
1303
|
|
|
854
1304
|
type HidMetadata = Record<string, unknown> & {
|
|
@@ -928,7 +1378,36 @@ const saving = ref(false);
|
|
|
928
1378
|
const deleting = ref(false);
|
|
929
1379
|
const formDialog = ref(false);
|
|
930
1380
|
const viewDialog = ref(false);
|
|
1381
|
+
/** One reader that holds this person, and the ids that reader knows them by. */
|
|
1382
|
+
type TPersonPlace = {
|
|
1383
|
+
readerId: string;
|
|
1384
|
+
name: string;
|
|
1385
|
+
identityId: string;
|
|
1386
|
+
hidUserId: string;
|
|
1387
|
+
};
|
|
1388
|
+
|
|
931
1389
|
const deleteDialog = ref(false);
|
|
1390
|
+
/* Where this person actually is, resolved when the dialog opens so the operator
|
|
1391
|
+
chooses the scope knowing the reach rather than after the fact. */
|
|
1392
|
+
const deletePlaces = ref<TPersonPlace[]>([]);
|
|
1393
|
+
const deletePlacesLoading = ref(false);
|
|
1394
|
+
const deleteScope = ref<"here" | "all">("here");
|
|
1395
|
+
/* Kept apart from the enrolment fan-out's outcomes on purpose: that list drives
|
|
1396
|
+
a Retry that RE-ENROLS, which is the wrong action for a reader a delete could
|
|
1397
|
+
not reach. */
|
|
1398
|
+
const deleteOutcomes = ref<THidReaderOutcome[]>([]);
|
|
1399
|
+
const deleteReach = computed(
|
|
1400
|
+
() => deleteReachText(deletePlaces.value, selectedReaderId.value),
|
|
1401
|
+
);
|
|
1402
|
+
const deleteScopeOptions = computed(() => deleteScopeChoices(
|
|
1403
|
+
deletePlaces.value.length,
|
|
1404
|
+
selectedReader.value?.name || "this reader",
|
|
1405
|
+
));
|
|
1406
|
+
const deleteSummary = computed(() =>
|
|
1407
|
+
deleteOutcomes.value.length && !deleting.value
|
|
1408
|
+
? deleteFanOutSummary(deleteOutcomes.value)
|
|
1409
|
+
: null
|
|
1410
|
+
);
|
|
932
1411
|
const cardDialog = ref(false);
|
|
933
1412
|
const selectedUser = ref<HidUser | null>(null);
|
|
934
1413
|
const cardUser = ref<HidUser | null>(null);
|
|
@@ -1034,7 +1513,11 @@ type UnitPerson = {
|
|
|
1034
1513
|
* the dialog, and `useHidReaderSelection` already set the precedent for where
|
|
1035
1514
|
* that line sits.
|
|
1036
1515
|
*/
|
|
1037
|
-
|
|
1516
|
+
/* The dropdown's values, plus `service_provider` - which the dropdown does not
|
|
1517
|
+
offer but `openEdit` resolves from records that name a COMPANY. */
|
|
1518
|
+
type TEnrolmentSubjectValue = HidEnrolmentSubject | "service_provider";
|
|
1519
|
+
|
|
1520
|
+
const REMEMBERED_SUBJECT_CATEGORY = ref<HidEnrolmentSubject>("resident");
|
|
1038
1521
|
|
|
1039
1522
|
const form = reactive({
|
|
1040
1523
|
reader: "",
|
|
@@ -1048,7 +1531,7 @@ const form = reactive({
|
|
|
1048
1531
|
accessPin: "",
|
|
1049
1532
|
password: "",
|
|
1050
1533
|
photoPreview: "",
|
|
1051
|
-
subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value,
|
|
1534
|
+
subjectCategory: REMEMBERED_SUBJECT_CATEGORY.value as TEnrolmentSubjectValue,
|
|
1052
1535
|
subjectId: "",
|
|
1053
1536
|
/** A mirror of the chosen member's role. Never sent; display only. */
|
|
1054
1537
|
subjectRole: "",
|
|
@@ -1167,9 +1650,137 @@ const unitResidentNote = computed(() => {
|
|
|
1167
1650
|
|
|
1168
1651
|
/* ── THE SUBJECT TYPE ──────────────────────────────────────────────────── */
|
|
1169
1652
|
|
|
1170
|
-
|
|
1653
|
+
/**
|
|
1654
|
+
* ONLY THE TYPES THE TARGETED READER WILL ACCEPT.
|
|
1655
|
+
*
|
|
1656
|
+
* Offering one it refuses is not merely untidy: the server rejects the grant on
|
|
1657
|
+
* SAVE, after a person has been chosen and a face captured - and the capture is
|
|
1658
|
+
* the expensive step at a reader.
|
|
1659
|
+
*
|
|
1660
|
+
* A SET of readers rather than one, so the fan-out enrolment that lands a person
|
|
1661
|
+
* on every matching reader asks the same question without this being rewritten.
|
|
1662
|
+
* Today the set holds whichever reader the screen has selected.
|
|
1663
|
+
*/
|
|
1664
|
+
const enrolmentSubjects = computed(() => availableEnrolmentSubjects(
|
|
1665
|
+
[selectedReader.value as Record<string, unknown> | null],
|
|
1666
|
+
HID_ENROLMENT_SUBJECTS,
|
|
1667
|
+
(option) => permissionCategoryOf(option.value),
|
|
1668
|
+
));
|
|
1669
|
+
const subjectCategoryOptions = computed(() => enrolmentSubjects.value.options);
|
|
1670
|
+
|
|
1671
|
+
/* ── WHICH READERS THIS ENROLMENT LANDS ON ─────────────────────────────────
|
|
1672
|
+
*
|
|
1673
|
+
* Enrolling is no longer "pick a reader". A person belongs on every reader at
|
|
1674
|
+
* the site that serves their account type, so the dialog states where they will
|
|
1675
|
+
* end up and why anything is left out, rather than asking.
|
|
1676
|
+
*
|
|
1677
|
+
* The excluded list is drawn too. An absent reader with no reason reads as a
|
|
1678
|
+
* screen that failed to load; a reader with "does not serve residents" beside it
|
|
1679
|
+
* reads as a setting somebody chose.
|
|
1680
|
+
*/
|
|
1681
|
+
const enrolmentTargets = computed(() => partitionEnrolmentTargets(
|
|
1682
|
+
readers.value as Array<Record<string, unknown>>,
|
|
1683
|
+
permissionCategoryOf(form.subjectCategory),
|
|
1684
|
+
));
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* How each reader is getting on, in the order they are being written.
|
|
1688
|
+
*
|
|
1689
|
+
* Held for the whole save so the list can be read after it finishes: a reader
|
|
1690
|
+
* that failed keeps its row and its Retry, and the person stays enrolled on the
|
|
1691
|
+
* ones that worked. Cleared when the dialog opens, never when a save ends.
|
|
1692
|
+
*/
|
|
1693
|
+
const readerOutcomes = ref<THidReaderOutcome[]>([]);
|
|
1694
|
+
const fanOutRunning = ref(false);
|
|
1695
|
+
const fanOutProgress = computed(() => fanOutProgressText(readerOutcomes.value));
|
|
1696
|
+
const fanOutResult = computed(() =>
|
|
1697
|
+
readerOutcomes.value.length && !fanOutRunning.value
|
|
1698
|
+
? fanOutSummary(readerOutcomes.value)
|
|
1699
|
+
: null
|
|
1700
|
+
);
|
|
1701
|
+
const failedReaders = computed(() =>
|
|
1702
|
+
readerOutcomes.value.filter((outcome) => outcome.step === "failed")
|
|
1703
|
+
);
|
|
1704
|
+
/* Where an edit will land, resolved when the form opens so the dialog can say
|
|
1705
|
+
it BEFORE the operator changes a PIN - "this also changes the gym" is only
|
|
1706
|
+
useful in advance. */
|
|
1707
|
+
const editPlaces = ref<TPersonPlace[]>([]);
|
|
1708
|
+
const editPlacesLoading = ref(false);
|
|
1709
|
+
/* Before a save, the readers that hold this person; during and after it, those
|
|
1710
|
+
same readers with each one's own state. One list either way, so the rows do
|
|
1711
|
+
not move when the save starts. */
|
|
1712
|
+
type TEditRow = {
|
|
1713
|
+
readerId: string;
|
|
1714
|
+
name: string;
|
|
1715
|
+
/* "target" is this list's own state and not a fan-out step: a reader that
|
|
1716
|
+
holds this person but has not been asked to do anything yet. */
|
|
1717
|
+
step: THidReaderStep | "target";
|
|
1718
|
+
message?: string;
|
|
1719
|
+
};
|
|
1720
|
+
const editRows = computed<TEditRow[]>(() => (
|
|
1721
|
+
readerOutcomes.value.length
|
|
1722
|
+
? readerOutcomes.value
|
|
1723
|
+
: editPlaces.value.map((place) => ({
|
|
1724
|
+
readerId: place.readerId,
|
|
1725
|
+
name: place.name,
|
|
1726
|
+
step: "target" as const,
|
|
1727
|
+
}))
|
|
1728
|
+
));
|
|
1729
|
+
/* The edit's own summary: it has to speak about the PIN, which an enrolment's
|
|
1730
|
+
does not - a PIN that reached some readers and not others is worse than one
|
|
1731
|
+
that reached none. */
|
|
1732
|
+
const editResult = computed(() =>
|
|
1733
|
+
readerOutcomes.value.length && !fanOutRunning.value
|
|
1734
|
+
? editFanOutSummary(readerOutcomes.value, Boolean(form.accessPin))
|
|
1735
|
+
: null
|
|
1736
|
+
);
|
|
1737
|
+
|
|
1738
|
+
function setReaderStep(
|
|
1739
|
+
readerId: string,
|
|
1740
|
+
step: THidReaderStep,
|
|
1741
|
+
extra: { message?: string; retryable?: boolean } = {},
|
|
1742
|
+
) {
|
|
1743
|
+
readerOutcomes.value = readerOutcomes.value.map((outcome) => (
|
|
1744
|
+
outcome.readerId === readerId ? { ...outcome, step, ...extra } : outcome
|
|
1745
|
+
));
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/* Why a type is missing, and - when none is left - why there is nothing to pick.
|
|
1749
|
+
An absent option with no explanation reads as a broken screen. */
|
|
1750
|
+
const subjectCategoryNote = computed(() => {
|
|
1751
|
+
if (enrolmentSubjects.value.emptied) {
|
|
1752
|
+
return "This reader has no types switched on. Switch Resident, Member or"
|
|
1753
|
+
+ " Service provider on in the reader's settings before enrolling anybody here.";
|
|
1754
|
+
}
|
|
1755
|
+
if (enrolmentSubjects.value.narrowed) {
|
|
1756
|
+
return "Only the kinds of people this reader serves are listed.";
|
|
1757
|
+
}
|
|
1758
|
+
return "";
|
|
1759
|
+
});
|
|
1171
1760
|
|
|
1172
1761
|
const isResidentSubject = computed(() => form.subjectCategory === "resident");
|
|
1762
|
+
/* Members and provider staff both pick ONE PERSON from a flat list, but from
|
|
1763
|
+
different lists, so they are separate branches rather than "not a resident". */
|
|
1764
|
+
const isMemberSubject = computed(
|
|
1765
|
+
() => form.subjectCategory === "property_management"
|
|
1766
|
+
);
|
|
1767
|
+
const isProviderSubject = computed(
|
|
1768
|
+
() => form.subjectCategory === "service_provider_staff"
|
|
1769
|
+
);
|
|
1770
|
+
/** Either of the two person pickers - what the Role mirror follows. */
|
|
1771
|
+
const isPersonSubject = computed(
|
|
1772
|
+
() => isMemberSubject.value || isProviderSubject.value
|
|
1773
|
+
);
|
|
1774
|
+
|
|
1775
|
+
/* The mirror's label follows the list it mirrors. A member's subtitle is their
|
|
1776
|
+
role at the site; a contractor's is the firm they work for, and calling that
|
|
1777
|
+
"Role" would read as a mistake. */
|
|
1778
|
+
const subjectMirrorLabel = computed(() =>
|
|
1779
|
+
isProviderSubject.value ? "Company" : "Role"
|
|
1780
|
+
);
|
|
1781
|
+
const subjectMirrorPlaceholder = computed(() =>
|
|
1782
|
+
isProviderSubject.value ? "Select a person above" : "Select a member above"
|
|
1783
|
+
);
|
|
1173
1784
|
|
|
1174
1785
|
/* What an EDIT shows in place of the control, since the type cannot change.
|
|
1175
1786
|
Looked up in the util, not in the switch's own options: records carry
|
|
@@ -1363,6 +1974,387 @@ function loadMoreMembers() {
|
|
|
1363
1974
|
return fetchMemberPage(memberPage.value + 1, true);
|
|
1364
1975
|
}
|
|
1365
1976
|
|
|
1977
|
+
/* ── SERVICE PROVIDER STAFF ───────────────────────────────────────────────
|
|
1978
|
+
*
|
|
1979
|
+
* ONE PERSON from a provider company, never the company.
|
|
1980
|
+
*
|
|
1981
|
+
* Read from `GET /api/service-providers/site-members` - the same call behind
|
|
1982
|
+
* Members > Service Providers, with the same `filterProviderMembers` rule - so
|
|
1983
|
+
* the picker lists exactly the people that screen lists. The server scopes it to
|
|
1984
|
+
* the site; these are the contractors who attend THIS site, not every employee
|
|
1985
|
+
* of every firm engaged with the organisation.
|
|
1986
|
+
*
|
|
1987
|
+
* Unpaged, deliberately, unlike the staff picker above: this endpoint returns
|
|
1988
|
+
* the site's people in one response (capped at 1000 by the repository) and has
|
|
1989
|
+
* no per-page cache entry to disagree with. The service filter and the search
|
|
1990
|
+
* box both run over the loaded list, so neither costs a request.
|
|
1991
|
+
*/
|
|
1992
|
+
const providerPeople = ref<Record<string, unknown>[]>([]);
|
|
1993
|
+
const providerCompanyCount = ref(0);
|
|
1994
|
+
const loadingProviders = ref(false);
|
|
1995
|
+
const providersLoaded = ref(false);
|
|
1996
|
+
/**
|
|
1997
|
+
* All services is the default, the same as the Members screen - but it carries a
|
|
1998
|
+
* REAL value here, not "". `AppSelect`'s `hasSingleValue` is false for an empty
|
|
1999
|
+
* string, so an empty-valued option draws the placeholder in placeholder grey and
|
|
2000
|
+
* is never marked as chosen: the filter worked and the field looked blank. See
|
|
2001
|
+
* `serviceFilterOf`.
|
|
2002
|
+
*/
|
|
2003
|
+
const providerService = ref<string>(HID_ALL_SERVICES);
|
|
2004
|
+
|
|
2005
|
+
/** What the filter actually receives: "" for All services. */
|
|
2006
|
+
const providerServiceFilter = computed(() => serviceFilterOf(providerService.value));
|
|
2007
|
+
let providerRequestSeq = 0;
|
|
2008
|
+
|
|
2009
|
+
/** Everyone who can actually be given a door: an app account, and active. */
|
|
2010
|
+
const providerEnrollable = computed(() =>
|
|
2011
|
+
providerEnrollableRows(providerPeople.value)
|
|
2012
|
+
);
|
|
2013
|
+
|
|
2014
|
+
/* Whether the endpoint told us who holds an app account. It only began
|
|
2015
|
+
returning `user` in core 3.130, and this layer can be deployed ahead of it. */
|
|
2016
|
+
const providerAccountsVisible = computed(() =>
|
|
2017
|
+
providerAccountsKnown(providerPeople.value)
|
|
2018
|
+
);
|
|
2019
|
+
|
|
2020
|
+
/** People the operator can see on the Members screen but cannot enrol here. */
|
|
2021
|
+
const hiddenProviderCount = computed(
|
|
2022
|
+
() => providerPeople.value.length - providerEnrollable.value.length
|
|
2023
|
+
);
|
|
2024
|
+
|
|
2025
|
+
const providerServiceOptions = computed(() => [
|
|
2026
|
+
{ title: "All services", value: HID_ALL_SERVICES },
|
|
2027
|
+
// From the ENROLLABLE set, so choosing a service never lands on an empty list.
|
|
2028
|
+
...serviceTypeOptions(providerEnrollable.value as Array<{ type?: string; typeLabel?: string }>),
|
|
2029
|
+
]);
|
|
2030
|
+
|
|
2031
|
+
/** The filtered list - what both the picker and the bulk table draw. */
|
|
2032
|
+
const providerRows = computed(() =>
|
|
2033
|
+
filterProviderMembers(providerEnrollable.value as Array<Record<string, any>>, {
|
|
2034
|
+
type: providerServiceFilter.value,
|
|
2035
|
+
})
|
|
2036
|
+
);
|
|
2037
|
+
|
|
2038
|
+
const providerOptions = computed(() =>
|
|
2039
|
+
providerRows.value.map((row) => {
|
|
2040
|
+
const candidate = providerCandidateOf(row);
|
|
2041
|
+
return {
|
|
2042
|
+
// The firm in the title as well as the mirror: two contractors sharing a
|
|
2043
|
+
// first name is the normal case, and the popover is where they have to be
|
|
2044
|
+
// told apart.
|
|
2045
|
+
title: candidate.subtitle
|
|
2046
|
+
? `${candidate.name} — ${candidate.subtitle}`
|
|
2047
|
+
: candidate.name,
|
|
2048
|
+
value: candidate.subjectId,
|
|
2049
|
+
};
|
|
2050
|
+
})
|
|
2051
|
+
);
|
|
2052
|
+
|
|
2053
|
+
const providerPlaceholder = computed(() => {
|
|
2054
|
+
if (loadingProviders.value) return "Loading people...";
|
|
2055
|
+
if (!providersLoaded.value) return "Select a person";
|
|
2056
|
+
if (providerOptions.value.length) return "Select a person";
|
|
2057
|
+
if (providerServiceFilter.value) return "Nobody in this service can be enrolled yet";
|
|
2058
|
+
if (hiddenProviderCount.value) return "Nobody here can be enrolled yet";
|
|
2059
|
+
return providerCompanyCount.value
|
|
2060
|
+
? "No service provider people at this site"
|
|
2061
|
+
: "No service providers work at this site yet";
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
/* Say why somebody on the Members screen is not in this list, rather than
|
|
2065
|
+
letting a missing colleague read as a broken dialog. */
|
|
2066
|
+
const hiddenProviderNote = computed(() => {
|
|
2067
|
+
if (loadingProviders.value || !hiddenProviderCount.value) return "";
|
|
2068
|
+
const count = hiddenProviderCount.value;
|
|
2069
|
+
const subject = count === 1 ? "1 person" : `${count} people`;
|
|
2070
|
+
const verb = count === 1 ? "is" : "are";
|
|
2071
|
+
const holder = count === 1 ? "that person is" : "they are";
|
|
2072
|
+
const needs = providerAccountsVisible.value
|
|
2073
|
+
? "an active membership with an app account"
|
|
2074
|
+
: "an active membership at this site";
|
|
2075
|
+
return `${subject} from this site's service providers ${verb} not listed:`
|
|
2076
|
+
+ ` HID enrollment needs ${needs}, and ${holder} without one.`;
|
|
2077
|
+
});
|
|
2078
|
+
|
|
2079
|
+
/** Back to "nothing loaded", so the next open starts clean. */
|
|
2080
|
+
function resetProviderCandidates() {
|
|
2081
|
+
providerRequestSeq += 1;
|
|
2082
|
+
providerPeople.value = [];
|
|
2083
|
+
providerCompanyCount.value = 0;
|
|
2084
|
+
providerService.value = HID_ALL_SERVICES;
|
|
2085
|
+
providersLoaded.value = false;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/**
|
|
2089
|
+
* This site's service provider people.
|
|
2090
|
+
*
|
|
2091
|
+
* NEVER THROWS, on the same rule as the staff loader: choosing Service provider
|
|
2092
|
+
* must not break the dialog. The placeholder carries the failure.
|
|
2093
|
+
*/
|
|
2094
|
+
async function loadProviderCandidates() {
|
|
2095
|
+
if (!props.site) {
|
|
2096
|
+
resetProviderCandidates();
|
|
2097
|
+
providersLoaded.value = true;
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
const seq = (providerRequestSeq += 1);
|
|
2101
|
+
loadingProviders.value = true;
|
|
2102
|
+
try {
|
|
2103
|
+
const data = await getSiteMembers(props.site);
|
|
2104
|
+
// A response that arrived after the dialog was reopened belongs to a list
|
|
2105
|
+
// that no longer exists - the same guard the staff pager uses.
|
|
2106
|
+
if (seq !== providerRequestSeq) return;
|
|
2107
|
+
providerPeople.value = Array.isArray(data?.items)
|
|
2108
|
+
? (data.items as unknown as Record<string, unknown>[])
|
|
2109
|
+
: [];
|
|
2110
|
+
providerCompanyCount.value = Array.isArray(data?.companies)
|
|
2111
|
+
? data.companies.length
|
|
2112
|
+
: 0;
|
|
2113
|
+
providersLoaded.value = true;
|
|
2114
|
+
} catch (error: unknown) {
|
|
2115
|
+
if (seq !== providerRequestSeq) return;
|
|
2116
|
+
console.error("Unable to load service provider people for HID enrollment:", error);
|
|
2117
|
+
providerPeople.value = [];
|
|
2118
|
+
providerCompanyCount.value = 0;
|
|
2119
|
+
providersLoaded.value = true;
|
|
2120
|
+
} finally {
|
|
2121
|
+
loadingProviders.value = false;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
/** The chosen person, restated into Name and the Company mirror. */
|
|
2126
|
+
function onProviderChanged() {
|
|
2127
|
+
// Compared on the TRIMMED id, because that is what the option's value carries
|
|
2128
|
+
// (`providerCandidateOf` trims) - matching the raw field would silently find
|
|
2129
|
+
// nobody and blank the name the operator just picked.
|
|
2130
|
+
const row = providerRows.value.find(
|
|
2131
|
+
(item) => String(item._id ?? "").trim() === form.subjectId
|
|
2132
|
+
);
|
|
2133
|
+
const candidate = row ? providerCandidateOf(row) : null;
|
|
2134
|
+
form.name = candidate?.name ?? "";
|
|
2135
|
+
form.subjectRole = candidate?.subtitle ?? "";
|
|
2136
|
+
void checkSubjectEnrollment();
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/* Changing the service can take the chosen person out of the list. Leaving a
|
|
2140
|
+
selection that is no longer on screen is how a save ends up naming somebody
|
|
2141
|
+
the operator cannot see. */
|
|
2142
|
+
function onProviderServiceChanged() {
|
|
2143
|
+
if (!form.subjectId) return;
|
|
2144
|
+
const stillThere = providerRows.value.some(
|
|
2145
|
+
(item) => String(item._id ?? "").trim() === form.subjectId
|
|
2146
|
+
);
|
|
2147
|
+
if (stillThere) return;
|
|
2148
|
+
form.subjectId = "";
|
|
2149
|
+
form.name = "";
|
|
2150
|
+
form.subjectRole = "";
|
|
2151
|
+
residentConflict.value = null;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/* ── ADD USERS IN BULK ────────────────────────────────────────────────────
|
|
2155
|
+
*
|
|
2156
|
+
* ACCESS, NOT CREDENTIALS. This grants a reader group to many people at once and
|
|
2157
|
+
* stops there - no face, no card, no PIN. Each person then adds their own
|
|
2158
|
+
* credential from their app, which attaches to the HID user this grant created
|
|
2159
|
+
* (`getProfileEnrollmentContext` reuses the existing profile identity), so
|
|
2160
|
+
* nobody has to be enrolled twice.
|
|
2161
|
+
*
|
|
2162
|
+
* It writes through `PUT /sites/:siteId/permissions`, the one call that writes
|
|
2163
|
+
* group membership, and the device users are created by the reconcile that call
|
|
2164
|
+
* triggers: `provisionPermissionUser` writes `{ name, registration }` and
|
|
2165
|
+
* nothing else. That is exactly "user record and group, credentials empty".
|
|
2166
|
+
*
|
|
2167
|
+
* THE TRAP, and the reason the saved set is fetched first: that PUT REPLACES
|
|
2168
|
+
* this reader's whole assignment set. Sending only the ticked contractors would
|
|
2169
|
+
* revoke every resident and every member on the door. The payload is always
|
|
2170
|
+
* built from the reader's existing assignments PLUS the selection.
|
|
2171
|
+
*/
|
|
2172
|
+
const bulkDialog = ref(false);
|
|
2173
|
+
const bulkLoading = ref(false);
|
|
2174
|
+
const bulkSaving = ref(false);
|
|
2175
|
+
const bulkSearch = ref("");
|
|
2176
|
+
/** The reader's CURRENT assignments, across every category. The merge base. */
|
|
2177
|
+
const bulkSaved = ref<HidAssignmentState>(new Map());
|
|
2178
|
+
/** Who is being added now. Already-granted people are never in here. */
|
|
2179
|
+
const bulkSelected = ref<Set<string>>(new Set());
|
|
2180
|
+
|
|
2181
|
+
/* NAME THE DOOR. A bulk grant is reader-scoped and the reader was chosen on the
|
|
2182
|
+
screen behind two dialogs, so the confirmation says which one rather than
|
|
2183
|
+
leaving the operator to remember. */
|
|
2184
|
+
const bulkReaderLabel = computed(() => {
|
|
2185
|
+
const reader = selectedReader.value;
|
|
2186
|
+
const name = String(reader?.name ?? "").trim() || "this HID reader";
|
|
2187
|
+
const location = String(reader?.location ?? "").trim();
|
|
2188
|
+
return location ? `${name} · ${location}` : name;
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
/** The same people the picker offers, narrowed by this dialog's own search. */
|
|
2192
|
+
const bulkRows = computed(() => {
|
|
2193
|
+
const rows = filterProviderMembers(
|
|
2194
|
+
providerRows.value as Array<Record<string, any>>,
|
|
2195
|
+
{ search: bulkSearch.value }
|
|
2196
|
+
);
|
|
2197
|
+
return rows.map((row) => {
|
|
2198
|
+
const candidate = providerCandidateOf(row);
|
|
2199
|
+
const id = candidate.subjectId;
|
|
2200
|
+
return {
|
|
2201
|
+
subjectId: id,
|
|
2202
|
+
name: candidate.name,
|
|
2203
|
+
company: String(row.company ?? "").trim(),
|
|
2204
|
+
service: String(row.typeLabel ?? row.type ?? "").trim(),
|
|
2205
|
+
// Already on the door. Ticked and locked rather than hidden: hiding them
|
|
2206
|
+
// would make "select all" look like it had missed people, and unticking
|
|
2207
|
+
// one here would revoke access this dialog is not for revoking.
|
|
2208
|
+
granted: bulkSaved.value.has(id),
|
|
2209
|
+
};
|
|
2210
|
+
});
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
const bulkSelectableRows = computed(() =>
|
|
2214
|
+
bulkRows.value.filter((row) => !row.granted)
|
|
2215
|
+
);
|
|
2216
|
+
const bulkSelectedCount = computed(() => bulkSelected.value.size);
|
|
2217
|
+
const bulkAllSelected = computed(
|
|
2218
|
+
() =>
|
|
2219
|
+
bulkSelectableRows.value.length > 0 &&
|
|
2220
|
+
bulkSelectableRows.value.every((row) => bulkSelected.value.has(row.subjectId))
|
|
2221
|
+
);
|
|
2222
|
+
const bulkGrantedCount = computed(
|
|
2223
|
+
() => bulkRows.value.filter((row) => row.granted).length
|
|
2224
|
+
);
|
|
2225
|
+
|
|
2226
|
+
const bulkEmptyText = computed(() => {
|
|
2227
|
+
if (bulkLoading.value) return "Loading...";
|
|
2228
|
+
if (bulkSearch.value.trim()) return "No one matches this search.";
|
|
2229
|
+
if (providerServiceFilter.value) return "No one in this service can be enrolled yet.";
|
|
2230
|
+
return "No service provider people can be enrolled at this site yet.";
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
function isBulkSelected(subjectId: string) {
|
|
2234
|
+
return bulkSelected.value.has(subjectId);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
function toggleBulkRow(subjectId: string, selected: boolean) {
|
|
2238
|
+
const next = new Set(bulkSelected.value);
|
|
2239
|
+
if (selected) next.add(subjectId);
|
|
2240
|
+
else next.delete(subjectId);
|
|
2241
|
+
bulkSelected.value = next;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/* Select-all acts on the ROWS IN FRONT OF THE OPERATOR - what the service filter
|
|
2245
|
+
and the search have left - never on the whole estate. A tick box cannot
|
|
2246
|
+
quietly grant a door to people who are not on screen. */
|
|
2247
|
+
function toggleBulkAll(selected: boolean) {
|
|
2248
|
+
const next = new Set(bulkSelected.value);
|
|
2249
|
+
for (const row of bulkSelectableRows.value) {
|
|
2250
|
+
if (selected) next.add(row.subjectId);
|
|
2251
|
+
else next.delete(row.subjectId);
|
|
2252
|
+
}
|
|
2253
|
+
bulkSelected.value = next;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* Open the bulk dialog, after reading what the reader already holds.
|
|
2258
|
+
*
|
|
2259
|
+
* The saved set is fetched EVERY time rather than cached: it is the merge base
|
|
2260
|
+
* for a write that replaces the reader's assignments, and a stale one would
|
|
2261
|
+
* revoke whoever was granted in between.
|
|
2262
|
+
*/
|
|
2263
|
+
async function openBulkAdd() {
|
|
2264
|
+
const readerId =
|
|
2265
|
+
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
2266
|
+
if (!readerId) {
|
|
2267
|
+
showToast("Select a HID reader before adding people in bulk.", "error");
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
/* The permissions endpoints REQUIRE an org - `schemaHidPermissionScopeQuery`
|
|
2271
|
+
has it as `required()`, unlike `createIdentity`, which falls back to the
|
|
2272
|
+
site's own. Both pages that mount this component pass one, so this is a
|
|
2273
|
+
guard against a 400 that would read as a server fault. */
|
|
2274
|
+
if (!props.org) {
|
|
2275
|
+
showToast(
|
|
2276
|
+
"This screen cannot grant access in bulk without an organization.",
|
|
2277
|
+
"error"
|
|
2278
|
+
);
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
bulkSearch.value = "";
|
|
2282
|
+
bulkSelected.value = new Set();
|
|
2283
|
+
bulkSaved.value = new Map();
|
|
2284
|
+
bulkDialog.value = true;
|
|
2285
|
+
bulkLoading.value = true;
|
|
2286
|
+
try {
|
|
2287
|
+
if (!providersLoaded.value) await loadProviderCandidates();
|
|
2288
|
+
const response = await getSitePermissions(props.site, props.org, readerId);
|
|
2289
|
+
bulkSaved.value = seedAssignments(response?.data?.assignments ?? []);
|
|
2290
|
+
} catch (error: unknown) {
|
|
2291
|
+
// Closed rather than left open on an empty merge base: saving from here
|
|
2292
|
+
// would send only the ticked people and revoke everybody else.
|
|
2293
|
+
bulkDialog.value = false;
|
|
2294
|
+
showToast(
|
|
2295
|
+
apiReason(error) || "Unable to read this reader's current access.",
|
|
2296
|
+
"error"
|
|
2297
|
+
);
|
|
2298
|
+
} finally {
|
|
2299
|
+
bulkLoading.value = false;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/** Grant the ticked people, keeping everyone the reader already admits. */
|
|
2304
|
+
async function saveBulkAdd() {
|
|
2305
|
+
if (bulkSaving.value || !bulkSelected.value.size) return;
|
|
2306
|
+
const readerId =
|
|
2307
|
+
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
2308
|
+
if (!readerId) {
|
|
2309
|
+
showToast("Select a HID reader before adding people in bulk.", "error");
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
bulkSaving.value = true;
|
|
2314
|
+
try {
|
|
2315
|
+
// Seeded from what the server holds, so every category the reader already
|
|
2316
|
+
// admits survives this write.
|
|
2317
|
+
let draft: HidAssignmentState = new Map(bulkSaved.value);
|
|
2318
|
+
for (const subjectId of bulkSelected.value) {
|
|
2319
|
+
draft = setAssignment(
|
|
2320
|
+
draft,
|
|
2321
|
+
bulkSaved.value,
|
|
2322
|
+
subjectId,
|
|
2323
|
+
// A provider employee's grant is stored under the STAFF category: core's
|
|
2324
|
+
// `permissionSubjectOfIdentity` reads the `member` link and cannot see
|
|
2325
|
+
// this dropdown, and that category's subject list already includes these
|
|
2326
|
+
// people. See `permissionCategoryOf`.
|
|
2327
|
+
permissionCategoryOf("service_provider_staff") as THidPermissionCategory,
|
|
2328
|
+
true
|
|
2329
|
+
);
|
|
2330
|
+
}
|
|
2331
|
+
const added = bulkSelected.value.size;
|
|
2332
|
+
await updateSitePermissions(
|
|
2333
|
+
props.site,
|
|
2334
|
+
props.org,
|
|
2335
|
+
readerId,
|
|
2336
|
+
toAssignmentPayload(draft)
|
|
2337
|
+
);
|
|
2338
|
+
// Both dialogs close and the list is re-read: the reconcile has just created
|
|
2339
|
+
// a HID user for each person, so the roster behind this dialog is stale.
|
|
2340
|
+
bulkDialog.value = false;
|
|
2341
|
+
formDialog.value = false;
|
|
2342
|
+
await loadUsers();
|
|
2343
|
+
showToast(
|
|
2344
|
+
`${added} ${added === 1 ? "person" : "people"} can now use this reader. `
|
|
2345
|
+
+ "They add their own face, QR or PIN from their app.",
|
|
2346
|
+
"success"
|
|
2347
|
+
);
|
|
2348
|
+
} catch (error: unknown) {
|
|
2349
|
+
showToast(
|
|
2350
|
+
apiReason(error) || "Unable to give these people access to this reader.",
|
|
2351
|
+
"error"
|
|
2352
|
+
);
|
|
2353
|
+
} finally {
|
|
2354
|
+
bulkSaving.value = false;
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
|
|
1366
2358
|
/**
|
|
1367
2359
|
* Switching type clears the chosen subject, and nothing else.
|
|
1368
2360
|
*
|
|
@@ -1372,15 +2364,20 @@ function loadMoreMembers() {
|
|
|
1372
2364
|
* choice, so they clear with it.
|
|
1373
2365
|
*/
|
|
1374
2366
|
async function onSubjectCategoryChanged() {
|
|
1375
|
-
REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory;
|
|
2367
|
+
REMEMBERED_SUBJECT_CATEGORY.value = form.subjectCategory as HidEnrolmentSubject;
|
|
1376
2368
|
form.subjectId = "";
|
|
1377
2369
|
form.subjectRole = "";
|
|
1378
2370
|
form.name = "";
|
|
1379
2371
|
residentConflict.value = null;
|
|
1380
2372
|
|
|
1381
|
-
|
|
2373
|
+
// Each list is fetched on first use and then kept, so flipping between the
|
|
2374
|
+
// three options costs one request each rather than one per flip.
|
|
2375
|
+
if (isMemberSubject.value && !membersLoaded.value) {
|
|
1382
2376
|
await loadMemberCandidates();
|
|
1383
2377
|
}
|
|
2378
|
+
if (isProviderSubject.value && !providersLoaded.value) {
|
|
2379
|
+
await loadProviderCandidates();
|
|
2380
|
+
}
|
|
1384
2381
|
}
|
|
1385
2382
|
|
|
1386
2383
|
/* The staff equivalent of `onResidentChanged`. */
|
|
@@ -1576,7 +2573,14 @@ async function init() {
|
|
|
1576
2573
|
await loadUsers();
|
|
1577
2574
|
}
|
|
1578
2575
|
|
|
1579
|
-
|
|
2576
|
+
/**
|
|
2577
|
+
* `resolveSelection` is off when the enrolment dialog refreshes this.
|
|
2578
|
+
*
|
|
2579
|
+
* The tail of this function re-resolves the SHARED reader selection, which is
|
|
2580
|
+
* right on first load and wrong from a dialog: opening Enroll should not be able
|
|
2581
|
+
* to move the reader the operator picked on the screen behind it.
|
|
2582
|
+
*/
|
|
2583
|
+
async function loadReaders(options: { resolveSelection?: boolean } = {}) {
|
|
1580
2584
|
if (!props.site) return;
|
|
1581
2585
|
const response = await getReaders({ site: props.site, page: 1, limit: 100 });
|
|
1582
2586
|
const responseRecord = toRecord(response);
|
|
@@ -1590,7 +2594,9 @@ async function loadReaders() {
|
|
|
1590
2594
|
resolver refuses a remembered reader that is not this site's, so a site
|
|
1591
2595
|
switch or a deleted reader falls back to the first rather than querying
|
|
1592
2596
|
an id the site does not own. */
|
|
1593
|
-
|
|
2597
|
+
if (options.resolveSelection !== false) {
|
|
2598
|
+
selectedReaderId.value = resolveForSite(props.site, readers.value);
|
|
2599
|
+
}
|
|
1594
2600
|
}
|
|
1595
2601
|
|
|
1596
2602
|
/**
|
|
@@ -1726,9 +2732,19 @@ function resetForm() {
|
|
|
1726
2732
|
selectedPhotoFile.value = null;
|
|
1727
2733
|
removePhotoRequested.value = false;
|
|
1728
2734
|
facialEnrolled.value = false;
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
2735
|
+
/*
|
|
2736
|
+
* The type the operator last enrolled, not a hard "resident": somebody adding
|
|
2737
|
+
* a shift of guards should not re-pick Member for each one.
|
|
2738
|
+
*
|
|
2739
|
+
* UNLESS this reader does not serve it. `AppSelect` draws blank when its value
|
|
2740
|
+
* is not among its items - the same way "All services" drew blank when its
|
|
2741
|
+
* value was "" - so the remembered type falls back to the first one on offer.
|
|
2742
|
+
*/
|
|
2743
|
+
const offered = subjectCategoryOptions.value;
|
|
2744
|
+
const remembered = REMEMBERED_SUBJECT_CATEGORY.value;
|
|
2745
|
+
form.subjectCategory = offered.some((option) => option.value === remembered)
|
|
2746
|
+
? remembered
|
|
2747
|
+
: (offered[0]?.value ?? remembered);
|
|
1732
2748
|
form.subjectId = "";
|
|
1733
2749
|
form.subjectRole = "";
|
|
1734
2750
|
residentConflict.value = null;
|
|
@@ -1925,11 +2941,25 @@ async function checkSubjectEnrollment() {
|
|
|
1925
2941
|
|
|
1926
2942
|
async function openEnroll() {
|
|
1927
2943
|
selectedUser.value = null;
|
|
1928
|
-
|
|
2944
|
+
/*
|
|
2945
|
+
* READERS FIRST, and before `resetForm`.
|
|
2946
|
+
*
|
|
2947
|
+
* The audiences decide which types are offered and which one the form opens
|
|
2948
|
+
* on, and they are edited on a different screen - so the copy loaded when this
|
|
2949
|
+
* screen mounted can be stale by the time somebody clicks Enroll. Without this
|
|
2950
|
+
* the dropdown offers a type the reader no longer serves until the page is
|
|
2951
|
+
* reloaded.
|
|
2952
|
+
*
|
|
2953
|
+
* Folded into the same wait as the id allocations so it costs no extra delay,
|
|
2954
|
+
* and with the shared selection left alone: opening Enroll must not move the
|
|
2955
|
+
* reader chosen on the screen behind it.
|
|
2956
|
+
*/
|
|
1929
2957
|
const [hidUserId, registration] = await Promise.all([
|
|
1930
|
-
getNextUid(),
|
|
1931
|
-
getNextRegistration(),
|
|
2958
|
+
getNextUid(selectedReaderId.value),
|
|
2959
|
+
getNextRegistration(selectedReaderId.value),
|
|
2960
|
+
loadReaders({ resolveSelection: false }),
|
|
1932
2961
|
]);
|
|
2962
|
+
resetForm();
|
|
1933
2963
|
form.hidUserId = hidUserId;
|
|
1934
2964
|
form.registration = registration;
|
|
1935
2965
|
formDialog.value = true;
|
|
@@ -1938,8 +2968,11 @@ async function openEnroll() {
|
|
|
1938
2968
|
// onto Member with an empty list. Loaded after the dialog is up, not before:
|
|
1939
2969
|
// the request must not delay it, and the picker carries its own loading
|
|
1940
2970
|
// placeholder. Fresh each opening, because staff change between them.
|
|
2971
|
+
readerOutcomes.value = [];
|
|
1941
2972
|
resetMemberCandidates();
|
|
1942
|
-
|
|
2973
|
+
resetProviderCandidates();
|
|
2974
|
+
if (isMemberSubject.value) await loadMemberCandidates();
|
|
2975
|
+
if (isProviderSubject.value) await loadProviderCandidates();
|
|
1943
2976
|
}
|
|
1944
2977
|
|
|
1945
2978
|
async function openEdit(user: HidUser) {
|
|
@@ -1961,14 +2994,21 @@ async function openEdit(user: HidUser) {
|
|
|
1961
2994
|
removePinRequested.value = false;
|
|
1962
2995
|
form.photoPreview = "";
|
|
1963
2996
|
facialEnrolled.value = hasFacialEnrollment(user);
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2997
|
+
/*
|
|
2998
|
+
* ONE reading of the links, shared with the Access Permissions screen.
|
|
2999
|
+
* `subjectOfIdentity` knows that a `member` link is how BOTH a
|
|
3000
|
+
* property-management member and a service provider's own employee are stored,
|
|
3001
|
+
* and uses the identity's `type` to tell them apart - without which a
|
|
3002
|
+
* contractor's edit would be headed "Member".
|
|
3003
|
+
*
|
|
3004
|
+
* This was the same ternary written out inline. The other copy of it went
|
|
3005
|
+
* stale, so neither is written out any more.
|
|
3006
|
+
*/
|
|
3007
|
+
const editedSubject = subjectOfIdentity(user as Record<string, unknown>);
|
|
3008
|
+
form.subjectCategory = enrolmentSubjectOfCategory(
|
|
3009
|
+
editedSubject?.category
|
|
3010
|
+
) as TEnrolmentSubjectValue;
|
|
3011
|
+
form.subjectId = editedSubject?.subjectId ?? "";
|
|
1972
3012
|
// The resident this user already holds is not a conflict with itself. A real
|
|
1973
3013
|
// one only appears if the operator reassigns the row to somebody else.
|
|
1974
3014
|
residentConflict.value = null;
|
|
@@ -1984,8 +3024,22 @@ async function openEdit(user: HidUser) {
|
|
|
1984
3024
|
if (form.unit) await loadUnitPeople();
|
|
1985
3025
|
selectedPhotoFile.value = null;
|
|
1986
3026
|
removePhotoRequested.value = false;
|
|
3027
|
+
readerOutcomes.value = [];
|
|
3028
|
+
editPlaces.value = [];
|
|
1987
3029
|
formDialog.value = true;
|
|
1988
3030
|
|
|
3031
|
+
// After the dialog is up, like the candidate lists: it is several requests and
|
|
3032
|
+
// the form must not wait on them. A failure leaves the list empty and `saveUser`
|
|
3033
|
+
// resolves it again rather than skipping readers.
|
|
3034
|
+
editPlacesLoading.value = true;
|
|
3035
|
+
try {
|
|
3036
|
+
editPlaces.value = await findPersonReaders(user);
|
|
3037
|
+
} catch (error: unknown) {
|
|
3038
|
+
console.error("Unable to work out which readers hold this person:", error);
|
|
3039
|
+
} finally {
|
|
3040
|
+
editPlacesLoading.value = false;
|
|
3041
|
+
}
|
|
3042
|
+
|
|
1989
3043
|
try {
|
|
1990
3044
|
const hidUserId = toHidNumericId(user.hidUserId);
|
|
1991
3045
|
const [pinStatus, passwordStatus] = await Promise.all([
|
|
@@ -2004,9 +3058,26 @@ function openView(user: HidUser) {
|
|
|
2004
3058
|
viewDialog.value = true;
|
|
2005
3059
|
}
|
|
2006
3060
|
|
|
2007
|
-
function openDelete(user: HidUser) {
|
|
3061
|
+
async function openDelete(user: HidUser) {
|
|
2008
3062
|
selectedUser.value = user;
|
|
3063
|
+
// Always reopened narrow. A scope carried over from the last delete would
|
|
3064
|
+
// widen a delete the operator did not widen.
|
|
3065
|
+
deleteScope.value = "here";
|
|
3066
|
+
deleteOutcomes.value = [];
|
|
3067
|
+
deletePlaces.value = [];
|
|
2009
3068
|
deleteDialog.value = true;
|
|
3069
|
+
|
|
3070
|
+
deletePlacesLoading.value = true;
|
|
3071
|
+
try {
|
|
3072
|
+
deletePlaces.value = await findPersonReaders(user);
|
|
3073
|
+
} catch (error: unknown) {
|
|
3074
|
+
// The reach is what the scope choice is FOR, so without it the dialog
|
|
3075
|
+
// offers only this reader rather than guessing at "all".
|
|
3076
|
+
console.error("Unable to work out which readers hold this person:", error);
|
|
3077
|
+
deletePlaces.value = [];
|
|
3078
|
+
} finally {
|
|
3079
|
+
deletePlacesLoading.value = false;
|
|
3080
|
+
}
|
|
2010
3081
|
}
|
|
2011
3082
|
|
|
2012
3083
|
function triggerPhotoInput() {
|
|
@@ -2081,6 +3152,8 @@ async function saveUser() {
|
|
|
2081
3152
|
: !selectedUser.value && !form.subjectId
|
|
2082
3153
|
? isResidentSubject.value
|
|
2083
3154
|
? "a resident"
|
|
3155
|
+
: isProviderSubject.value
|
|
3156
|
+
? "a person"
|
|
2084
3157
|
: "a member"
|
|
2085
3158
|
: !form.name
|
|
2086
3159
|
? "a name"
|
|
@@ -2122,7 +3195,6 @@ async function saveUser() {
|
|
|
2122
3195
|
}
|
|
2123
3196
|
|
|
2124
3197
|
saving.value = true;
|
|
2125
|
-
let newlyCreatedHidUserId: number | undefined;
|
|
2126
3198
|
try {
|
|
2127
3199
|
const hidUser = toHidUserObject({
|
|
2128
3200
|
hidUserId: form.hidUserId,
|
|
@@ -2139,27 +3211,7 @@ async function saveUser() {
|
|
|
2139
3211
|
// an empty string as a link being removed and then refuses the write for
|
|
2140
3212
|
// having no subject at all. Omitting the keys is what makes it keep the
|
|
2141
3213
|
// subject the record already has.
|
|
2142
|
-
const subjectLink = form.subjectId
|
|
2143
|
-
? {
|
|
2144
|
-
person: form.subjectCategory === "resident" ? form.subjectId : "",
|
|
2145
|
-
member:
|
|
2146
|
-
form.subjectCategory === "property_management"
|
|
2147
|
-
? form.subjectId
|
|
2148
|
-
: "",
|
|
2149
|
-
serviceProvider:
|
|
2150
|
-
form.subjectCategory === "service_provider" ? form.subjectId : "",
|
|
2151
|
-
type: (form.subjectCategory === "resident"
|
|
2152
|
-
? "resident"
|
|
2153
|
-
: form.subjectCategory === "service_provider"
|
|
2154
|
-
? "contractor"
|
|
2155
|
-
: "staff") as
|
|
2156
|
-
| "resident"
|
|
2157
|
-
| "staff"
|
|
2158
|
-
| "contractor"
|
|
2159
|
-
| "visitor"
|
|
2160
|
-
| "unknown",
|
|
2161
|
-
}
|
|
2162
|
-
: {};
|
|
3214
|
+
const subjectLink = subjectLinkOf(form.subjectCategory, form.subjectId);
|
|
2163
3215
|
|
|
2164
3216
|
const payload = {
|
|
2165
3217
|
hidUserId: String(hidUser.id),
|
|
@@ -2187,61 +3239,359 @@ async function saveUser() {
|
|
|
2187
3239
|
};
|
|
2188
3240
|
|
|
2189
3241
|
if (selectedUser.value) {
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
3242
|
+
/*
|
|
3243
|
+
* AN EDIT REACHES EVERY READER THIS PERSON IS ON.
|
|
3244
|
+
*
|
|
3245
|
+
* After a fan-out they are on several, and a change applied to one leaves
|
|
3246
|
+
* the others wrong in a way nothing on screen would show: a PIN changed at
|
|
3247
|
+
* the lobby and not at the gym means the person has two PINs and no way to
|
|
3248
|
+
* know which door wants which.
|
|
3249
|
+
*
|
|
3250
|
+
* Each reader knows them by its OWN HID user id, so the id comes from that
|
|
3251
|
+
* reader's identity rather than from the row on screen.
|
|
3252
|
+
*
|
|
3253
|
+
* REGISTRATION IS NOT FANNED OUT. Each reader allocates its own from its
|
|
3254
|
+
* own numbering, and one number written to all of them would collide with
|
|
3255
|
+
* whatever else holds it there.
|
|
3256
|
+
*/
|
|
3257
|
+
const places = editPlaces.value.length
|
|
3258
|
+
? editPlaces.value
|
|
3259
|
+
: await findPersonReaders(selectedUser.value);
|
|
3260
|
+
readerOutcomes.value = places.map((place) => ({
|
|
3261
|
+
readerId: place.readerId,
|
|
3262
|
+
name: place.name,
|
|
3263
|
+
step: "waiting" as const,
|
|
3264
|
+
}));
|
|
3265
|
+
fanOutRunning.value = true;
|
|
3266
|
+
for (const place of places) {
|
|
3267
|
+
setReaderStep(place.readerId, "working");
|
|
3268
|
+
try {
|
|
3269
|
+
const readerUser = toHidUserObject({
|
|
3270
|
+
hidUserId: place.hidUserId,
|
|
3271
|
+
name: form.name,
|
|
3272
|
+
// This reader's own, left exactly as it is.
|
|
3273
|
+
registration: "",
|
|
3274
|
+
});
|
|
3275
|
+
if (!readerUser.id) throw new Error("This reader has no usable HID user ID for this person.");
|
|
3276
|
+
await updateHidUserOnReader(place.readerId, {
|
|
3277
|
+
...readerUser,
|
|
3278
|
+
registration: "",
|
|
3279
|
+
});
|
|
3280
|
+
const perReader = { ...payload, metadata: { ...payload.metadata } };
|
|
3281
|
+
applyFacialResult(
|
|
3282
|
+
perReader.metadata,
|
|
3283
|
+
await syncFacialImage(place.readerId, readerUser.id),
|
|
3284
|
+
);
|
|
3285
|
+
perReader.metadata.pinEnrolled = await syncAccessPin(place.readerId, readerUser.id);
|
|
3286
|
+
perReader.metadata.passwordSet = await syncPassword(place.readerId, readerUser.id);
|
|
3287
|
+
if (place.identityId) {
|
|
3288
|
+
await updateIdentity(place.identityId, {
|
|
3289
|
+
...perReader,
|
|
3290
|
+
hidUserId: place.hidUserId,
|
|
3291
|
+
// Untouched: see above.
|
|
3292
|
+
registration: undefined as unknown as string,
|
|
3293
|
+
});
|
|
3294
|
+
}
|
|
3295
|
+
setReaderStep(place.readerId, "done");
|
|
3296
|
+
} catch (error: unknown) {
|
|
3297
|
+
setReaderStep(place.readerId, "failed", {
|
|
3298
|
+
message: getHidErrorMessage(error),
|
|
3299
|
+
retryable: false,
|
|
3300
|
+
});
|
|
3301
|
+
console.error(`Unable to update this person on reader ${place.readerId}:`, error);
|
|
3302
|
+
}
|
|
2199
3303
|
}
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
3304
|
+
fanOutRunning.value = false;
|
|
3305
|
+
|
|
3306
|
+
const summary = editFanOutSummary(readerOutcomes.value, Boolean(form.accessPin));
|
|
3307
|
+
await loadUsers();
|
|
3308
|
+
if (summary.tone === "success") formDialog.value = false;
|
|
3309
|
+
showToast(summary.message, summary.tone === "success" ? "success" : "error");
|
|
3310
|
+
return;
|
|
2203
3311
|
} else {
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
3312
|
+
/*
|
|
3313
|
+
* EVERY READER THAT SERVES THIS PERSON, one at a time.
|
|
3314
|
+
*
|
|
3315
|
+
* Sequential rather than parallel on purpose: each reader is an embedded
|
|
3316
|
+
* server, several of them are reached over the same VPN, and the operator
|
|
3317
|
+
* is watching a list that only means anything if the rows settle in order.
|
|
3318
|
+
* It is also what keeps a failure attributable to one reader.
|
|
3319
|
+
*
|
|
3320
|
+
* The loop does not stop at the first failure. A reader being asleep is the
|
|
3321
|
+
* ordinary case, and abandoning the rest would deny somebody three doors
|
|
3322
|
+
* because of a fourth.
|
|
3323
|
+
*/
|
|
3324
|
+
const targets = enrolmentTargets.value.targets;
|
|
3325
|
+
if (!targets.length) {
|
|
3326
|
+
showToast(
|
|
3327
|
+
"No reader at this site serves this kind of person. Switch an account type"
|
|
3328
|
+
+ " on for a reader before enrolling anybody.",
|
|
3329
|
+
"error",
|
|
3330
|
+
);
|
|
3331
|
+
return;
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
readerOutcomes.value = targets.map((target) => ({
|
|
3335
|
+
readerId: target.readerId,
|
|
3336
|
+
name: target.name,
|
|
3337
|
+
step: "waiting" as const,
|
|
3338
|
+
}));
|
|
3339
|
+
fanOutRunning.value = true;
|
|
3340
|
+
for (const target of targets) {
|
|
3341
|
+
setReaderStep(target.readerId, "working");
|
|
3342
|
+
try {
|
|
3343
|
+
await enrolOnReader(target.readerId);
|
|
3344
|
+
setReaderStep(target.readerId, "done");
|
|
3345
|
+
} catch (error: unknown) {
|
|
3346
|
+
setReaderStep(target.readerId, "failed", {
|
|
3347
|
+
message: getHidErrorMessage(error),
|
|
3348
|
+
// Retry needs no photo in hand once one is on file - that is what
|
|
3349
|
+
// the stored image bought. Without it, a retry can only work while
|
|
3350
|
+
// the capture is still in this dialog.
|
|
3351
|
+
retryable: true,
|
|
3352
|
+
});
|
|
3353
|
+
console.error(`Unable to enroll on reader ${target.readerId}:`, error);
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
fanOutRunning.value = false;
|
|
3357
|
+
|
|
3358
|
+
const summary = fanOutSummary(readerOutcomes.value);
|
|
3359
|
+
await loadUsers();
|
|
3360
|
+
/*
|
|
3361
|
+
* THE DIALOG STAYS OPEN WHEN ANYTHING FAILED, because the Retry lives in
|
|
3362
|
+
* it. Closing would leave the operator with a toast naming readers they
|
|
3363
|
+
* can no longer act on.
|
|
3364
|
+
*/
|
|
3365
|
+
if (summary.tone === "success") formDialog.value = false;
|
|
3366
|
+
showToast(
|
|
3367
|
+
summary.message,
|
|
3368
|
+
summary.tone === "success" ? "success" : "error",
|
|
3369
|
+
);
|
|
3370
|
+
return;
|
|
2212
3371
|
}
|
|
2213
3372
|
|
|
2214
3373
|
formDialog.value = false;
|
|
2215
3374
|
await loadUsers();
|
|
2216
|
-
showToast(
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
3375
|
+
showToast("HID user updated on reader.");
|
|
3376
|
+
} catch (error: unknown) {
|
|
3377
|
+
// No rollback here any more: `enrolOnReader` undoes ITS OWN reader, which is
|
|
3378
|
+
// the only place a HID user is created. A second copy of that logic here
|
|
3379
|
+
// could only ever fire for a reader this function no longer touches.
|
|
3380
|
+
console.error("Unable to save HID user:", error);
|
|
3381
|
+
showToast(getHidErrorMessage(error), "error");
|
|
3382
|
+
} finally {
|
|
3383
|
+
saving.value = false;
|
|
3384
|
+
fanOutRunning.value = false;
|
|
3385
|
+
// Discard the captured image on every path. On a part-way failure this is what stops
|
|
3386
|
+
// the photo lingering in the open form after a retry or a cancel.
|
|
3387
|
+
form.photoPreview = "";
|
|
3388
|
+
selectedPhotoFile.value = null;
|
|
3389
|
+
removePhotoRequested.value = false;
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
/**
|
|
3394
|
+
* ENROL THIS PERSON ON ONE READER.
|
|
3395
|
+
*
|
|
3396
|
+
* Lifted out of `saveUser` so the fan-out loop and the per-reader Retry run the
|
|
3397
|
+
* SAME sequence. Two paths that each "enrol on a reader" would drift, and the
|
|
3398
|
+
* one that drifts is the retry - the path nobody exercises until something has
|
|
3399
|
+
* already gone wrong.
|
|
3400
|
+
*
|
|
3401
|
+
* Every reader allocates its own UID and registration number, from the device
|
|
3402
|
+
* that will hold them. Reading one reader's numbering and writing it to another
|
|
3403
|
+
* is how two people end up sharing a UID.
|
|
3404
|
+
*
|
|
3405
|
+
* Rolls back ITS OWN reader only. A reader that failed should not undo the three
|
|
3406
|
+
* that worked - the person genuinely has access on those, and the whole point of
|
|
3407
|
+
* a per-reader result is that a partial enrolment is a real, recoverable state.
|
|
3408
|
+
*/
|
|
3409
|
+
async function enrolOnReader(readerId: string) {
|
|
3410
|
+
const uid = await getNextUid(readerId);
|
|
3411
|
+
const registration = await getNextRegistration(readerId);
|
|
3412
|
+
const hidUser = toHidUserObject({
|
|
3413
|
+
hidUserId: uid,
|
|
3414
|
+
name: form.name,
|
|
3415
|
+
registration,
|
|
3416
|
+
});
|
|
3417
|
+
if (!hidUser.id) {
|
|
3418
|
+
throw new Error("This reader did not return a usable HID user ID.");
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
let created = false;
|
|
3422
|
+
try {
|
|
3423
|
+
created = Boolean(await createHidUserOnReader(readerId, hidUser));
|
|
3424
|
+
const payload = {
|
|
3425
|
+
hidUserId: String(hidUser.id),
|
|
3426
|
+
registration,
|
|
3427
|
+
...subjectLinkOf(form.subjectCategory, form.subjectId),
|
|
3428
|
+
status: "active" as const,
|
|
3429
|
+
metadata: {
|
|
3430
|
+
name: form.name,
|
|
3431
|
+
block: form.block,
|
|
3432
|
+
level: form.level,
|
|
3433
|
+
unit: form.unit,
|
|
3434
|
+
unitLabel: buildUnitLabel(),
|
|
3435
|
+
facialEnrolled: false,
|
|
3436
|
+
facialEnrolledAt: "",
|
|
3437
|
+
facialData: "",
|
|
3438
|
+
imageTimestamp: "",
|
|
3439
|
+
facialScores: {},
|
|
3440
|
+
pinEnrolled: false,
|
|
3441
|
+
passwordSet: false,
|
|
3442
|
+
hidPhysicalCards: [],
|
|
3443
|
+
},
|
|
3444
|
+
};
|
|
3445
|
+
applyFacialResult(payload.metadata, await syncFacialImage(readerId, hidUser.id));
|
|
3446
|
+
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
3447
|
+
payload.metadata.passwordSet = await syncPassword(readerId, hidUser.id);
|
|
3448
|
+
await saveIdentityOnReader(readerId, payload);
|
|
2221
3449
|
} catch (error: unknown) {
|
|
2222
|
-
if (
|
|
3450
|
+
if (created) {
|
|
2223
3451
|
try {
|
|
2224
3452
|
await runObjectOperation(readerId, {
|
|
2225
3453
|
operation: "destroy",
|
|
2226
3454
|
object: "users",
|
|
2227
|
-
where: { users: { id:
|
|
3455
|
+
where: { users: { id: hidUser.id } },
|
|
2228
3456
|
});
|
|
2229
3457
|
} catch (rollbackError) {
|
|
2230
3458
|
console.error(
|
|
2231
|
-
|
|
2232
|
-
rollbackError
|
|
3459
|
+
`Unable to roll back the partially enrolled HID user on reader ${readerId}:`,
|
|
3460
|
+
rollbackError,
|
|
2233
3461
|
);
|
|
2234
3462
|
}
|
|
2235
3463
|
}
|
|
2236
|
-
|
|
3464
|
+
throw error;
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
/**
|
|
3469
|
+
* TRY ONE READER AGAIN.
|
|
3470
|
+
*
|
|
3471
|
+
* Runs the same `enrolOnReader` the fan-out ran, so the retry cannot drift from
|
|
3472
|
+
* the thing it is retrying - and the drift would be invisible until somebody
|
|
3473
|
+
* needed it.
|
|
3474
|
+
*
|
|
3475
|
+
* When the capture is no longer in the dialog - reopened, or a photo discarded
|
|
3476
|
+
* after the first attempt - the face comes from the copy on file instead, which
|
|
3477
|
+
* is the whole reason the image is stored. Without that, retrying a reader that
|
|
3478
|
+
* was asleep meant bringing the person back.
|
|
3479
|
+
*/
|
|
3480
|
+
/**
|
|
3481
|
+
* This person's iService account id, read off a reader that already has them.
|
|
3482
|
+
*
|
|
3483
|
+
* The form holds a SUBJECT - a resident record, a membership - and the account
|
|
3484
|
+
* behind it is resolved server-side, so the only way to learn it on this side is
|
|
3485
|
+
* to read back an identity that has been written. Returns "" when no reader has
|
|
3486
|
+
* one yet, which is also the case where there is no stored photo to push.
|
|
3487
|
+
*/
|
|
3488
|
+
async function enrolledAccountId() {
|
|
3489
|
+
const done = readerOutcomes.value.find((outcome) => outcome.step === "done");
|
|
3490
|
+
if (!done || !form.subjectId) return "";
|
|
3491
|
+
try {
|
|
3492
|
+
const identity = await findResidentIdentity(done.readerId, form.subjectId);
|
|
3493
|
+
return String(identity?.user ?? "");
|
|
3494
|
+
} catch (error: unknown) {
|
|
3495
|
+
console.error("Unable to resolve the account behind this enrolment:", error);
|
|
3496
|
+
return "";
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
/**
|
|
3501
|
+
* EVERY READER THAT HOLDS THIS PERSON.
|
|
3502
|
+
*
|
|
3503
|
+
* Needed by both editing and deleting, because after a fan-out a person is on
|
|
3504
|
+
* several readers and each one knows them by a DIFFERENT HID user id. Acting on
|
|
3505
|
+
* the id from the reader on screen would write to the wrong user on every other
|
|
3506
|
+
* reader - or to nobody, which is worse because it looks like it worked.
|
|
3507
|
+
*
|
|
3508
|
+
* Matched on the ACCOUNT, not the subject record. `listIdentities` checks
|
|
3509
|
+
* `person`, `member`, `serviceProvider` and `user`, and the account is the one
|
|
3510
|
+
* field every row carries however it was written - an enrolment, a bulk grant or
|
|
3511
|
+
* a reconcile. A row written by the reconcile may hold no subject at all.
|
|
3512
|
+
*
|
|
3513
|
+
* Falls back to the reader on screen when there is no account to match: a device
|
|
3514
|
+
* user we have never linked exists on that reader and nowhere else we can prove.
|
|
3515
|
+
*/
|
|
3516
|
+
async function findPersonReaders(user: HidUser): Promise<TPersonPlace[]> {
|
|
3517
|
+
const here = {
|
|
3518
|
+
readerId: selectedReaderId.value,
|
|
3519
|
+
name: selectedReader.value?.name || "this reader",
|
|
3520
|
+
identityId: await findIdentityIdForUser(selectedReaderId.value, user),
|
|
3521
|
+
hidUserId: String(user.hidUserId ?? ""),
|
|
3522
|
+
};
|
|
3523
|
+
|
|
3524
|
+
const current = await findExistingIdentityOnReader(selectedReaderId.value, {
|
|
3525
|
+
hidUserId: String(user.hidUserId ?? ""),
|
|
3526
|
+
registration: String(user.registration ?? ""),
|
|
3527
|
+
cardNo: String(user.cardNo ?? ""),
|
|
3528
|
+
metadata: {},
|
|
3529
|
+
}).catch(() => null);
|
|
3530
|
+
const accountId = String(current?.user ?? "").trim();
|
|
3531
|
+
if (!accountId) return [here];
|
|
3532
|
+
|
|
3533
|
+
const found: TPersonPlace[] = [];
|
|
3534
|
+
for (const reader of readers.value) {
|
|
3535
|
+
const readerId = String(reader._id ?? "");
|
|
3536
|
+
if (!readerId) continue;
|
|
3537
|
+
try {
|
|
3538
|
+
const response = await getIdentities(readerId, {
|
|
3539
|
+
page: 1,
|
|
3540
|
+
// A person should hold one per reader; a few are asked for so a stray
|
|
3541
|
+
// duplicate is seen rather than hidden behind the first row.
|
|
3542
|
+
limit: 5,
|
|
3543
|
+
subject: accountId,
|
|
3544
|
+
});
|
|
3545
|
+
const items = readCandidatePage(response).items.map(toHidUser);
|
|
3546
|
+
for (const identity of items) {
|
|
3547
|
+
if (!identity._id) continue;
|
|
3548
|
+
found.push({
|
|
3549
|
+
readerId,
|
|
3550
|
+
name: String(reader.name ?? "") || "HID Reader",
|
|
3551
|
+
identityId: identity._id,
|
|
3552
|
+
hidUserId: String(identity.hidUserId ?? ""),
|
|
3553
|
+
});
|
|
3554
|
+
}
|
|
3555
|
+
} catch (error: unknown) {
|
|
3556
|
+
// One unreachable reader must not hide the others. It is reported as a
|
|
3557
|
+
// failure when acted on, not silently dropped from the count.
|
|
3558
|
+
console.error(`Unable to check reader ${readerId} for this person:`, error);
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
return found.length ? found : [here];
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
async function retryReader(outcome: THidReaderOutcome) {
|
|
3565
|
+
if (fanOutRunning.value) return;
|
|
3566
|
+
fanOutRunning.value = true;
|
|
3567
|
+
setReaderStep(outcome.readerId, "working", { message: "" });
|
|
3568
|
+
try {
|
|
3569
|
+
await enrolOnReader(outcome.readerId);
|
|
3570
|
+
/*
|
|
3571
|
+
* NO PHOTO IN HAND, so use the one on file.
|
|
3572
|
+
*
|
|
3573
|
+
* `enrolOnReader` sends the capture when the dialog still holds it. Reopened
|
|
3574
|
+
* after a failure, it does not - and that is exactly when the stored image
|
|
3575
|
+
* earns its keep. The account id is read off a reader that already worked,
|
|
3576
|
+
* because it is the server that resolves a subject to an account and the
|
|
3577
|
+
* form only ever knew the subject.
|
|
3578
|
+
*/
|
|
3579
|
+
if (!selectedPhotoFile.value) {
|
|
3580
|
+
const accountId = await enrolledAccountId();
|
|
3581
|
+
if (accountId) await pushStoredFace(outcome.readerId, accountId);
|
|
3582
|
+
}
|
|
3583
|
+
setReaderStep(outcome.readerId, "done", { message: "" });
|
|
3584
|
+
await loadUsers();
|
|
3585
|
+
showToast(`Enrolled on ${outcome.name}.`);
|
|
3586
|
+
} catch (error: unknown) {
|
|
3587
|
+
setReaderStep(outcome.readerId, "failed", {
|
|
3588
|
+
message: getHidErrorMessage(error),
|
|
3589
|
+
retryable: true,
|
|
3590
|
+
});
|
|
3591
|
+
console.error(`Retry failed on reader ${outcome.readerId}:`, error);
|
|
2237
3592
|
showToast(getHidErrorMessage(error), "error");
|
|
2238
3593
|
} finally {
|
|
2239
|
-
|
|
2240
|
-
// Discard the captured image on every path. On a part-way failure this is what stops
|
|
2241
|
-
// the photo lingering in the open form after a retry or a cancel.
|
|
2242
|
-
form.photoPreview = "";
|
|
2243
|
-
selectedPhotoFile.value = null;
|
|
2244
|
-
removePhotoRequested.value = false;
|
|
3594
|
+
fanOutRunning.value = false;
|
|
2245
3595
|
}
|
|
2246
3596
|
}
|
|
2247
3597
|
|
|
@@ -2373,41 +3723,84 @@ async function findIdentityIdForUser(readerId: string, user: HidUser) {
|
|
|
2373
3723
|
}
|
|
2374
3724
|
}
|
|
2375
3725
|
|
|
3726
|
+
/**
|
|
3727
|
+
* Remove this person from ONE reader: the admin role, the PIN, the device user,
|
|
3728
|
+
* then our own row.
|
|
3729
|
+
*
|
|
3730
|
+
* Order matters. The identity id is resolved by the caller before anything is
|
|
3731
|
+
* destroyed, because it is read from our own records and a failure there costs
|
|
3732
|
+
* nothing - whereas a device user destroyed with no row left to clear is the
|
|
3733
|
+
* orphan that `findIdentityIdForUser` exists to prevent.
|
|
3734
|
+
*/
|
|
3735
|
+
async function deleteOnReader(place: TPersonPlace) {
|
|
3736
|
+
const hidUserId = toHidNumericId(place.hidUserId);
|
|
3737
|
+
await syncHidUserRole(place.readerId, hidUserId, false);
|
|
3738
|
+
await deleteUserPin(place.readerId, hidUserId);
|
|
3739
|
+
await deleteHidUserFromReader(place.readerId, place.hidUserId);
|
|
3740
|
+
if (place.identityId) {
|
|
3741
|
+
// Soft-deletes the row AND removes the access grant that let this person
|
|
3742
|
+
// through, which is what stops the next reconcile rebuilding them.
|
|
3743
|
+
await deleteIdentity(place.identityId);
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
|
|
2376
3747
|
async function deleteUser() {
|
|
2377
3748
|
if (!selectedUser.value) return;
|
|
2378
3749
|
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
const identityId = await findIdentityIdForUser(
|
|
3750
|
+
const here: TPersonPlace = {
|
|
3751
|
+
readerId: selectedReaderId.value,
|
|
3752
|
+
name: selectedReader.value?.name || "this reader",
|
|
3753
|
+
identityId: await findIdentityIdForUser(
|
|
2384
3754
|
selectedReaderId.value,
|
|
2385
3755
|
selectedUser.value
|
|
2386
|
-
)
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
);
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
3756
|
+
),
|
|
3757
|
+
hidUserId: String(selectedUser.value.hidUserId ?? ""),
|
|
3758
|
+
};
|
|
3759
|
+
const places = deleteScope.value === "all" && deletePlaces.value.length
|
|
3760
|
+
? deletePlaces.value
|
|
3761
|
+
: [deletePlaces.value.find((p) => p.readerId === here.readerId) ?? here];
|
|
3762
|
+
|
|
3763
|
+
deleting.value = true;
|
|
3764
|
+
deleteOutcomes.value = places.map((place) => ({
|
|
3765
|
+
readerId: place.readerId,
|
|
3766
|
+
name: place.name,
|
|
3767
|
+
step: "waiting" as const,
|
|
3768
|
+
}));
|
|
3769
|
+
try {
|
|
3770
|
+
for (const place of places) {
|
|
3771
|
+
setDeleteStep(place.readerId, "working");
|
|
3772
|
+
try {
|
|
3773
|
+
await deleteOnReader(place);
|
|
3774
|
+
setDeleteStep(place.readerId, "done");
|
|
3775
|
+
} catch (error: unknown) {
|
|
3776
|
+
// One reader refusing must not leave the others untouched - the point of
|
|
3777
|
+
// "all readers" is that the person loses the doors that CAN be reached.
|
|
3778
|
+
setDeleteStep(place.readerId, "failed", {
|
|
3779
|
+
message: getHidErrorMessage(error),
|
|
3780
|
+
});
|
|
3781
|
+
console.error(`Unable to delete this person from reader ${place.readerId}:`, error);
|
|
3782
|
+
}
|
|
2401
3783
|
}
|
|
2402
|
-
deleteDialog.value = false;
|
|
2403
|
-
await loadUsers();
|
|
2404
|
-
showToast("HID user deleted from reader.");
|
|
2405
|
-
} catch (error: unknown) {
|
|
2406
|
-
console.error("Unable to delete HID user:", error);
|
|
2407
|
-
showToast(getHidErrorMessage(error), "error");
|
|
2408
3784
|
} finally {
|
|
2409
3785
|
deleting.value = false;
|
|
2410
3786
|
}
|
|
3787
|
+
|
|
3788
|
+
const summary = deleteFanOutSummary(deleteOutcomes.value);
|
|
3789
|
+
await loadUsers();
|
|
3790
|
+
// A reader still holding this person keeps the dialog open, with its name on
|
|
3791
|
+
// screen, so the operator can see which door is still live and try again.
|
|
3792
|
+
if (summary.tone === "success") deleteDialog.value = false;
|
|
3793
|
+
showToast(summary.message, summary.tone === "success" ? "success" : "error");
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
function setDeleteStep(
|
|
3797
|
+
readerId: string,
|
|
3798
|
+
step: THidReaderStep,
|
|
3799
|
+
extra: { message?: string } = {},
|
|
3800
|
+
) {
|
|
3801
|
+
deleteOutcomes.value = deleteOutcomes.value.map((outcome) => (
|
|
3802
|
+
outcome.readerId === readerId ? { ...outcome, step, ...extra } : outcome
|
|
3803
|
+
));
|
|
2411
3804
|
}
|
|
2412
3805
|
|
|
2413
3806
|
async function saveIdentityOnReader(
|
|
@@ -2523,8 +3916,8 @@ async function updateHidUserOnReader(
|
|
|
2523
3916
|
});
|
|
2524
3917
|
}
|
|
2525
3918
|
|
|
2526
|
-
async function deleteHidUserFromReader(readerId: string,
|
|
2527
|
-
const id = toHidNumericId(
|
|
3919
|
+
async function deleteHidUserFromReader(readerId: string, hidUserId: unknown) {
|
|
3920
|
+
const id = toHidNumericId(hidUserId);
|
|
2528
3921
|
if (!readerId || !id) return;
|
|
2529
3922
|
|
|
2530
3923
|
await runObjectOperation(readerId, {
|
|
@@ -2965,9 +4358,16 @@ function getFacialData(user: HidUser) {
|
|
|
2965
4358
|
);
|
|
2966
4359
|
}
|
|
2967
4360
|
|
|
2968
|
-
async function getNextUid() {
|
|
2969
|
-
const readerIds = await getExistingHidReaderIds();
|
|
2970
|
-
|
|
4361
|
+
async function getNextUid(readerId: string) {
|
|
4362
|
+
const readerIds = await getExistingHidReaderIds(readerId);
|
|
4363
|
+
/*
|
|
4364
|
+
* `users.value` is the ROSTER ON SCREEN, which belongs to the selected reader.
|
|
4365
|
+
* It is still consulted for the one reader the operator is looking at - it
|
|
4366
|
+
* catches a number the device has but has not reported - and it is simply a
|
|
4367
|
+
* second opinion for the others, never the only one: `readerIds` above comes
|
|
4368
|
+
* from the reader being allocated for.
|
|
4369
|
+
*/
|
|
4370
|
+
const maxNumber = (readerId === selectedReaderId.value ? users.value : []).reduce((max, user) => {
|
|
2971
4371
|
const match = String(user.hidUserId ?? "").match(/^UID(\d+)$/i);
|
|
2972
4372
|
return Math.max(
|
|
2973
4373
|
max,
|
|
@@ -2978,39 +4378,48 @@ async function getNextUid() {
|
|
|
2978
4378
|
return `UID${String(nextNumber).padStart(6, "0")}`;
|
|
2979
4379
|
}
|
|
2980
4380
|
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
4381
|
+
/**
|
|
4382
|
+
* The next registration number ON THIS READER.
|
|
4383
|
+
*
|
|
4384
|
+
* Every reader numbers its own users (see `enrolOnReader`), so the list has to
|
|
4385
|
+
* come from the reader being allocated for. This used to read it with no
|
|
4386
|
+
* reader at all, which `getExistingHidReaderUsers` answers with an empty list,
|
|
4387
|
+
* so every enrolment proposed 000001 and the second person on a reader was
|
|
4388
|
+
* refused with "HID Amico identity already exists for this reader"
|
|
4389
|
+
* (2026-10-20, Landscape Management on HID 1 after Pest Management took
|
|
4390
|
+
* 000001). The roster on screen is the second opinion for the selected reader,
|
|
4391
|
+
* exactly as `getNextUid` consults it: it catches a number the device holds
|
|
4392
|
+
* but has not reported.
|
|
4393
|
+
*/
|
|
4394
|
+
async function getNextRegistration(readerId: string) {
|
|
4395
|
+
const existingUsers = await getExistingHidReaderUsers(readerId, ["id", "registration"]);
|
|
4396
|
+
const onScreen = readerId === selectedReaderId.value ? users.value : [];
|
|
4397
|
+
return nextRegistration([
|
|
4398
|
+
...existingUsers.map((user) => user.registration),
|
|
4399
|
+
...onScreen.map((user) => user.registration),
|
|
4400
|
+
]);
|
|
3001
4401
|
}
|
|
3002
4402
|
|
|
3003
|
-
async function getExistingHidReaderIds() {
|
|
3004
|
-
const users = await getExistingHidReaderUsers(["id"]);
|
|
4403
|
+
async function getExistingHidReaderIds(readerId: string) {
|
|
4404
|
+
const users = await getExistingHidReaderUsers(readerId, ["id"]);
|
|
3005
4405
|
return users
|
|
3006
4406
|
.map((user) => toHidNumericId(user.hidUserId) || 0)
|
|
3007
4407
|
.filter((value) => Number.isInteger(value) && value > 0);
|
|
3008
4408
|
}
|
|
3009
4409
|
|
|
3010
|
-
|
|
3011
|
-
|
|
4410
|
+
/**
|
|
4411
|
+
* A reader's own `users` rows.
|
|
4412
|
+
*
|
|
4413
|
+
* `readerId` is a parameter rather than the screen's selection, because every
|
|
4414
|
+
* reader numbers its own users: enrolling one person across three readers means
|
|
4415
|
+
* three different UIDs and three different registration numbers, each allocated
|
|
4416
|
+
* from the device that will hold it. Reading one reader's numbering and writing
|
|
4417
|
+
* it to another is how two people end up sharing a UID.
|
|
4418
|
+
*/
|
|
4419
|
+
async function getExistingHidReaderUsers(readerId: string, fields?: string[]) {
|
|
4420
|
+
if (!readerId) return [];
|
|
3012
4421
|
try {
|
|
3013
|
-
const response = await runObjectOperation(
|
|
4422
|
+
const response = await runObjectOperation(readerId, {
|
|
3014
4423
|
operation: "load",
|
|
3015
4424
|
object: "users",
|
|
3016
4425
|
...(fields ? { fields } : {}),
|
|
@@ -3283,6 +4692,97 @@ watch(selectedReaderId, (value) => {
|
|
|
3283
4692
|
color: var(--err);
|
|
3284
4693
|
}
|
|
3285
4694
|
|
|
4695
|
+
/* The bulk button and the sentence that says what it does. Stacked rather than
|
|
4696
|
+
side by side: the sentence is the part that stops somebody expecting this to
|
|
4697
|
+
enrol faces, so it must not be squeezed into a margin at narrow widths. */
|
|
4698
|
+
/* The reader list, and the same list as a progress strip once a save starts.
|
|
4699
|
+
Rows rather than chips: each one carries a reason or a failure, and those are
|
|
4700
|
+
sentences. */
|
|
4701
|
+
.hid-fanout {
|
|
4702
|
+
display: flex;
|
|
4703
|
+
flex-direction: column;
|
|
4704
|
+
gap: 4px;
|
|
4705
|
+
}
|
|
4706
|
+
|
|
4707
|
+
.hid-fanout__row {
|
|
4708
|
+
display: flex;
|
|
4709
|
+
align-items: flex-start;
|
|
4710
|
+
gap: 8px;
|
|
4711
|
+
padding: 6px 8px;
|
|
4712
|
+
border-radius: 6px;
|
|
4713
|
+
background: var(--surface2, rgba(127, 127, 127, 0.06));
|
|
4714
|
+
}
|
|
4715
|
+
|
|
4716
|
+
.hid-fanout__body {
|
|
4717
|
+
display: flex;
|
|
4718
|
+
flex-direction: column;
|
|
4719
|
+
min-width: 0;
|
|
4720
|
+
}
|
|
4721
|
+
|
|
4722
|
+
.hid-fanout__body strong {
|
|
4723
|
+
font-size: 13px;
|
|
4724
|
+
font-weight: 700;
|
|
4725
|
+
}
|
|
4726
|
+
|
|
4727
|
+
/* Reasons and failures wrap rather than clip: a truncated explanation is worse
|
|
4728
|
+
than none, because it reads as a bug rather than as a setting. */
|
|
4729
|
+
.hid-fanout__body span {
|
|
4730
|
+
color: var(--muted);
|
|
4731
|
+
font-size: 12px;
|
|
4732
|
+
line-height: 1.4;
|
|
4733
|
+
white-space: normal;
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
.hid-fanout__row--excluded {
|
|
4737
|
+
opacity: 0.72;
|
|
4738
|
+
}
|
|
4739
|
+
|
|
4740
|
+
.hid-fanout__row--failed {
|
|
4741
|
+
color: var(--err);
|
|
4742
|
+
}
|
|
4743
|
+
|
|
4744
|
+
.hid-fanout__row--done {
|
|
4745
|
+
color: var(--ok, var(--accent-text));
|
|
4746
|
+
}
|
|
4747
|
+
|
|
4748
|
+
.hid-bulk-cta {
|
|
4749
|
+
display: flex;
|
|
4750
|
+
flex-direction: column;
|
|
4751
|
+
align-items: flex-start;
|
|
4752
|
+
gap: 4px;
|
|
4753
|
+
}
|
|
4754
|
+
|
|
4755
|
+
.hid-bulk-cta .field-hint {
|
|
4756
|
+
margin: 0;
|
|
4757
|
+
}
|
|
4758
|
+
|
|
4759
|
+
/* Checkbox column only as wide as the control, so the three columns that carry
|
|
4760
|
+
meaning keep the width. */
|
|
4761
|
+
.hid-bulk-table__check {
|
|
4762
|
+
width: 44px;
|
|
4763
|
+
padding-right: 0 !important;
|
|
4764
|
+
}
|
|
4765
|
+
|
|
4766
|
+
.hid-bulk-table :deep(th) {
|
|
4767
|
+
font-size: 12px;
|
|
4768
|
+
font-weight: 700;
|
|
4769
|
+
color: var(--muted);
|
|
4770
|
+
white-space: nowrap;
|
|
4771
|
+
}
|
|
4772
|
+
|
|
4773
|
+
.hid-bulk-table :deep(td) {
|
|
4774
|
+
font-size: 13px;
|
|
4775
|
+
}
|
|
4776
|
+
|
|
4777
|
+
/* Why this row cannot be unticked, on the row itself. A disabled checkbox with
|
|
4778
|
+
no reason beside it reads as a broken control. */
|
|
4779
|
+
.hid-bulk-granted {
|
|
4780
|
+
display: block;
|
|
4781
|
+
color: var(--muted);
|
|
4782
|
+
font-size: 11px;
|
|
4783
|
+
font-weight: 600;
|
|
4784
|
+
}
|
|
4785
|
+
|
|
3286
4786
|
.photo-wrap {
|
|
3287
4787
|
display: grid;
|
|
3288
4788
|
place-items: center;
|