@7365admin1/layer-common 4.97.0 → 4.97.1-staging.500
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -4
- package/assets/css/primitives.css +57 -0
- package/assets/css/screens.css +8 -4
- package/components/AccessCardDetailsDialog.vue +59 -6
- package/components/AccessCardPreviewDialog.vue +371 -53
- package/components/AppSelect.vue +143 -4
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +424 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +2239 -276
- package/components/InvitationClientForm.vue +19 -1
- package/components/Nfc/NFCPatrolRouteForm.vue +109 -58
- package/components/Nfc/NFCPatrolRouteMain.vue +3 -18
- package/components/Nfc/PatrolReport/PatrolActivityTable.vue +0 -8
- package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +1 -1
- package/components/ServiceProviderMain.vue +48 -0
- package/components/SiteProviderChange.vue +371 -0
- package/components/TableMain.vue +22 -7
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +267 -5
- package/composables/useAccessManagement.ts +17 -1
- package/composables/useCustomerSite.ts +29 -0
- package/composables/useDocument.ts +7 -0
- package/composables/useFacility.ts +11 -0
- package/composables/useHidAmico.ts +165 -0
- package/composables/useHidNavigation.ts +25 -1
- package/composables/useHidReaderSelection.ts +50 -0
- package/composables/useMember.ts +12 -0
- package/composables/useNFCPatrolReport.ts +36 -32
- package/composables/useNFCPatrolReportFilters.ts +21 -3
- package/composables/useNFCPatrolRoute.ts +10 -3
- package/composables/usePeople.ts +19 -0
- package/composables/useServiceProvider.ts +45 -2
- package/composables/useSiteCategory.ts +45 -0
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/nfc-patrol-report.ts +0 -2
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/types/site.d.ts +7 -1
- package/utils/hid-enrolment-subject.ts +344 -0
- package/utils/hid-permission-assignments.ts +198 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/nfc-patrol-time.ts +99 -0
- package/utils/occupancy-role.ts +109 -0
- package/utils/service-type.ts +30 -0
|
@@ -87,6 +87,8 @@
|
|
|
87
87
|
|
|
88
88
|
<v-list density="compact" min-width="180">
|
|
89
89
|
<v-list-item title="View" @click="openView(item)" />
|
|
90
|
+
<v-list-item title="Access Permissions" @click="openPermissions(item)" />
|
|
91
|
+
<v-list-item title="Identification Methods" @click="openIdentification(item)" />
|
|
90
92
|
<v-list-item title="Sync HID Reader" @click="openSyncConfirm(item)" />
|
|
91
93
|
<v-list-item title="Test Connection" @click="openTestConfirm(item)" />
|
|
92
94
|
<v-list-item title="Edit" @click="openEdit(item)" />
|
|
@@ -144,6 +146,125 @@
|
|
|
144
146
|
</v-card>
|
|
145
147
|
</v-dialog>
|
|
146
148
|
|
|
149
|
+
<!--
|
|
150
|
+
IDENTIFICATION METHODS - what this reader accepts at the door.
|
|
151
|
+
|
|
152
|
+
Per reader, not per site: the device's own `identifier` module is the
|
|
153
|
+
authority, and a site-wide copy could only ever describe one of them.
|
|
154
|
+
|
|
155
|
+
There is deliberately no Bluetooth control. The HID API has no Bluetooth
|
|
156
|
+
identification field; the old site-level toggle of that name referred to
|
|
157
|
+
the phone-as-a-card feature in the mobile app, which is a different thing.
|
|
158
|
+
-->
|
|
159
|
+
<v-dialog v-model="identificationDialog" max-width="430" persistent>
|
|
160
|
+
<v-card rounded="lg">
|
|
161
|
+
<v-card-title class="screen-card-title">Identification Methods</v-card-title>
|
|
162
|
+
|
|
163
|
+
<v-card-text v-if="identificationStage === 'edit'">
|
|
164
|
+
<p class="identification-lead">
|
|
165
|
+
Credentials {{ selectedReader?.name || "this HID Reader" }} will accept at the door.
|
|
166
|
+
</p>
|
|
167
|
+
|
|
168
|
+
<div class="identification-grid">
|
|
169
|
+
<div
|
|
170
|
+
v-for="method in identificationMethodRows"
|
|
171
|
+
:key="method.key"
|
|
172
|
+
class="identification-row"
|
|
173
|
+
>
|
|
174
|
+
<div class="identification-copy">
|
|
175
|
+
<span>{{ method.label }}</span>
|
|
176
|
+
<small v-if="!method.supported">Not supported by this reader</small>
|
|
177
|
+
</div>
|
|
178
|
+
<v-switch
|
|
179
|
+
v-model="identificationForm[method.key]"
|
|
180
|
+
class="app-switch"
|
|
181
|
+
hide-details
|
|
182
|
+
density="compact"
|
|
183
|
+
:disabled="!method.supported || identificationSaving"
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<!--
|
|
189
|
+
One reader setting with two modes, not two switches:
|
|
190
|
+
`pin_identification_enabled` is 1 for PIN and 0 for ID-and-password.
|
|
191
|
+
A reader without the PIN capability can be given neither.
|
|
192
|
+
-->
|
|
193
|
+
<div class="identification-credential">
|
|
194
|
+
<div class="identification-copy">
|
|
195
|
+
<span>Credential entry</span>
|
|
196
|
+
<small v-if="!credentialModeSupported">Not supported by this reader</small>
|
|
197
|
+
</div>
|
|
198
|
+
<v-radio-group
|
|
199
|
+
v-model="identificationForm.credentialMode"
|
|
200
|
+
hide-details
|
|
201
|
+
density="compact"
|
|
202
|
+
:disabled="!credentialModeSupported || identificationSaving"
|
|
203
|
+
>
|
|
204
|
+
<v-radio label="PIN" value="pin" />
|
|
205
|
+
<v-radio label="ID and password" value="idPassword" />
|
|
206
|
+
</v-radio-group>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<p class="identification-applied">
|
|
210
|
+
Confirmed on the reader:
|
|
211
|
+
<strong>{{ formatDate(selectedReader?.identificationMethodsAppliedAt) }}</strong>
|
|
212
|
+
</p>
|
|
213
|
+
|
|
214
|
+
<!--
|
|
215
|
+
Its own action, not a save. A reader's settings can change without
|
|
216
|
+
us — a firmware update, its own touchscreen, a restore, a reset —
|
|
217
|
+
and saving only writes what is on screen. This reads the device
|
|
218
|
+
first and reports what had moved.
|
|
219
|
+
-->
|
|
220
|
+
<div class="dialog-actions-inline">
|
|
221
|
+
<AppButton
|
|
222
|
+
variant="ghost"
|
|
223
|
+
icon="mdi-sync"
|
|
224
|
+
:disabled="identificationReconciling || identificationSaving || !selectedReader?.identificationMethods"
|
|
225
|
+
@click="reapplyIdentificationMethods"
|
|
226
|
+
>
|
|
227
|
+
{{ identificationReconciling ? "Checking reader..." : "Re-apply settings" }}
|
|
228
|
+
</AppButton>
|
|
229
|
+
</div>
|
|
230
|
+
</v-card-text>
|
|
231
|
+
|
|
232
|
+
<v-card-text v-else class="reader-action-content">
|
|
233
|
+
<v-icon icon="mdi-shield-key-outline" class="dialog-mark" size="48" />
|
|
234
|
+
<h3>Change what opens this door?</h3>
|
|
235
|
+
<p>
|
|
236
|
+
You are about to change the credentials {{ selectedReader?.name || "this HID Reader" }}
|
|
237
|
+
accepts.
|
|
238
|
+
</p>
|
|
239
|
+
<p v-if="identificationBeingDisabled.length" class="identification-warning">
|
|
240
|
+
Turning off: <strong>{{ identificationBeingDisabled.join(", ") }}</strong>.
|
|
241
|
+
Anyone relying on that credential will stop getting in at this reader.
|
|
242
|
+
</p>
|
|
243
|
+
</v-card-text>
|
|
244
|
+
|
|
245
|
+
<v-card-actions>
|
|
246
|
+
<v-spacer />
|
|
247
|
+
<AppButton
|
|
248
|
+
variant="ghost"
|
|
249
|
+
:disabled="identificationSaving || identificationReconciling"
|
|
250
|
+
@click="identificationStage === 'confirm' ? (identificationStage = 'edit') : (identificationDialog = false)"
|
|
251
|
+
>
|
|
252
|
+
{{ identificationStage === "confirm" ? "Back" : "Cancel" }}
|
|
253
|
+
</AppButton>
|
|
254
|
+
<AppButton
|
|
255
|
+
v-if="identificationStage === 'edit'"
|
|
256
|
+
:disabled="!identificationChanged || identificationReconciling"
|
|
257
|
+
@click="identificationStage = 'confirm'"
|
|
258
|
+
>
|
|
259
|
+
Save
|
|
260
|
+
</AppButton>
|
|
261
|
+
<AppButton v-else :disabled="identificationSaving" @click="saveIdentificationMethods">
|
|
262
|
+
{{ identificationSaving ? "Applying..." : "Apply to reader" }}
|
|
263
|
+
</AppButton>
|
|
264
|
+
</v-card-actions>
|
|
265
|
+
</v-card>
|
|
266
|
+
</v-dialog>
|
|
267
|
+
|
|
147
268
|
<v-dialog v-model="confirmDialog" max-width="390" persistent>
|
|
148
269
|
<v-card rounded="lg" class="reader-action-dialog">
|
|
149
270
|
<v-card-title class="screen-card-title">{{ confirmTitle }}</v-card-title>
|
|
@@ -241,6 +362,18 @@
|
|
|
241
362
|
</v-card-actions>
|
|
242
363
|
</v-card>
|
|
243
364
|
</v-dialog>
|
|
365
|
+
|
|
366
|
+
<!-- Who may actually pass this reader. Enrolment makes a person
|
|
367
|
+
RECOGNISABLE; this is what puts them in the group the access rule is
|
|
368
|
+
attached to, and without it a recognised person is refused at the
|
|
369
|
+
door. Reloads the list on save so the reader's own counts stay true. -->
|
|
370
|
+
<HidAccessPermissions
|
|
371
|
+
v-model="permissionsDialog"
|
|
372
|
+
:site="props.site"
|
|
373
|
+
:org="props.org"
|
|
374
|
+
:reader="selectedReader"
|
|
375
|
+
@saved="loadReaders"
|
|
376
|
+
/>
|
|
244
377
|
</section>
|
|
245
378
|
</template>
|
|
246
379
|
|
|
@@ -284,6 +417,21 @@ type HidRecord = {
|
|
|
284
417
|
cardReadingMode?: "unconfigured" | "pacs" | "csn" | "csn_for_mifare";
|
|
285
418
|
csnByteOrder?: "msb" | "lsb";
|
|
286
419
|
pacsFormat?: "raw" | "wiegand";
|
|
420
|
+
capabilities?: {
|
|
421
|
+
card?: boolean;
|
|
422
|
+
facial?: boolean;
|
|
423
|
+
qr?: boolean;
|
|
424
|
+
pin?: boolean;
|
|
425
|
+
intercom?: boolean;
|
|
426
|
+
};
|
|
427
|
+
identificationMethods?: {
|
|
428
|
+
facial?: boolean;
|
|
429
|
+
card?: boolean;
|
|
430
|
+
qrCode?: boolean;
|
|
431
|
+
credentialMode?: "pin" | "idPassword";
|
|
432
|
+
};
|
|
433
|
+
/** When the READER confirmed the methods, not when they were saved here. */
|
|
434
|
+
identificationMethodsAppliedAt?: string;
|
|
287
435
|
};
|
|
288
436
|
|
|
289
437
|
type HidReaderFormPayload = {
|
|
@@ -322,6 +470,13 @@ const props = defineProps({
|
|
|
322
470
|
type: String,
|
|
323
471
|
required: true,
|
|
324
472
|
},
|
|
473
|
+
// The permission endpoints are org-scoped (`assertOrganizationSiteAccess`),
|
|
474
|
+
// and this component previously had no reason to know the org. Passed from
|
|
475
|
+
// the page, as `HidQrCodeConfiguration` already does.
|
|
476
|
+
org: {
|
|
477
|
+
type: String,
|
|
478
|
+
required: true,
|
|
479
|
+
},
|
|
325
480
|
});
|
|
326
481
|
|
|
327
482
|
const {
|
|
@@ -333,6 +488,8 @@ const {
|
|
|
333
488
|
configureReaderIntegration,
|
|
334
489
|
setReaderMonitor,
|
|
335
490
|
setReaderOperatingMode,
|
|
491
|
+
setReaderIdentificationMethods,
|
|
492
|
+
reconcileReaderIdentificationMethods,
|
|
336
493
|
getReaderUsers,
|
|
337
494
|
} = useHidAmico();
|
|
338
495
|
|
|
@@ -347,6 +504,20 @@ const viewDialog = ref(false);
|
|
|
347
504
|
const confirmDialog = ref(false);
|
|
348
505
|
const resultDialog = ref(false);
|
|
349
506
|
const selectedReader = ref<HidRecord | null>(null);
|
|
507
|
+
const permissionsDialog = ref(false);
|
|
508
|
+
const identificationDialog = ref(false);
|
|
509
|
+
/* Two stages in one dialog, like the sync flow: the controls, then the
|
|
510
|
+
confirmation. Switching a credential off decides what stops opening a door,
|
|
511
|
+
so it is never one click away. */
|
|
512
|
+
const identificationStage = ref<"edit" | "confirm">("edit");
|
|
513
|
+
const identificationSaving = ref(false);
|
|
514
|
+
const identificationReconciling = ref(false);
|
|
515
|
+
const identificationForm = ref<{
|
|
516
|
+
facial: boolean;
|
|
517
|
+
card: boolean;
|
|
518
|
+
qrCode: boolean;
|
|
519
|
+
credentialMode: "pin" | "idPassword";
|
|
520
|
+
}>({ facial: false, card: false, qrCode: false, credentialMode: "idPassword" });
|
|
350
521
|
const pendingAction = ref<"test" | "sync" | "delete" | null>(null);
|
|
351
522
|
const actionStage = ref<"confirm" | "review" | "loading">("confirm");
|
|
352
523
|
const resultSuccess = ref(true);
|
|
@@ -484,6 +655,182 @@ function openView(reader: HidRecord) {
|
|
|
484
655
|
viewDialog.value = true;
|
|
485
656
|
}
|
|
486
657
|
|
|
658
|
+
function openPermissions(reader: HidRecord) {
|
|
659
|
+
selectedReader.value = reader;
|
|
660
|
+
permissionsDialog.value = true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/*
|
|
664
|
+
* The reader record is the source of truth, including for what is already on.
|
|
665
|
+
* A missing `identificationMethods` means nothing has been applied through this
|
|
666
|
+
* screen yet; the switches start off rather than guessing at the device.
|
|
667
|
+
*/
|
|
668
|
+
function openIdentification(reader: HidRecord) {
|
|
669
|
+
selectedReader.value = reader;
|
|
670
|
+
const current = reader.identificationMethods || {};
|
|
671
|
+
identificationForm.value = {
|
|
672
|
+
facial: current.facial === true,
|
|
673
|
+
card: current.card === true,
|
|
674
|
+
qrCode: current.qrCode === true,
|
|
675
|
+
credentialMode: current.credentialMode === "pin" ? "pin" : "idPassword",
|
|
676
|
+
};
|
|
677
|
+
identificationStage.value = "edit";
|
|
678
|
+
identificationDialog.value = true;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/*
|
|
682
|
+
* A reader saved before capabilities were recorded has none. Those are treated
|
|
683
|
+
* as capable rather than having every control disabled on an older record —
|
|
684
|
+
* the backend gate makes the same allowance, and it is the one that decides.
|
|
685
|
+
*/
|
|
686
|
+
function readerSupports(key: "facial" | "card" | "qr" | "pin") {
|
|
687
|
+
const capabilities = selectedReader.value?.capabilities;
|
|
688
|
+
if (!capabilities) return true;
|
|
689
|
+
return capabilities[key] === true;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const identificationMethodRows = computed(() => [
|
|
693
|
+
{ key: "facial" as const, label: "Facial", supported: readerSupports("facial") },
|
|
694
|
+
{ key: "card" as const, label: "Card", supported: readerSupports("card") },
|
|
695
|
+
{ key: "qrCode" as const, label: "QR code", supported: readerSupports("qr") },
|
|
696
|
+
]);
|
|
697
|
+
|
|
698
|
+
/* Both modes ride the reader's single PIN switch, so the pair stands or falls
|
|
699
|
+
together on that one capability. */
|
|
700
|
+
const credentialModeSupported = computed(() => readerSupports("pin"));
|
|
701
|
+
|
|
702
|
+
const identificationChanged = computed(() => {
|
|
703
|
+
const current = selectedReader.value?.identificationMethods || {};
|
|
704
|
+
const form = identificationForm.value;
|
|
705
|
+
return (
|
|
706
|
+
form.facial !== (current.facial === true)
|
|
707
|
+
|| form.card !== (current.card === true)
|
|
708
|
+
|| form.qrCode !== (current.qrCode === true)
|
|
709
|
+
|| form.credentialMode !== (current.credentialMode === "pin" ? "pin" : "idPassword")
|
|
710
|
+
);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
/* Named in the confirmation, because switching a credential OFF is the change
|
|
714
|
+
that locks somebody out. */
|
|
715
|
+
const identificationBeingDisabled = computed(() => {
|
|
716
|
+
const current = selectedReader.value?.identificationMethods || {};
|
|
717
|
+
const form = identificationForm.value;
|
|
718
|
+
const disabled: string[] = [];
|
|
719
|
+
if (current.facial === true && !form.facial) disabled.push("Facial");
|
|
720
|
+
if (current.card === true && !form.card) disabled.push("Card");
|
|
721
|
+
if (current.qrCode === true && !form.qrCode) disabled.push("QR code");
|
|
722
|
+
if (current.credentialMode === "pin" && form.credentialMode !== "pin") disabled.push("PIN");
|
|
723
|
+
return disabled;
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
/*
|
|
727
|
+
* Reload after BOTH outcomes.
|
|
728
|
+
*
|
|
729
|
+
* The confirmed time and the stored methods must come from the server, which
|
|
730
|
+
* only writes them once the reader has echoed the values back. Assuming them
|
|
731
|
+
* here would let the screen claim a door accepts something it refused. On
|
|
732
|
+
* failure the reload matters just as much: the API names the credential the
|
|
733
|
+
* reader would not take, and the controls must go back to what the device
|
|
734
|
+
* actually holds rather than keeping the rejected choice on screen.
|
|
735
|
+
*/
|
|
736
|
+
/*
|
|
737
|
+
* What the device is actually holding, put back to what we hold for it.
|
|
738
|
+
*
|
|
739
|
+
* Worth a button of its own because drift has causes nobody here controls — a
|
|
740
|
+
* firmware update, somebody using the reader's own touchscreen, a restore, a
|
|
741
|
+
* reset. Saving the dialog only writes what is on screen; this reads the
|
|
742
|
+
* device first and reports what had moved.
|
|
743
|
+
*/
|
|
744
|
+
async function reapplyIdentificationMethods() {
|
|
745
|
+
const readerId = String(selectedReader.value?._id || "");
|
|
746
|
+
if (!readerId) return;
|
|
747
|
+
|
|
748
|
+
const readerName = String(selectedReader.value?.name || "HID Reader");
|
|
749
|
+
identificationReconciling.value = true;
|
|
750
|
+
try {
|
|
751
|
+
const response = await reconcileReaderIdentificationMethods(readerId);
|
|
752
|
+
const result = (response?.data || response || {}) as HidRecord;
|
|
753
|
+
identificationDialog.value = false;
|
|
754
|
+
await loadReaders();
|
|
755
|
+
selectedReader.value = readers.value.find((reader) => String(reader._id) === readerId) || null;
|
|
756
|
+
|
|
757
|
+
const status = String(result.status || "");
|
|
758
|
+
const drifted = Array.isArray(result.drifted) ? result.drifted.map(String) : [];
|
|
759
|
+
if (status === "drift_corrected") {
|
|
760
|
+
showResult(
|
|
761
|
+
true,
|
|
762
|
+
"Settings Re-applied",
|
|
763
|
+
`${readerName} had drifted and has been put back. Corrected: ${drifted.map(identificationFieldLabel).join(", ")}.`,
|
|
764
|
+
{ readerName },
|
|
765
|
+
);
|
|
766
|
+
} else if (status === "in_sync") {
|
|
767
|
+
showResult(true, "Settings Already Correct", `${readerName} matches what iService365 holds for it.`, { readerName });
|
|
768
|
+
} else if (status === "skipped" && result.reason === "duplicate_reader") {
|
|
769
|
+
// Not a failure to retry: the device is registered twice, and until a
|
|
770
|
+
// person removes one record both would fight over its configuration.
|
|
771
|
+
showResult(
|
|
772
|
+
false,
|
|
773
|
+
"Duplicate HID Reader",
|
|
774
|
+
`${readerName} shares a device with another registered reader. Remove or deactivate one of them before re-applying settings.`,
|
|
775
|
+
{ readerName },
|
|
776
|
+
);
|
|
777
|
+
} else if (status === "skipped") {
|
|
778
|
+
showResult(
|
|
779
|
+
false,
|
|
780
|
+
"Nothing to Re-apply",
|
|
781
|
+
`No identification methods have been set for ${readerName} yet.`,
|
|
782
|
+
{ readerName },
|
|
783
|
+
);
|
|
784
|
+
} else {
|
|
785
|
+
showResult(false, "Could Not Re-apply Settings", String(result.message || "The HID reader could not be reached."), { readerName });
|
|
786
|
+
}
|
|
787
|
+
} catch (error: unknown) {
|
|
788
|
+
console.error("Unable to reconcile HID identification methods:", error);
|
|
789
|
+
identificationDialog.value = false;
|
|
790
|
+
await loadReaders();
|
|
791
|
+
showResult(false, "Could Not Re-apply Settings", getFriendlyHidError(error), { readerName });
|
|
792
|
+
} finally {
|
|
793
|
+
identificationReconciling.value = false;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** The reader's own field names, as the person in front of the door knows them. */
|
|
798
|
+
function identificationFieldLabel(field: string) {
|
|
799
|
+
const labels: Record<string, string> = {
|
|
800
|
+
face_identification_enabled: "Facial",
|
|
801
|
+
card_identification_enabled: "Card",
|
|
802
|
+
qrcode_identification_enabled: "QR code",
|
|
803
|
+
pin_identification_enabled: "Credential entry",
|
|
804
|
+
multi_factor_authentication: "Multi-factor",
|
|
805
|
+
};
|
|
806
|
+
return labels[field] || field;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function saveIdentificationMethods() {
|
|
810
|
+
const readerId = String(selectedReader.value?._id || "");
|
|
811
|
+
if (!readerId) return;
|
|
812
|
+
|
|
813
|
+
const readerName = String(selectedReader.value?.name || "HID Reader");
|
|
814
|
+
identificationSaving.value = true;
|
|
815
|
+
try {
|
|
816
|
+
await setReaderIdentificationMethods(readerId, { ...identificationForm.value });
|
|
817
|
+
identificationDialog.value = false;
|
|
818
|
+
await loadReaders();
|
|
819
|
+
selectedReader.value = readers.value.find((reader) => String(reader._id) === readerId) || null;
|
|
820
|
+
showResult(true, "Identification Methods Updated", `${readerName} now accepts the credentials you selected.`, {
|
|
821
|
+
readerName,
|
|
822
|
+
});
|
|
823
|
+
} catch (error: unknown) {
|
|
824
|
+
console.error("Unable to set HID identification methods:", error);
|
|
825
|
+
identificationDialog.value = false;
|
|
826
|
+
await loadReaders();
|
|
827
|
+
showResult(false, "Identification Methods Not Applied", getFriendlyHidError(error), { readerName });
|
|
828
|
+
} finally {
|
|
829
|
+
identificationSaving.value = false;
|
|
830
|
+
identificationStage.value = "edit";
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
487
834
|
function openTestConfirm(reader?: HidRecord | null) {
|
|
488
835
|
if (reader) selectedReader.value = reader;
|
|
489
836
|
pendingAction.value = "test";
|
|
@@ -919,6 +1266,57 @@ function formatCallback(reader?: HidRecord | null) {
|
|
|
919
1266
|
margin-top: 20px;
|
|
920
1267
|
}
|
|
921
1268
|
|
|
1269
|
+
.identification-lead {
|
|
1270
|
+
margin: 0 0 14px;
|
|
1271
|
+
color: var(--muted);
|
|
1272
|
+
font-size: 13px;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
.identification-grid {
|
|
1276
|
+
display: grid;
|
|
1277
|
+
gap: 4px;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
.identification-row,
|
|
1281
|
+
.identification-credential {
|
|
1282
|
+
display: flex;
|
|
1283
|
+
align-items: center;
|
|
1284
|
+
justify-content: space-between;
|
|
1285
|
+
gap: 16px;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
.identification-credential {
|
|
1289
|
+
align-items: flex-start;
|
|
1290
|
+
margin-top: 12px;
|
|
1291
|
+
padding-top: 12px;
|
|
1292
|
+
border-top: 1px solid var(--border);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
.identification-copy {
|
|
1296
|
+
display: grid;
|
|
1297
|
+
gap: 2px;
|
|
1298
|
+
font-size: 13px;
|
|
1299
|
+
color: var(--text);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/* The reason a control is dead, next to the control rather than in a tooltip
|
|
1303
|
+
nobody opens. */
|
|
1304
|
+
.identification-copy small {
|
|
1305
|
+
color: var(--muted);
|
|
1306
|
+
font-size: 11px;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
.identification-applied {
|
|
1310
|
+
margin: 16px 0 0;
|
|
1311
|
+
color: var(--muted);
|
|
1312
|
+
font-size: 12px;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
.identification-warning {
|
|
1316
|
+
color: var(--text);
|
|
1317
|
+
font-size: 13px;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
922
1320
|
.reader-action-dialog {
|
|
923
1321
|
overflow: hidden;
|
|
924
1322
|
}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
v-model="search"
|
|
28
28
|
search
|
|
29
29
|
placeholder="Search Amico user, UID or registration"
|
|
30
|
-
@keyup.enter="
|
|
30
|
+
@keyup.enter="onSearchSubmit"
|
|
31
31
|
/>
|
|
32
32
|
<AppSelect
|
|
33
33
|
v-model="userType"
|
|
@@ -208,6 +208,7 @@ const {
|
|
|
208
208
|
} = useHidAmico();
|
|
209
209
|
const readers = ref<HidReader[]>([]);
|
|
210
210
|
const users = ref<HidReaderUser[]>([]);
|
|
211
|
+
const { selectHidReader, resolveForSite } = useHidReaderSelection();
|
|
211
212
|
const selectedReaderId = ref("");
|
|
212
213
|
const search = ref("");
|
|
213
214
|
const userType = ref<"all" | "user" | "visitor">("all");
|
|
@@ -270,7 +271,11 @@ async function loadReaders() {
|
|
|
270
271
|
readers.value = (Array.isArray(items) ? items : [])
|
|
271
272
|
.map(toReader)
|
|
272
273
|
.filter((reader): reader is HidReader => reader !== null);
|
|
273
|
-
|
|
274
|
+
/* Shared with every other HID screen — see `useHidReaderSelection`. The
|
|
275
|
+
resolver refuses a remembered reader that is not this site's, so a site
|
|
276
|
+
switch or a deleted reader falls back to the first rather than querying
|
|
277
|
+
an id the site does not own. */
|
|
278
|
+
selectedReaderId.value = resolveForSite(props.site, readers.value);
|
|
274
279
|
} catch (error: unknown) {
|
|
275
280
|
readers.value = [];
|
|
276
281
|
showToast(getErrorMessage(error), "error");
|
|
@@ -284,6 +289,8 @@ async function loadUsers() {
|
|
|
284
289
|
return;
|
|
285
290
|
}
|
|
286
291
|
|
|
292
|
+
const sequence = loadUsersSequence.value + 1;
|
|
293
|
+
loadUsersSequence.value = sequence;
|
|
287
294
|
loading.value = true;
|
|
288
295
|
try {
|
|
289
296
|
let clientFiltersUserType = false;
|
|
@@ -319,6 +326,7 @@ async function loadUsers() {
|
|
|
319
326
|
const pageItems = clientFiltersUserType
|
|
320
327
|
? matchingItems.slice(pageStart, pageStart + 10)
|
|
321
328
|
: matchingItems;
|
|
329
|
+
if (sequence !== loadUsersSequence.value) return;
|
|
322
330
|
users.value = pageItems.map(toReaderUser);
|
|
323
331
|
total.value = clientFiltersUserType
|
|
324
332
|
? matchingItems.length
|
|
@@ -330,13 +338,15 @@ async function loadUsers() {
|
|
|
330
338
|
? ""
|
|
331
339
|
: toText(response.pageRange ?? data.pageRange);
|
|
332
340
|
} catch (error: unknown) {
|
|
341
|
+
if (sequence !== loadUsersSequence.value) return;
|
|
333
342
|
users.value = [];
|
|
334
343
|
total.value = 0;
|
|
335
344
|
pages.value = 1;
|
|
336
345
|
serverPageRange.value = "";
|
|
337
346
|
showToast(getErrorMessage(error), "error");
|
|
338
347
|
} finally {
|
|
339
|
-
|
|
348
|
+
// Only the newest load owns the spinner.
|
|
349
|
+
if (sequence === loadUsersSequence.value) loading.value = false;
|
|
340
350
|
}
|
|
341
351
|
}
|
|
342
352
|
|
|
@@ -350,6 +360,31 @@ function reloadFromFirstPage() {
|
|
|
350
360
|
loadUsers();
|
|
351
361
|
}
|
|
352
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Typing is the search. It used to reload on `@keyup.enter` and on nothing
|
|
365
|
+
* else, so unless you guessed Enter was required the screen sent no request at
|
|
366
|
+
* all. Debounced, because each reload is a login/load/logout round trip to a
|
|
367
|
+
* reader over a VPN; Enter still works and skips the wait.
|
|
368
|
+
*/
|
|
369
|
+
const SEARCH_DEBOUNCE_MS = 400;
|
|
370
|
+
let searchDebounce: ReturnType<typeof setTimeout> | undefined;
|
|
371
|
+
|
|
372
|
+
function onSearchInput() {
|
|
373
|
+
clearTimeout(searchDebounce);
|
|
374
|
+
searchDebounce = setTimeout(reloadFromFirstPage, SEARCH_DEBOUNCE_MS);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function onSearchSubmit() {
|
|
378
|
+
clearTimeout(searchDebounce);
|
|
379
|
+
reloadFromFirstPage();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
watch(search, onSearchInput);
|
|
383
|
+
onBeforeUnmount(() => clearTimeout(searchDebounce));
|
|
384
|
+
|
|
385
|
+
/** Discards a load whose answer arrives after a newer one started. */
|
|
386
|
+
const loadUsersSequence = ref(0);
|
|
387
|
+
|
|
353
388
|
function hasFacialData(user: HidReaderUser) {
|
|
354
389
|
return user.metadata?.facialEnrolled === true || Boolean(toText(user.metadata?.facialData));
|
|
355
390
|
}
|
|
@@ -511,6 +546,12 @@ function toNumber(value: unknown, fallback: number) {
|
|
|
511
546
|
const number = Number(value);
|
|
512
547
|
return Number.isFinite(number) ? number : fallback;
|
|
513
548
|
}
|
|
549
|
+
|
|
550
|
+
/* Publish every change, including the seed above: a screen that had to fall
|
|
551
|
+
back to the first reader should leave the others agreeing with it. */
|
|
552
|
+
watch(selectedReaderId, (value) => {
|
|
553
|
+
if (value) selectHidReader(props.site, value);
|
|
554
|
+
});
|
|
514
555
|
</script>
|
|
515
556
|
|
|
516
557
|
<style scoped>
|