@7365admin1/layer-common 3.2.1-staging.62 → 3.2.1-staging.63
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.
|
@@ -327,6 +327,8 @@ type AccessTab = "resident" | "visitor" | "administrator";
|
|
|
327
327
|
type AccessRow = {
|
|
328
328
|
key: string;
|
|
329
329
|
identityKey: string;
|
|
330
|
+
identityType?: string;
|
|
331
|
+
visitorId?: string;
|
|
330
332
|
name: string;
|
|
331
333
|
facialData: string;
|
|
332
334
|
faceImage?: string;
|
|
@@ -351,6 +353,7 @@ const { getVisitors } = useVisitor();
|
|
|
351
353
|
|
|
352
354
|
const readers = ref<Record<string, any>[]>([]);
|
|
353
355
|
const identities = ref<Record<string, any>[]>([]);
|
|
356
|
+
const allIdentities = ref<Record<string, any>[]>([]);
|
|
354
357
|
const visitorRecords = ref<Record<string, any>>({});
|
|
355
358
|
const logs = ref<Record<string, any>[]>([]);
|
|
356
359
|
const liveAccessLogs = ref<Record<string, any>[]>([]);
|
|
@@ -556,12 +559,10 @@ async function loadDashboard() {
|
|
|
556
559
|
resetDashboardData();
|
|
557
560
|
try {
|
|
558
561
|
const isAdministratorTab = activeTab.value === "administrator";
|
|
559
|
-
const isVisitorTab = activeTab.value === "visitor";
|
|
560
562
|
const [identityResponse, logResponse, hidAccessLogs, configurationResponse, roleResponse, visitorResponse, readerUserResponse] = await Promise.all([
|
|
561
563
|
getIdentities(selectedReaderId.value, {
|
|
562
564
|
page: 1,
|
|
563
565
|
limit: 500,
|
|
564
|
-
...(!isAdministratorTab ? { type: getIdentityType(activeTab.value) } : {}),
|
|
565
566
|
}),
|
|
566
567
|
getLogs(selectedReaderId.value, {
|
|
567
568
|
page: 1,
|
|
@@ -570,7 +571,7 @@ async function loadDashboard() {
|
|
|
570
571
|
loadLiveAccessLogs(selectedReaderId.value),
|
|
571
572
|
loadReaderConfiguration(selectedReaderId.value),
|
|
572
573
|
isAdministratorTab ? loadAdministratorRoles(selectedReaderId.value) : Promise.resolve(null),
|
|
573
|
-
|
|
574
|
+
props.org
|
|
574
575
|
? getVisitors({
|
|
575
576
|
org: props.org,
|
|
576
577
|
site: props.site,
|
|
@@ -587,18 +588,19 @@ async function loadDashboard() {
|
|
|
587
588
|
const loadedIdentities = identityResponse?.items ?? identityResponse?.data?.items ?? identityResponse?.data?.identities ?? [];
|
|
588
589
|
const mergedIdentities = mergeReaderUsersWithIdentities(readerUserResponse, loadedIdentities);
|
|
589
590
|
const loadedVisitors = visitorResponse?.items ?? visitorResponse?.data?.items ?? visitorResponse?.data?.visitors ?? [];
|
|
590
|
-
visitorRecords.value =
|
|
591
|
-
|
|
592
|
-
|
|
591
|
+
visitorRecords.value = Object.fromEntries(
|
|
592
|
+
loadedVisitors.map((visitor: Record<string, any>) => [String(visitor._id), visitor]),
|
|
593
|
+
);
|
|
593
594
|
administratorUserIds.value = getAdministratorRoleUserIds(roleResponse);
|
|
595
|
+
allIdentities.value = mergedIdentities;
|
|
594
596
|
identities.value = isAdministratorTab
|
|
595
597
|
? mergedIdentities.filter((identity: Record<string, any>) => {
|
|
596
598
|
const hidUserId = toHidNumericId(identity.hidUserId);
|
|
597
599
|
return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
|
|
598
600
|
})
|
|
599
|
-
:
|
|
600
|
-
? mergedIdentities.filter(
|
|
601
|
-
|
|
601
|
+
: activeTab.value === "visitor"
|
|
602
|
+
? mergedIdentities.filter(isVisitorIdentity)
|
|
603
|
+
: mergedIdentities.filter((identity: Record<string, any>) => !isVisitorIdentity(identity));
|
|
602
604
|
logs.value = logResponse?.items ?? logResponse?.data?.items ?? logResponse?.data?.logs ?? [];
|
|
603
605
|
liveAccessLogs.value = hidAccessLogs;
|
|
604
606
|
readerConfiguration.value = normalizeReaderConfiguration(configurationResponse);
|
|
@@ -775,6 +777,7 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
|
|
|
775
777
|
function resetDashboardData() {
|
|
776
778
|
selectedHistoryRow.value = undefined;
|
|
777
779
|
identities.value = [];
|
|
780
|
+
allIdentities.value = [];
|
|
778
781
|
visitorRecords.value = {};
|
|
779
782
|
logs.value = [];
|
|
780
783
|
liveAccessLogs.value = [];
|
|
@@ -782,15 +785,11 @@ function resetDashboardData() {
|
|
|
782
785
|
administratorUserIds.value = new Set();
|
|
783
786
|
}
|
|
784
787
|
|
|
785
|
-
function getIdentityType(tab: AccessTab) {
|
|
786
|
-
if (tab === "administrator") return "admin";
|
|
787
|
-
return tab;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
788
|
function isVisibleForActiveTab(row: AccessRow) {
|
|
791
789
|
const identity = findIdentityByKey(row.identityKey);
|
|
790
|
+
const isVisitor = row.identityType === "visitor" || Boolean(row.visitorId) || isVisitorIdentity(identity);
|
|
792
791
|
if (activeTab.value === "visitor") {
|
|
793
|
-
return
|
|
792
|
+
return isVisitor;
|
|
794
793
|
}
|
|
795
794
|
|
|
796
795
|
if (activeTab.value === "administrator") {
|
|
@@ -798,7 +797,11 @@ function isVisibleForActiveTab(row: AccessRow) {
|
|
|
798
797
|
return Boolean(hidUserId && administratorUserIds.value.has(hidUserId));
|
|
799
798
|
}
|
|
800
799
|
|
|
801
|
-
return !
|
|
800
|
+
return !isVisitor;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function isVisitorIdentity(identity: Record<string, unknown> | undefined) {
|
|
804
|
+
return Boolean(identity && (identity.type === "visitor" || identity.visitor));
|
|
802
805
|
}
|
|
803
806
|
|
|
804
807
|
function loadAdministratorRoles(readerId: string) {
|
|
@@ -850,7 +853,7 @@ function getIdentityKey(identity: Record<string, any>) {
|
|
|
850
853
|
|
|
851
854
|
function findIdentityByKey(identityKey: string) {
|
|
852
855
|
if (!identityKey) return undefined;
|
|
853
|
-
return
|
|
856
|
+
return allIdentities.value.find((identity) => getIdentityKey(identity) === identityKey);
|
|
854
857
|
}
|
|
855
858
|
|
|
856
859
|
function getLogIdentityKey(log: Record<string, any>) {
|
|
@@ -1154,12 +1157,15 @@ function getRawAccessLogs(event: Record<string, any>) {
|
|
|
1154
1157
|
|
|
1155
1158
|
function mapAccessLogToRow(rawLog: Record<string, any>, event: Record<string, any>, index: string): AccessRow {
|
|
1156
1159
|
const identity = findIdentityForAccessLog(rawLog, event);
|
|
1160
|
+
const eventIdentity = event.payload?.identity || event.payload?.identityLookup || {};
|
|
1157
1161
|
const rawAccessTime = getAccessLogTime(rawLog, event);
|
|
1158
1162
|
const identityKey = identity ? getIdentityKey(identity) : getLogIdentityKey({ ...event, payload: { ...event.payload, rawAccessLog: rawLog } });
|
|
1159
1163
|
|
|
1160
1164
|
return {
|
|
1161
1165
|
key: `${event._id || event.id || "log"}-${rawLog.id || rawLog.time || index}`,
|
|
1162
1166
|
identityKey,
|
|
1167
|
+
identityType: String(identity?.type || eventIdentity.type || "").toLowerCase() || undefined,
|
|
1168
|
+
visitorId: String(identity?.visitor || eventIdentity.visitor || "") || undefined,
|
|
1163
1169
|
name: identity ? getName(identity) : getRawLogName(rawLog, event),
|
|
1164
1170
|
facialData: identity ? getFacialData(identity) : "N/A",
|
|
1165
1171
|
faceImage: identity ? getUserImageSrc(identity) : "",
|
|
@@ -1175,7 +1181,7 @@ function mapAccessLogToRow(rawLog: Record<string, any>, event: Record<string, an
|
|
|
1175
1181
|
function findIdentityForAccessLog(rawLog: Record<string, any>, event: Record<string, any>) {
|
|
1176
1182
|
const eventIdentityId = event.payload?.identity?._id;
|
|
1177
1183
|
if (eventIdentityId) {
|
|
1178
|
-
const byId =
|
|
1184
|
+
const byId = allIdentities.value.find((identity) => String(identity._id) === String(eventIdentityId));
|
|
1179
1185
|
if (byId) return byId;
|
|
1180
1186
|
}
|
|
1181
1187
|
|
|
@@ -1184,7 +1190,7 @@ function findIdentityForAccessLog(rawLog: Record<string, any>, event: Record<str
|
|
|
1184
1190
|
const cardNo = rawLog.card_value || rawLog.cardNo || event.payload?.identity?.cardNo || event.payload?.identityLookup?.cardNo;
|
|
1185
1191
|
const qrCodeValue = rawLog.qrcode_value || rawLog.qrCode || rawLog.qrcode;
|
|
1186
1192
|
|
|
1187
|
-
return
|
|
1193
|
+
return allIdentities.value.find((identity) => {
|
|
1188
1194
|
return (
|
|
1189
1195
|
(hidUserId !== undefined && hidUserId !== null && String(identity.hidUserId) === String(hidUserId)) ||
|
|
1190
1196
|
(registration && identity.registration === registration) ||
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-dialog
|
|
3
|
+
:model-value="modelValue"
|
|
4
|
+
max-width="520"
|
|
5
|
+
persistent
|
|
6
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
7
|
+
>
|
|
8
|
+
<v-card class="camera-dialog" rounded="lg">
|
|
9
|
+
<v-card-title class="d-flex align-center justify-space-between pa-4">
|
|
10
|
+
<span class="text-subtitle-1 font-weight-bold">Take Facial Photo</span>
|
|
11
|
+
<v-btn
|
|
12
|
+
icon="mdi-close"
|
|
13
|
+
variant="text"
|
|
14
|
+
size="small"
|
|
15
|
+
aria-label="Close camera"
|
|
16
|
+
@click="close"
|
|
17
|
+
/>
|
|
18
|
+
</v-card-title>
|
|
19
|
+
|
|
20
|
+
<v-divider />
|
|
21
|
+
|
|
22
|
+
<v-card-text class="pa-4">
|
|
23
|
+
<div class="camera-viewport">
|
|
24
|
+
<video
|
|
25
|
+
ref="videoElement"
|
|
26
|
+
class="camera-preview"
|
|
27
|
+
autoplay
|
|
28
|
+
muted
|
|
29
|
+
playsinline
|
|
30
|
+
/>
|
|
31
|
+
|
|
32
|
+
<div v-if="starting" class="camera-state">
|
|
33
|
+
<v-progress-circular indeterminate color="primary" />
|
|
34
|
+
<span class="mt-3">Starting camera...</span>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div v-else-if="cameraError" class="camera-state px-6 text-center">
|
|
38
|
+
<v-icon icon="mdi-camera-off-outline" size="42" color="error" />
|
|
39
|
+
<span class="mt-3 text-error">{{ cameraError }}</span>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<canvas ref="canvasElement" class="d-none" />
|
|
44
|
+
|
|
45
|
+
<div class="text-caption text-medium-emphasis mt-3 text-center">
|
|
46
|
+
Keep your face centered and make sure the area is well lit.
|
|
47
|
+
</div>
|
|
48
|
+
</v-card-text>
|
|
49
|
+
|
|
50
|
+
<v-divider />
|
|
51
|
+
|
|
52
|
+
<v-card-actions class="pa-4">
|
|
53
|
+
<v-btn variant="text" @click="close">Cancel</v-btn>
|
|
54
|
+
<v-spacer />
|
|
55
|
+
<v-btn
|
|
56
|
+
color="primary"
|
|
57
|
+
variant="flat"
|
|
58
|
+
prepend-icon="mdi-camera"
|
|
59
|
+
:disabled="starting || !!cameraError || !stream"
|
|
60
|
+
@click="capture"
|
|
61
|
+
>
|
|
62
|
+
Take Photo
|
|
63
|
+
</v-btn>
|
|
64
|
+
</v-card-actions>
|
|
65
|
+
</v-card>
|
|
66
|
+
</v-dialog>
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<script setup lang="ts">
|
|
70
|
+
const props = defineProps<{ modelValue: boolean }>();
|
|
71
|
+
const emit = defineEmits<{
|
|
72
|
+
"update:modelValue": [value: boolean];
|
|
73
|
+
capture: [dataUrl: string];
|
|
74
|
+
}>();
|
|
75
|
+
|
|
76
|
+
const videoElement = ref<HTMLVideoElement | null>(null);
|
|
77
|
+
const canvasElement = ref<HTMLCanvasElement | null>(null);
|
|
78
|
+
const stream = shallowRef<MediaStream | null>(null);
|
|
79
|
+
const starting = ref(false);
|
|
80
|
+
const cameraError = ref("");
|
|
81
|
+
|
|
82
|
+
function stopCamera() {
|
|
83
|
+
stream.value?.getTracks().forEach((track) => track.stop());
|
|
84
|
+
stream.value = null;
|
|
85
|
+
if (videoElement.value) videoElement.value.srcObject = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getCameraErrorMessage(caught: unknown) {
|
|
89
|
+
if (caught instanceof DOMException) {
|
|
90
|
+
if (caught.name === "NotAllowedError") {
|
|
91
|
+
return "Camera permission was denied. Allow camera access and try again.";
|
|
92
|
+
}
|
|
93
|
+
if (caught.name === "NotFoundError") {
|
|
94
|
+
return "No camera was found on this device.";
|
|
95
|
+
}
|
|
96
|
+
if (caught.name === "NotReadableError") {
|
|
97
|
+
return "The camera is currently being used by another application.";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return "Unable to open the camera. Use HTTPS or check the browser camera permission.";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function startCamera() {
|
|
104
|
+
stopCamera();
|
|
105
|
+
cameraError.value = "";
|
|
106
|
+
starting.value = true;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
110
|
+
throw new Error("Camera API is unavailable");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
stream.value = await navigator.mediaDevices.getUserMedia({
|
|
114
|
+
audio: false,
|
|
115
|
+
video: {
|
|
116
|
+
facingMode: "user",
|
|
117
|
+
width: { ideal: 1280 },
|
|
118
|
+
height: { ideal: 960 },
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await nextTick();
|
|
123
|
+
if (!videoElement.value) throw new Error("Camera preview is unavailable");
|
|
124
|
+
videoElement.value.srcObject = stream.value;
|
|
125
|
+
await videoElement.value.play();
|
|
126
|
+
} catch (caught: unknown) {
|
|
127
|
+
stopCamera();
|
|
128
|
+
cameraError.value = getCameraErrorMessage(caught);
|
|
129
|
+
} finally {
|
|
130
|
+
starting.value = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function capture() {
|
|
135
|
+
const video = videoElement.value;
|
|
136
|
+
const canvas = canvasElement.value;
|
|
137
|
+
if (!video || !canvas || !video.videoWidth || !video.videoHeight) return;
|
|
138
|
+
|
|
139
|
+
canvas.width = video.videoWidth;
|
|
140
|
+
canvas.height = video.videoHeight;
|
|
141
|
+
const context = canvas.getContext("2d");
|
|
142
|
+
if (!context) return;
|
|
143
|
+
|
|
144
|
+
context.translate(canvas.width, 0);
|
|
145
|
+
context.scale(-1, 1);
|
|
146
|
+
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
147
|
+
|
|
148
|
+
emit("capture", canvas.toDataURL("image/jpeg", 0.9));
|
|
149
|
+
close();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function close() {
|
|
153
|
+
stopCamera();
|
|
154
|
+
emit("update:modelValue", false);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
watch(
|
|
158
|
+
() => props.modelValue,
|
|
159
|
+
(isOpen) => {
|
|
160
|
+
if (isOpen) void startCamera();
|
|
161
|
+
else stopCamera();
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
onBeforeUnmount(stopCamera);
|
|
166
|
+
</script>
|
|
167
|
+
|
|
168
|
+
<style scoped>
|
|
169
|
+
.camera-dialog {
|
|
170
|
+
overflow: hidden;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.camera-viewport {
|
|
174
|
+
position: relative;
|
|
175
|
+
width: 100%;
|
|
176
|
+
overflow: hidden;
|
|
177
|
+
aspect-ratio: 3 / 4;
|
|
178
|
+
border-radius: 8px;
|
|
179
|
+
background: #111722;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.camera-preview {
|
|
183
|
+
width: 100%;
|
|
184
|
+
height: 100%;
|
|
185
|
+
object-fit: cover;
|
|
186
|
+
transform: scaleX(-1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.camera-state {
|
|
190
|
+
position: absolute;
|
|
191
|
+
inset: 0;
|
|
192
|
+
display: flex;
|
|
193
|
+
flex-direction: column;
|
|
194
|
+
align-items: center;
|
|
195
|
+
justify-content: center;
|
|
196
|
+
background: #111722;
|
|
197
|
+
color: #ffffff;
|
|
198
|
+
}
|
|
199
|
+
</style>
|
|
@@ -237,7 +237,7 @@
|
|
|
237
237
|
<div class="sip-switch-row sip-section-switch">
|
|
238
238
|
<div>
|
|
239
239
|
<span>Enable video</span>
|
|
240
|
-
<small>Allow this reader to send
|
|
240
|
+
<small>Allow this reader to send its camera feed during SIP intercom calls.</small>
|
|
241
241
|
</div>
|
|
242
242
|
<v-switch v-model="sipForm.videoEnabled" :disabled="!sipForm.enabled" color="success" density="compact" hide-details />
|
|
243
243
|
</div>
|
|
@@ -417,7 +417,10 @@
|
|
|
417
417
|
<span class="sip-account-address">{{ sipAccount.sipAddress }}</span>
|
|
418
418
|
</div>
|
|
419
419
|
<div class="call-toggle-row web-video-toggle">
|
|
420
|
-
<
|
|
420
|
+
<div>
|
|
421
|
+
<span>Receive HID video</span>
|
|
422
|
+
<small>Show the HID camera feed without sharing this device's camera.</small>
|
|
423
|
+
</div>
|
|
421
424
|
<v-switch
|
|
422
425
|
v-model="webPhoneForm.video"
|
|
423
426
|
color="success"
|
|
@@ -457,12 +460,10 @@
|
|
|
457
460
|
class="web-phone-media"
|
|
458
461
|
:class="{
|
|
459
462
|
'has-video': webPhoneForm.video && webPhone.callState.value === 'active',
|
|
460
|
-
'has-local-video': webPhone.localVideoAvailable.value,
|
|
461
463
|
'has-remote-video': webPhone.remoteVideoAvailable.value,
|
|
462
464
|
}"
|
|
463
465
|
>
|
|
464
466
|
<video ref="remoteVideoEl" autoplay playsinline class="remote-video" />
|
|
465
|
-
<video ref="localVideoEl" autoplay muted playsinline class="local-video" />
|
|
466
467
|
<audio ref="remoteAudioEl" autoplay />
|
|
467
468
|
<div
|
|
468
469
|
v-if="!webPhoneForm.video || webPhone.callState.value !== 'active' || !webPhone.remoteVideoAvailable.value"
|
|
@@ -472,7 +473,7 @@
|
|
|
472
473
|
<strong>{{ webPhoneCallLabel }}</strong>
|
|
473
474
|
<span v-if="webPhone.currentTarget.value">{{ webPhone.currentTarget.value }}</span>
|
|
474
475
|
<span v-if="webPhoneForm.video && webPhone.callState.value === 'active' && !webPhone.remoteVideoAvailable.value">
|
|
475
|
-
Waiting for video from the
|
|
476
|
+
Waiting for video from the HID device
|
|
476
477
|
</span>
|
|
477
478
|
</div>
|
|
478
479
|
</div>
|
|
@@ -529,14 +530,6 @@
|
|
|
529
530
|
:title="webPhone.muted.value ? 'Unmute' : 'Mute'"
|
|
530
531
|
@click="toggleWebMute"
|
|
531
532
|
/>
|
|
532
|
-
<v-btn
|
|
533
|
-
v-if="webPhoneForm.video"
|
|
534
|
-
:icon="webPhone.cameraEnabled.value ? 'mdi-video' : 'mdi-video-off'"
|
|
535
|
-
variant="outlined"
|
|
536
|
-
:disabled="webPhone.callState.value !== 'active' || !webPhone.localVideoAvailable.value"
|
|
537
|
-
:title="webPhone.cameraEnabled.value ? 'Turn camera off' : 'Turn camera on'"
|
|
538
|
-
@click="toggleWebCamera"
|
|
539
|
-
/>
|
|
540
533
|
<v-btn
|
|
541
534
|
icon="mdi-phone-hangup"
|
|
542
535
|
class="hangup-web-call"
|
|
@@ -723,7 +716,6 @@ const webPhoneActionLoading = ref(false);
|
|
|
723
716
|
const sipAccount = ref<SipAccount | null>(null);
|
|
724
717
|
const sipAccountLoading = ref(false);
|
|
725
718
|
const sipAccountError = ref("");
|
|
726
|
-
const localVideoEl = ref<HTMLVideoElement | null>(null);
|
|
727
719
|
const remoteVideoEl = ref<HTMLVideoElement | null>(null);
|
|
728
720
|
const remoteAudioEl = ref<HTMLAudioElement | null>(null);
|
|
729
721
|
const webPhone = useSipWebPhone();
|
|
@@ -1249,7 +1241,6 @@ async function connectWebPhone() {
|
|
|
1249
1241
|
displayName: "iService365 Web",
|
|
1250
1242
|
video: webPhoneForm.video,
|
|
1251
1243
|
}, {
|
|
1252
|
-
localVideo: localVideoEl.value,
|
|
1253
1244
|
remoteVideo: remoteVideoEl.value,
|
|
1254
1245
|
remoteAudio: remoteAudioEl.value,
|
|
1255
1246
|
});
|
|
@@ -1315,14 +1306,6 @@ async function hangupWebCall() {
|
|
|
1315
1306
|
}
|
|
1316
1307
|
}
|
|
1317
1308
|
|
|
1318
|
-
function toggleWebCamera() {
|
|
1319
|
-
try {
|
|
1320
|
-
webPhone.toggleCamera();
|
|
1321
|
-
} catch (error: unknown) {
|
|
1322
|
-
showMessage(getErrorMessage(error), "error");
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
1309
|
function toggleWebMute() {
|
|
1327
1310
|
try {
|
|
1328
1311
|
webPhone.toggleMute();
|
|
@@ -2462,23 +2445,6 @@ export default {
|
|
|
2462
2445
|
display: block;
|
|
2463
2446
|
}
|
|
2464
2447
|
|
|
2465
|
-
.local-video {
|
|
2466
|
-
position: absolute;
|
|
2467
|
-
right: 12px;
|
|
2468
|
-
bottom: 12px;
|
|
2469
|
-
display: none;
|
|
2470
|
-
width: 108px;
|
|
2471
|
-
aspect-ratio: 3 / 4;
|
|
2472
|
-
border: 2px solid #fff;
|
|
2473
|
-
border-radius: 4px;
|
|
2474
|
-
background: #263b49;
|
|
2475
|
-
object-fit: cover;
|
|
2476
|
-
}
|
|
2477
|
-
|
|
2478
|
-
.web-phone-media.has-local-video .local-video {
|
|
2479
|
-
display: block;
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
2448
|
.web-phone-media.has-remote-video .audio-call-state {
|
|
2483
2449
|
display: none;
|
|
2484
2450
|
}
|
|
@@ -50,7 +50,13 @@
|
|
|
50
50
|
|
|
51
51
|
<v-card flat border class="table-card">
|
|
52
52
|
<div class="table-refresh">
|
|
53
|
-
<v-btn
|
|
53
|
+
<v-btn
|
|
54
|
+
icon="mdi-refresh"
|
|
55
|
+
variant="text"
|
|
56
|
+
density="comfortable"
|
|
57
|
+
:loading="loading"
|
|
58
|
+
@click="loadUsers"
|
|
59
|
+
/>
|
|
54
60
|
<div class="page-count">{{ pageRange }}</div>
|
|
55
61
|
</div>
|
|
56
62
|
|
|
@@ -66,10 +72,18 @@
|
|
|
66
72
|
</tr>
|
|
67
73
|
</thead>
|
|
68
74
|
<tbody>
|
|
69
|
-
<tr
|
|
75
|
+
<tr
|
|
76
|
+
v-for="user in users"
|
|
77
|
+
:key="user._rowKey || user._id || user.hidUserId"
|
|
78
|
+
>
|
|
70
79
|
<td>{{ getName(user) }}</td>
|
|
71
80
|
<td>
|
|
72
|
-
<v-avatar
|
|
81
|
+
<v-avatar
|
|
82
|
+
v-if="getUserImageSrc(user)"
|
|
83
|
+
size="34"
|
|
84
|
+
rounded="lg"
|
|
85
|
+
class="hid-face-avatar"
|
|
86
|
+
>
|
|
73
87
|
<v-img :src="getUserImageSrc(user)" cover />
|
|
74
88
|
</v-avatar>
|
|
75
89
|
<span v-else>{{ getFacialData(user) }}</span>
|
|
@@ -88,13 +102,22 @@
|
|
|
88
102
|
<td class="text-right">
|
|
89
103
|
<v-menu>
|
|
90
104
|
<template #activator="{ props: menuProps }">
|
|
91
|
-
<v-btn
|
|
105
|
+
<v-btn
|
|
106
|
+
v-bind="menuProps"
|
|
107
|
+
icon="mdi-dots-vertical"
|
|
108
|
+
variant="text"
|
|
109
|
+
density="comfortable"
|
|
110
|
+
/>
|
|
92
111
|
</template>
|
|
93
112
|
|
|
94
113
|
<v-list density="compact" min-width="150">
|
|
95
114
|
<v-list-item title="View" @click="openView(user)" />
|
|
96
115
|
<v-list-item title="Edit" @click="openEdit(user)" />
|
|
97
|
-
<v-list-item
|
|
116
|
+
<v-list-item
|
|
117
|
+
title="Delete"
|
|
118
|
+
class="text-error"
|
|
119
|
+
@click="openDelete(user)"
|
|
120
|
+
/>
|
|
98
121
|
</v-list>
|
|
99
122
|
</v-menu>
|
|
100
123
|
</td>
|
|
@@ -109,8 +132,26 @@
|
|
|
109
132
|
|
|
110
133
|
<div class="table-footer">
|
|
111
134
|
<span>{{ pageRange }}</span>
|
|
112
|
-
<v-btn
|
|
113
|
-
|
|
135
|
+
<v-btn
|
|
136
|
+
icon="mdi-chevron-left"
|
|
137
|
+
variant="text"
|
|
138
|
+
density="comfortable"
|
|
139
|
+
:disabled="page <= 1"
|
|
140
|
+
@click="
|
|
141
|
+
page--;
|
|
142
|
+
loadUsers();
|
|
143
|
+
"
|
|
144
|
+
/>
|
|
145
|
+
<v-btn
|
|
146
|
+
icon="mdi-chevron-right"
|
|
147
|
+
variant="text"
|
|
148
|
+
density="comfortable"
|
|
149
|
+
:disabled="page >= pages"
|
|
150
|
+
@click="
|
|
151
|
+
page++;
|
|
152
|
+
loadUsers();
|
|
153
|
+
"
|
|
154
|
+
/>
|
|
114
155
|
</div>
|
|
115
156
|
</v-card>
|
|
116
157
|
|
|
@@ -125,7 +166,12 @@
|
|
|
125
166
|
<v-menu>
|
|
126
167
|
<template #activator="{ props: menuProps }">
|
|
127
168
|
<button v-bind="menuProps" class="photo-button" type="button">
|
|
128
|
-
<v-img
|
|
169
|
+
<v-img
|
|
170
|
+
v-if="form.photoPreview"
|
|
171
|
+
:src="form.photoPreview"
|
|
172
|
+
cover
|
|
173
|
+
class="photo-preview"
|
|
174
|
+
/>
|
|
129
175
|
<span v-if="form.photoPreview" class="photo-edit-icon">
|
|
130
176
|
<v-icon icon="mdi-pencil" size="16" />
|
|
131
177
|
</span>
|
|
@@ -137,7 +183,10 @@
|
|
|
137
183
|
</template>
|
|
138
184
|
<v-list density="compact" min-width="190">
|
|
139
185
|
<v-list-item title="Browse" @click="triggerPhotoInput" />
|
|
140
|
-
<v-list-item
|
|
186
|
+
<v-list-item
|
|
187
|
+
title="Take Photo using Camera"
|
|
188
|
+
@click="triggerCameraInput"
|
|
189
|
+
/>
|
|
141
190
|
<v-list-item
|
|
142
191
|
v-if="form.photoPreview"
|
|
143
192
|
title="Remove Photo"
|
|
@@ -146,12 +195,10 @@
|
|
|
146
195
|
/>
|
|
147
196
|
</v-list>
|
|
148
197
|
</v-menu>
|
|
149
|
-
<input ref="photoInput" type="file" accept="image/*" class="hidden-input" @change="onPhotoChange" />
|
|
150
198
|
<input
|
|
151
|
-
ref="
|
|
199
|
+
ref="photoInput"
|
|
152
200
|
type="file"
|
|
153
201
|
accept="image/*"
|
|
154
|
-
capture="user"
|
|
155
202
|
class="hidden-input"
|
|
156
203
|
@change="onPhotoChange"
|
|
157
204
|
/>
|
|
@@ -217,31 +264,61 @@
|
|
|
217
264
|
</v-card-text>
|
|
218
265
|
|
|
219
266
|
<v-card-actions class="pa-0">
|
|
220
|
-
<v-btn
|
|
221
|
-
|
|
267
|
+
<v-btn
|
|
268
|
+
class="text-none action-cancel"
|
|
269
|
+
variant="flat"
|
|
270
|
+
height="44"
|
|
271
|
+
@click="formDialog = false"
|
|
272
|
+
>Cancel</v-btn
|
|
273
|
+
>
|
|
274
|
+
<v-btn
|
|
275
|
+
class="text-none action-submit"
|
|
276
|
+
variant="flat"
|
|
277
|
+
height="44"
|
|
278
|
+
:loading="saving"
|
|
279
|
+
@click="saveUser"
|
|
280
|
+
>
|
|
222
281
|
Save
|
|
223
282
|
</v-btn>
|
|
224
283
|
</v-card-actions>
|
|
225
284
|
</v-card>
|
|
226
285
|
</v-dialog>
|
|
227
286
|
|
|
287
|
+
<HidCameraCaptureDialog v-model="cameraDialog" @capture="onCameraCapture" />
|
|
288
|
+
|
|
228
289
|
<v-dialog v-model="viewDialog" max-width="430">
|
|
229
290
|
<v-card rounded="lg">
|
|
230
291
|
<v-card-title class="small-title">User Information</v-card-title>
|
|
231
292
|
<v-card-text>
|
|
232
293
|
<div class="detail-grid">
|
|
233
294
|
<span>Name</span><strong>{{ getName(selectedUser) }}</strong>
|
|
234
|
-
<span>User ID</span
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
<span>
|
|
239
|
-
|
|
295
|
+
<span>User ID</span
|
|
296
|
+
><strong>{{
|
|
297
|
+
selectedUser ? formatHidUid(selectedUser.hidUserId) : "N/A"
|
|
298
|
+
}}</strong>
|
|
299
|
+
<span>Registration No.</span
|
|
300
|
+
><strong>{{ selectedUser?.registration || "N/A" }}</strong>
|
|
301
|
+
<span>Last access time</span
|
|
302
|
+
><strong>{{
|
|
303
|
+
formatDate(selectedUser?.metadata?.lastAccessAt)
|
|
304
|
+
}}</strong>
|
|
305
|
+
<span>Status</span
|
|
306
|
+
><strong>{{
|
|
307
|
+
selectedUser ? getMappingStatus(selectedUser) : "N/A"
|
|
308
|
+
}}</strong>
|
|
309
|
+
<span>Panic Pin</span
|
|
310
|
+
><strong>{{ selectedUser?.cardNo || "N/A" }}</strong>
|
|
311
|
+
<span>Private Password</span
|
|
312
|
+
><strong>{{
|
|
313
|
+
selectedUser?.metadata?.pinPassword ? "********" : "N/A"
|
|
314
|
+
}}</strong>
|
|
240
315
|
</div>
|
|
241
316
|
</v-card-text>
|
|
242
317
|
<v-card-actions>
|
|
243
318
|
<v-spacer />
|
|
244
|
-
<v-btn class="text-none" variant="text" @click="viewDialog = false"
|
|
319
|
+
<v-btn class="text-none" variant="text" @click="viewDialog = false"
|
|
320
|
+
>Close</v-btn
|
|
321
|
+
>
|
|
245
322
|
</v-card-actions>
|
|
246
323
|
</v-card>
|
|
247
324
|
</v-dialog>
|
|
@@ -252,12 +329,26 @@
|
|
|
252
329
|
<v-card-text class="text-body-2">
|
|
253
330
|
Are you sure you want to permanently delete this HID user?
|
|
254
331
|
<div class="text-caption text-medium-emphasis mt-2">
|
|
255
|
-
This action will remove the user from the HID reader and mark the
|
|
332
|
+
This action will remove the user from the HID reader and mark the
|
|
333
|
+
identity as deleted.
|
|
256
334
|
</div>
|
|
257
335
|
</v-card-text>
|
|
258
336
|
<v-card-actions class="pa-0">
|
|
259
|
-
<v-btn
|
|
260
|
-
|
|
337
|
+
<v-btn
|
|
338
|
+
class="text-none action-cancel"
|
|
339
|
+
variant="flat"
|
|
340
|
+
height="44"
|
|
341
|
+
@click="deleteDialog = false"
|
|
342
|
+
>Cancel</v-btn
|
|
343
|
+
>
|
|
344
|
+
<v-btn
|
|
345
|
+
class="text-none action-submit"
|
|
346
|
+
variant="flat"
|
|
347
|
+
height="44"
|
|
348
|
+
:loading="deleting"
|
|
349
|
+
@click="deleteUser"
|
|
350
|
+
>Delete</v-btn
|
|
351
|
+
>
|
|
261
352
|
</v-card-actions>
|
|
262
353
|
</v-card>
|
|
263
354
|
</v-dialog>
|
|
@@ -312,7 +403,7 @@ const deleteDialog = ref(false);
|
|
|
312
403
|
const selectedUser = ref<Record<string, any> | null>(null);
|
|
313
404
|
const showPassword = ref(false);
|
|
314
405
|
const photoInput = ref<HTMLInputElement | null>(null);
|
|
315
|
-
const
|
|
406
|
+
const cameraDialog = ref(false);
|
|
316
407
|
const selectedPhotoFile = ref<File | null>(null);
|
|
317
408
|
const removePhotoRequested = ref(false);
|
|
318
409
|
const snackbar = reactive({
|
|
@@ -340,7 +431,7 @@ const readerOptions = computed(() =>
|
|
|
340
431
|
readers.value.map((reader) => ({
|
|
341
432
|
title: reader.name || reader.deviceId || reader._id,
|
|
342
433
|
value: reader._id,
|
|
343
|
-
}))
|
|
434
|
+
}))
|
|
344
435
|
);
|
|
345
436
|
|
|
346
437
|
const pageRange = computed(() => {
|
|
@@ -354,7 +445,7 @@ const pageRange = computed(() => {
|
|
|
354
445
|
watch(
|
|
355
446
|
() => props.site,
|
|
356
447
|
() => init(),
|
|
357
|
-
{ immediate: true }
|
|
448
|
+
{ immediate: true }
|
|
358
449
|
);
|
|
359
450
|
|
|
360
451
|
async function init() {
|
|
@@ -365,7 +456,8 @@ async function init() {
|
|
|
365
456
|
async function loadReaders() {
|
|
366
457
|
if (!props.site) return;
|
|
367
458
|
const response = await getReaders({ site: props.site, page: 1, limit: 100 });
|
|
368
|
-
readers.value =
|
|
459
|
+
readers.value =
|
|
460
|
+
response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
|
|
369
461
|
if (!selectedReaderId.value && readers.value.length) {
|
|
370
462
|
selectedReaderId.value = readers.value[0]._id;
|
|
371
463
|
}
|
|
@@ -394,10 +486,16 @@ async function loadUsers() {
|
|
|
394
486
|
limit: 500,
|
|
395
487
|
offset: 0,
|
|
396
488
|
}),
|
|
397
|
-
isAdministratorIdentity()
|
|
489
|
+
isAdministratorIdentity()
|
|
490
|
+
? loadAdministratorRoles(selectedReaderId.value)
|
|
491
|
+
: Promise.resolve(null),
|
|
398
492
|
]);
|
|
399
493
|
|
|
400
|
-
const identities =
|
|
494
|
+
const identities =
|
|
495
|
+
identityResponse?.items ??
|
|
496
|
+
identityResponse?.data?.items ??
|
|
497
|
+
identityResponse?.data?.identities ??
|
|
498
|
+
[];
|
|
401
499
|
const adminRoleIds = getAdministratorRoleUserIds(roleResponse);
|
|
402
500
|
const readerUsers = normalizeHidUsers(readerResponse).filter((user) => {
|
|
403
501
|
if (Number(user.userTypeId) === 1) return false;
|
|
@@ -429,21 +527,23 @@ async function loadUsers() {
|
|
|
429
527
|
async function loadUserImages(items: Record<string, any>[]) {
|
|
430
528
|
if (!selectedReaderId.value) return;
|
|
431
529
|
|
|
432
|
-
await Promise.all(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
530
|
+
await Promise.all(
|
|
531
|
+
items.map(async (user) => {
|
|
532
|
+
const hidUserId = toHidNumericId(user.hidUserId);
|
|
533
|
+
const key = String(hidUserId || "");
|
|
534
|
+
if (!hidUserId || userImages.value[key] || user?.metadata?.photo) return;
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
const response = await getUserImage(selectedReaderId.value, hidUserId);
|
|
538
|
+
const image = response?.data || response;
|
|
539
|
+
userImages.value[key] = image?.base64
|
|
540
|
+
? `data:${image.contentType || "image/jpeg"};base64,${image.base64}`
|
|
541
|
+
: "";
|
|
542
|
+
} catch {
|
|
543
|
+
userImages.value[key] = "";
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
);
|
|
447
547
|
}
|
|
448
548
|
|
|
449
549
|
function resetForm() {
|
|
@@ -512,7 +612,28 @@ function triggerPhotoInput() {
|
|
|
512
612
|
}
|
|
513
613
|
|
|
514
614
|
function triggerCameraInput() {
|
|
515
|
-
|
|
615
|
+
cameraDialog.value = true;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function onCameraCapture(dataUrl: string) {
|
|
619
|
+
const [header, encodedData] = dataUrl.split(",", 2);
|
|
620
|
+
if (!header || !encodedData) {
|
|
621
|
+
showToast("Unable to capture the facial image.", "error");
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const mimeType = header.match(/^data:([^;]+);base64$/)?.[1] || "image/jpeg";
|
|
626
|
+
const decodedData = atob(encodedData);
|
|
627
|
+
const bytes = new Uint8Array(decodedData.length);
|
|
628
|
+
for (let index = 0; index < decodedData.length; index += 1) {
|
|
629
|
+
bytes[index] = decodedData.charCodeAt(index);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
selectedPhotoFile.value = new File([bytes], `hid-facial-${Date.now()}.jpg`, {
|
|
633
|
+
type: mimeType,
|
|
634
|
+
});
|
|
635
|
+
form.photoPreview = dataUrl;
|
|
636
|
+
removePhotoRequested.value = false;
|
|
516
637
|
}
|
|
517
638
|
|
|
518
639
|
function removePhoto() {
|
|
@@ -545,7 +666,8 @@ function onPhotoChange(event: Event) {
|
|
|
545
666
|
}
|
|
546
667
|
|
|
547
668
|
async function saveUser() {
|
|
548
|
-
const readerId =
|
|
669
|
+
const readerId =
|
|
670
|
+
selectedReaderId.value || form.reader || readers.value[0]?._id;
|
|
549
671
|
if (!readerId || !form.name || !form.hidUserId || !form.registration) {
|
|
550
672
|
showToast("Please complete the required HID user fields.", "error");
|
|
551
673
|
return;
|
|
@@ -567,7 +689,13 @@ async function saveUser() {
|
|
|
567
689
|
hidUserId: String(hidUser.id),
|
|
568
690
|
registration: form.registration,
|
|
569
691
|
cardNo: form.cardNo,
|
|
570
|
-
type: props.identityType as
|
|
692
|
+
type: props.identityType as
|
|
693
|
+
| "resident"
|
|
694
|
+
| "staff"
|
|
695
|
+
| "contractor"
|
|
696
|
+
| "visitor"
|
|
697
|
+
| "admin"
|
|
698
|
+
| "unknown",
|
|
571
699
|
status: "active" as const,
|
|
572
700
|
metadata: {
|
|
573
701
|
name: form.name,
|
|
@@ -604,7 +732,11 @@ async function saveUser() {
|
|
|
604
732
|
|
|
605
733
|
formDialog.value = false;
|
|
606
734
|
await loadUsers();
|
|
607
|
-
showToast(
|
|
735
|
+
showToast(
|
|
736
|
+
selectedUser.value?._id
|
|
737
|
+
? "HID user updated on reader."
|
|
738
|
+
: "HID user enrolled on reader."
|
|
739
|
+
);
|
|
608
740
|
} catch (error: any) {
|
|
609
741
|
console.error("Unable to save HID user:", error);
|
|
610
742
|
showToast(getHidErrorMessage(error), "error");
|
|
@@ -619,13 +751,21 @@ type FacialSyncResult = {
|
|
|
619
751
|
scores?: Record<string, unknown>;
|
|
620
752
|
};
|
|
621
753
|
|
|
622
|
-
async function syncFacialImage(
|
|
754
|
+
async function syncFacialImage(
|
|
755
|
+
readerId: string,
|
|
756
|
+
hidUserId: number
|
|
757
|
+
): Promise<FacialSyncResult> {
|
|
623
758
|
if (selectedPhotoFile.value) {
|
|
624
759
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
625
|
-
const response = await setUserImage(
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
760
|
+
const response = await setUserImage(
|
|
761
|
+
readerId,
|
|
762
|
+
hidUserId,
|
|
763
|
+
selectedPhotoFile.value,
|
|
764
|
+
{
|
|
765
|
+
timestamp,
|
|
766
|
+
match: true,
|
|
767
|
+
}
|
|
768
|
+
);
|
|
629
769
|
const result = response?.data;
|
|
630
770
|
userImages.value[String(hidUserId)] = form.photoPreview;
|
|
631
771
|
return { action: "enrolled", timestamp, scores: result?.scores };
|
|
@@ -640,7 +780,10 @@ async function syncFacialImage(readerId: string, hidUserId: number): Promise<Fac
|
|
|
640
780
|
return { action: "none" };
|
|
641
781
|
}
|
|
642
782
|
|
|
643
|
-
function applyFacialResult(
|
|
783
|
+
function applyFacialResult(
|
|
784
|
+
metadata: Record<string, unknown>,
|
|
785
|
+
result: FacialSyncResult
|
|
786
|
+
) {
|
|
644
787
|
if (result.action === "enrolled") {
|
|
645
788
|
metadata.facialData = form.hidUserId;
|
|
646
789
|
metadata.imageTimestamp = String(result.timestamp || "");
|
|
@@ -658,7 +801,11 @@ async function deleteUser() {
|
|
|
658
801
|
|
|
659
802
|
deleting.value = true;
|
|
660
803
|
try {
|
|
661
|
-
await syncHidUserRole(
|
|
804
|
+
await syncHidUserRole(
|
|
805
|
+
selectedReaderId.value,
|
|
806
|
+
toHidNumericId(selectedUser.value.hidUserId),
|
|
807
|
+
false
|
|
808
|
+
);
|
|
662
809
|
await deleteHidUserFromReader(selectedReaderId.value, selectedUser.value);
|
|
663
810
|
if (selectedUser.value._id) {
|
|
664
811
|
await deleteIdentity(selectedUser.value._id);
|
|
@@ -674,8 +821,14 @@ async function deleteUser() {
|
|
|
674
821
|
}
|
|
675
822
|
}
|
|
676
823
|
|
|
677
|
-
async function saveIdentityOnReader(
|
|
678
|
-
|
|
824
|
+
async function saveIdentityOnReader(
|
|
825
|
+
readerId: string,
|
|
826
|
+
payload: Record<string, any>
|
|
827
|
+
) {
|
|
828
|
+
const existingIdentity = await findExistingIdentityOnReader(
|
|
829
|
+
readerId,
|
|
830
|
+
payload
|
|
831
|
+
);
|
|
679
832
|
if (existingIdentity?._id) {
|
|
680
833
|
await updateIdentity(existingIdentity._id, payload);
|
|
681
834
|
return;
|
|
@@ -687,31 +840,47 @@ async function saveIdentityOnReader(readerId: string, payload: Record<string, an
|
|
|
687
840
|
const message = getHidErrorMessage(error);
|
|
688
841
|
if (!message.toLowerCase().includes("already exists")) throw error;
|
|
689
842
|
|
|
690
|
-
const duplicatedIdentity = await findExistingIdentityOnReader(
|
|
843
|
+
const duplicatedIdentity = await findExistingIdentityOnReader(
|
|
844
|
+
readerId,
|
|
845
|
+
payload
|
|
846
|
+
);
|
|
691
847
|
if (!duplicatedIdentity?._id) throw error;
|
|
692
848
|
await updateIdentity(duplicatedIdentity._id, payload);
|
|
693
849
|
}
|
|
694
850
|
}
|
|
695
851
|
|
|
696
|
-
async function findExistingIdentityOnReader(
|
|
852
|
+
async function findExistingIdentityOnReader(
|
|
853
|
+
readerId: string,
|
|
854
|
+
payload: Record<string, any>
|
|
855
|
+
) {
|
|
697
856
|
const response = await getIdentities(readerId, {
|
|
698
857
|
page: 1,
|
|
699
858
|
limit: 500,
|
|
700
859
|
search: payload.hidUserId || payload.registration || payload.cardNo || "",
|
|
701
860
|
});
|
|
702
|
-
const identities =
|
|
861
|
+
const identities =
|
|
862
|
+
response?.items ??
|
|
863
|
+
response?.data?.items ??
|
|
864
|
+
response?.data?.identities ??
|
|
865
|
+
[];
|
|
703
866
|
const hidUserId = String(payload.hidUserId || "");
|
|
704
867
|
const registration = String(payload.registration || "");
|
|
705
868
|
const cardNo = String(payload.cardNo || "");
|
|
706
869
|
|
|
707
|
-
return identities.find(
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
870
|
+
return identities.find(
|
|
871
|
+
(identity: Record<string, any>) =>
|
|
872
|
+
String(identity.hidUserId || "") === hidUserId ||
|
|
873
|
+
Boolean(
|
|
874
|
+
registration && String(identity.registration || "") === registration
|
|
875
|
+
) ||
|
|
876
|
+
Boolean(cardNo && String(identity.cardNo || "") === cardNo)
|
|
711
877
|
);
|
|
712
878
|
}
|
|
713
879
|
|
|
714
|
-
async function createHidUserOnReader(
|
|
880
|
+
async function createHidUserOnReader(
|
|
881
|
+
readerId: string,
|
|
882
|
+
user: Record<string, any>
|
|
883
|
+
) {
|
|
715
884
|
const existing = await getHidUserFromReader(readerId, user.id);
|
|
716
885
|
if (existing) {
|
|
717
886
|
await updateHidUserOnReader(readerId, user);
|
|
@@ -741,7 +910,10 @@ async function getHidUserFromReader(readerId: string, id: unknown) {
|
|
|
741
910
|
return normalizeHidUsers(response)[0];
|
|
742
911
|
}
|
|
743
912
|
|
|
744
|
-
async function updateHidUserOnReader(
|
|
913
|
+
async function updateHidUserOnReader(
|
|
914
|
+
readerId: string,
|
|
915
|
+
user: Record<string, any>
|
|
916
|
+
) {
|
|
745
917
|
const { id, ...values } = user;
|
|
746
918
|
await runObjectOperation(readerId, {
|
|
747
919
|
operation: "modify",
|
|
@@ -755,7 +927,10 @@ async function updateHidUserOnReader(readerId: string, user: Record<string, any>
|
|
|
755
927
|
});
|
|
756
928
|
}
|
|
757
929
|
|
|
758
|
-
async function deleteHidUserFromReader(
|
|
930
|
+
async function deleteHidUserFromReader(
|
|
931
|
+
readerId: string,
|
|
932
|
+
user: Record<string, any>
|
|
933
|
+
) {
|
|
759
934
|
const id = toHidNumericId(user.hidUserId);
|
|
760
935
|
if (!readerId || !id) return;
|
|
761
936
|
|
|
@@ -770,7 +945,11 @@ async function deleteHidUserFromReader(readerId: string, user: Record<string, an
|
|
|
770
945
|
});
|
|
771
946
|
}
|
|
772
947
|
|
|
773
|
-
async function syncHidUserRole(
|
|
948
|
+
async function syncHidUserRole(
|
|
949
|
+
readerId: string,
|
|
950
|
+
hidUserId: unknown,
|
|
951
|
+
isAdministrator: boolean
|
|
952
|
+
) {
|
|
774
953
|
const userId = toHidNumericId(hidUserId);
|
|
775
954
|
if (!readerId || !userId) return;
|
|
776
955
|
|
|
@@ -864,14 +1043,17 @@ function getAdministratorRoleUserIds(response: Record<string, any> | null) {
|
|
|
864
1043
|
return new Set(
|
|
865
1044
|
(Array.isArray(roles) ? roles : [])
|
|
866
1045
|
.map((role) => toHidNumericId(role.user_id))
|
|
867
|
-
.filter((userId): userId is number => Boolean(userId))
|
|
1046
|
+
.filter((userId): userId is number => Boolean(userId))
|
|
868
1047
|
);
|
|
869
1048
|
}
|
|
870
1049
|
|
|
871
1050
|
function toHidUserObject(identity: Record<string, any>) {
|
|
872
1051
|
return {
|
|
873
1052
|
id: toHidNumericId(identity.hidUserId),
|
|
874
|
-
name:
|
|
1053
|
+
name:
|
|
1054
|
+
identity.metadata?.name ||
|
|
1055
|
+
identity.name ||
|
|
1056
|
+
`User ${identity.hidUserId || ""}`.trim(),
|
|
875
1057
|
registration: identity.registration || "",
|
|
876
1058
|
};
|
|
877
1059
|
}
|
|
@@ -900,23 +1082,40 @@ function getName(user?: Record<string, any> | null) {
|
|
|
900
1082
|
}
|
|
901
1083
|
|
|
902
1084
|
function getUnit(user?: Record<string, any> | null) {
|
|
903
|
-
return
|
|
1085
|
+
return (
|
|
1086
|
+
user?.metadata?.unitLabel ||
|
|
1087
|
+
[user?.metadata?.block, user?.metadata?.level, user?.metadata?.unit]
|
|
1088
|
+
.filter(Boolean)
|
|
1089
|
+
.join("/") ||
|
|
1090
|
+
"N/A"
|
|
1091
|
+
);
|
|
904
1092
|
}
|
|
905
1093
|
|
|
906
1094
|
function getFacialData(user: Record<string, any>) {
|
|
907
|
-
return
|
|
1095
|
+
return (
|
|
1096
|
+
user?.metadata?.facialData ||
|
|
1097
|
+
user?.metadata?.imageTimestamp ||
|
|
1098
|
+
(user?.metadata?.photo ? user.hidUserId : "N/A")
|
|
1099
|
+
);
|
|
908
1100
|
}
|
|
909
1101
|
|
|
910
1102
|
function getUserImageSrc(user: Record<string, any>) {
|
|
911
1103
|
const hidUserId = toHidNumericId(user?.hidUserId);
|
|
912
|
-
return
|
|
1104
|
+
return (
|
|
1105
|
+
user?.metadata?.photo ||
|
|
1106
|
+
(hidUserId ? userImages.value[String(hidUserId)] : "") ||
|
|
1107
|
+
""
|
|
1108
|
+
);
|
|
913
1109
|
}
|
|
914
1110
|
|
|
915
1111
|
async function getNextUid() {
|
|
916
1112
|
const readerIds = await getExistingHidReaderIds();
|
|
917
1113
|
const maxNumber = users.value.reduce((max, user) => {
|
|
918
1114
|
const match = String(user.hidUserId ?? "").match(/^UID(\d+)$/i);
|
|
919
|
-
return Math.max(
|
|
1115
|
+
return Math.max(
|
|
1116
|
+
max,
|
|
1117
|
+
match ? Number(match[1]) : toHidNumericId(user.hidUserId) || 0
|
|
1118
|
+
);
|
|
920
1119
|
}, 0);
|
|
921
1120
|
const nextNumber = Math.max(maxNumber, ...readerIds, 0) + 1;
|
|
922
1121
|
return `UID${String(nextNumber).padStart(6, "0")}`;
|
|
@@ -930,10 +1129,15 @@ async function getNextRegistration() {
|
|
|
930
1129
|
const numericRegistrations = registrations
|
|
931
1130
|
.map((registration) => {
|
|
932
1131
|
const match = registration.match(/^(\d+)$/);
|
|
933
|
-
return match
|
|
1132
|
+
return match
|
|
1133
|
+
? { value: Number(match[1]), width: match[1].length }
|
|
1134
|
+
: undefined;
|
|
934
1135
|
})
|
|
935
1136
|
.filter((item): item is { value: number; width: number } => Boolean(item));
|
|
936
|
-
const maxRegistration = numericRegistrations.reduce(
|
|
1137
|
+
const maxRegistration = numericRegistrations.reduce(
|
|
1138
|
+
(max, item) => Math.max(max, item.value),
|
|
1139
|
+
0
|
|
1140
|
+
);
|
|
937
1141
|
const width = Math.max(6, ...numericRegistrations.map((item) => item.width));
|
|
938
1142
|
|
|
939
1143
|
return String(maxRegistration + 1).padStart(width, "0");
|
|
@@ -980,7 +1184,9 @@ function normalizeHidUsers(response: Record<string, any>) {
|
|
|
980
1184
|
name: user.name,
|
|
981
1185
|
metadata: {
|
|
982
1186
|
name: user.name,
|
|
983
|
-
imageTimestamp: user.image_timestamp
|
|
1187
|
+
imageTimestamp: user.image_timestamp
|
|
1188
|
+
? String(user.image_timestamp)
|
|
1189
|
+
: "",
|
|
984
1190
|
lastAccessAt: fromUnixSeconds(user.last_access),
|
|
985
1191
|
hidReaderSource: true,
|
|
986
1192
|
},
|
|
@@ -988,7 +1194,10 @@ function normalizeHidUsers(response: Record<string, any>) {
|
|
|
988
1194
|
: [];
|
|
989
1195
|
}
|
|
990
1196
|
|
|
991
|
-
function mergeReaderUsersWithIdentities(
|
|
1197
|
+
function mergeReaderUsersWithIdentities(
|
|
1198
|
+
readerUsers: Record<string, any>[],
|
|
1199
|
+
identities: Record<string, any>[]
|
|
1200
|
+
) {
|
|
992
1201
|
const identityById = new Map<string, Record<string, any>>();
|
|
993
1202
|
identities.forEach((identity) => {
|
|
994
1203
|
const id = toHidNumericId(identity.hidUserId);
|
|
@@ -996,7 +1205,9 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
|
|
|
996
1205
|
});
|
|
997
1206
|
|
|
998
1207
|
const merged = readerUsers.map((readerUser) => {
|
|
999
|
-
const identity = identityById.get(
|
|
1208
|
+
const identity = identityById.get(
|
|
1209
|
+
String(toHidNumericId(readerUser.hidUserId))
|
|
1210
|
+
);
|
|
1000
1211
|
if (!identity) return readerUser;
|
|
1001
1212
|
|
|
1002
1213
|
return {
|
|
@@ -1011,8 +1222,11 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
|
|
|
1011
1222
|
metadata: {
|
|
1012
1223
|
...readerUser.metadata,
|
|
1013
1224
|
...identity.metadata,
|
|
1014
|
-
imageTimestamp:
|
|
1015
|
-
|
|
1225
|
+
imageTimestamp:
|
|
1226
|
+
identity.metadata?.imageTimestamp ||
|
|
1227
|
+
readerUser.metadata?.imageTimestamp,
|
|
1228
|
+
lastAccessAt:
|
|
1229
|
+
identity.metadata?.lastAccessAt || readerUser.metadata?.lastAccessAt,
|
|
1016
1230
|
},
|
|
1017
1231
|
};
|
|
1018
1232
|
});
|
|
@@ -1023,13 +1237,16 @@ function mergeReaderUsersWithIdentities(readerUsers: Record<string, any>[], iden
|
|
|
1023
1237
|
function filterUsers(items: Record<string, any>[]) {
|
|
1024
1238
|
const searchText = search.value.trim().toLowerCase();
|
|
1025
1239
|
return items.filter((item) => {
|
|
1026
|
-
const matchesSearch =
|
|
1027
|
-
|
|
1028
|
-
item.hidUserId,
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1240
|
+
const matchesSearch =
|
|
1241
|
+
!searchText ||
|
|
1242
|
+
[getName(item), item.hidUserId, item.registration, item.cardNo].some(
|
|
1243
|
+
(value) =>
|
|
1244
|
+
String(value || "")
|
|
1245
|
+
.toLowerCase()
|
|
1246
|
+
.includes(searchText)
|
|
1247
|
+
);
|
|
1248
|
+
const matchesStatus =
|
|
1249
|
+
status.value === "Status" || getMappingStatus(item) === status.value;
|
|
1033
1250
|
return matchesSearch && matchesStatus;
|
|
1034
1251
|
});
|
|
1035
1252
|
}
|
|
@@ -1089,7 +1306,7 @@ function getHidErrorMessage(error: any) {
|
|
|
1089
1306
|
data?.statusMessage ||
|
|
1090
1307
|
error?.statusMessage ||
|
|
1091
1308
|
error?.message ||
|
|
1092
|
-
"Unable to save HID user."
|
|
1309
|
+
"Unable to save HID user."
|
|
1093
1310
|
);
|
|
1094
1311
|
}
|
|
1095
1312
|
</script>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computed, onScopeDispose, readonly, ref, shallowRef } from "vue";
|
|
2
2
|
import type { SimpleUser, SimpleUserDelegate, SimpleUserOptions } from "sip.js/lib/platform/web";
|
|
3
|
+
import type { SessionDescriptionHandlerOptions } from "sip.js/lib/platform/web/session-description-handler/session-description-handler-options";
|
|
3
4
|
|
|
4
5
|
export type SipWebPhoneConfig = {
|
|
5
6
|
webSocketServer: string;
|
|
@@ -11,7 +12,6 @@ export type SipWebPhoneConfig = {
|
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
export type SipWebPhoneMedia = {
|
|
14
|
-
localVideo?: HTMLVideoElement | null;
|
|
15
15
|
remoteAudio?: HTMLAudioElement | null;
|
|
16
16
|
remoteVideo?: HTMLVideoElement | null;
|
|
17
17
|
};
|
|
@@ -25,8 +25,6 @@ export default function useSipWebPhone() {
|
|
|
25
25
|
const callState = ref<CallState>("idle");
|
|
26
26
|
const errorMessage = ref("");
|
|
27
27
|
const muted = ref(false);
|
|
28
|
-
const cameraEnabled = ref(false);
|
|
29
|
-
const localVideoAvailable = ref(false);
|
|
30
28
|
const remoteVideoAvailable = ref(false);
|
|
31
29
|
const currentTarget = ref("");
|
|
32
30
|
const activeConfig = shallowRef<SipWebPhoneConfig>();
|
|
@@ -106,8 +104,7 @@ export default function useSipWebPhone() {
|
|
|
106
104
|
aor: normalizeSipUri(config.aor),
|
|
107
105
|
delegate,
|
|
108
106
|
media: {
|
|
109
|
-
constraints: { audio: true, video:
|
|
110
|
-
local: { video: media.localVideo || undefined },
|
|
107
|
+
constraints: { audio: true, video: false },
|
|
111
108
|
remote: {
|
|
112
109
|
audio: media.remoteAudio || undefined,
|
|
113
110
|
video: media.remoteVideo || undefined,
|
|
@@ -169,7 +166,14 @@ export default function useSipWebPhone() {
|
|
|
169
166
|
currentTarget.value = destination;
|
|
170
167
|
callState.value = "calling";
|
|
171
168
|
try {
|
|
172
|
-
|
|
169
|
+
const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
|
|
170
|
+
constraints: { audio: true, video: false },
|
|
171
|
+
offerOptions: {
|
|
172
|
+
offerToReceiveAudio: true,
|
|
173
|
+
offerToReceiveVideo: Boolean(activeConfig.value?.video),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
await user.call(destination, { sessionDescriptionHandlerOptions });
|
|
173
177
|
} catch (error) {
|
|
174
178
|
resetCall();
|
|
175
179
|
errorMessage.value = getErrorMessage(error);
|
|
@@ -179,7 +183,10 @@ export default function useSipWebPhone() {
|
|
|
179
183
|
|
|
180
184
|
async function answer() {
|
|
181
185
|
const user = requireRegisteredUser();
|
|
182
|
-
|
|
186
|
+
const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
|
|
187
|
+
constraints: { audio: true, video: false },
|
|
188
|
+
};
|
|
189
|
+
await user.answer({ sessionDescriptionHandlerOptions });
|
|
183
190
|
}
|
|
184
191
|
|
|
185
192
|
async function decline() {
|
|
@@ -207,42 +214,19 @@ export default function useSipWebPhone() {
|
|
|
207
214
|
muted.value = !enable;
|
|
208
215
|
}
|
|
209
216
|
|
|
210
|
-
function toggleCamera() {
|
|
211
|
-
const user = requireUser();
|
|
212
|
-
const tracks = user.localMediaStream?.getVideoTracks() || [];
|
|
213
|
-
if (!tracks.length) throw new Error("No camera track is available. Reconnect the web phone with video enabled.");
|
|
214
|
-
const enable = !cameraEnabled.value;
|
|
215
|
-
tracks.forEach((track) => {
|
|
216
|
-
track.enabled = enable;
|
|
217
|
-
});
|
|
218
|
-
cameraEnabled.value = enable;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
217
|
function syncVideoState() {
|
|
222
218
|
const user = simpleUser.value;
|
|
223
|
-
const localStream = user?.localMediaStream;
|
|
224
219
|
const remoteStream = user?.remoteMediaStream;
|
|
225
|
-
const localTrack = localStream?.getVideoTracks()[0];
|
|
226
220
|
const remoteTrack = remoteStream?.getVideoTracks()[0];
|
|
227
221
|
|
|
228
222
|
const updateState = () => {
|
|
229
|
-
const currentLocalTrack = localStream?.getVideoTracks()[0];
|
|
230
223
|
const currentRemoteTrack = remoteStream?.getVideoTracks()[0];
|
|
231
|
-
localVideoAvailable.value = Boolean(currentLocalTrack && currentLocalTrack.readyState === "live");
|
|
232
224
|
remoteVideoAvailable.value = Boolean(currentRemoteTrack && currentRemoteTrack.readyState === "live");
|
|
233
|
-
cameraEnabled.value = Boolean(currentLocalTrack?.enabled);
|
|
234
225
|
};
|
|
235
226
|
|
|
236
227
|
updateState();
|
|
237
|
-
if (localStream) localStream.onaddtrack = updateState;
|
|
238
228
|
if (remoteStream) remoteStream.onaddtrack = updateState;
|
|
239
229
|
|
|
240
|
-
if (localTrack) {
|
|
241
|
-
localTrack.onended = () => {
|
|
242
|
-
localVideoAvailable.value = false;
|
|
243
|
-
cameraEnabled.value = false;
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
230
|
if (remoteTrack) {
|
|
247
231
|
remoteTrack.onended = () => {
|
|
248
232
|
remoteVideoAvailable.value = false;
|
|
@@ -254,8 +238,6 @@ export default function useSipWebPhone() {
|
|
|
254
238
|
callState.value = "idle";
|
|
255
239
|
currentTarget.value = "";
|
|
256
240
|
muted.value = false;
|
|
257
|
-
cameraEnabled.value = false;
|
|
258
|
-
localVideoAvailable.value = false;
|
|
259
241
|
remoteVideoAvailable.value = false;
|
|
260
242
|
}
|
|
261
243
|
|
|
@@ -279,8 +261,6 @@ export default function useSipWebPhone() {
|
|
|
279
261
|
callState: readonly(callState),
|
|
280
262
|
errorMessage: readonly(errorMessage),
|
|
281
263
|
muted: readonly(muted),
|
|
282
|
-
cameraEnabled: readonly(cameraEnabled),
|
|
283
|
-
localVideoAvailable: readonly(localVideoAvailable),
|
|
284
264
|
remoteVideoAvailable: readonly(remoteVideoAvailable),
|
|
285
265
|
currentTarget: readonly(currentTarget),
|
|
286
266
|
isRegistered,
|
|
@@ -292,7 +272,6 @@ export default function useSipWebPhone() {
|
|
|
292
272
|
decline,
|
|
293
273
|
hangup,
|
|
294
274
|
toggleMute,
|
|
295
|
-
toggleCamera,
|
|
296
275
|
};
|
|
297
276
|
}
|
|
298
277
|
|
|
@@ -323,10 +302,10 @@ function normalizeDestination(target: string, aor: string) {
|
|
|
323
302
|
|
|
324
303
|
function getErrorMessage(error: unknown) {
|
|
325
304
|
if (error instanceof DOMException && error.name === "NotAllowedError") {
|
|
326
|
-
return "
|
|
305
|
+
return "Microphone permission was denied. Allow microphone access in the browser and reconnect the web phone.";
|
|
327
306
|
}
|
|
328
307
|
if (error instanceof DOMException && error.name === "NotFoundError") {
|
|
329
|
-
return "No
|
|
308
|
+
return "No microphone was found for this call.";
|
|
330
309
|
}
|
|
331
310
|
return error instanceof Error ? error.message : "SIP connection failed.";
|
|
332
311
|
}
|