@7365admin1/layer-common 3.2.2-staging.83 → 3.2.2-staging.85

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