@7365admin1/layer-common 4.90.2-staging.491 → 4.90.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -4
- package/assets/css/primitives.css +0 -57
- package/assets/css/screens.css +4 -8
- package/components/AccessCardDetailsDialog.vue +6 -59
- package/components/AccessCardPreviewDialog.vue +53 -371
- package/components/AppSelect.vue +4 -143
- package/components/BuildingUnitFormEdit.vue +0 -6
- package/components/Dialog/UpdateMoreAction.vue +1 -11
- package/components/DocumentForm.vue +112 -407
- package/components/DocumentManagement.vue +12 -213
- package/components/Facility/BookingSetup.vue +0 -63
- package/components/HidAccessLogDashboard.vue +17 -74
- package/components/HidIntercomManagement.vue +14 -183
- package/components/HidQrCodeConfiguration.vue +195 -17
- package/components/HidReaderManagement.vue +0 -398
- package/components/HidReaderUserRoster.vue +3 -44
- package/components/HidUserEnrollment.vue +292 -2255
- package/components/InventoryItemDetail.vue +1 -2
- package/components/InventoryLinesDialog.vue +13 -91
- package/components/InventoryPhotoInput.vue +6 -44
- package/components/InventoryReportsTab.vue +3 -23
- package/components/InventoryRequestsTab.vue +4 -27
- package/components/InventoryStockTab.vue +0 -3
- package/components/InvitationClientForm.vue +1 -19
- package/components/InvitationForm.vue +4 -31
- package/components/InvitationMain.vue +22 -124
- package/components/SiteSettings.vue +0 -48
- package/components/TableMain.vue +7 -22
- package/components/VehicleManagement.vue +5 -80
- package/components/VisitorForm.vue +0 -12
- package/components/VisitorManagement.vue +5 -267
- package/composables/useAccessManagement.ts +1 -17
- package/composables/useDocument.ts +0 -7
- package/composables/useFacility.ts +0 -11
- package/composables/useHidAmico.ts +0 -165
- package/composables/useHidNavigation.ts +1 -25
- package/composables/useInventory.ts +1 -4
- package/composables/useMember.ts +0 -12
- package/composables/useNFCPatrolReportFilters.ts +3 -21
- package/composables/useNFCPatrolRoute.ts +1 -8
- package/composables/usePeople.ts +0 -19
- package/composables/useServiceProvider.ts +2 -45
- package/composables/useSettingsPermission.ts +0 -14
- package/composables/useUser.ts +2 -6
- package/composables/useVehicle.ts +1 -40
- package/composables/useVerification.ts +0 -3
- package/middleware/01.auth.ts +7 -3
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/document.d.ts +0 -14
- package/types/facility.d.ts +0 -4
- package/types/inventory.d.ts +0 -27
- package/types/member.d.ts +0 -4
- package/types/people.d.ts +1 -28
- package/types/service-provider.d.ts +0 -5
- package/types/site.d.ts +1 -7
- package/components/BuildingUnitManagement.vue +0 -298
- package/components/HidAccessPermissions.vue +0 -424
- package/components/HidProfileQrCode.vue +0 -332
- package/components/InventoryPoDialog.vue +0 -111
- package/components/VehicleQrStickerDialog.vue +0 -249
- package/composables/useHidReaderSelection.ts +0 -50
- package/composables/useSiteCategory.ts +0 -45
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
- package/utils/hid-enrolment-subject.ts +0 -344
- package/utils/hid-permission-assignments.ts +0 -198
- package/utils/hid-reader-selection.ts +0 -52
- package/utils/inventory-receiving.ts +0 -90
- package/utils/invite-group.ts +0 -61
- package/utils/invite-sites.ts +0 -25
- package/utils/occupancy-role.ts +0 -109
- package/utils/service-type.ts +0 -30
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
...
|
|
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 {
|