@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.
@@ -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="call-label">WebSocket Server <span>*</span></div>
390
- <v-text-field
391
- v-model="webPhoneForm.webSocketServer"
392
- placeholder="wss://sip.example.com/ws"
393
- variant="outlined"
394
- density="compact"
395
- hide-details="auto"
396
- class="call-input"
397
- :disabled="webPhone.isRegistered.value"
398
- />
399
- <div class="call-label">SIP Address <span>*</span></div>
400
- <v-text-field
401
- v-model="webPhoneForm.aor"
402
- placeholder="operator@example.com"
403
- variant="outlined"
404
- density="compact"
405
- hide-details="auto"
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
- <div class="call-label">Username <span>*</span></div>
412
- <v-text-field
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
- () => reload(),
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: webPhoneForm.webSocketServer,
1188
- aor: webPhoneForm.aor,
1189
- authorizationUsername: webPhoneForm.username,
1190
- authorizationPassword: webPhoneForm.password,
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: any) {
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 getErrorMessage(error: any) {
1496
- return error?.data?.message || error?.response?._data?.message || error?.message || "HID intercom request failed.";
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="640" persistent>
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
- ? "Select from people and providers who already have HID access permission."
482
- : `Select the ${permissionCategoryLabel(permissionDialogCategory.value).toLowerCase()} entries allowed to use HID access at this site.`
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
- await Promise.all([loadReaderConfiguration(), loadPermissions()]);
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) return;
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(props.site, assignments);
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: 20px;
1006
- padding: 20px 22px 16px;
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: 16px;
1026
- padding: 18px 22px;
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: 240px;
1031
- max-height: 390px;
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: 34px minmax(0, 1fr) auto;
1045
- gap: 10px;
1174
+ grid-template-columns: minmax(0, 1fr) max-content 32px;
1175
+ gap: 12px;
1046
1176
  align-items: center;
1047
- min-height: 62px;
1048
- padding: 8px 14px 8px 8px;
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
- padding: 4px 8px;
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: 240px;
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: 14px 22px;
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: 34px minmax(0, 1fr);
1414
+ grid-template-columns: minmax(0, 1fr) 32px;
1415
+ gap: 8px;
1260
1416
  }
1261
1417
 
1262
1418
  .permission-list-item__group {
1263
- display: none;
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="triggerPhotoInput" />
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 file = (event.target as HTMLInputElement).files?.[0];
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(() => props.orgId || String((siteData.value as any)?.orgId ?? ""));
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
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.1.4-staging.56",
5
+ "version": "3.1.4-staging.57",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -5,7 +5,7 @@
5
5
  :org="orgId"
6
6
  message="Enable HID as a service for this site before configuring HID intercom."
7
7
  >
8
- <HidIntercomManagement :site="siteId" />
8
+ <HidIntercomManagement :site="siteId" :org="orgId" />
9
9
  </HidEnabledGate>
10
10
  </v-container>
11
11
  </template>
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;