@7365admin1/layer-common 3.2.2-staging.83 → 3.2.2-staging.85
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/components/HidIntercomManagement.vue +40 -24
- package/components/HidQrCodeConfiguration.vue +24 -2
- package/components/HidUserEnrollment.vue +68 -31
- package/components/RolePermissionMain.vue +136 -43
- package/components/VisitorForm.vue +100 -7
- package/components/VisitorManagement.vue +170 -2
- package/composables/useHidAmico.ts +26 -2
- package/composables/useRole.ts +22 -0
- package/composables/useSipWebPhone.ts +28 -3
- package/package.json +1 -1
|
@@ -338,10 +338,10 @@
|
|
|
338
338
|
<v-card-title class="dialog-title">Make a Call</v-card-title>
|
|
339
339
|
<v-card-text class="dialog-body make-call-body">
|
|
340
340
|
<div class="make-call-field">
|
|
341
|
-
<div class="call-label">
|
|
341
|
+
<div class="call-label">Extension, SIP address, or peer IP</div>
|
|
342
342
|
<v-text-field
|
|
343
343
|
v-model="makeCallForm.target"
|
|
344
|
-
placeholder="
|
|
344
|
+
placeholder="7100 or user@example.com"
|
|
345
345
|
variant="outlined"
|
|
346
346
|
density="compact"
|
|
347
347
|
hide-details="auto"
|
|
@@ -385,9 +385,19 @@
|
|
|
385
385
|
<v-card class="web-phone-dialog" rounded="lg">
|
|
386
386
|
<v-card-title class="dialog-title web-phone-title">
|
|
387
387
|
<span>Web Call</span>
|
|
388
|
-
<
|
|
389
|
-
|
|
390
|
-
|
|
388
|
+
<div class="web-phone-title-actions">
|
|
389
|
+
<v-chip size="small" variant="flat" :class="webPhoneStatusClass">
|
|
390
|
+
{{ webPhoneStatusLabel }}
|
|
391
|
+
</v-chip>
|
|
392
|
+
<v-btn
|
|
393
|
+
icon="mdi-close"
|
|
394
|
+
size="small"
|
|
395
|
+
variant="text"
|
|
396
|
+
title="Close Web Call"
|
|
397
|
+
aria-label="Close Web Call"
|
|
398
|
+
@click="webPhoneDialog = false"
|
|
399
|
+
/>
|
|
400
|
+
</div>
|
|
391
401
|
</v-card-title>
|
|
392
402
|
|
|
393
403
|
<v-card-text class="web-phone-body">
|
|
@@ -547,11 +557,6 @@
|
|
|
547
557
|
</div>
|
|
548
558
|
</v-card-text>
|
|
549
559
|
|
|
550
|
-
<v-card-actions class="dialog-actions pa-0">
|
|
551
|
-
<v-btn class="text-none action-submit full-action" variant="flat" height="44" @click="webPhoneDialog = false">
|
|
552
|
-
Close
|
|
553
|
-
</v-btn>
|
|
554
|
-
</v-card-actions>
|
|
555
560
|
</v-card>
|
|
556
561
|
</v-dialog>
|
|
557
562
|
|
|
@@ -1184,12 +1189,18 @@ function openDialCodeDialog(row: IntercomRow) {
|
|
|
1184
1189
|
dialCodeDialog.value = true;
|
|
1185
1190
|
}
|
|
1186
1191
|
|
|
1187
|
-
function openMakeCallDialog(row: IntercomRow) {
|
|
1192
|
+
async function openMakeCallDialog(row: IntercomRow) {
|
|
1188
1193
|
selectedRow.value = row;
|
|
1189
|
-
makeCallForm.target =
|
|
1194
|
+
makeCallForm.target = sipAccount.value?.extension || "";
|
|
1190
1195
|
makeCallForm.status = "";
|
|
1191
1196
|
makeCallForm.statusType = "info";
|
|
1192
1197
|
makeCallDialog.value = true;
|
|
1198
|
+
if (!sipAccount.value && !sipAccountLoading.value) {
|
|
1199
|
+
await loadSipAccount();
|
|
1200
|
+
if (makeCallDialog.value && !makeCallForm.target) {
|
|
1201
|
+
makeCallForm.target = sipAccount.value?.extension || "";
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1193
1204
|
}
|
|
1194
1205
|
|
|
1195
1206
|
function openWebPhoneDialog(row?: IntercomRow) {
|
|
@@ -1397,13 +1408,13 @@ async function runMakeCall() {
|
|
|
1397
1408
|
if (!selectedRow.value) return;
|
|
1398
1409
|
const target = makeCallForm.target.trim();
|
|
1399
1410
|
if (!target) {
|
|
1400
|
-
makeCallForm.status = "Please enter an
|
|
1411
|
+
makeCallForm.status = "Please enter an extension, SIP address, or peer IP.";
|
|
1401
1412
|
makeCallForm.statusType = "warning";
|
|
1402
1413
|
return;
|
|
1403
1414
|
}
|
|
1404
1415
|
|
|
1405
|
-
if (!
|
|
1406
|
-
makeCallForm.status = "
|
|
1416
|
+
if (!isSipCallTarget(target)) {
|
|
1417
|
+
makeCallForm.status = "Enter a valid extension, SIP address, or IPv4 address.";
|
|
1407
1418
|
makeCallForm.statusType = "warning";
|
|
1408
1419
|
return;
|
|
1409
1420
|
}
|
|
@@ -1425,13 +1436,8 @@ async function runMakeCall() {
|
|
|
1425
1436
|
}
|
|
1426
1437
|
}
|
|
1427
1438
|
|
|
1428
|
-
function
|
|
1429
|
-
|
|
1430
|
-
return parts.length === 4 && parts.every((part) => {
|
|
1431
|
-
if (!/^\d{1,3}$/.test(part)) return false;
|
|
1432
|
-
const value = Number(part);
|
|
1433
|
-
return value >= 0 && value <= 255;
|
|
1434
|
-
});
|
|
1439
|
+
function isSipCallTarget(value = "") {
|
|
1440
|
+
return /^(?:sip:)?[A-Za-z0-9_.+-]+(?:@[A-Za-z0-9.-]+(?::\d{1,5})?)?$/.test(value.trim());
|
|
1435
1441
|
}
|
|
1436
1442
|
|
|
1437
1443
|
async function runHangupCall() {
|
|
@@ -2273,19 +2279,29 @@ export default {
|
|
|
2273
2279
|
}
|
|
2274
2280
|
|
|
2275
2281
|
.web-phone-dialog {
|
|
2282
|
+
display: flex;
|
|
2276
2283
|
width: 820px;
|
|
2284
|
+
max-height: min(90vh, 760px);
|
|
2277
2285
|
max-width: 94vw;
|
|
2278
2286
|
overflow: hidden;
|
|
2279
2287
|
background: #fff;
|
|
2288
|
+
flex-direction: column;
|
|
2280
2289
|
}
|
|
2281
2290
|
|
|
2282
2291
|
.web-phone-title {
|
|
2292
|
+
flex: 0 0 auto;
|
|
2283
2293
|
display: flex;
|
|
2284
2294
|
align-items: center;
|
|
2285
2295
|
justify-content: space-between;
|
|
2286
2296
|
gap: 16px;
|
|
2287
2297
|
}
|
|
2288
2298
|
|
|
2299
|
+
.web-phone-title-actions {
|
|
2300
|
+
display: flex;
|
|
2301
|
+
align-items: center;
|
|
2302
|
+
gap: 8px;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2289
2305
|
.web-phone-status {
|
|
2290
2306
|
min-width: 92px;
|
|
2291
2307
|
justify-content: center;
|
|
@@ -2317,6 +2333,7 @@ export default {
|
|
|
2317
2333
|
grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.1fr);
|
|
2318
2334
|
gap: 24px;
|
|
2319
2335
|
min-height: 440px;
|
|
2336
|
+
overflow: auto;
|
|
2320
2337
|
padding: 22px 24px 24px;
|
|
2321
2338
|
}
|
|
2322
2339
|
|
|
@@ -2668,8 +2685,7 @@ export default {
|
|
|
2668
2685
|
|
|
2669
2686
|
.web-phone-body {
|
|
2670
2687
|
grid-template-columns: minmax(0, 1fr);
|
|
2671
|
-
|
|
2672
|
-
overflow: auto;
|
|
2688
|
+
min-height: 0;
|
|
2673
2689
|
}
|
|
2674
2690
|
|
|
2675
2691
|
.web-phone-settings {
|
|
@@ -583,7 +583,7 @@ async function loadConfig() {
|
|
|
583
583
|
enabled: Boolean(config.enabled),
|
|
584
584
|
onlineMode: Boolean(config.onlineMode),
|
|
585
585
|
readerId: String(config.readerId || readers.value[0]?._id || ""),
|
|
586
|
-
qrFormat:
|
|
586
|
+
qrFormat: "0",
|
|
587
587
|
identificationMethods: {
|
|
588
588
|
facial: configuredMethods?.facial ?? true,
|
|
589
589
|
card: configuredMethods?.card ?? true,
|
|
@@ -750,13 +750,20 @@ async function loadReaderConfiguration() {
|
|
|
750
750
|
try {
|
|
751
751
|
const response = await getReaderConfiguration(form.readerId, {
|
|
752
752
|
general: ["online"],
|
|
753
|
+
identifier: ["pin_identification_enabled", "multi_factor_authentication"],
|
|
753
754
|
});
|
|
754
755
|
const configuration = getConfigurationData(response);
|
|
755
756
|
const general = isUnknownRecord(configuration.general) ? configuration.general : {};
|
|
757
|
+
const identifier = isUnknownRecord(configuration.identifier) ? configuration.identifier : {};
|
|
756
758
|
|
|
757
759
|
if (general.online !== undefined) {
|
|
758
760
|
form.onlineMode = isEnabledConfiguration(general.online);
|
|
759
761
|
}
|
|
762
|
+
if (identifier.pin_identification_enabled !== undefined) {
|
|
763
|
+
const pinEnabled = isEnabledConfiguration(identifier.pin_identification_enabled);
|
|
764
|
+
form.identificationMethods.pin = pinEnabled;
|
|
765
|
+
form.identificationMethods.idPassword = !pinEnabled;
|
|
766
|
+
}
|
|
760
767
|
} catch (error: unknown) {
|
|
761
768
|
console.warn("Unable to load HID reader configuration:", errorConverter(error));
|
|
762
769
|
}
|
|
@@ -769,7 +776,8 @@ function buildPayload() {
|
|
|
769
776
|
enabled: form.identificationMethods.qrCode,
|
|
770
777
|
onlineMode: form.onlineMode,
|
|
771
778
|
readerId: form.readerId,
|
|
772
|
-
|
|
779
|
+
// Visitor QR passes always use Amico alphanumeric qrcodes.
|
|
780
|
+
qrFormat: "0",
|
|
773
781
|
identificationMethods: { ...form.identificationMethods },
|
|
774
782
|
printer: {
|
|
775
783
|
vendorId: form.printer.vendorId.trim(),
|
|
@@ -827,6 +835,20 @@ async function saveIdentificationMethod(method: HidIdentificationMethod) {
|
|
|
827
835
|
|
|
828
836
|
savingSection.value = `method-${method}`;
|
|
829
837
|
try {
|
|
838
|
+
if (method === "pin" || method === "idPassword") {
|
|
839
|
+
if (method === "pin" && form.identificationMethods.pin) {
|
|
840
|
+
form.identificationMethods.idPassword = false;
|
|
841
|
+
} else if (method === "idPassword" && form.identificationMethods.idPassword) {
|
|
842
|
+
form.identificationMethods.pin = false;
|
|
843
|
+
}
|
|
844
|
+
await setReaderConfiguration(form.readerId, {
|
|
845
|
+
identifier: {
|
|
846
|
+
pin_identification_enabled: form.identificationMethods.pin ? "1" : "0",
|
|
847
|
+
// Mode 1: PIN is an alternative credential, never a second factor.
|
|
848
|
+
multi_factor_authentication: "0",
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
}
|
|
830
852
|
await updateSitebyId(props.site, buildPayload());
|
|
831
853
|
showMessage(`${identificationMethodOptions.find((item) => item.key === method)?.label || "Identification method"} updated.`, "success");
|
|
832
854
|
} catch (error: unknown) {
|
|
@@ -238,28 +238,28 @@
|
|
|
238
238
|
class="mb-3"
|
|
239
239
|
/>
|
|
240
240
|
|
|
241
|
-
<div class="field-label">
|
|
241
|
+
<div class="field-label">Access PIN (optional)</div>
|
|
242
242
|
<v-text-field
|
|
243
|
-
v-model="form.
|
|
244
|
-
aria-label="
|
|
245
|
-
placeholder="Enter
|
|
243
|
+
v-model="form.accessPin"
|
|
244
|
+
aria-label="Access PIN"
|
|
245
|
+
placeholder="Enter numeric access PIN"
|
|
246
246
|
density="compact"
|
|
247
247
|
variant="outlined"
|
|
248
|
-
|
|
248
|
+
inputmode="numeric"
|
|
249
|
+
autocomplete="new-password"
|
|
250
|
+
:type="showPin ? 'text' : 'password'"
|
|
251
|
+
:append-inner-icon="showPin ? 'mdi-eye-off' : 'mdi-eye'"
|
|
252
|
+
:hint="selectedUser && pinEnrolled ? 'Leave blank to keep the current PIN.' : 'PIN works as an alternative to face or QR.'"
|
|
253
|
+
persistent-hint
|
|
254
|
+
@click:append-inner="showPin = !showPin"
|
|
249
255
|
class="mb-3"
|
|
250
256
|
/>
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
aria-label="Panic Password"
|
|
256
|
-
placeholder="Enter password"
|
|
257
|
+
<v-checkbox
|
|
258
|
+
v-if="selectedUser && pinEnrolled"
|
|
259
|
+
v-model="removePinRequested"
|
|
260
|
+
label="Remove current access PIN"
|
|
257
261
|
density="compact"
|
|
258
|
-
|
|
259
|
-
hide-details="auto"
|
|
260
|
-
:type="showPassword ? 'text' : 'password'"
|
|
261
|
-
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
|
262
|
-
@click:append-inner="showPassword = !showPassword"
|
|
262
|
+
hide-details
|
|
263
263
|
/>
|
|
264
264
|
</v-card-text>
|
|
265
265
|
|
|
@@ -306,12 +306,8 @@
|
|
|
306
306
|
><strong>{{
|
|
307
307
|
selectedUser ? getMappingStatus(selectedUser) : "N/A"
|
|
308
308
|
}}</strong>
|
|
309
|
-
<span>
|
|
310
|
-
><strong>{{ selectedUser?.
|
|
311
|
-
<span>Private Password</span
|
|
312
|
-
><strong>{{
|
|
313
|
-
selectedUser?.metadata?.pinPassword ? "********" : "N/A"
|
|
314
|
-
}}</strong>
|
|
309
|
+
<span>Access PIN</span
|
|
310
|
+
><strong>{{ selectedUser?.metadata?.pinEnrolled ? "Configured" : "N/A" }}</strong>
|
|
315
311
|
</div>
|
|
316
312
|
</v-card-text>
|
|
317
313
|
<v-card-actions>
|
|
@@ -377,6 +373,9 @@ const {
|
|
|
377
373
|
getUserImage,
|
|
378
374
|
setUserImage,
|
|
379
375
|
deleteUserImage,
|
|
376
|
+
getUserPinStatus,
|
|
377
|
+
setUserPin,
|
|
378
|
+
deleteUserPin,
|
|
380
379
|
runObjectOperation,
|
|
381
380
|
createIdentity,
|
|
382
381
|
updateIdentity,
|
|
@@ -401,11 +400,13 @@ const formDialog = ref(false);
|
|
|
401
400
|
const viewDialog = ref(false);
|
|
402
401
|
const deleteDialog = ref(false);
|
|
403
402
|
const selectedUser = ref<Record<string, any> | null>(null);
|
|
404
|
-
const
|
|
403
|
+
const showPin = ref(false);
|
|
404
|
+
const pinEnrolled = ref(false);
|
|
405
405
|
const photoInput = ref<HTMLInputElement | null>(null);
|
|
406
406
|
const cameraDialog = ref(false);
|
|
407
407
|
const selectedPhotoFile = ref<File | null>(null);
|
|
408
408
|
const removePhotoRequested = ref(false);
|
|
409
|
+
const removePinRequested = ref(false);
|
|
409
410
|
const snackbar = reactive({
|
|
410
411
|
show: false,
|
|
411
412
|
color: "success",
|
|
@@ -420,8 +421,7 @@ const form = reactive({
|
|
|
420
421
|
name: "",
|
|
421
422
|
hidUserId: "",
|
|
422
423
|
registration: "",
|
|
423
|
-
|
|
424
|
-
pinPassword: "",
|
|
424
|
+
accessPin: "",
|
|
425
425
|
photoPreview: "",
|
|
426
426
|
});
|
|
427
427
|
|
|
@@ -554,8 +554,10 @@ function resetForm() {
|
|
|
554
554
|
form.name = "";
|
|
555
555
|
form.hidUserId = "";
|
|
556
556
|
form.registration = "";
|
|
557
|
-
form.
|
|
558
|
-
|
|
557
|
+
form.accessPin = "";
|
|
558
|
+
pinEnrolled.value = false;
|
|
559
|
+
showPin.value = false;
|
|
560
|
+
removePinRequested.value = false;
|
|
559
561
|
form.photoPreview = "";
|
|
560
562
|
selectedPhotoFile.value = null;
|
|
561
563
|
removePhotoRequested.value = false;
|
|
@@ -582,13 +584,24 @@ async function openEdit(user: Record<string, any>) {
|
|
|
582
584
|
form.name = getName(user);
|
|
583
585
|
form.hidUserId = formatHidUid(user.hidUserId);
|
|
584
586
|
form.registration = user.registration ?? "";
|
|
585
|
-
form.
|
|
586
|
-
|
|
587
|
+
form.accessPin = "";
|
|
588
|
+
pinEnrolled.value = Boolean(user.metadata?.pinEnrolled);
|
|
589
|
+
removePinRequested.value = false;
|
|
587
590
|
form.photoPreview = getUserImageSrc(user);
|
|
588
591
|
selectedPhotoFile.value = null;
|
|
589
592
|
removePhotoRequested.value = false;
|
|
590
593
|
formDialog.value = true;
|
|
591
594
|
|
|
595
|
+
try {
|
|
596
|
+
const status = await getUserPinStatus(
|
|
597
|
+
form.reader,
|
|
598
|
+
toHidNumericId(user.hidUserId),
|
|
599
|
+
);
|
|
600
|
+
pinEnrolled.value = Boolean(status?.data?.pinEnrolled);
|
|
601
|
+
} catch {
|
|
602
|
+
// Preserve the last known status if the reader is temporarily offline.
|
|
603
|
+
}
|
|
604
|
+
|
|
592
605
|
if (!form.photoPreview) {
|
|
593
606
|
await loadUserImages([user]);
|
|
594
607
|
if (selectedUser.value?._rowKey === user._rowKey) {
|
|
@@ -672,6 +685,10 @@ async function saveUser() {
|
|
|
672
685
|
showToast("Please complete the required HID user fields.", "error");
|
|
673
686
|
return;
|
|
674
687
|
}
|
|
688
|
+
if (form.accessPin && !/^\d{1,32}$/.test(form.accessPin)) {
|
|
689
|
+
showToast("Access PIN must contain numbers only.", "error");
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
675
692
|
|
|
676
693
|
saving.value = true;
|
|
677
694
|
try {
|
|
@@ -688,7 +705,6 @@ async function saveUser() {
|
|
|
688
705
|
const payload = {
|
|
689
706
|
hidUserId: String(hidUser.id),
|
|
690
707
|
registration: form.registration,
|
|
691
|
-
cardNo: form.cardNo,
|
|
692
708
|
type: props.identityType as
|
|
693
709
|
| "resident"
|
|
694
710
|
| "staff"
|
|
@@ -707,7 +723,7 @@ async function saveUser() {
|
|
|
707
723
|
photo: form.photoPreview,
|
|
708
724
|
imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
|
|
709
725
|
facialScores: selectedUser.value?.metadata?.facialScores || {},
|
|
710
|
-
|
|
726
|
+
pinEnrolled: pinEnrolled.value,
|
|
711
727
|
},
|
|
712
728
|
};
|
|
713
729
|
|
|
@@ -716,6 +732,7 @@ async function saveUser() {
|
|
|
716
732
|
await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
|
|
717
733
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
718
734
|
applyFacialResult(payload.metadata, facialResult);
|
|
735
|
+
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
719
736
|
if (selectedUser.value._id) {
|
|
720
737
|
await updateIdentity(selectedUser.value._id, payload);
|
|
721
738
|
} else {
|
|
@@ -726,6 +743,7 @@ async function saveUser() {
|
|
|
726
743
|
await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
|
|
727
744
|
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
728
745
|
applyFacialResult(payload.metadata, facialResult);
|
|
746
|
+
payload.metadata.pinEnrolled = await syncAccessPin(readerId, hidUser.id);
|
|
729
747
|
await saveIdentityOnReader(readerId, payload);
|
|
730
748
|
selectedReaderId.value = readerId;
|
|
731
749
|
}
|
|
@@ -745,6 +763,21 @@ async function saveUser() {
|
|
|
745
763
|
}
|
|
746
764
|
}
|
|
747
765
|
|
|
766
|
+
async function syncAccessPin(readerId: string, hidUserId: number) {
|
|
767
|
+
if (removePinRequested.value) {
|
|
768
|
+
await deleteUserPin(readerId, hidUserId);
|
|
769
|
+
pinEnrolled.value = false;
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
if (form.accessPin) {
|
|
773
|
+
await setUserPin(readerId, hidUserId, form.accessPin);
|
|
774
|
+
form.accessPin = "";
|
|
775
|
+
pinEnrolled.value = true;
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
return pinEnrolled.value;
|
|
779
|
+
}
|
|
780
|
+
|
|
748
781
|
type FacialSyncResult = {
|
|
749
782
|
action: "none" | "enrolled" | "removed";
|
|
750
783
|
timestamp?: number;
|
|
@@ -806,6 +839,10 @@ async function deleteUser() {
|
|
|
806
839
|
toHidNumericId(selectedUser.value.hidUserId),
|
|
807
840
|
false
|
|
808
841
|
);
|
|
842
|
+
await deleteUserPin(
|
|
843
|
+
selectedReaderId.value,
|
|
844
|
+
toHidNumericId(selectedUser.value.hidUserId),
|
|
845
|
+
);
|
|
809
846
|
await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
|
|
810
847
|
if (selectedUser.value._id) {
|
|
811
848
|
await deleteIdentity(selectedUser.value._id);
|
|
@@ -105,42 +105,90 @@
|
|
|
105
105
|
/>
|
|
106
106
|
</v-dialog>
|
|
107
107
|
|
|
108
|
-
<
|
|
109
|
-
v-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
</
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
108
|
+
<v-dialog v-model="confirmDialog" max-width="450">
|
|
109
|
+
<v-card>
|
|
110
|
+
<v-toolbar>
|
|
111
|
+
<v-row no-gutters class="fill-height px-6" align="center">
|
|
112
|
+
<span class="font-weight-bold text-h6 text-capitalize">
|
|
113
|
+
Delete Role
|
|
114
|
+
</span>
|
|
115
|
+
</v-row>
|
|
116
|
+
</v-toolbar>
|
|
117
|
+
|
|
118
|
+
<v-card-text>
|
|
119
|
+
<span v-if="!hasAssignedMembers" class="text-subtitle-2">
|
|
120
|
+
Are you sure you want to delete this role? This action cannot be
|
|
121
|
+
undone.
|
|
122
|
+
</span>
|
|
123
|
+
<template v-else>
|
|
124
|
+
<p class="text-subtitle-2 mb-4">
|
|
125
|
+
There are currently
|
|
126
|
+
<span class="text-error font-weight-bold">
|
|
127
|
+
{{ deletionPreview.members.length }}
|
|
128
|
+
{{ deletionPreview.members.length === 1 ? "user" : "users" }}
|
|
129
|
+
</span>
|
|
130
|
+
assigned to this role. Reassign each user before deleting it so
|
|
131
|
+
they do not lose access.
|
|
132
|
+
</p>
|
|
133
|
+
|
|
134
|
+
<v-row
|
|
135
|
+
v-for="member in deletionPreview.members"
|
|
136
|
+
:key="member._id"
|
|
137
|
+
no-gutters
|
|
138
|
+
class="mb-3 pa-3 border rounded-lg"
|
|
139
|
+
align="center"
|
|
140
|
+
>
|
|
141
|
+
<v-col cols="12" sm="5" class="text-body-2 mb-2 mb-sm-0">
|
|
142
|
+
{{ member.name || member.user }}
|
|
143
|
+
</v-col>
|
|
144
|
+
<v-col cols="12" sm="7">
|
|
145
|
+
<v-select
|
|
146
|
+
v-model="memberReassignments[member._id]"
|
|
147
|
+
:items="deletionPreview.replacementRoles"
|
|
148
|
+
item-title="name"
|
|
149
|
+
item-value="_id"
|
|
150
|
+
label="Reassign role"
|
|
151
|
+
density="comfortable"
|
|
152
|
+
hide-details
|
|
153
|
+
/>
|
|
154
|
+
</v-col>
|
|
155
|
+
</v-row>
|
|
156
|
+
</template>
|
|
157
|
+
</v-card-text>
|
|
158
|
+
|
|
159
|
+
<v-toolbar class="pa-0" density="compact">
|
|
160
|
+
<v-row no-gutters>
|
|
161
|
+
<v-col cols="6" class="pa-0">
|
|
162
|
+
<v-btn
|
|
163
|
+
block
|
|
164
|
+
variant="text"
|
|
165
|
+
class="text-none"
|
|
166
|
+
size="large"
|
|
167
|
+
height="48"
|
|
168
|
+
@click="closeDeleteDialog"
|
|
169
|
+
:disabled="deleteLoading"
|
|
170
|
+
>
|
|
171
|
+
Cancel
|
|
172
|
+
</v-btn>
|
|
173
|
+
</v-col>
|
|
174
|
+
<v-col cols="6" class="pa-0">
|
|
175
|
+
<v-btn
|
|
176
|
+
block
|
|
177
|
+
variant="flat"
|
|
178
|
+
color="black"
|
|
179
|
+
class="text-none font-weight-bold rounded-0"
|
|
180
|
+
height="48"
|
|
181
|
+
@click="handleDeleteRole"
|
|
182
|
+
:loading="deleteLoading"
|
|
183
|
+
:disabled="hasAssignedMembers && !canConfirmDeletion"
|
|
184
|
+
>
|
|
185
|
+
{{ hasAssignedMembers ? "Confirm" : "Delete" }}
|
|
186
|
+
</v-btn>
|
|
187
|
+
</v-col>
|
|
188
|
+
</v-row>
|
|
189
|
+
</v-toolbar>
|
|
190
|
+
</v-card>
|
|
191
|
+
</v-dialog>
|
|
144
192
|
|
|
145
193
|
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
146
194
|
</v-row>
|
|
@@ -367,10 +415,49 @@ async function refreshRolesAfterUpdate(savedPermissions: string[]) {
|
|
|
367
415
|
const confirmDialog = ref(false);
|
|
368
416
|
const selectedRoleId = ref<string | null>(null);
|
|
369
417
|
const deleteLoading = ref(false);
|
|
418
|
+
const deletionPreview = ref({
|
|
419
|
+
members: [] as Array<Record<string, any>>,
|
|
420
|
+
replacementRoles: [] as Array<Record<string, any>>,
|
|
421
|
+
});
|
|
422
|
+
const memberReassignments = ref<Record<string, string>>({});
|
|
423
|
+
|
|
424
|
+
const hasAssignedMembers = computed(
|
|
425
|
+
() => deletionPreview.value.members.length > 0
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const canConfirmDeletion = computed(() =>
|
|
429
|
+
deletionPreview.value.members.every(
|
|
430
|
+
(member) => Boolean(memberReassignments.value[member._id])
|
|
431
|
+
)
|
|
432
|
+
);
|
|
370
433
|
|
|
371
|
-
|
|
434
|
+
const { getDeletionPreview, deleteWithReassignments } = useRole();
|
|
435
|
+
|
|
436
|
+
async function openDeleteDialog(id: string) {
|
|
372
437
|
selectedRoleId.value = id;
|
|
373
|
-
|
|
438
|
+
deletionPreview.value = { members: [], replacementRoles: [] };
|
|
439
|
+
memberReassignments.value = {};
|
|
440
|
+
deleteLoading.value = true;
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
const res = await getDeletionPreview(id);
|
|
444
|
+
deletionPreview.value = res.data;
|
|
445
|
+
confirmDialog.value = true;
|
|
446
|
+
} catch (error: any) {
|
|
447
|
+
const errorMessage =
|
|
448
|
+
error?.response?._data?.message || "Failed to load role assignments.";
|
|
449
|
+
showMessage(errorMessage, "error");
|
|
450
|
+
selectedRoleId.value = null;
|
|
451
|
+
} finally {
|
|
452
|
+
deleteLoading.value = false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function closeDeleteDialog() {
|
|
457
|
+
confirmDialog.value = false;
|
|
458
|
+
selectedRoleId.value = null;
|
|
459
|
+
deletionPreview.value = { members: [], replacementRoles: [] };
|
|
460
|
+
memberReassignments.value = {};
|
|
374
461
|
}
|
|
375
462
|
|
|
376
463
|
function showMessage(msg: string, color: string) {
|
|
@@ -383,17 +470,23 @@ async function handleDeleteRole() {
|
|
|
383
470
|
if (!selectedRoleId.value) return;
|
|
384
471
|
deleteLoading.value = true;
|
|
385
472
|
try {
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
473
|
+
const reassignments = deletionPreview.value.members.map((member) => ({
|
|
474
|
+
memberId: member._id,
|
|
475
|
+
roleId: memberReassignments.value[member._id],
|
|
476
|
+
}));
|
|
477
|
+
const res = hasAssignedMembers.value
|
|
478
|
+
? await deleteWithReassignments(selectedRoleId.value, reassignments)
|
|
479
|
+
: await deleteRole(selectedRoleId.value);
|
|
480
|
+
|
|
481
|
+
closeDeleteDialog();
|
|
389
482
|
showMessage(res.message, "success");
|
|
390
483
|
getRoles();
|
|
391
484
|
} catch (error: any) {
|
|
392
|
-
const errorMessage =
|
|
485
|
+
const errorMessage =
|
|
486
|
+
error?.response?._data?.message || "Failed to delete role.";
|
|
393
487
|
showMessage(errorMessage, "error");
|
|
394
488
|
} finally {
|
|
395
489
|
deleteLoading.value = false;
|
|
396
|
-
selectedRoleId.value = null;
|
|
397
490
|
}
|
|
398
491
|
}
|
|
399
492
|
</script>
|
|
@@ -515,13 +515,49 @@
|
|
|
515
515
|
</div>
|
|
516
516
|
|
|
517
517
|
<div class="hid-qr-image-shell my-5">
|
|
518
|
-
<v-img :src="hidQrPreview.imageUrl" width="280" height="280" alt="Visitor access QR code" />
|
|
518
|
+
<v-img v-if="hidQrVisible" :src="hidQrPreview.imageUrl" width="280" height="280" alt="Visitor access QR code" />
|
|
519
|
+
<button v-else type="button" class="hid-qr-reveal" aria-label="Show QR code" @click="hidQrVisible = true">
|
|
520
|
+
<v-icon icon="mdi-eye-outline" size="42" />
|
|
521
|
+
<span>Click to show QR code</span>
|
|
522
|
+
</button>
|
|
519
523
|
</div>
|
|
520
524
|
|
|
525
|
+
<v-btn
|
|
526
|
+
variant="text"
|
|
527
|
+
density="comfortable"
|
|
528
|
+
class="text-none"
|
|
529
|
+
:prepend-icon="hidQrVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
|
530
|
+
@click="hidQrVisible = !hidQrVisible"
|
|
531
|
+
>
|
|
532
|
+
{{ hidQrVisible ? "Hide QR code" : "Show QR code" }}
|
|
533
|
+
</v-btn>
|
|
534
|
+
|
|
521
535
|
<div class="text-h6 font-weight-bold">{{ visitor.name }}</div>
|
|
522
536
|
<div v-if="hidQrPreview.expiresAt" class="text-caption text-medium-emphasis mt-1">
|
|
523
537
|
Valid until {{ formatHidQrExpiry(hidQrPreview.expiresAt) }}
|
|
524
538
|
</div>
|
|
539
|
+
<v-chip
|
|
540
|
+
v-if="hidQrPreview.expiresAt"
|
|
541
|
+
class="mt-2"
|
|
542
|
+
size="small"
|
|
543
|
+
:color="hidQrExpired ? 'error' : 'success'"
|
|
544
|
+
variant="tonal"
|
|
545
|
+
>
|
|
546
|
+
{{ hidQrExpired ? "Expired" : `Expires in ${formatHidQrCountdown(hidQrRemainingSeconds)}` }}
|
|
547
|
+
</v-chip>
|
|
548
|
+
|
|
549
|
+
<v-btn
|
|
550
|
+
v-if="hidQrExpired"
|
|
551
|
+
block
|
|
552
|
+
class="mt-4 text-none"
|
|
553
|
+
color="primary-button"
|
|
554
|
+
variant="flat"
|
|
555
|
+
prepend-icon="mdi-refresh"
|
|
556
|
+
:loading="hidQrRegenerating"
|
|
557
|
+
@click="regenerateHidQrCode"
|
|
558
|
+
>
|
|
559
|
+
Generate QR again
|
|
560
|
+
</v-btn>
|
|
525
561
|
|
|
526
562
|
<v-alert v-if="hidQrPreviewError" type="error" variant="tonal" density="compact" class="mt-4 text-left">
|
|
527
563
|
{{ hidQrPreviewError }}
|
|
@@ -684,6 +720,11 @@ const isNricFieldFocused = ref(false);
|
|
|
684
720
|
const hidQrPrinting = ref(false);
|
|
685
721
|
const hidQrPreviewDialog = ref(false);
|
|
686
722
|
const hidQrPreviewError = ref("");
|
|
723
|
+
const hidQrVisible = ref(false);
|
|
724
|
+
const hidQrRegenerating = ref(false);
|
|
725
|
+
const hidQrVisitorId = ref("");
|
|
726
|
+
const hidQrNow = ref(Date.now());
|
|
727
|
+
let hidQrTimer: ReturnType<typeof setInterval> | null = null;
|
|
687
728
|
const hidQrPendingCompletion = ref<"done" | "done:more" | null>(null);
|
|
688
729
|
const hidQrPreview = reactive({
|
|
689
730
|
imageUrl: "",
|
|
@@ -691,6 +732,24 @@ const hidQrPreview = reactive({
|
|
|
691
732
|
expiresAt: "",
|
|
692
733
|
});
|
|
693
734
|
|
|
735
|
+
const hidQrRemainingSeconds = computed(() => {
|
|
736
|
+
const expiry = new Date(hidQrPreview.expiresAt).getTime();
|
|
737
|
+
return Number.isFinite(expiry) ? Math.max(0, Math.ceil((expiry - hidQrNow.value) / 1000)) : 0;
|
|
738
|
+
});
|
|
739
|
+
const hidQrExpired = computed(() => Boolean(hidQrPreview.expiresAt) && hidQrRemainingSeconds.value === 0);
|
|
740
|
+
|
|
741
|
+
watch(hidQrPreviewDialog, (open) => {
|
|
742
|
+
if (hidQrTimer) clearInterval(hidQrTimer);
|
|
743
|
+
hidQrTimer = null;
|
|
744
|
+
if (!open) return;
|
|
745
|
+
hidQrNow.value = Date.now();
|
|
746
|
+
hidQrTimer = setInterval(() => { hidQrNow.value = Date.now(); }, 1000);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
onBeforeUnmount(() => {
|
|
750
|
+
if (hidQrTimer) clearInterval(hidQrTimer);
|
|
751
|
+
});
|
|
752
|
+
|
|
694
753
|
const registeredUnitCompanyName = ref("N/A");
|
|
695
754
|
|
|
696
755
|
const dialog = reactive({
|
|
@@ -818,10 +877,6 @@ const hidQrValidityMinutes = computed(() => {
|
|
|
818
877
|
});
|
|
819
878
|
|
|
820
879
|
const hidQrReaderId = computed(() => String(hidQrCodePassConfig.value?.readerId || ""));
|
|
821
|
-
const hidQrFormat = computed<"0" | "1" | "2">(() => {
|
|
822
|
-
const value = String(hidQrCodePassConfig.value?.qrFormat || "0");
|
|
823
|
-
return value === "1" || value === "2" ? value : "0";
|
|
824
|
-
});
|
|
825
880
|
|
|
826
881
|
const hidQrCodePassAllowed = computed(() => {
|
|
827
882
|
return (
|
|
@@ -2088,10 +2143,10 @@ async function prepareHidQrCodePreview(visitorId: string) {
|
|
|
2088
2143
|
}
|
|
2089
2144
|
|
|
2090
2145
|
try {
|
|
2146
|
+
hidQrVisitorId.value = visitorId;
|
|
2091
2147
|
const response = await issueVisitorQr(hidQrReaderId.value, {
|
|
2092
2148
|
visitorId,
|
|
2093
2149
|
validityMinutes: hidQrValidityMinutes.value,
|
|
2094
|
-
qrFormat: hidQrFormat.value,
|
|
2095
2150
|
});
|
|
2096
2151
|
const credential = "data" in response ? response.data : response;
|
|
2097
2152
|
hidQrPrintPayload.value = credential.qrValue;
|
|
@@ -2107,14 +2162,37 @@ async function prepareHidQrCodePreview(visitorId: string) {
|
|
|
2107
2162
|
});
|
|
2108
2163
|
hidQrPreview.issuedAt = credential.issuedAt || "";
|
|
2109
2164
|
hidQrPreview.expiresAt = credential.expiresAt || "";
|
|
2165
|
+
hidQrVisible.value = false;
|
|
2166
|
+
hidQrNow.value = Date.now();
|
|
2110
2167
|
hidQrPreviewDialog.value = true;
|
|
2111
2168
|
return true;
|
|
2112
2169
|
} catch (error: unknown) {
|
|
2113
|
-
|
|
2170
|
+
const message = errorConverter(error) || "Unable to generate HID QR Code.";
|
|
2171
|
+
if (hidQrPreviewDialog.value) hidQrPreviewError.value = message;
|
|
2172
|
+
else errorMessage.value = message;
|
|
2114
2173
|
return false;
|
|
2115
2174
|
}
|
|
2116
2175
|
}
|
|
2117
2176
|
|
|
2177
|
+
function formatHidQrCountdown(totalSeconds: number) {
|
|
2178
|
+
const safe = Math.max(0, totalSeconds);
|
|
2179
|
+
const hours = Math.floor(safe / 3600);
|
|
2180
|
+
const minutes = Math.floor((safe % 3600) / 60);
|
|
2181
|
+
const seconds = safe % 60;
|
|
2182
|
+
return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
async function regenerateHidQrCode() {
|
|
2186
|
+
if (!hidQrVisitorId.value) return;
|
|
2187
|
+
hidQrRegenerating.value = true;
|
|
2188
|
+
hidQrPreviewError.value = "";
|
|
2189
|
+
try {
|
|
2190
|
+
await prepareHidQrCodePreview(hidQrVisitorId.value);
|
|
2191
|
+
} finally {
|
|
2192
|
+
hidQrRegenerating.value = false;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2118
2196
|
async function printHidQrCodePass() {
|
|
2119
2197
|
if (hidQrPrinted.value) return true;
|
|
2120
2198
|
|
|
@@ -2335,4 +2413,19 @@ onMounted(() => {
|
|
|
2335
2413
|
border: 1px solid #dce3e8;
|
|
2336
2414
|
background: #ffffff;
|
|
2337
2415
|
}
|
|
2416
|
+
|
|
2417
|
+
.hid-qr-reveal {
|
|
2418
|
+
display: flex;
|
|
2419
|
+
width: 280px;
|
|
2420
|
+
height: 280px;
|
|
2421
|
+
align-items: center;
|
|
2422
|
+
justify-content: center;
|
|
2423
|
+
border: 0;
|
|
2424
|
+
background: #f5f7f9;
|
|
2425
|
+
color: #344054;
|
|
2426
|
+
cursor: pointer;
|
|
2427
|
+
flex-direction: column;
|
|
2428
|
+
gap: 10px;
|
|
2429
|
+
font: inherit;
|
|
2430
|
+
}
|
|
2338
2431
|
</style>
|
|
@@ -303,6 +303,18 @@
|
|
|
303
303
|
>
|
|
304
304
|
</div>
|
|
305
305
|
|
|
306
|
+
<v-btn
|
|
307
|
+
v-if="hidQrEnabled"
|
|
308
|
+
icon="mdi-eye-outline"
|
|
309
|
+
size="x-small"
|
|
310
|
+
variant="text"
|
|
311
|
+
color="primary"
|
|
312
|
+
title="View HID QR code"
|
|
313
|
+
aria-label="View HID QR code"
|
|
314
|
+
class="align-self-start mt-1"
|
|
315
|
+
@click.stop="openVisitorHidQr(item)"
|
|
316
|
+
/>
|
|
317
|
+
|
|
306
318
|
<v-menu :close-on-content-click="true">
|
|
307
319
|
<template v-slot:activator="{ props: menuProps }">
|
|
308
320
|
<div
|
|
@@ -1177,12 +1189,52 @@
|
|
|
1177
1189
|
</v-card>
|
|
1178
1190
|
</v-dialog>
|
|
1179
1191
|
|
|
1192
|
+
<v-dialog v-model="hidQrDialog" max-width="440" persistent>
|
|
1193
|
+
<v-card>
|
|
1194
|
+
<v-toolbar color="transparent" density="comfortable" class="px-2">
|
|
1195
|
+
<v-toolbar-title class="text-subtitle-1 font-weight-bold">Visitor QR Code</v-toolbar-title>
|
|
1196
|
+
<v-btn icon="mdi-close" variant="text" title="Close" @click="hidQrDialog = false" />
|
|
1197
|
+
</v-toolbar>
|
|
1198
|
+
<v-card-text class="px-8 pb-6 text-center">
|
|
1199
|
+
<v-progress-circular v-if="hidQrLoading" indeterminate color="primary" class="my-10" />
|
|
1200
|
+
<template v-else-if="hidQrImageUrl">
|
|
1201
|
+
<div class="text-h6 font-weight-bold">{{ hidQrVisitor?.name || "Visitor Access Pass" }}</div>
|
|
1202
|
+
<div class="visitor-hid-qr my-5">
|
|
1203
|
+
<v-img :src="hidQrImageUrl" width="280" height="280" alt="Visitor HID QR code" />
|
|
1204
|
+
</div>
|
|
1205
|
+
<div class="text-caption text-medium-emphasis">
|
|
1206
|
+
Valid until {{ formatHidQrExpiry(hidQrExpiresAt) }}
|
|
1207
|
+
</div>
|
|
1208
|
+
<v-chip class="mt-2" size="small" :color="hidQrExpired ? 'error' : 'success'" variant="tonal">
|
|
1209
|
+
{{ hidQrExpired ? "Expired" : `Expires in ${formatHidQrCountdown(hidQrRemainingSeconds)}` }}
|
|
1210
|
+
</v-chip>
|
|
1211
|
+
</template>
|
|
1212
|
+
<v-alert v-if="hidQrError" type="warning" variant="tonal" density="compact" class="mt-4 text-left">
|
|
1213
|
+
{{ hidQrError }}
|
|
1214
|
+
</v-alert>
|
|
1215
|
+
<v-btn
|
|
1216
|
+
v-if="!hidQrLoading && (!hidQrImageUrl || hidQrExpired)"
|
|
1217
|
+
block
|
|
1218
|
+
color="primary-button"
|
|
1219
|
+
variant="flat"
|
|
1220
|
+
class="mt-4 text-none"
|
|
1221
|
+
prepend-icon="mdi-refresh"
|
|
1222
|
+
:loading="hidQrGenerating"
|
|
1223
|
+
@click="generateVisitorHidQr"
|
|
1224
|
+
>
|
|
1225
|
+
{{ hidQrExpired ? "Generate QR again" : "Generate HID QR code" }}
|
|
1226
|
+
</v-btn>
|
|
1227
|
+
</v-card-text>
|
|
1228
|
+
</v-card>
|
|
1229
|
+
</v-dialog>
|
|
1230
|
+
|
|
1180
1231
|
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
1181
1232
|
</v-row>
|
|
1182
1233
|
</template>
|
|
1183
1234
|
|
|
1184
1235
|
<script setup lang="ts">
|
|
1185
1236
|
import moment from "moment-timezone";
|
|
1237
|
+
import QRCode from "qrcode";
|
|
1186
1238
|
|
|
1187
1239
|
definePageMeta({
|
|
1188
1240
|
middleware: ["01-auth", "02-org"],
|
|
@@ -1244,6 +1296,8 @@ const {
|
|
|
1244
1296
|
} = useUtils();
|
|
1245
1297
|
const { formatLocation } = useSecurityUtils();
|
|
1246
1298
|
const { getFileUrlAnpr } = useFile();
|
|
1299
|
+
const { getSiteById } = useSite();
|
|
1300
|
+
const { getIdentities, issueVisitorQr } = useHidAmico();
|
|
1247
1301
|
// const { status: visitorStatus, search } = useRoute().query as { status: string, search: string};
|
|
1248
1302
|
|
|
1249
1303
|
const routeName = route.name;
|
|
@@ -1255,6 +1309,106 @@ const pageRange = ref("-- - -- of --");
|
|
|
1255
1309
|
const activeTab = ref("registered");
|
|
1256
1310
|
const activeVisitorFormType = ref<TVisitorType | null>(null);
|
|
1257
1311
|
const selectedVisitorId = ref<string | null>(""); // selected visitor for viewing/actions
|
|
1312
|
+
const hidQrDialog = ref(false);
|
|
1313
|
+
const hidQrLoading = ref(false);
|
|
1314
|
+
const hidQrGenerating = ref(false);
|
|
1315
|
+
const hidQrError = ref("");
|
|
1316
|
+
const hidQrImageUrl = ref("");
|
|
1317
|
+
const hidQrExpiresAt = ref("");
|
|
1318
|
+
const hidQrNow = ref(Date.now());
|
|
1319
|
+
const hidQrVisitor = ref<Record<string, any> | null>(null);
|
|
1320
|
+
const hidQrConfig = ref({ enabled: false, readerId: "", validityMinutes: 60 });
|
|
1321
|
+
let hidQrTimer: ReturnType<typeof setInterval> | null = null;
|
|
1322
|
+
const hidQrEnabled = computed(() => hidQrConfig.value.enabled && Boolean(hidQrConfig.value.readerId));
|
|
1323
|
+
const hidQrRemainingSeconds = computed(() => {
|
|
1324
|
+
const expiry = new Date(hidQrExpiresAt.value).getTime();
|
|
1325
|
+
return Number.isFinite(expiry) ? Math.max(0, Math.ceil((expiry - hidQrNow.value) / 1000)) : 0;
|
|
1326
|
+
});
|
|
1327
|
+
const hidQrExpired = computed(() => Boolean(hidQrExpiresAt.value) && hidQrRemainingSeconds.value === 0);
|
|
1328
|
+
|
|
1329
|
+
async function loadHidQrConfig() {
|
|
1330
|
+
try {
|
|
1331
|
+
const response: any = await getSiteById(siteId);
|
|
1332
|
+
const site = response?.data ?? response;
|
|
1333
|
+
const config = site?.metadata?.hidQrCodePass ?? {};
|
|
1334
|
+
hidQrConfig.value = {
|
|
1335
|
+
enabled: Boolean(config.enabled && config.onlineMode !== false),
|
|
1336
|
+
readerId: String(config.readerId || ""),
|
|
1337
|
+
validityMinutes: Number(config.validityMinutes) > 0 ? Number(config.validityMinutes) : 60,
|
|
1338
|
+
};
|
|
1339
|
+
} catch {
|
|
1340
|
+
hidQrConfig.value = { enabled: false, readerId: "", validityMinutes: 60 };
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function extractIdentityItems(response: any) {
|
|
1345
|
+
return response?.items ?? response?.data?.items ?? [];
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
async function openVisitorHidQr(visitor: Record<string, any>) {
|
|
1349
|
+
hidQrVisitor.value = visitor;
|
|
1350
|
+
hidQrDialog.value = true;
|
|
1351
|
+
hidQrLoading.value = true;
|
|
1352
|
+
hidQrError.value = "";
|
|
1353
|
+
hidQrImageUrl.value = "";
|
|
1354
|
+
hidQrExpiresAt.value = "";
|
|
1355
|
+
try {
|
|
1356
|
+
const response = await getIdentities(hidQrConfig.value.readerId, { type: "visitor", limit: 100 });
|
|
1357
|
+
const identity = extractIdentityItems(response).find(
|
|
1358
|
+
(item: any) => String(item.visitor || item.metadata?.visitor || "") === String(visitor._id),
|
|
1359
|
+
);
|
|
1360
|
+
const qrValue = String(identity?.metadata?.qrValue || identity?.cardNo || "");
|
|
1361
|
+
hidQrExpiresAt.value = String(identity?.metadata?.expiresAt || "");
|
|
1362
|
+
if (!qrValue || !hidQrExpiresAt.value) {
|
|
1363
|
+
hidQrError.value = "This visitor does not have an HID QR code yet.";
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
hidQrImageUrl.value = await QRCode.toDataURL(qrValue, { width: 360, margin: 2, errorCorrectionLevel: "M" });
|
|
1367
|
+
hidQrNow.value = Date.now();
|
|
1368
|
+
} catch (error: unknown) {
|
|
1369
|
+
hidQrError.value = errorConverter(error) || "Unable to load the visitor HID QR code.";
|
|
1370
|
+
} finally {
|
|
1371
|
+
hidQrLoading.value = false;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
async function generateVisitorHidQr() {
|
|
1376
|
+
if (!hidQrVisitor.value?._id || !hidQrConfig.value.readerId) return;
|
|
1377
|
+
hidQrGenerating.value = true;
|
|
1378
|
+
hidQrError.value = "";
|
|
1379
|
+
try {
|
|
1380
|
+
const response = await issueVisitorQr(hidQrConfig.value.readerId, {
|
|
1381
|
+
visitorId: String(hidQrVisitor.value._id),
|
|
1382
|
+
validityMinutes: hidQrConfig.value.validityMinutes,
|
|
1383
|
+
});
|
|
1384
|
+
const credential = "data" in response ? response.data : response;
|
|
1385
|
+
hidQrImageUrl.value = await QRCode.toDataURL(credential.qrValue, { width: 360, margin: 2, errorCorrectionLevel: "M" });
|
|
1386
|
+
hidQrExpiresAt.value = credential.expiresAt;
|
|
1387
|
+
hidQrNow.value = Date.now();
|
|
1388
|
+
} catch (error: unknown) {
|
|
1389
|
+
hidQrError.value = errorConverter(error) || "Unable to generate the visitor HID QR code.";
|
|
1390
|
+
} finally {
|
|
1391
|
+
hidQrGenerating.value = false;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function formatHidQrExpiry(value: string) {
|
|
1396
|
+
const date = new Date(value);
|
|
1397
|
+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function formatHidQrCountdown(totalSeconds: number) {
|
|
1401
|
+
const safe = Math.max(0, totalSeconds);
|
|
1402
|
+
const hours = Math.floor(safe / 3600);
|
|
1403
|
+
const minutes = Math.floor((safe % 3600) / 60);
|
|
1404
|
+
const seconds = safe % 60;
|
|
1405
|
+
return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
watch(hidQrDialog, (open) => {
|
|
1409
|
+
if (hidQrTimer) clearInterval(hidQrTimer);
|
|
1410
|
+
hidQrTimer = open ? setInterval(() => { hidQrNow.value = Date.now(); }, 1000) : null;
|
|
1411
|
+
});
|
|
1258
1412
|
|
|
1259
1413
|
//filter states
|
|
1260
1414
|
const searchInput = ref("");
|
|
@@ -2346,7 +2500,14 @@ const syncVisitorFiltersFromRoute = () => {
|
|
|
2346
2500
|
}
|
|
2347
2501
|
};
|
|
2348
2502
|
|
|
2349
|
-
onMounted(
|
|
2503
|
+
onMounted(() => {
|
|
2504
|
+
syncVisitorFiltersFromRoute();
|
|
2505
|
+
void loadHidQrConfig();
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
onBeforeUnmount(() => {
|
|
2509
|
+
if (hidQrTimer) clearInterval(hidQrTimer);
|
|
2510
|
+
});
|
|
2350
2511
|
|
|
2351
2512
|
watch(() => route.query, syncVisitorFiltersFromRoute, { deep: true });
|
|
2352
2513
|
|
|
@@ -2397,4 +2558,11 @@ watchEffect(async () => {
|
|
|
2397
2558
|
});
|
|
2398
2559
|
</script>
|
|
2399
2560
|
|
|
2400
|
-
<style scoped
|
|
2561
|
+
<style scoped>
|
|
2562
|
+
.visitor-hid-qr {
|
|
2563
|
+
display: inline-flex;
|
|
2564
|
+
padding: 12px;
|
|
2565
|
+
border: 1px solid #dce3e8;
|
|
2566
|
+
background: #fff;
|
|
2567
|
+
}
|
|
2568
|
+
</style>
|
|
@@ -33,7 +33,7 @@ type HidVisitorQrData = {
|
|
|
33
33
|
readerId: string;
|
|
34
34
|
hidUserId: number;
|
|
35
35
|
qrValue: string;
|
|
36
|
-
qrFormat: "0"
|
|
36
|
+
qrFormat: "0";
|
|
37
37
|
issuedAt: string;
|
|
38
38
|
expiresAt: string;
|
|
39
39
|
};
|
|
@@ -120,7 +120,7 @@ export default function useHidAmico() {
|
|
|
120
120
|
|
|
121
121
|
function issueVisitorQr(
|
|
122
122
|
readerId: string,
|
|
123
|
-
payload: { visitorId: string; validityMinutes: number
|
|
123
|
+
payload: { visitorId: string; validityMinutes: number },
|
|
124
124
|
) {
|
|
125
125
|
return useNuxtApp().$api<HidVisitorQrResponse>(`${basePath}/readers/${readerId}/visitor-qr`, {
|
|
126
126
|
method: "POST",
|
|
@@ -189,6 +189,27 @@ export default function useHidAmico() {
|
|
|
189
189
|
);
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
function getUserPinStatus(readerId: string, hidUserId: string | number) {
|
|
193
|
+
return useNuxtApp().$api<{ data: { pinEnrolled: boolean } }>(
|
|
194
|
+
`${basePath}/readers/${readerId}/users/${hidUserId}/pin`,
|
|
195
|
+
{ method: "GET" },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function setUserPin(readerId: string, hidUserId: string | number, pin: string) {
|
|
200
|
+
return useNuxtApp().$api<{ data: { pinEnrolled: boolean } }>(
|
|
201
|
+
`${basePath}/readers/${readerId}/users/${hidUserId}/pin`,
|
|
202
|
+
{ method: "PUT", body: { pin } },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function deleteUserPin(readerId: string, hidUserId: string | number) {
|
|
207
|
+
return useNuxtApp().$api<{ data: { pinEnrolled: boolean } }>(
|
|
208
|
+
`${basePath}/readers/${readerId}/users/${hidUserId}/pin`,
|
|
209
|
+
{ method: "DELETE" },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
192
213
|
function createIdentity(readerId: string, payload: HidIdentityPayload) {
|
|
193
214
|
return useNuxtApp().$api<Record<string, any>>(`${basePath}/readers/${readerId}/identities`, {
|
|
194
215
|
method: "POST",
|
|
@@ -286,6 +307,9 @@ export default function useHidAmico() {
|
|
|
286
307
|
getUserImage,
|
|
287
308
|
setUserImage,
|
|
288
309
|
deleteUserImage,
|
|
310
|
+
getUserPinStatus,
|
|
311
|
+
setUserPin,
|
|
312
|
+
deleteUserPin,
|
|
289
313
|
getIdentities,
|
|
290
314
|
createIdentity,
|
|
291
315
|
updateIdentity,
|
package/composables/useRole.ts
CHANGED
|
@@ -68,6 +68,26 @@ export default function useRole() {
|
|
|
68
68
|
});
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function getDeletionPreview(_id: string) {
|
|
72
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
73
|
+
`/api/roles/${_id}/deletion-preview`,
|
|
74
|
+
{ method: "GET" }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deleteWithReassignments(
|
|
79
|
+
_id: string,
|
|
80
|
+
reassignments: Array<{ memberId: string; roleId: string }>
|
|
81
|
+
) {
|
|
82
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
83
|
+
`/api/roles/${_id}/delete-with-reassignments`,
|
|
84
|
+
{
|
|
85
|
+
method: "PUT",
|
|
86
|
+
body: { reassignments },
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
const role = useState<TRole>("userRole", () => {
|
|
72
92
|
return {
|
|
73
93
|
_id: "",
|
|
@@ -103,6 +123,8 @@ export default function useRole() {
|
|
|
103
123
|
updateRoleById,
|
|
104
124
|
updateRoleFieldById,
|
|
105
125
|
deleteRole,
|
|
126
|
+
getDeletionPreview,
|
|
127
|
+
deleteWithReassignments,
|
|
106
128
|
updatePermissionById,
|
|
107
129
|
getRoleByUserId,
|
|
108
130
|
};
|
|
@@ -31,6 +31,12 @@ export default function useSipWebPhone() {
|
|
|
31
31
|
|
|
32
32
|
const isRegistered = computed(() => connectionState.value === "registered");
|
|
33
33
|
const hasCall = computed(() => callState.value !== "idle");
|
|
34
|
+
const audioConstraints: MediaTrackConstraints = {
|
|
35
|
+
echoCancellation: true,
|
|
36
|
+
noiseSuppression: true,
|
|
37
|
+
autoGainControl: true,
|
|
38
|
+
channelCount: 1,
|
|
39
|
+
};
|
|
34
40
|
|
|
35
41
|
async function connect(config: SipWebPhoneConfig, media: SipWebPhoneMedia) {
|
|
36
42
|
validateConfig(config);
|
|
@@ -73,6 +79,7 @@ export default function useSipWebPhone() {
|
|
|
73
79
|
},
|
|
74
80
|
onCallAnswered: () => {
|
|
75
81
|
callState.value = "active";
|
|
82
|
+
ensureMicrophoneSending();
|
|
76
83
|
syncVideoState();
|
|
77
84
|
},
|
|
78
85
|
onCallHangup: () => {
|
|
@@ -104,7 +111,9 @@ export default function useSipWebPhone() {
|
|
|
104
111
|
aor: normalizeSipUri(config.aor),
|
|
105
112
|
delegate,
|
|
106
113
|
media: {
|
|
107
|
-
|
|
114
|
+
// The browser sends microphone audio and receives HID audio/video.
|
|
115
|
+
// Do not request a local camera merely because the HID video leg is enabled.
|
|
116
|
+
constraints: { audio: audioConstraints, video: false },
|
|
108
117
|
remote: {
|
|
109
118
|
audio: media.remoteAudio || undefined,
|
|
110
119
|
video: media.remoteVideo || undefined,
|
|
@@ -167,7 +176,7 @@ export default function useSipWebPhone() {
|
|
|
167
176
|
callState.value = "calling";
|
|
168
177
|
try {
|
|
169
178
|
const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
|
|
170
|
-
constraints: { audio:
|
|
179
|
+
constraints: { audio: audioConstraints, video: false },
|
|
171
180
|
offerOptions: {
|
|
172
181
|
offerToReceiveAudio: true,
|
|
173
182
|
offerToReceiveVideo: Boolean(activeConfig.value?.video),
|
|
@@ -184,7 +193,11 @@ export default function useSipWebPhone() {
|
|
|
184
193
|
async function answer() {
|
|
185
194
|
const user = requireRegisteredUser();
|
|
186
195
|
const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
|
|
187
|
-
constraints: { audio:
|
|
196
|
+
constraints: { audio: audioConstraints, video: false },
|
|
197
|
+
offerOptions: {
|
|
198
|
+
offerToReceiveAudio: true,
|
|
199
|
+
offerToReceiveVideo: Boolean(activeConfig.value?.video),
|
|
200
|
+
},
|
|
188
201
|
};
|
|
189
202
|
await user.answer({ sessionDescriptionHandlerOptions });
|
|
190
203
|
}
|
|
@@ -214,6 +227,18 @@ export default function useSipWebPhone() {
|
|
|
214
227
|
muted.value = !enable;
|
|
215
228
|
}
|
|
216
229
|
|
|
230
|
+
function ensureMicrophoneSending() {
|
|
231
|
+
const tracks = simpleUser.value?.localMediaStream?.getAudioTracks() || [];
|
|
232
|
+
if (!tracks.length) {
|
|
233
|
+
errorMessage.value = "The call connected without a microphone track. Reconnect and allow microphone access.";
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
tracks.forEach((track) => {
|
|
237
|
+
track.enabled = true;
|
|
238
|
+
});
|
|
239
|
+
muted.value = false;
|
|
240
|
+
}
|
|
241
|
+
|
|
217
242
|
function syncVideoState() {
|
|
218
243
|
const user = simpleUser.value;
|
|
219
244
|
const remoteStream = user?.remoteMediaStream;
|