@7365admin1/layer-common 4.90.2-staging.490 → 4.90.2

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +10 -4
  2. package/assets/css/primitives.css +0 -57
  3. package/assets/css/screens.css +4 -8
  4. package/components/AccessCardDetailsDialog.vue +6 -59
  5. package/components/AccessCardPreviewDialog.vue +53 -371
  6. package/components/AppSelect.vue +4 -143
  7. package/components/BuildingUnitFormEdit.vue +0 -6
  8. package/components/Dialog/UpdateMoreAction.vue +1 -11
  9. package/components/DocumentForm.vue +112 -407
  10. package/components/DocumentManagement.vue +12 -213
  11. package/components/Facility/BookingSetup.vue +0 -63
  12. package/components/HidAccessLogDashboard.vue +17 -74
  13. package/components/HidIntercomManagement.vue +14 -183
  14. package/components/HidQrCodeConfiguration.vue +195 -17
  15. package/components/HidReaderManagement.vue +0 -398
  16. package/components/HidReaderUserRoster.vue +3 -44
  17. package/components/HidUserEnrollment.vue +292 -2255
  18. package/components/InvitationClientForm.vue +1 -19
  19. package/components/InvitationForm.vue +4 -31
  20. package/components/InvitationMain.vue +22 -124
  21. package/components/MemberInformation.vue +4 -4
  22. package/components/PassInformation.vue +2 -2
  23. package/components/SiteSettings.vue +0 -48
  24. package/components/TableMain.vue +7 -22
  25. package/components/VehicleManagement.vue +5 -80
  26. package/components/VisitorForm.vue +19 -65
  27. package/components/VisitorManagement.vue +5 -267
  28. package/composables/useAccessManagement.ts +1 -17
  29. package/composables/useDocument.ts +0 -7
  30. package/composables/useFacility.ts +0 -11
  31. package/composables/useHidAmico.ts +0 -165
  32. package/composables/useHidNavigation.ts +1 -25
  33. package/composables/useMember.ts +0 -12
  34. package/composables/useNFCPatrolReportFilters.ts +3 -21
  35. package/composables/useNFCPatrolRoute.ts +1 -8
  36. package/composables/usePeople.ts +0 -19
  37. package/composables/useServiceProvider.ts +2 -45
  38. package/composables/useSettingsPermission.ts +0 -14
  39. package/composables/useUser.ts +2 -6
  40. package/composables/useVehicle.ts +1 -40
  41. package/composables/useVerification.ts +0 -3
  42. package/middleware/01.auth.ts +7 -3
  43. package/package.json +1 -1
  44. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  45. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  46. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  47. package/types/document.d.ts +0 -14
  48. package/types/facility.d.ts +0 -4
  49. package/types/member.d.ts +0 -4
  50. package/types/people.d.ts +1 -28
  51. package/types/service-provider.d.ts +0 -5
  52. package/types/site.d.ts +1 -7
  53. package/components/BuildingUnitManagement.vue +0 -298
  54. package/components/HidAccessPermissions.vue +0 -424
  55. package/components/HidProfileQrCode.vue +0 -332
  56. package/components/VehicleQrStickerDialog.vue +0 -249
  57. package/composables/useHidReaderSelection.ts +0 -50
  58. package/composables/useSiteCategory.ts +0 -45
  59. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
  60. package/utils/hid-enrolment-subject.ts +0 -344
  61. package/utils/hid-permission-assignments.ts +0 -198
  62. package/utils/hid-reader-selection.ts +0 -52
  63. package/utils/invite-group.ts +0 -61
  64. package/utils/invite-sites.ts +0 -25
  65. package/utils/occupancy-role.ts +0 -109
  66. package/utils/service-type.ts +0 -30
  67. package/utils/vehicle-qr-sticker.ts +0 -184
@@ -162,8 +162,11 @@
162
162
  />
163
163
  <v-list-item class="screen-menu__item" title="Call Settings" prepend-icon="mdi-cog" @click="openCallDialog(item)" />
164
164
  <v-list-item class="screen-menu__item" title="Dial Code" prepend-icon="mdi-dialpad" @click="openDialCodeDialog(item)" />
165
+ <v-list-item class="screen-menu__item" title="Make a call" prepend-icon="mdi-phone" @click="openMakeCallDialog(item)" />
166
+ <v-list-item class="screen-menu__item" title="Web call" prepend-icon="mdi-headset" @click="openWebPhoneDialog(item)" />
165
167
  </v-list>
166
168
  <v-list v-else density="compact" class="screen-menu">
169
+ <v-list-item class="screen-menu__item" title="Call" prepend-icon="mdi-phone" @click="openContactCall(item)" />
167
170
  <v-list-item class="screen-menu__item" title="Check reader SIP status" prepend-icon="mdi-lan-check" @click="testContactSipStatus(item)" />
168
171
  <v-list-item class="screen-menu__item" title="View" prepend-icon="mdi-eye" @click="openContactView(item)" />
169
172
  <v-list-item class="screen-menu__item" title="Edit" prepend-icon="mdi-pencil" @click="openContactDialog(item)" />
@@ -586,36 +589,9 @@
586
589
  :disabled="Boolean(contactForm.id)"
587
590
  />
588
591
  <FieldLabel text="Name" required />
589
- <!-- The name is picked from the site's SIP accounts (`hid-sip-accounts`)
590
- rather than typed, so a contact always names a person who holds an
591
- extension. The model stays the plain name string the reader stores,
592
- so `saveContact` is unchanged; an existing contact whose name is not
593
- in the list still displays it (AppSelect falls back to the raw value). -->
594
- <!-- Searchable: this is the one list on the screen that grows with
595
- the estate, and the unit is part of every option's text, so the
596
- box finds a person by name or by where they live. -->
597
- <AppSelect
598
- v-model="contactForm.name"
599
- :items="sipAccountOptions"
600
- :disabled="sipAccountsLoading"
601
- :placeholder="sipAccountsLoading ? 'Loading SIP accounts…' : 'Name shown on calls'"
602
- searchable
603
- search-placeholder="Search name or unit"
604
- class="my-3"
605
- @update:model-value="applySipAccountToContact"
606
- />
607
- <p v-if="sipAccountsError" class="field-hint field-hint--error">{{ sipAccountsError }}</p>
608
- <p v-else-if="!sipAccountsLoading && !sipAccounts.length" class="field-hint">
609
- No SIP accounts on this site yet. An account is created the first time a user opens Web Call.
610
- </p>
592
+ <AppField v-model="contactForm.name" placeholder="Name shown on calls" class="my-3" />
611
593
  <FieldLabel text="Address" required />
612
- <!-- Always read-only: it shows the extension of the chosen SIP account
613
- and is never typed into. -->
614
- <AppField
615
- v-model="contactForm.serverAddress"
616
- :placeholder="contactAddressPlaceholder"
617
- disabled
618
- />
594
+ <AppField v-model="contactForm.serverAddress" :placeholder="contactAddressPlaceholder" />
619
595
  <p class="field-hint">{{ contactAddressHint }}</p>
620
596
  </v-card-text>
621
597
  <v-card-actions class="dialog-actions pa-0">
@@ -660,8 +636,6 @@
660
636
  </template>
661
637
 
662
638
  <script setup lang="ts">
663
- import type { HidSipAccountListItem } from "../composables/useHidAmico";
664
-
665
639
  const props = defineProps({
666
640
  org: {
667
641
  type: String,
@@ -758,8 +732,6 @@ const {
758
732
  makeIntercomCall,
759
733
  finalizeIntercomCall,
760
734
  ensureSipAccount,
761
- getSipAccounts,
762
- notifyIntercomCall,
763
735
  } = useHidAmico();
764
736
 
765
737
  const activeTab = ref<IntercomTab>("intercom");
@@ -788,11 +760,6 @@ const webPhoneActionLoading = ref(false);
788
760
  const sipAccount = ref<SipAccount | null>(null);
789
761
  const sipAccountLoading = ref(false);
790
762
  const sipAccountError = ref("");
791
- // The site's SIP account list for the contact dialog's name picker. Distinct
792
- // from `sipAccount` above, which is the CALLER's own account for the web phone.
793
- const sipAccounts = ref<HidSipAccountListItem[]>([]);
794
- const sipAccountsLoading = ref(false);
795
- const sipAccountsError = ref("");
796
763
  const remoteVideoEl = ref<HTMLVideoElement | null>(null);
797
764
  const remoteAudioEl = ref<HTMLAudioElement | null>(null);
798
765
  const webPhone = useSipWebPhone();
@@ -897,58 +864,6 @@ const readerOptions = computed(() => readers.value.map((reader) => ({
897
864
  value: reader._id,
898
865
  })));
899
866
 
900
- /**
901
- * "Harris - Blk 1/Level 1/Unit 1" - the name a contact is stored under.
902
- *
903
- * The unit belongs in the name itself, not only in the picker's label: the
904
- * reader's contact list is what a visitor reads off the door panel, and
905
- * "Harris" alone does not say which home it rings. An account whose owner has
906
- * no address, and every account until the API ships the `unitLabel` field,
907
- * falls back to the bare name rather than trailing a separator.
908
- */
909
- function buildContactName(account: HidSipAccountListItem) {
910
- const unit = String(account.unitLabel ?? "").trim();
911
- return unit ? `${account.name} - ${unit}` : account.name;
912
- }
913
-
914
- /**
915
- * The contact name picker. `value` is the composed name because that is what
916
- * the reader stores and what `saveContact` sends; the extension goes into the
917
- * Address field, not the label. A name that already sits on the contact being
918
- * edited but is no longer in the SIP list is kept as an option so the picker
919
- * does not silently drop it.
920
- */
921
- const sipAccountOptions = computed(() => {
922
- const options: Array<{ title: string; value: string }> = sipAccounts.value
923
- .filter((account: HidSipAccountListItem) => account.name)
924
- .map((account: HidSipAccountListItem) => {
925
- const name = buildContactName(account);
926
- return { title: name, value: name };
927
- });
928
- const current = contactForm.name.trim();
929
- if (current && !options.some((option) => option.value === current)) {
930
- options.unshift({ title: current, value: current });
931
- }
932
- return options;
933
- });
934
-
935
- /**
936
- * The SIP account behind a stored contact name, or null when the name is not
937
- * one. The bare name is matched as well as the composed one, so a contact
938
- * created before the unit was part of the name still resolves to its account
939
- * on edit instead of looking unrecognised.
940
- */
941
- function findSipAccount(name: string) {
942
- const value = name.trim();
943
- if (!value) return null;
944
- const match = sipAccounts.value.find((account: HidSipAccountListItem) => buildContactName(account) === value)
945
- ?? sipAccounts.value.find((account: HidSipAccountListItem) => account.name === value);
946
- return (match ?? null) as HidSipAccountListItem | null;
947
- }
948
-
949
- /** The SIP account behind the picked name, or null for a legacy / unpicked name. */
950
- const selectedSipAccount = computed(() => findSipAccount(contactForm.name));
951
-
952
867
  const visibleRows = computed(() => activeTab.value === "intercom" ? filteredIntercomRows.value : filteredContacts.value);
953
868
  const pages = computed(() => Math.max(1, Math.ceil(visibleRows.value.length / limit)));
954
869
  const pageRange = computed(() => {
@@ -1061,11 +976,14 @@ const contactReaderRow = computed(() =>
1061
976
  );
1062
977
  const isContactReaderPeerToPeer = computed(() => contactReaderRow.value?.operatingMode === "Peer-to-Peer");
1063
978
  const contactAddressPlaceholder = computed(() => (isContactReaderPeerToPeer.value ? "192.168.0.129" : "7100"));
1064
- const contactAddressHint = computed(() => (
1065
- selectedSipAccount.value
1066
- ? "Extension of the selected SIP account."
1067
- : "Filled from the SIP account of the name selected above."
1068
- ));
979
+ const contactAddressHint = computed(() => {
980
+ if (!contactReaderRow.value) {
981
+ return "Extension number (SIP Client) or local IP address (Peer-to-Peer), depending on the reader's operating mode.";
982
+ }
983
+ return isContactReaderPeerToPeer.value
984
+ ? "Peer-to-Peer mode: enter the contact's local IP address."
985
+ : "SIP Client mode: enter the contact's extension number.";
986
+ });
1069
987
  const areSipCredentialFieldsDisabled = computed(() => !sipForm.enabled || isPeerToPeerMode.value);
1070
988
  const intercomDetailSections = computed(() => {
1071
989
  const row = selectedRow.value;
@@ -1481,40 +1399,6 @@ async function loadSipAccount() {
1481
1399
  }
1482
1400
  }
1483
1401
 
1484
- async function loadSipAccounts() {
1485
- // Site only. The contact list belongs to the door, not to an organization,
1486
- // so the picker loads wherever a site is known - including the screens that
1487
- // are opened without an `org` prop.
1488
- if (!props.site) return;
1489
-
1490
- sipAccountsLoading.value = true;
1491
- sipAccountsError.value = "";
1492
- try {
1493
- const response = await getSipAccounts(props.site);
1494
- const rows = toRecord(response).data;
1495
- sipAccounts.value = Array.isArray(rows) ? rows as HidSipAccountListItem[] : [];
1496
- // An existing contact opened before the list arrived: once its name is
1497
- // matched to an account, show that account's extension in the locked field.
1498
- applySipAccountToContact(contactForm.name.trim());
1499
- } catch (error: unknown) {
1500
- sipAccounts.value = [];
1501
- sipAccountsError.value = getErrorMessage(error);
1502
- } finally {
1503
- sipAccountsLoading.value = false;
1504
- }
1505
- }
1506
-
1507
- /**
1508
- * Picking a SIP account sets the address to its extension. The Address field
1509
- * is always disabled, so this is the only way it gets a value; a name that is
1510
- * not an account (a legacy contact) leaves whatever address it already had.
1511
- */
1512
- function applySipAccountToContact(name: string) {
1513
- const account = findSipAccount(name);
1514
- if (!account) return;
1515
- contactForm.serverAddress = account.extension;
1516
- }
1517
-
1518
1402
  async function connectWebPhone() {
1519
1403
  const account = sipAccount.value;
1520
1404
  if (!account) {
@@ -1558,10 +1442,8 @@ async function disconnectWebPhone() {
1558
1442
 
1559
1443
  async function startWebCall() {
1560
1444
  webPhoneActionLoading.value = true;
1561
- const destination = webPhoneForm.destination;
1562
1445
  try {
1563
- await webPhone.call(destination);
1564
- void ringResidentPhone(destination);
1446
+ await webPhone.call(webPhoneForm.destination);
1565
1447
  } catch (error: unknown) {
1566
1448
  showMessage(getErrorMessage(error), "error");
1567
1449
  } finally {
@@ -1569,50 +1451,6 @@ async function startWebCall() {
1569
1451
  }
1570
1452
  }
1571
1453
 
1572
- /**
1573
- * Wake the called resident's mobile so they are there to answer.
1574
- *
1575
- * Deliberately fired AFTER the INVITE and never awaited. The SIP leg is the
1576
- * call: it is already ringing by the time this runs, and a resident whose
1577
- * phone is asleep is the only thing this fixes. So a failure here is logged
1578
- * and dropped rather than shown — telling the operator "call failed" for a
1579
- * call that is ringing would be worse than a silent miss.
1580
- *
1581
- * A destination with no resident behind it (a staff extension, an external SIP
1582
- * address) comes back `notified: false` with a reason and is not an error.
1583
- */
1584
- async function ringResidentPhone(destination: string) {
1585
- if (!props.org || !props.site) return;
1586
-
1587
- try {
1588
- // No `readerId`: this call came from the web phone, not a door station.
1589
- // The reader selected in the table above is whatever the operator happens
1590
- // to be looking at, and sending it would tell the resident's app the call
1591
- // arrived at a door it never touched.
1592
- await notifyIntercomCall(props.site, {
1593
- orgId: props.org,
1594
- destination,
1595
- callId: createCallId(),
1596
- });
1597
- } catch (error: unknown) {
1598
- console.warn("[intercom] could not send the incoming-call push", error);
1599
- }
1600
- }
1601
-
1602
- /**
1603
- * A per-call id, so a second push for the same call replaces the first on the
1604
- * resident's lock screen rather than stacking a second ringing card.
1605
- * `randomUUID` needs a secure context, which the web phone already requires —
1606
- * getUserMedia does not run without one — but the fallback keeps a call from
1607
- * failing on that account alone.
1608
- */
1609
- function createCallId() {
1610
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1611
- return crypto.randomUUID();
1612
- }
1613
- return `call-${Date.now()}-${Math.random().toString(16).slice(2)}`;
1614
- }
1615
-
1616
1454
  async function answerWebCall() {
1617
1455
  webPhoneActionLoading.value = true;
1618
1456
  try {
@@ -1670,9 +1508,6 @@ function openContactDialog(contact?: ContactRow) {
1670
1508
  contactForm.location = contact?.location === "N/A" ? "" : contact?.location || selectedReader?.location || "";
1671
1509
  contactForm.serverAddress = contact?.serverAddress === "N/A" ? "" : contact?.serverAddress || "";
1672
1510
  contactDialog.value = true;
1673
- // Fetched on every open, not cached: an account is provisioned the first
1674
- // time a user opens Web Call, so the list can grow between two dialogs.
1675
- void loadSipAccounts();
1676
1511
  }
1677
1512
 
1678
1513
  function openContactView(contact: ContactRow) {
@@ -2261,10 +2096,6 @@ export default {
2261
2096
  line-height: 1.4;
2262
2097
  }
2263
2098
 
2264
- .field-hint--error {
2265
- color: var(--err);
2266
- }
2267
-
2268
2099
  .sip-settings-dialog .field-label {
2269
2100
  margin-top: 14px;
2270
2101
  }
@@ -21,6 +21,33 @@
21
21
  <div class="config-body">
22
22
  <div class="section-label config-body__title">HID Configuration</div>
23
23
 
24
+ <div class="form-section identification-section">
25
+ <div class="section-label">Enable/disable reader identification method</div>
26
+ <p class="section-description">
27
+ Toggles multi-factor access methods, allowing users to authenticate via biometric, physical card, or credential inputs.
28
+ </p>
29
+ <div class="methods-grid">
30
+ <div
31
+ v-for="method in identificationMethodOptions"
32
+ :key="method.key"
33
+ class="method-control"
34
+ >
35
+ <span>{{ method.label }}</span>
36
+ <v-switch
37
+ v-model="form.identificationMethods[method.key]"
38
+ class="app-switch"
39
+ hide-details
40
+ density="compact"
41
+ :loading="savingSection === `method-${method.key}`"
42
+ :disabled="Boolean(savingSection) || !form.readerId"
43
+ @update:model-value="saveIdentificationMethod(method.key)"
44
+ />
45
+ </div>
46
+ </div>
47
+ </div>
48
+
49
+ <v-divider />
50
+
24
51
  <div class="form-section">
25
52
  <div class="section-label">Printer Setup</div>
26
53
  <div class="printer-grid">
@@ -197,11 +224,14 @@
197
224
 
198
225
 
199
226
  <script setup lang="ts">
227
+ type HidIdentificationMethod = keyof NonNullable<THidQrCodePassConfig["identificationMethods"]>;
228
+
200
229
  type HidQrConfig = Omit<
201
230
  THidQrCodePassConfig,
202
231
  "identificationMethods" | "onlineMode" | "updatedAt" | "qrGatePermissions"
203
232
  > & {
204
233
  onlineMode: boolean;
234
+ identificationMethods: Record<HidIdentificationMethod, boolean>;
205
235
  qrGatePermissions: {
206
236
  resident: string[];
207
237
  propertyManagement: string[];
@@ -235,7 +265,11 @@ const runtimeConfig = useRuntimeConfig();
235
265
  const isPermissionManager = computed(() => runtimeConfig.public.APP === "security_agency");
236
266
 
237
267
  const { getSiteById, updateSitebyId } = useSiteSettings();
238
- const { getReaders } = useHidAmico();
268
+ const {
269
+ getReaders,
270
+ getReaderConfiguration,
271
+ setReaderConfiguration,
272
+ } = useHidAmico();
239
273
  const {
240
274
  requestDevice: requestPrinterDevice,
241
275
  testConnection: testPrinterConnection,
@@ -250,6 +284,14 @@ const defaultConfig: HidQrConfig = {
250
284
  propertyManagement: [],
251
285
  },
252
286
  qrFormat: "0",
287
+ identificationMethods: {
288
+ facial: true,
289
+ card: true,
290
+ qrCode: false,
291
+ idPassword: true,
292
+ pin: false,
293
+ bluetooth: false,
294
+ },
253
295
  printer: {
254
296
  vendorId: "",
255
297
  productId: "",
@@ -279,6 +321,16 @@ const visitorQrGateOptions = computed(() => readers.value
279
321
  title: String(reader.name || reader.deviceId || reader._id) + " - " + String(reader.portalName || "Portal"),
280
322
  value: reader._id,
281
323
  })));
324
+ const identificationMethodOptions: Array<{
325
+ key: HidIdentificationMethod;
326
+ label: string;
327
+ }> = [
328
+ { key: "facial", label: "Facial" },
329
+ { key: "card", label: "Card" },
330
+ { key: "qrCode", label: "QR Code" },
331
+ { key: "idPassword", label: "ID Password" },
332
+ { key: "pin", label: "PIN" },
333
+ ];
282
334
  const siteName = computed(() => siteData.value?.name || "Site");
283
335
  const siteLabel = computed(() => siteData.value?.code || siteData.value?.block || props.site);
284
336
  const primaryQrGateReaderId = computed(() => (
@@ -319,6 +371,7 @@ async function loadConfig() {
319
371
  const readerList = readerResponse as HidReaderListResponse;
320
372
  readers.value = readerList.items ?? readerList.data?.items ?? readerList.data?.readers ?? [];
321
373
  const config = siteData.value?.metadata?.hidQrCodePass || {};
374
+ const configuredMethods = config.identificationMethods;
322
375
  Object.assign(form, {
323
376
  enabled: Boolean(config.enabled),
324
377
  // Visitor QR is reader-local. Do not restore a legacy Online Mode value
@@ -339,6 +392,14 @@ async function loadConfig() {
339
392
  || "",
340
393
  ),
341
394
  qrFormat: "0",
395
+ identificationMethods: {
396
+ facial: configuredMethods?.facial ?? true,
397
+ card: configuredMethods?.card ?? true,
398
+ qrCode: configuredMethods?.qrCode ?? Boolean(config.enabled),
399
+ idPassword: configuredMethods?.idPassword ?? true,
400
+ pin: configuredMethods?.pin ?? false,
401
+ bluetooth: false,
402
+ },
342
403
  printer: {
343
404
  vendorId: String(config.printer?.vendorId || ""),
344
405
  productId: String(config.printer?.productId || ""),
@@ -349,25 +410,57 @@ async function loadConfig() {
349
410
  },
350
411
  validityMinutes: config.validityMinutes ? Number(config.validityMinutes) : null,
351
412
  });
413
+
414
+ await loadReaderConfiguration();
415
+ }
416
+
417
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
418
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
419
+ }
420
+
421
+ function getConfigurationData(response: unknown) {
422
+ if (!isUnknownRecord(response)) return {};
423
+ return isUnknownRecord(response.data) ? response.data : response;
424
+ }
425
+
426
+ function isEnabledConfiguration(value: unknown) {
427
+ return value === true || value === 1 || value === "1";
428
+ }
429
+
430
+ async function loadReaderConfiguration() {
431
+ if (!form.readerId) return;
432
+
433
+ try {
434
+ const response = await getReaderConfiguration(form.readerId, {
435
+ identifier: [
436
+ "card_identification_enabled",
437
+ "pin_identification_enabled",
438
+ "multi_factor_authentication",
439
+ ],
440
+ });
441
+ const configuration = getConfigurationData(response);
442
+ const identifier = isUnknownRecord(configuration.identifier) ? configuration.identifier : {};
443
+
444
+ if (identifier.pin_identification_enabled !== undefined) {
445
+ const pinEnabled = isEnabledConfiguration(identifier.pin_identification_enabled);
446
+ form.identificationMethods.pin = pinEnabled;
447
+ form.identificationMethods.idPassword = !pinEnabled;
448
+ }
449
+ if (identifier.card_identification_enabled !== undefined) {
450
+ form.identificationMethods.card = isEnabledConfiguration(
451
+ identifier.card_identification_enabled,
452
+ );
453
+ }
454
+ } catch (error: unknown) {
455
+ console.warn("Unable to load HID reader configuration:", errorConverter(error));
456
+ }
352
457
  }
353
458
 
354
459
  function buildPayload() {
355
- /*
356
- * `enabled` and `identificationMethods` are passed through from what was
357
- * loaded, never recomputed.
358
- *
359
- * Which credentials a reader accepts now lives on the READER record and is
360
- * set from HID Readers > Identification Methods, because the device is what
361
- * holds them. The toggles that used to derive these two values are gone from
362
- * this screen, so deriving them again from a form that no longer has them
363
- * would erase the stored copy and flip `enabled` every time somebody saved
364
- * the printer settings.
365
- */
366
- const storedQrPass = siteData.value?.metadata?.hidQrCodePass || {};
367
460
  const metadata = {
368
461
  ...(siteData.value?.metadata || {}),
369
462
  hidQrCodePass: {
370
- enabled: Boolean(storedQrPass.enabled),
463
+ enabled: form.identificationMethods.qrCode,
371
464
  onlineMode: false,
372
465
  // Kept only for old background/mobile flows. Gate permissions, not this
373
466
  // compatibility value, decide where a new Visitor QR may be scanned.
@@ -378,9 +471,7 @@ function buildPayload() {
378
471
  },
379
472
  // Visitor QR passes always use Amico alphanumeric qrcodes.
380
473
  qrFormat: "0",
381
- ...(storedQrPass.identificationMethods
382
- ? { identificationMethods: storedQrPass.identificationMethods }
383
- : {}),
474
+ identificationMethods: { ...form.identificationMethods },
384
475
  printer: {
385
476
  vendorId: form.printer.vendorId.trim(),
386
477
  productId: form.printer.productId.trim(),
@@ -416,6 +507,43 @@ async function saveSection(section: string) {
416
507
  }
417
508
  }
418
509
 
510
+ async function saveIdentificationMethod(method: HidIdentificationMethod) {
511
+ if (!props.site) return;
512
+
513
+ form.enabled = form.identificationMethods.qrCode;
514
+
515
+ savingSection.value = `method-${method}`;
516
+ try {
517
+ if (method === "card") {
518
+ await setReaderConfiguration(form.readerId, {
519
+ identifier: {
520
+ card_identification_enabled: form.identificationMethods.card ? "1" : "0",
521
+ },
522
+ });
523
+ } else if (method === "pin" || method === "idPassword") {
524
+ if (method === "pin" && form.identificationMethods.pin) {
525
+ form.identificationMethods.idPassword = false;
526
+ } else if (method === "idPassword" && form.identificationMethods.idPassword) {
527
+ form.identificationMethods.pin = false;
528
+ }
529
+ await setReaderConfiguration(form.readerId, {
530
+ identifier: {
531
+ pin_identification_enabled: form.identificationMethods.pin ? "1" : "0",
532
+ // Mode 1: PIN is an alternative credential, never a second factor.
533
+ multi_factor_authentication: "0",
534
+ },
535
+ });
536
+ }
537
+ await updateSitebyId(props.site, buildPayload());
538
+ showMessage(`${identificationMethodOptions.find((item) => item.key === method)?.label || "Identification method"} updated.`, "success");
539
+ } catch (error: unknown) {
540
+ await loadConfig();
541
+ showMessage(errorConverter(error), "error");
542
+ } finally {
543
+ savingSection.value = "";
544
+ }
545
+ }
546
+
419
547
  async function scanPrinter() {
420
548
  if (!("usb" in navigator)) {
421
549
  showMessage("USB printer scan is not available in this browser.", "error");
@@ -523,6 +651,51 @@ function showMessage(text: string, color: string) {
523
651
  padding: 16px 0;
524
652
  }
525
653
 
654
+ .identification-section {
655
+ padding-bottom: 20px;
656
+ }
657
+
658
+ .methods-grid {
659
+ display: grid;
660
+ grid-template-columns: repeat(6, minmax(120px, 1fr));
661
+ gap: 18px;
662
+ margin-top: 16px;
663
+ }
664
+
665
+ .method-control {
666
+ display: flex;
667
+ align-items: center;
668
+ justify-content: space-between;
669
+ min-width: 0;
670
+ min-height: 42px;
671
+ padding: 0 10px 0 14px;
672
+ border: 1px solid var(--border);
673
+ border-radius: var(--r-inner);
674
+ color: var(--text2);
675
+ font-family: var(--font-sans);
676
+ font-size: 12px;
677
+ font-weight: 700;
678
+ }
679
+
680
+ .method-control span {
681
+ overflow: hidden;
682
+ text-overflow: ellipsis;
683
+ white-space: nowrap;
684
+ }
685
+
686
+ .method-control--disabled {
687
+ background-color: var(--hover);
688
+ color: var(--muted);
689
+ }
690
+
691
+ .method-control :deep(.v-switch) {
692
+ flex: 0 0 auto;
693
+ }
694
+
695
+ .method-control :deep(.v-selection-control) {
696
+ min-height: 32px;
697
+ }
698
+
526
699
  /* The three `.hid-config-switch` rules are gone: `.app-switch` in
527
700
  primitives.css is the handoff's toggle, and it is shared with the other two
528
701
  HID panels that have one. */
@@ -694,6 +867,10 @@ function showMessage(text: string, color: string) {
694
867
  }
695
868
 
696
869
  @media (max-width: 1100px) {
870
+ .methods-grid {
871
+ grid-template-columns: repeat(3, minmax(140px, 1fr));
872
+ }
873
+
697
874
  .printer-grid,
698
875
  .reader-grid,
699
876
  .template-grid,
@@ -714,6 +891,7 @@ function showMessage(text: string, color: string) {
714
891
  .reader-grid,
715
892
  .template-grid,
716
893
  .validity-grid,
894
+ .methods-grid,
717
895
  .permission-location,
718
896
  .permission-row,
719
897
  .permission-actions {