@7365admin1/layer-common 3.1.4-staging.56 → 3.1.4-staging.57
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 +184 -60
- package/components/HidQrCodeConfiguration.vue +195 -31
- package/components/HidUserEnrollment.vue +94 -2
- package/components/SiteSettings.vue +4 -2
- package/composables/useHidAmico.ts +64 -3
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/types/site.d.ts +6 -0
|
@@ -386,54 +386,28 @@
|
|
|
386
386
|
<v-card-text class="web-phone-body">
|
|
387
387
|
<div class="web-phone-settings">
|
|
388
388
|
<div class="web-phone-section-title">SIP Account</div>
|
|
389
|
-
<div class="
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
class="call-input"
|
|
407
|
-
:disabled="webPhone.isRegistered.value"
|
|
408
|
-
/>
|
|
409
|
-
<div class="form-two-col">
|
|
389
|
+
<div v-if="sipAccountLoading" class="sip-account-state">
|
|
390
|
+
<v-progress-circular indeterminate color="primary" size="30" width="3" />
|
|
391
|
+
<span>Preparing your private SIP account...</span>
|
|
392
|
+
</div>
|
|
393
|
+
<div v-else-if="sipAccountError" class="sip-account-state is-error">
|
|
394
|
+
<v-icon icon="mdi-alert-circle-outline" size="28" />
|
|
395
|
+
<span>{{ sipAccountError }}</span>
|
|
396
|
+
<v-btn
|
|
397
|
+
class="text-none sip-account-retry"
|
|
398
|
+
variant="outlined"
|
|
399
|
+
prepend-icon="mdi-refresh"
|
|
400
|
+
@click="loadSipAccount"
|
|
401
|
+
>
|
|
402
|
+
Retry
|
|
403
|
+
</v-btn>
|
|
404
|
+
</div>
|
|
405
|
+
<div v-else-if="sipAccount" class="sip-account-summary">
|
|
410
406
|
<div>
|
|
411
|
-
<
|
|
412
|
-
<
|
|
413
|
-
v-model="webPhoneForm.username"
|
|
414
|
-
placeholder="Username"
|
|
415
|
-
variant="outlined"
|
|
416
|
-
density="compact"
|
|
417
|
-
hide-details="auto"
|
|
418
|
-
class="call-input"
|
|
419
|
-
:disabled="webPhone.isRegistered.value"
|
|
420
|
-
/>
|
|
421
|
-
</div>
|
|
422
|
-
<div>
|
|
423
|
-
<div class="call-label">Password <span>*</span></div>
|
|
424
|
-
<v-text-field
|
|
425
|
-
v-model="webPhoneForm.password"
|
|
426
|
-
placeholder="Password"
|
|
427
|
-
:type="showWebPhonePassword ? 'text' : 'password'"
|
|
428
|
-
:append-inner-icon="showWebPhonePassword ? 'mdi-eye-off' : 'mdi-eye'"
|
|
429
|
-
variant="outlined"
|
|
430
|
-
density="compact"
|
|
431
|
-
hide-details="auto"
|
|
432
|
-
class="call-input"
|
|
433
|
-
:disabled="webPhone.isRegistered.value"
|
|
434
|
-
@click:append-inner="showWebPhonePassword = !showWebPhonePassword"
|
|
435
|
-
/>
|
|
407
|
+
<span class="sip-account-label">Assigned extension</span>
|
|
408
|
+
<strong class="sip-account-extension">{{ sipAccount.extension }}</strong>
|
|
436
409
|
</div>
|
|
410
|
+
<span class="sip-account-address">{{ sipAccount.sipAddress }}</span>
|
|
437
411
|
</div>
|
|
438
412
|
<div class="call-toggle-row web-video-toggle">
|
|
439
413
|
<span>Enable video</span>
|
|
@@ -452,6 +426,7 @@
|
|
|
452
426
|
variant="flat"
|
|
453
427
|
prepend-icon="mdi-lan-connect"
|
|
454
428
|
:loading="webPhoneConnecting"
|
|
429
|
+
:disabled="!sipAccount || sipAccountLoading"
|
|
455
430
|
@click="connectWebPhone"
|
|
456
431
|
>
|
|
457
432
|
Connect Web Phone
|
|
@@ -613,6 +588,10 @@
|
|
|
613
588
|
|
|
614
589
|
<script setup lang="ts">
|
|
615
590
|
const props = defineProps({
|
|
591
|
+
org: {
|
|
592
|
+
type: String,
|
|
593
|
+
required: true,
|
|
594
|
+
},
|
|
616
595
|
site: {
|
|
617
596
|
type: String,
|
|
618
597
|
required: true,
|
|
@@ -631,6 +610,19 @@ type ContactRow = {
|
|
|
631
610
|
dialCode: string;
|
|
632
611
|
};
|
|
633
612
|
|
|
613
|
+
type SipAccount = {
|
|
614
|
+
orgId: string;
|
|
615
|
+
site: string;
|
|
616
|
+
userId: string;
|
|
617
|
+
extension: string;
|
|
618
|
+
username: string;
|
|
619
|
+
password: string;
|
|
620
|
+
sipAddress: string;
|
|
621
|
+
websocketUrl: string;
|
|
622
|
+
status: "active";
|
|
623
|
+
provisionedAt: string;
|
|
624
|
+
};
|
|
625
|
+
|
|
634
626
|
type DetailItem = {
|
|
635
627
|
label: string;
|
|
636
628
|
value?: string | number | boolean;
|
|
@@ -678,6 +670,7 @@ const {
|
|
|
678
670
|
getIntercomStatus,
|
|
679
671
|
makeIntercomCall,
|
|
680
672
|
finalizeIntercomCall,
|
|
673
|
+
ensureSipAccount,
|
|
681
674
|
} = useHidAmico();
|
|
682
675
|
|
|
683
676
|
const activeTab = ref<IntercomTab>("intercom");
|
|
@@ -696,9 +689,11 @@ const limit = 20;
|
|
|
696
689
|
const selectedRow = ref<IntercomRow | null>(null);
|
|
697
690
|
const selectedContact = ref<ContactRow | null>(null);
|
|
698
691
|
const showSipPassword = ref(false);
|
|
699
|
-
const showWebPhonePassword = ref(false);
|
|
700
692
|
const webPhoneConnecting = ref(false);
|
|
701
693
|
const webPhoneActionLoading = ref(false);
|
|
694
|
+
const sipAccount = ref<SipAccount | null>(null);
|
|
695
|
+
const sipAccountLoading = ref(false);
|
|
696
|
+
const sipAccountError = ref("");
|
|
702
697
|
const localVideoEl = ref<HTMLVideoElement | null>(null);
|
|
703
698
|
const remoteVideoEl = ref<HTMLVideoElement | null>(null);
|
|
704
699
|
const remoteAudioEl = ref<HTMLAudioElement | null>(null);
|
|
@@ -787,10 +782,6 @@ const makeCallForm = reactive({
|
|
|
787
782
|
});
|
|
788
783
|
|
|
789
784
|
const webPhoneForm = reactive({
|
|
790
|
-
webSocketServer: "",
|
|
791
|
-
aor: "",
|
|
792
|
-
username: "",
|
|
793
|
-
password: "",
|
|
794
785
|
destination: "",
|
|
795
786
|
video: false,
|
|
796
787
|
});
|
|
@@ -926,8 +917,14 @@ const webPhoneCallIcon = computed(() => {
|
|
|
926
917
|
});
|
|
927
918
|
|
|
928
919
|
watch(
|
|
929
|
-
() => props.site,
|
|
930
|
-
() =>
|
|
920
|
+
[() => props.org, () => props.site],
|
|
921
|
+
() => {
|
|
922
|
+
if (webPhone.isRegistered.value) void webPhone.disconnect();
|
|
923
|
+
sipAccount.value = null;
|
|
924
|
+
sipAccountError.value = "";
|
|
925
|
+
webPhoneForm.destination = "";
|
|
926
|
+
void reload();
|
|
927
|
+
},
|
|
931
928
|
{ immediate: true },
|
|
932
929
|
);
|
|
933
930
|
|
|
@@ -1178,16 +1175,42 @@ function openWebPhoneDialog(row?: IntercomRow) {
|
|
|
1178
1175
|
}
|
|
1179
1176
|
}
|
|
1180
1177
|
webPhoneDialog.value = true;
|
|
1178
|
+
if (!sipAccount.value && !sipAccountLoading.value) void loadSipAccount();
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
async function loadSipAccount() {
|
|
1182
|
+
if (!props.org || !props.site) return;
|
|
1183
|
+
|
|
1184
|
+
sipAccountLoading.value = true;
|
|
1185
|
+
sipAccountError.value = "";
|
|
1186
|
+
try {
|
|
1187
|
+
const response = await ensureSipAccount(props.site, props.org);
|
|
1188
|
+
sipAccount.value = normalizeSipAccount(response);
|
|
1189
|
+
} catch (error: unknown) {
|
|
1190
|
+
sipAccount.value = null;
|
|
1191
|
+
sipAccountError.value = getErrorMessage(error);
|
|
1192
|
+
} finally {
|
|
1193
|
+
sipAccountLoading.value = false;
|
|
1194
|
+
}
|
|
1181
1195
|
}
|
|
1182
1196
|
|
|
1183
1197
|
async function connectWebPhone() {
|
|
1198
|
+
const account = sipAccount.value;
|
|
1199
|
+
if (!account) {
|
|
1200
|
+
await loadSipAccount();
|
|
1201
|
+
if (!sipAccount.value) return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const activeAccount = sipAccount.value;
|
|
1205
|
+
if (!activeAccount) return;
|
|
1206
|
+
|
|
1184
1207
|
webPhoneConnecting.value = true;
|
|
1185
1208
|
try {
|
|
1186
1209
|
await webPhone.connect({
|
|
1187
|
-
webSocketServer:
|
|
1188
|
-
aor:
|
|
1189
|
-
authorizationUsername:
|
|
1190
|
-
authorizationPassword:
|
|
1210
|
+
webSocketServer: activeAccount.websocketUrl,
|
|
1211
|
+
aor: activeAccount.sipAddress,
|
|
1212
|
+
authorizationUsername: activeAccount.username,
|
|
1213
|
+
authorizationPassword: activeAccount.password,
|
|
1191
1214
|
displayName: "iService365 Web",
|
|
1192
1215
|
video: webPhoneForm.video,
|
|
1193
1216
|
}, {
|
|
@@ -1196,7 +1219,7 @@ async function connectWebPhone() {
|
|
|
1196
1219
|
remoteAudio: remoteAudioEl.value,
|
|
1197
1220
|
});
|
|
1198
1221
|
showMessage("Web phone connected and ready to receive calls.", "success");
|
|
1199
|
-
} catch (error:
|
|
1222
|
+
} catch (error: unknown) {
|
|
1200
1223
|
showMessage(getErrorMessage(error), "error");
|
|
1201
1224
|
} finally {
|
|
1202
1225
|
webPhoneConnecting.value = false;
|
|
@@ -1492,8 +1515,42 @@ function showMessage(text: string, type: MessageType = "info") {
|
|
|
1492
1515
|
message.type = type;
|
|
1493
1516
|
}
|
|
1494
1517
|
|
|
1495
|
-
function
|
|
1496
|
-
|
|
1518
|
+
function normalizeSipAccount(response: unknown): SipAccount {
|
|
1519
|
+
const responseRecord = toRecord(response);
|
|
1520
|
+
const payload = toRecord(responseRecord.data || responseRecord);
|
|
1521
|
+
const requiredFields = ["extension", "username", "password", "sipAddress", "websocketUrl"] as const;
|
|
1522
|
+
|
|
1523
|
+
if (requiredFields.some((field) => !toText(payload[field]))) {
|
|
1524
|
+
throw new Error("The SIP account response is incomplete. Please try again.");
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
return {
|
|
1528
|
+
orgId: toText(payload.orgId, props.org),
|
|
1529
|
+
site: toText(payload.site, props.site),
|
|
1530
|
+
userId: toText(payload.userId),
|
|
1531
|
+
extension: toText(payload.extension),
|
|
1532
|
+
username: toText(payload.username),
|
|
1533
|
+
password: toText(payload.password),
|
|
1534
|
+
sipAddress: toText(payload.sipAddress),
|
|
1535
|
+
websocketUrl: toText(payload.websocketUrl),
|
|
1536
|
+
status: "active",
|
|
1537
|
+
provisionedAt: toText(payload.provisionedAt),
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function getErrorMessage(error: unknown) {
|
|
1542
|
+
const errorRecord = toRecord(error);
|
|
1543
|
+
const data = toRecord(errorRecord.data);
|
|
1544
|
+
const response = toRecord(errorRecord.response);
|
|
1545
|
+
const responseData = toRecord(response._data);
|
|
1546
|
+
return toText(data.message)
|
|
1547
|
+
|| toText(responseData.message)
|
|
1548
|
+
|| toText(errorRecord.message)
|
|
1549
|
+
|| "HID intercom request failed.";
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function toRecord(value: unknown): Record<string, unknown> {
|
|
1553
|
+
return typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
|
|
1497
1554
|
}
|
|
1498
1555
|
|
|
1499
1556
|
function toText(value: unknown, fallback = "") {
|
|
@@ -2226,6 +2283,73 @@ export default {
|
|
|
2226
2283
|
font-weight: 700;
|
|
2227
2284
|
}
|
|
2228
2285
|
|
|
2286
|
+
.sip-account-state {
|
|
2287
|
+
display: flex;
|
|
2288
|
+
min-height: 126px;
|
|
2289
|
+
align-items: center;
|
|
2290
|
+
justify-content: center;
|
|
2291
|
+
padding: 18px;
|
|
2292
|
+
border: 1px solid #dce2e8;
|
|
2293
|
+
border-radius: 6px;
|
|
2294
|
+
color: #5d6673;
|
|
2295
|
+
flex-direction: column;
|
|
2296
|
+
font-size: 12px;
|
|
2297
|
+
gap: 12px;
|
|
2298
|
+
text-align: center;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
.sip-account-state.is-error {
|
|
2302
|
+
border-color: #f0c7c7;
|
|
2303
|
+
background: #fff8f8;
|
|
2304
|
+
color: #a92222;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
.sip-account-retry {
|
|
2308
|
+
min-width: 104px;
|
|
2309
|
+
border-color: #aeb8c2;
|
|
2310
|
+
color: #344054;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
.sip-account-summary {
|
|
2314
|
+
display: grid;
|
|
2315
|
+
min-height: 96px;
|
|
2316
|
+
align-items: center;
|
|
2317
|
+
padding: 16px;
|
|
2318
|
+
border: 1px solid #dce2e8;
|
|
2319
|
+
border-radius: 6px;
|
|
2320
|
+
background: #f8fafc;
|
|
2321
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
2322
|
+
gap: 18px;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
.sip-account-summary > div {
|
|
2326
|
+
display: flex;
|
|
2327
|
+
min-width: 0;
|
|
2328
|
+
flex-direction: column;
|
|
2329
|
+
gap: 4px;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
.sip-account-label {
|
|
2333
|
+
color: #667085;
|
|
2334
|
+
font-size: 11px;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
.sip-account-extension {
|
|
2338
|
+
color: #0d1720;
|
|
2339
|
+
font-size: 24px;
|
|
2340
|
+
line-height: 1.1;
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
.sip-account-address {
|
|
2344
|
+
max-width: 170px;
|
|
2345
|
+
overflow: hidden;
|
|
2346
|
+
color: #536273;
|
|
2347
|
+
font-size: 11px;
|
|
2348
|
+
text-align: right;
|
|
2349
|
+
text-overflow: ellipsis;
|
|
2350
|
+
white-space: nowrap;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2229
2353
|
.web-video-toggle {
|
|
2230
2354
|
margin-top: 12px;
|
|
2231
2355
|
}
|
|
@@ -178,7 +178,41 @@
|
|
|
178
178
|
|
|
179
179
|
<v-divider />
|
|
180
180
|
|
|
181
|
-
<div class="permission-section">
|
|
181
|
+
<div v-if="isPermissionManager" class="permission-section">
|
|
182
|
+
<div class="permission-location">
|
|
183
|
+
<div class="permission-copy">
|
|
184
|
+
<div class="section-label">Permission Location</div>
|
|
185
|
+
<p class="section-description">
|
|
186
|
+
Permissions are scoped to the location configured on the HID Face Reader.
|
|
187
|
+
</p>
|
|
188
|
+
</div>
|
|
189
|
+
<v-select
|
|
190
|
+
v-model="permissionReaderId"
|
|
191
|
+
class="permission-location__select"
|
|
192
|
+
:items="permissionLocations"
|
|
193
|
+
item-title="label"
|
|
194
|
+
item-value="readerId"
|
|
195
|
+
placeholder="Select a Face Reader location"
|
|
196
|
+
variant="outlined"
|
|
197
|
+
density="compact"
|
|
198
|
+
hide-details
|
|
199
|
+
:disabled="permissionLocations.length === 0"
|
|
200
|
+
@update:model-value="loadPermissions"
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<v-alert
|
|
205
|
+
v-if="permissionLocations.length === 0"
|
|
206
|
+
class="mb-4"
|
|
207
|
+
type="warning"
|
|
208
|
+
variant="tonal"
|
|
209
|
+
density="compact"
|
|
210
|
+
>
|
|
211
|
+
Add a Location to a Face Reader before assigning HID permissions.
|
|
212
|
+
</v-alert>
|
|
213
|
+
|
|
214
|
+
<v-divider />
|
|
215
|
+
|
|
182
216
|
<div class="permission-row">
|
|
183
217
|
<div class="permission-copy">
|
|
184
218
|
<div class="section-label">Access Permission</div>
|
|
@@ -192,7 +226,7 @@
|
|
|
192
226
|
:key="option.category"
|
|
193
227
|
class="permission-button text-none"
|
|
194
228
|
variant="outlined"
|
|
195
|
-
:disabled="!form.onlineMode"
|
|
229
|
+
:disabled="!form.onlineMode || !permissionReaderId"
|
|
196
230
|
@click="openPermissionDialog(option.category)"
|
|
197
231
|
>
|
|
198
232
|
<span class="permission-count">{{ permissionCount(option.category) }}</span>
|
|
@@ -214,7 +248,7 @@
|
|
|
214
248
|
<v-btn
|
|
215
249
|
class="permission-button text-none"
|
|
216
250
|
variant="outlined"
|
|
217
|
-
:disabled="!form.onlineMode || permissionState.assignments.length === 0"
|
|
251
|
+
:disabled="!form.onlineMode || !permissionReaderId || permissionState.assignments.length === 0"
|
|
218
252
|
@click="openPermissionDialog('intercom')"
|
|
219
253
|
>
|
|
220
254
|
<span class="permission-count">{{ permissionState.counts.intercom }}</span>
|
|
@@ -261,7 +295,7 @@
|
|
|
261
295
|
</v-card>
|
|
262
296
|
</v-dialog>
|
|
263
297
|
|
|
264
|
-
<v-dialog v-model="permissionDialog" max-width="
|
|
298
|
+
<v-dialog v-model="permissionDialog" max-width="680" persistent>
|
|
265
299
|
<v-card class="permission-dialog">
|
|
266
300
|
<v-card-title class="permission-dialog__header">
|
|
267
301
|
<div>
|
|
@@ -280,6 +314,7 @@
|
|
|
280
314
|
<v-card-text class="permission-dialog__body">
|
|
281
315
|
<v-text-field
|
|
282
316
|
v-model="permissionSearch"
|
|
317
|
+
class="permission-search"
|
|
283
318
|
prepend-inner-icon="mdi-magnify"
|
|
284
319
|
placeholder="Search by name or details"
|
|
285
320
|
variant="outlined"
|
|
@@ -288,6 +323,13 @@
|
|
|
288
323
|
clearable
|
|
289
324
|
/>
|
|
290
325
|
|
|
326
|
+
<div class="permission-list-summary" aria-live="polite">
|
|
327
|
+
<span>{{ visiblePermissionCandidates.length }} entries</span>
|
|
328
|
+
<span class="permission-list-summary__selected">
|
|
329
|
+
{{ selectedPermissionIds.size }} selected
|
|
330
|
+
</span>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
291
333
|
<div class="permission-list" :class="{ 'permission-list--loading': loadingPermissionCandidates }">
|
|
292
334
|
<div v-if="loadingPermissionCandidates" class="permission-empty">
|
|
293
335
|
<v-progress-circular indeterminate color="primary" size="28" />
|
|
@@ -299,17 +341,20 @@
|
|
|
299
341
|
v-for="candidate in visiblePermissionCandidates"
|
|
300
342
|
:key="`${candidate.category}:${candidate.subjectId}`"
|
|
301
343
|
class="permission-list-item"
|
|
344
|
+
:class="{ 'permission-list-item--selected': selectedPermissionIds.has(candidate.subjectId) }"
|
|
302
345
|
>
|
|
303
|
-
<v-checkbox-btn
|
|
304
|
-
:model-value="selectedPermissionIds.has(candidate.subjectId)"
|
|
305
|
-
color="success"
|
|
306
|
-
@update:model-value="togglePermissionCandidate(candidate.subjectId, Boolean($event))"
|
|
307
|
-
/>
|
|
308
346
|
<span class="permission-list-item__content">
|
|
309
347
|
<strong>{{ candidate.name }}</strong>
|
|
310
348
|
<small>{{ candidate.subtitle || permissionCategoryLabel(candidate.category) }}</small>
|
|
311
349
|
</span>
|
|
312
350
|
<span class="permission-list-item__group">{{ permissionCategoryLabel(candidate.category) }}</span>
|
|
351
|
+
<v-checkbox-btn
|
|
352
|
+
class="permission-list-item__checkbox"
|
|
353
|
+
:model-value="selectedPermissionIds.has(candidate.subjectId)"
|
|
354
|
+
:aria-label="`Select ${candidate.name}`"
|
|
355
|
+
color="success"
|
|
356
|
+
@update:model-value="togglePermissionCandidate(candidate.subjectId, Boolean($event))"
|
|
357
|
+
/>
|
|
313
358
|
</label>
|
|
314
359
|
</template>
|
|
315
360
|
|
|
@@ -355,6 +400,7 @@ type HidReaderOption = {
|
|
|
355
400
|
_id: string;
|
|
356
401
|
name?: string;
|
|
357
402
|
deviceId?: string;
|
|
403
|
+
location?: string;
|
|
358
404
|
};
|
|
359
405
|
|
|
360
406
|
type HidReaderListResponse = {
|
|
@@ -368,8 +414,12 @@ type HidReaderListResponse = {
|
|
|
368
414
|
|
|
369
415
|
const props = defineProps<{
|
|
370
416
|
site: string;
|
|
417
|
+
org: string;
|
|
371
418
|
}>();
|
|
372
419
|
|
|
420
|
+
const runtimeConfig = useRuntimeConfig();
|
|
421
|
+
const isPermissionManager = computed(() => runtimeConfig.public.APP === "security_agency");
|
|
422
|
+
|
|
373
423
|
const { getSiteById, updateSitebyId } = useSiteSettings();
|
|
374
424
|
const {
|
|
375
425
|
getReaders,
|
|
@@ -418,7 +468,10 @@ const message = ref("");
|
|
|
418
468
|
const messageColor = ref("success");
|
|
419
469
|
const messageSnackbar = ref(false);
|
|
420
470
|
const permissionState = reactive<THidSitePermissions>({
|
|
471
|
+
orgId: props.org,
|
|
421
472
|
site: props.site,
|
|
473
|
+
readerId: "",
|
|
474
|
+
location: "",
|
|
422
475
|
assignments: [],
|
|
423
476
|
counts: {
|
|
424
477
|
resident: 0,
|
|
@@ -428,6 +481,7 @@ const permissionState = reactive<THidSitePermissions>({
|
|
|
428
481
|
},
|
|
429
482
|
});
|
|
430
483
|
const permissionDialog = ref(false);
|
|
484
|
+
const permissionReaderId = ref("");
|
|
431
485
|
const permissionDialogCategory = ref<THidPermissionCategory | "intercom">("resident");
|
|
432
486
|
const permissionCandidates = ref<THidPermissionCandidate[]>([]);
|
|
433
487
|
const selectedPermissionIds = ref(new Set<string>());
|
|
@@ -458,6 +512,26 @@ const accessPermissionOptions: Array<{
|
|
|
458
512
|
{ category: "service_provider", label: "Service Provider" },
|
|
459
513
|
];
|
|
460
514
|
|
|
515
|
+
const permissionLocations = computed(() => {
|
|
516
|
+
const seen = new Set<string>();
|
|
517
|
+
return readers.value.reduce<Array<{ readerId: string; location: string; label: string }>>((items, reader) => {
|
|
518
|
+
const location = reader.location?.trim();
|
|
519
|
+
const key = location?.toLocaleLowerCase();
|
|
520
|
+
if (!location || !key || seen.has(key)) return items;
|
|
521
|
+
seen.add(key);
|
|
522
|
+
items.push({
|
|
523
|
+
readerId: reader._id,
|
|
524
|
+
location,
|
|
525
|
+
label: location,
|
|
526
|
+
});
|
|
527
|
+
return items;
|
|
528
|
+
}, []);
|
|
529
|
+
});
|
|
530
|
+
const selectedPermissionLocation = computed(() => (
|
|
531
|
+
permissionLocations.value.find((item) => item.readerId === permissionReaderId.value)?.location
|
|
532
|
+
|| "the selected location"
|
|
533
|
+
));
|
|
534
|
+
|
|
461
535
|
const siteName = computed(() => siteData.value?.name || "Site");
|
|
462
536
|
const siteLabel = computed(() => siteData.value?.code || siteData.value?.block || props.site);
|
|
463
537
|
const validityText = computed(() =>
|
|
@@ -478,8 +552,8 @@ const permissionDialogTitle = computed(() => (
|
|
|
478
552
|
));
|
|
479
553
|
const permissionDialogDescription = computed(() => (
|
|
480
554
|
permissionDialogCategory.value === "intercom"
|
|
481
|
-
?
|
|
482
|
-
: `Select the ${permissionCategoryLabel(permissionDialogCategory.value).toLowerCase()} entries allowed to use HID access at
|
|
555
|
+
? `Select from people and providers who already have HID access permission at ${selectedPermissionLocation.value}.`
|
|
556
|
+
: `Select the ${permissionCategoryLabel(permissionDialogCategory.value).toLowerCase()} entries allowed to use HID access at ${selectedPermissionLocation.value}.`
|
|
483
557
|
));
|
|
484
558
|
const permissionEmptyMessage = computed(() => (
|
|
485
559
|
permissionDialogCategory.value === "intercom" && permissionState.assignments.length === 0
|
|
@@ -488,7 +562,7 @@ const permissionEmptyMessage = computed(() => (
|
|
|
488
562
|
));
|
|
489
563
|
|
|
490
564
|
watch(
|
|
491
|
-
() => props.site,
|
|
565
|
+
() => [props.org, props.site],
|
|
492
566
|
() => loadConfig(),
|
|
493
567
|
{ immediate: true },
|
|
494
568
|
);
|
|
@@ -529,13 +603,30 @@ async function loadConfig() {
|
|
|
529
603
|
validityMinutes: config.validityMinutes ? Number(config.validityMinutes) : null,
|
|
530
604
|
});
|
|
531
605
|
|
|
532
|
-
|
|
606
|
+
permissionReaderId.value = permissionLocations.value.find((item) => item.readerId === form.readerId)?.readerId
|
|
607
|
+
|| permissionLocations.value[0]?.readerId
|
|
608
|
+
|| "";
|
|
609
|
+
|
|
610
|
+
await Promise.all([
|
|
611
|
+
loadReaderConfiguration(),
|
|
612
|
+
isPermissionManager.value ? loadPermissions() : Promise.resolve(),
|
|
613
|
+
]);
|
|
533
614
|
}
|
|
534
615
|
|
|
535
616
|
async function loadPermissions() {
|
|
536
|
-
if (!props.site)
|
|
617
|
+
if (!props.org || !props.site || !permissionReaderId.value) {
|
|
618
|
+
Object.assign(permissionState, {
|
|
619
|
+
orgId: props.org,
|
|
620
|
+
site: props.site,
|
|
621
|
+
readerId: "",
|
|
622
|
+
location: "",
|
|
623
|
+
assignments: [],
|
|
624
|
+
counts: { resident: 0, propertyManagement: 0, serviceProvider: 0, intercom: 0 },
|
|
625
|
+
});
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
537
628
|
try {
|
|
538
|
-
const response = await getSitePermissions(props.site);
|
|
629
|
+
const response = await getSitePermissions(props.site, props.org, permissionReaderId.value);
|
|
539
630
|
Object.assign(permissionState, response.data);
|
|
540
631
|
} catch (error: unknown) {
|
|
541
632
|
console.error("Unable to load HID permissions:", errorConverter(error));
|
|
@@ -553,6 +644,7 @@ function permissionCount(category: THidPermissionCategory) {
|
|
|
553
644
|
}
|
|
554
645
|
|
|
555
646
|
async function openPermissionDialog(category: THidPermissionCategory | "intercom") {
|
|
647
|
+
if (!props.org || !permissionReaderId.value) return;
|
|
556
648
|
permissionDialogCategory.value = category;
|
|
557
649
|
permissionSearch.value = "";
|
|
558
650
|
permissionCandidates.value = [];
|
|
@@ -562,6 +654,8 @@ async function openPermissionDialog(category: THidPermissionCategory | "intercom
|
|
|
562
654
|
|
|
563
655
|
try {
|
|
564
656
|
const response = await getPermissionCandidates(props.site, {
|
|
657
|
+
orgId: props.org,
|
|
658
|
+
readerId: permissionReaderId.value,
|
|
565
659
|
category,
|
|
566
660
|
page: 1,
|
|
567
661
|
limit: 500,
|
|
@@ -621,7 +715,12 @@ async function savePermissions() {
|
|
|
621
715
|
|
|
622
716
|
savingPermissions.value = true;
|
|
623
717
|
try {
|
|
624
|
-
const response = await updateSitePermissions(
|
|
718
|
+
const response = await updateSitePermissions(
|
|
719
|
+
props.site,
|
|
720
|
+
props.org,
|
|
721
|
+
permissionReaderId.value,
|
|
722
|
+
assignments,
|
|
723
|
+
);
|
|
625
724
|
Object.assign(permissionState, response.data);
|
|
626
725
|
permissionDialog.value = false;
|
|
627
726
|
showMessage("HID permissions updated.", "success");
|
|
@@ -940,6 +1039,18 @@ function showMessage(text: string, color: string) {
|
|
|
940
1039
|
padding-top: 2px;
|
|
941
1040
|
}
|
|
942
1041
|
|
|
1042
|
+
.permission-location {
|
|
1043
|
+
display: grid;
|
|
1044
|
+
grid-template-columns: minmax(260px, 1fr) minmax(320px, 1.35fr);
|
|
1045
|
+
gap: 36px;
|
|
1046
|
+
align-items: center;
|
|
1047
|
+
padding: 18px 0;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
.permission-location__select {
|
|
1051
|
+
width: 100%;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
943
1054
|
.permission-row {
|
|
944
1055
|
display: grid;
|
|
945
1056
|
grid-template-columns: minmax(260px, 1fr) minmax(520px, 1.35fr);
|
|
@@ -1002,8 +1113,8 @@ function showMessage(text: string, color: string) {
|
|
|
1002
1113
|
display: flex;
|
|
1003
1114
|
align-items: flex-start;
|
|
1004
1115
|
justify-content: space-between;
|
|
1005
|
-
gap:
|
|
1006
|
-
padding:
|
|
1116
|
+
gap: 16px;
|
|
1117
|
+
padding: 16px 18px 14px;
|
|
1007
1118
|
border-bottom: 1px solid #e5e9ed;
|
|
1008
1119
|
}
|
|
1009
1120
|
|
|
@@ -1022,16 +1133,35 @@ function showMessage(text: string, color: string) {
|
|
|
1022
1133
|
|
|
1023
1134
|
.permission-dialog__body {
|
|
1024
1135
|
display: grid;
|
|
1025
|
-
gap:
|
|
1026
|
-
padding: 18px
|
|
1136
|
+
gap: 10px;
|
|
1137
|
+
padding: 14px 18px 16px;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
.permission-search :deep(.v-field) {
|
|
1141
|
+
min-height: 44px;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
.permission-list-summary {
|
|
1145
|
+
display: flex;
|
|
1146
|
+
align-items: center;
|
|
1147
|
+
justify-content: space-between;
|
|
1148
|
+
min-height: 22px;
|
|
1149
|
+
color: #778391;
|
|
1150
|
+
font-size: 11px;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
.permission-list-summary__selected {
|
|
1154
|
+
color: #286c3c;
|
|
1155
|
+
font-weight: 700;
|
|
1027
1156
|
}
|
|
1028
1157
|
|
|
1029
1158
|
.permission-list {
|
|
1030
|
-
min-height:
|
|
1031
|
-
max-height:
|
|
1159
|
+
min-height: 180px;
|
|
1160
|
+
max-height: min(360px, 48vh);
|
|
1032
1161
|
overflow-y: auto;
|
|
1033
1162
|
border: 1px solid #dfe5ea;
|
|
1034
1163
|
border-radius: 6px;
|
|
1164
|
+
background: #ffffff;
|
|
1035
1165
|
}
|
|
1036
1166
|
|
|
1037
1167
|
.permission-list--loading {
|
|
@@ -1041,13 +1171,14 @@ function showMessage(text: string, color: string) {
|
|
|
1041
1171
|
|
|
1042
1172
|
.permission-list-item {
|
|
1043
1173
|
display: grid;
|
|
1044
|
-
grid-template-columns:
|
|
1045
|
-
gap:
|
|
1174
|
+
grid-template-columns: minmax(0, 1fr) max-content 32px;
|
|
1175
|
+
gap: 12px;
|
|
1046
1176
|
align-items: center;
|
|
1047
|
-
min-height:
|
|
1048
|
-
padding:
|
|
1177
|
+
min-height: 56px;
|
|
1178
|
+
padding: 7px 12px;
|
|
1049
1179
|
border-bottom: 1px solid #edf0f3;
|
|
1050
1180
|
cursor: pointer;
|
|
1181
|
+
transition: background-color 120ms ease;
|
|
1051
1182
|
}
|
|
1052
1183
|
|
|
1053
1184
|
.permission-list-item:last-child {
|
|
@@ -1058,8 +1189,17 @@ function showMessage(text: string, color: string) {
|
|
|
1058
1189
|
background: #f7fafc;
|
|
1059
1190
|
}
|
|
1060
1191
|
|
|
1192
|
+
.permission-list-item--selected {
|
|
1193
|
+
background: #f1f8f3;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
.permission-list-item--selected:hover {
|
|
1197
|
+
background: #eaf5ed;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1061
1200
|
.permission-list-item__content {
|
|
1062
1201
|
display: grid;
|
|
1202
|
+
gap: 2px;
|
|
1063
1203
|
min-width: 0;
|
|
1064
1204
|
}
|
|
1065
1205
|
|
|
@@ -1073,6 +1213,7 @@ function showMessage(text: string, color: string) {
|
|
|
1073
1213
|
.permission-list-item__content strong {
|
|
1074
1214
|
color: #243443;
|
|
1075
1215
|
font-size: 13px;
|
|
1216
|
+
line-height: 1.35;
|
|
1076
1217
|
}
|
|
1077
1218
|
|
|
1078
1219
|
.permission-list-item__content small,
|
|
@@ -1082,9 +1223,21 @@ function showMessage(text: string, color: string) {
|
|
|
1082
1223
|
}
|
|
1083
1224
|
|
|
1084
1225
|
.permission-list-item__group {
|
|
1085
|
-
|
|
1226
|
+
max-width: 170px;
|
|
1227
|
+
overflow: hidden;
|
|
1228
|
+
padding: 4px 9px;
|
|
1086
1229
|
border-radius: 4px;
|
|
1087
1230
|
background: #eef2f5;
|
|
1231
|
+
text-overflow: ellipsis;
|
|
1232
|
+
white-space: nowrap;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
.permission-list-item__checkbox {
|
|
1236
|
+
justify-self: end;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
.permission-list-item__checkbox :deep(.v-selection-control) {
|
|
1240
|
+
min-height: 32px;
|
|
1088
1241
|
}
|
|
1089
1242
|
|
|
1090
1243
|
.permission-empty {
|
|
@@ -1093,7 +1246,7 @@ function showMessage(text: string, color: string) {
|
|
|
1093
1246
|
align-items: center;
|
|
1094
1247
|
justify-content: center;
|
|
1095
1248
|
gap: 10px;
|
|
1096
|
-
min-height:
|
|
1249
|
+
min-height: 180px;
|
|
1097
1250
|
padding: 24px;
|
|
1098
1251
|
color: #75818c;
|
|
1099
1252
|
font-size: 13px;
|
|
@@ -1103,7 +1256,7 @@ function showMessage(text: string, color: string) {
|
|
|
1103
1256
|
.permission-dialog__actions {
|
|
1104
1257
|
justify-content: flex-end;
|
|
1105
1258
|
gap: 8px;
|
|
1106
|
-
padding:
|
|
1259
|
+
padding: 12px 18px;
|
|
1107
1260
|
border-top: 1px solid #e5e9ed;
|
|
1108
1261
|
}
|
|
1109
1262
|
|
|
@@ -1223,6 +1376,7 @@ function showMessage(text: string, color: string) {
|
|
|
1223
1376
|
.printer-grid,
|
|
1224
1377
|
.template-grid,
|
|
1225
1378
|
.validity-grid,
|
|
1379
|
+
.permission-location,
|
|
1226
1380
|
.permission-row {
|
|
1227
1381
|
grid-template-columns: 1fr 1fr;
|
|
1228
1382
|
}
|
|
@@ -1242,6 +1396,7 @@ function showMessage(text: string, color: string) {
|
|
|
1242
1396
|
.template-grid,
|
|
1243
1397
|
.validity-grid,
|
|
1244
1398
|
.methods-grid,
|
|
1399
|
+
.permission-location,
|
|
1245
1400
|
.permission-row,
|
|
1246
1401
|
.permission-actions {
|
|
1247
1402
|
grid-template-columns: 1fr;
|
|
@@ -1256,11 +1411,20 @@ function showMessage(text: string, color: string) {
|
|
|
1256
1411
|
}
|
|
1257
1412
|
|
|
1258
1413
|
.permission-list-item {
|
|
1259
|
-
grid-template-columns:
|
|
1414
|
+
grid-template-columns: minmax(0, 1fr) 32px;
|
|
1415
|
+
gap: 8px;
|
|
1260
1416
|
}
|
|
1261
1417
|
|
|
1262
1418
|
.permission-list-item__group {
|
|
1263
|
-
|
|
1419
|
+
grid-column: 1;
|
|
1420
|
+
grid-row: 2;
|
|
1421
|
+
justify-self: start;
|
|
1422
|
+
max-width: 100%;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
.permission-list-item__checkbox {
|
|
1426
|
+
grid-column: 2;
|
|
1427
|
+
grid-row: 1 / span 2;
|
|
1264
1428
|
}
|
|
1265
1429
|
}
|
|
1266
1430
|
</style>
|
|
@@ -137,10 +137,24 @@
|
|
|
137
137
|
</template>
|
|
138
138
|
<v-list density="compact" min-width="190">
|
|
139
139
|
<v-list-item title="Browse" @click="triggerPhotoInput" />
|
|
140
|
-
<v-list-item title="Take Photo using Camera" @click="
|
|
140
|
+
<v-list-item title="Take Photo using Camera" @click="triggerCameraInput" />
|
|
141
|
+
<v-list-item
|
|
142
|
+
v-if="form.photoPreview"
|
|
143
|
+
title="Remove Photo"
|
|
144
|
+
class="text-error"
|
|
145
|
+
@click="removePhoto"
|
|
146
|
+
/>
|
|
141
147
|
</v-list>
|
|
142
148
|
</v-menu>
|
|
143
149
|
<input ref="photoInput" type="file" accept="image/*" class="hidden-input" @change="onPhotoChange" />
|
|
150
|
+
<input
|
|
151
|
+
ref="cameraInput"
|
|
152
|
+
type="file"
|
|
153
|
+
accept="image/*"
|
|
154
|
+
capture="user"
|
|
155
|
+
class="hidden-input"
|
|
156
|
+
@change="onPhotoChange"
|
|
157
|
+
/>
|
|
144
158
|
</div>
|
|
145
159
|
|
|
146
160
|
<div class="field-label">Name <span>*</span></div>
|
|
@@ -270,6 +284,8 @@ const {
|
|
|
270
284
|
getReaders,
|
|
271
285
|
getIdentities,
|
|
272
286
|
getUserImage,
|
|
287
|
+
setUserImage,
|
|
288
|
+
deleteUserImage,
|
|
273
289
|
runObjectOperation,
|
|
274
290
|
createIdentity,
|
|
275
291
|
updateIdentity,
|
|
@@ -296,6 +312,9 @@ const deleteDialog = ref(false);
|
|
|
296
312
|
const selectedUser = ref<Record<string, any> | null>(null);
|
|
297
313
|
const showPassword = ref(false);
|
|
298
314
|
const photoInput = ref<HTMLInputElement | null>(null);
|
|
315
|
+
const cameraInput = ref<HTMLInputElement | null>(null);
|
|
316
|
+
const selectedPhotoFile = ref<File | null>(null);
|
|
317
|
+
const removePhotoRequested = ref(false);
|
|
299
318
|
const snackbar = reactive({
|
|
300
319
|
show: false,
|
|
301
320
|
color: "success",
|
|
@@ -438,6 +457,8 @@ function resetForm() {
|
|
|
438
457
|
form.cardNo = "";
|
|
439
458
|
form.pinPassword = "";
|
|
440
459
|
form.photoPreview = "";
|
|
460
|
+
selectedPhotoFile.value = null;
|
|
461
|
+
removePhotoRequested.value = false;
|
|
441
462
|
}
|
|
442
463
|
|
|
443
464
|
async function openEnroll() {
|
|
@@ -464,6 +485,8 @@ async function openEdit(user: Record<string, any>) {
|
|
|
464
485
|
form.cardNo = user.cardNo ?? "";
|
|
465
486
|
form.pinPassword = user.metadata?.pinPassword ?? "";
|
|
466
487
|
form.photoPreview = getUserImageSrc(user);
|
|
488
|
+
selectedPhotoFile.value = null;
|
|
489
|
+
removePhotoRequested.value = false;
|
|
467
490
|
formDialog.value = true;
|
|
468
491
|
|
|
469
492
|
if (!form.photoPreview) {
|
|
@@ -488,9 +511,32 @@ function triggerPhotoInput() {
|
|
|
488
511
|
photoInput.value?.click();
|
|
489
512
|
}
|
|
490
513
|
|
|
514
|
+
function triggerCameraInput() {
|
|
515
|
+
cameraInput.value?.click();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function removePhoto() {
|
|
519
|
+
form.photoPreview = "";
|
|
520
|
+
selectedPhotoFile.value = null;
|
|
521
|
+
removePhotoRequested.value = true;
|
|
522
|
+
}
|
|
523
|
+
|
|
491
524
|
function onPhotoChange(event: Event) {
|
|
492
|
-
const
|
|
525
|
+
const input = event.target as HTMLInputElement;
|
|
526
|
+
const file = input.files?.[0];
|
|
527
|
+
input.value = "";
|
|
493
528
|
if (!file) return;
|
|
529
|
+
if (!["image/jpeg", "image/png"].includes(file.type)) {
|
|
530
|
+
showToast("Facial enrollment only accepts JPG or PNG images.", "error");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (file.size > 2 * 1024 * 1024) {
|
|
534
|
+
showToast("Facial enrollment images must be smaller than 2 MB.", "error");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
selectedPhotoFile.value = file;
|
|
539
|
+
removePhotoRequested.value = false;
|
|
494
540
|
const reader = new FileReader();
|
|
495
541
|
reader.onload = () => {
|
|
496
542
|
form.photoPreview = String(reader.result || "");
|
|
@@ -531,6 +577,8 @@ async function saveUser() {
|
|
|
531
577
|
unitLabel: buildUnitLabel(),
|
|
532
578
|
facialData: form.photoPreview ? form.hidUserId : "",
|
|
533
579
|
photo: form.photoPreview,
|
|
580
|
+
imageTimestamp: selectedUser.value?.metadata?.imageTimestamp || "",
|
|
581
|
+
facialScores: selectedUser.value?.metadata?.facialScores || {},
|
|
534
582
|
pinPassword: form.pinPassword,
|
|
535
583
|
},
|
|
536
584
|
};
|
|
@@ -538,6 +586,8 @@ async function saveUser() {
|
|
|
538
586
|
if (selectedUser.value) {
|
|
539
587
|
await updateHidUserOnReader(readerId, hidUser);
|
|
540
588
|
await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
|
|
589
|
+
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
590
|
+
applyFacialResult(payload.metadata, facialResult);
|
|
541
591
|
if (selectedUser.value._id) {
|
|
542
592
|
await updateIdentity(selectedUser.value._id, payload);
|
|
543
593
|
} else {
|
|
@@ -546,6 +596,8 @@ async function saveUser() {
|
|
|
546
596
|
} else {
|
|
547
597
|
await createHidUserOnReader(readerId, hidUser);
|
|
548
598
|
await syncHidUserRole(readerId, hidUser.id, isAdministratorIdentity());
|
|
599
|
+
const facialResult = await syncFacialImage(readerId, hidUser.id);
|
|
600
|
+
applyFacialResult(payload.metadata, facialResult);
|
|
549
601
|
await saveIdentityOnReader(readerId, payload);
|
|
550
602
|
selectedReaderId.value = readerId;
|
|
551
603
|
}
|
|
@@ -561,6 +613,46 @@ async function saveUser() {
|
|
|
561
613
|
}
|
|
562
614
|
}
|
|
563
615
|
|
|
616
|
+
type FacialSyncResult = {
|
|
617
|
+
action: "none" | "enrolled" | "removed";
|
|
618
|
+
timestamp?: number;
|
|
619
|
+
scores?: Record<string, unknown>;
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
async function syncFacialImage(readerId: string, hidUserId: number): Promise<FacialSyncResult> {
|
|
623
|
+
if (selectedPhotoFile.value) {
|
|
624
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
625
|
+
const response = await setUserImage(readerId, hidUserId, selectedPhotoFile.value, {
|
|
626
|
+
timestamp,
|
|
627
|
+
match: true,
|
|
628
|
+
});
|
|
629
|
+
const result = response?.data;
|
|
630
|
+
userImages.value[String(hidUserId)] = form.photoPreview;
|
|
631
|
+
return { action: "enrolled", timestamp, scores: result?.scores };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (removePhotoRequested.value) {
|
|
635
|
+
await deleteUserImage(readerId, hidUserId);
|
|
636
|
+
delete userImages.value[String(hidUserId)];
|
|
637
|
+
return { action: "removed" };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return { action: "none" };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function applyFacialResult(metadata: Record<string, unknown>, result: FacialSyncResult) {
|
|
644
|
+
if (result.action === "enrolled") {
|
|
645
|
+
metadata.facialData = form.hidUserId;
|
|
646
|
+
metadata.imageTimestamp = String(result.timestamp || "");
|
|
647
|
+
metadata.facialScores = result.scores || {};
|
|
648
|
+
} else if (result.action === "removed") {
|
|
649
|
+
metadata.facialData = "";
|
|
650
|
+
metadata.photo = "";
|
|
651
|
+
metadata.imageTimestamp = "";
|
|
652
|
+
metadata.facialScores = {};
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
564
656
|
async function deleteUser() {
|
|
565
657
|
if (!selectedUser.value) return;
|
|
566
658
|
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
@refresh="refreshSiteData"
|
|
164
164
|
/>
|
|
165
165
|
<v-divider class="my-4" />
|
|
166
|
-
<HidQrCodeConfiguration :site="siteId" />
|
|
166
|
+
<HidQrCodeConfiguration :site="siteId" :org="orgId" />
|
|
167
167
|
</v-expansion-panel-text>
|
|
168
168
|
</v-expansion-panel>
|
|
169
169
|
|
|
@@ -266,7 +266,9 @@ const { data: siteData, refresh: refreshSiteData } = await useLazyAsyncData(
|
|
|
266
266
|
`site-${props.siteId}`,
|
|
267
267
|
() => getSiteById(props.siteId)
|
|
268
268
|
);
|
|
269
|
-
const orgId = computed(
|
|
269
|
+
const orgId = computed(
|
|
270
|
+
() => props.orgId || String((siteData.value as TSite | null)?.orgId ?? "")
|
|
271
|
+
);
|
|
270
272
|
|
|
271
273
|
watch(
|
|
272
274
|
siteData,
|
|
@@ -48,6 +48,26 @@ type HidPermissionListResponse = {
|
|
|
48
48
|
limit: number;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
type HidFacialEnrollmentResult = {
|
|
52
|
+
user_id?: number;
|
|
53
|
+
success?: boolean;
|
|
54
|
+
scores?: Record<string, unknown>;
|
|
55
|
+
errors?: Array<{ code?: number; message?: string }>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type HidSipAccountData = {
|
|
59
|
+
orgId: string;
|
|
60
|
+
site: string;
|
|
61
|
+
userId: string;
|
|
62
|
+
extension: string;
|
|
63
|
+
username: string;
|
|
64
|
+
password: string;
|
|
65
|
+
sipAddress: string;
|
|
66
|
+
websocketUrl: string;
|
|
67
|
+
status: "active";
|
|
68
|
+
provisionedAt: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
51
71
|
export default function useHidAmico() {
|
|
52
72
|
const basePath = "/api/access-management/hid";
|
|
53
73
|
|
|
@@ -142,6 +162,33 @@ export default function useHidAmico() {
|
|
|
142
162
|
});
|
|
143
163
|
}
|
|
144
164
|
|
|
165
|
+
function setUserImage(
|
|
166
|
+
readerId: string,
|
|
167
|
+
hidUserId: string | number,
|
|
168
|
+
image: Blob,
|
|
169
|
+
options: { timestamp?: number; match?: boolean } = {},
|
|
170
|
+
) {
|
|
171
|
+
return useNuxtApp().$api<{ data: HidFacialEnrollmentResult }>(
|
|
172
|
+
`${basePath}/readers/${readerId}/users/${hidUserId}/image`,
|
|
173
|
+
{
|
|
174
|
+
method: "PUT",
|
|
175
|
+
query: {
|
|
176
|
+
...(options.timestamp ? { timestamp: options.timestamp } : {}),
|
|
177
|
+
match: options.match ?? true,
|
|
178
|
+
},
|
|
179
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
180
|
+
body: image,
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function deleteUserImage(readerId: string, hidUserId: string | number) {
|
|
186
|
+
return useNuxtApp().$api<Record<string, unknown>>(
|
|
187
|
+
`${basePath}/readers/${readerId}/users/${hidUserId}/image`,
|
|
188
|
+
{ method: "DELETE" },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
145
192
|
function createIdentity(readerId: string, payload: HidIdentityPayload) {
|
|
146
193
|
return useNuxtApp().$api<Record<string, any>>(`${basePath}/readers/${readerId}/identities`, {
|
|
147
194
|
method: "POST",
|
|
@@ -181,16 +228,25 @@ export default function useHidAmico() {
|
|
|
181
228
|
});
|
|
182
229
|
}
|
|
183
230
|
|
|
184
|
-
function getSitePermissions(siteId: string) {
|
|
231
|
+
function getSitePermissions(siteId: string, orgId: string, readerId: string) {
|
|
185
232
|
return useNuxtApp().$api<{ data: THidSitePermissions }>(
|
|
186
233
|
`${basePath}/sites/${siteId}/permissions`,
|
|
187
|
-
{ method: "GET" },
|
|
234
|
+
{ method: "GET", query: { orgId, readerId } },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function ensureSipAccount(siteId: string, orgId: string) {
|
|
239
|
+
return useNuxtApp().$api<{ data: HidSipAccountData }>(
|
|
240
|
+
`${basePath}/sites/${siteId}/intercom/sip-account`,
|
|
241
|
+
{ method: "POST", body: { orgId } },
|
|
188
242
|
);
|
|
189
243
|
}
|
|
190
244
|
|
|
191
245
|
function getPermissionCandidates(
|
|
192
246
|
siteId: string,
|
|
193
247
|
query: {
|
|
248
|
+
orgId: string;
|
|
249
|
+
readerId: string;
|
|
194
250
|
category: THidPermissionCategory | "intercom";
|
|
195
251
|
search?: string;
|
|
196
252
|
page?: number;
|
|
@@ -205,11 +261,13 @@ export default function useHidAmico() {
|
|
|
205
261
|
|
|
206
262
|
function updateSitePermissions(
|
|
207
263
|
siteId: string,
|
|
264
|
+
orgId: string,
|
|
265
|
+
readerId: string,
|
|
208
266
|
assignments: Array<Pick<THidPermissionAssignment, "subjectId" | "category" | "intercom">>,
|
|
209
267
|
) {
|
|
210
268
|
return useNuxtApp().$api<{ data: THidSitePermissions; message: string }>(
|
|
211
269
|
`${basePath}/sites/${siteId}/permissions`,
|
|
212
|
-
{ method: "PUT", body: { assignments } },
|
|
270
|
+
{ method: "PUT", body: { orgId, readerId, assignments } },
|
|
213
271
|
);
|
|
214
272
|
}
|
|
215
273
|
|
|
@@ -226,6 +284,8 @@ export default function useHidAmico() {
|
|
|
226
284
|
setReaderConfiguration,
|
|
227
285
|
getLogs,
|
|
228
286
|
getUserImage,
|
|
287
|
+
setUserImage,
|
|
288
|
+
deleteUserImage,
|
|
229
289
|
getIdentities,
|
|
230
290
|
createIdentity,
|
|
231
291
|
updateIdentity,
|
|
@@ -233,6 +293,7 @@ export default function useHidAmico() {
|
|
|
233
293
|
getIntercomStatus,
|
|
234
294
|
makeIntercomCall,
|
|
235
295
|
finalizeIntercomCall,
|
|
296
|
+
ensureSipAccount,
|
|
236
297
|
getSitePermissions,
|
|
237
298
|
getPermissionCandidates,
|
|
238
299
|
updateSitePermissions,
|
package/package.json
CHANGED
package/types/site.d.ts
CHANGED
|
@@ -13,10 +13,16 @@ declare type THidPermissionAssignment = {
|
|
|
13
13
|
name: string;
|
|
14
14
|
subtitle?: string;
|
|
15
15
|
intercom: boolean;
|
|
16
|
+
readerId?: string;
|
|
17
|
+
location?: string;
|
|
18
|
+
locationKey?: string;
|
|
16
19
|
};
|
|
17
20
|
|
|
18
21
|
declare type THidSitePermissions = {
|
|
22
|
+
orgId: string;
|
|
19
23
|
site: string;
|
|
24
|
+
readerId: string;
|
|
25
|
+
location: string;
|
|
20
26
|
assignments: THidPermissionAssignment[];
|
|
21
27
|
counts: {
|
|
22
28
|
resident: number;
|