@7365admin1/layer-common 4.0.4-staging.241 → 4.0.4-staging.243
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/EntryPass/SelfServiceQrPreview.vue +79 -0
- package/components/EntryPassMain.vue +289 -0
- package/components/PublicOnboarding/ContractorRegistrationForm.vue +278 -0
- package/components/PublicOnboarding/LocationFields.vue +125 -0
- package/components/PublicOnboarding/TypeSelectCard.vue +34 -0
- package/components/PublicOnboarding/VisitorRegistrationForm.vue +273 -0
- package/components/VisitorManagement.vue +205 -14
- package/composables/useContactPicker.ts +36 -0
- package/composables/usePublicVisitorOnboarding.ts +73 -0
- package/composables/useServiceProviderPermission.ts +30 -4
- package/composables/useWebUsb.ts +64 -1
- package/constants/permissions.ts +9 -4
- package/package.json +1 -1
|
@@ -457,6 +457,15 @@
|
|
|
457
457
|
>
|
|
458
458
|
{{ item?.overnightParking?.status }}
|
|
459
459
|
</v-card>
|
|
460
|
+
<v-card
|
|
461
|
+
v-else-if="activeTab === 'guests' && item?.status"
|
|
462
|
+
size="small"
|
|
463
|
+
class="rounded-xl text-capitalize text-center elevation-0 py-1 px-2"
|
|
464
|
+
:color="getVisitorStatusColor(item.status)"
|
|
465
|
+
style="max-width: 150px; margin: auto"
|
|
466
|
+
>
|
|
467
|
+
{{ getVisitorStatusLabel(item.status) }}
|
|
468
|
+
</v-card>
|
|
460
469
|
<span v-else> N/A </span>
|
|
461
470
|
</template>
|
|
462
471
|
|
|
@@ -536,6 +545,27 @@
|
|
|
536
545
|
<v-divider />
|
|
537
546
|
</v-card>
|
|
538
547
|
</v-menu>
|
|
548
|
+
<v-menu v-else-if="activeTab === 'guests' && item?.status === 'pending' && canUpdateVisitor">
|
|
549
|
+
<template #activator="{ props }">
|
|
550
|
+
<v-avatar v-bind="props" class="rounded-xl border-md">
|
|
551
|
+
<v-icon icon="mdi-dots-vertical" />
|
|
552
|
+
</v-avatar>
|
|
553
|
+
</template>
|
|
554
|
+
|
|
555
|
+
<v-card>
|
|
556
|
+
<v-list-item @click.stop="openApproveRejectVisitorDialog(item, 'approved')">
|
|
557
|
+
<template #title>
|
|
558
|
+
<span class="text-caption">Approve</span>
|
|
559
|
+
</template>
|
|
560
|
+
</v-list-item>
|
|
561
|
+
<v-divider />
|
|
562
|
+
<v-list-item @click.stop="openApproveRejectVisitorDialog(item, 'rejected')">
|
|
563
|
+
<template #title>
|
|
564
|
+
<span class="text-caption">Reject</span>
|
|
565
|
+
</template>
|
|
566
|
+
</v-list-item>
|
|
567
|
+
</v-card>
|
|
568
|
+
</v-menu>
|
|
539
569
|
</template>
|
|
540
570
|
|
|
541
571
|
<template v-slot:item.checkInRemarks="{ item }">
|
|
@@ -1179,6 +1209,78 @@
|
|
|
1179
1209
|
</v-card>
|
|
1180
1210
|
</v-dialog>
|
|
1181
1211
|
|
|
1212
|
+
<v-dialog
|
|
1213
|
+
v-model="dialog.approveRejectVisitorDialog"
|
|
1214
|
+
transition="dialog-bottom-transition"
|
|
1215
|
+
persistent
|
|
1216
|
+
width="60vh"
|
|
1217
|
+
>
|
|
1218
|
+
<v-card>
|
|
1219
|
+
<v-toolbar density="compact">
|
|
1220
|
+
<v-row
|
|
1221
|
+
no-gutters
|
|
1222
|
+
class="d-flex fill-height justify-space-between align-center px-4"
|
|
1223
|
+
>
|
|
1224
|
+
<span class="font-weight-bold">{{ approveRejectVisitorDialogTitle }}</span>
|
|
1225
|
+
<v-btn
|
|
1226
|
+
icon="mdi-close"
|
|
1227
|
+
variant="text"
|
|
1228
|
+
@click="closeApproveRejectVisitorDialog"
|
|
1229
|
+
/>
|
|
1230
|
+
</v-row>
|
|
1231
|
+
</v-toolbar>
|
|
1232
|
+
<v-card-text class="px-4 pb-6">
|
|
1233
|
+
<v-row no-gutters justify="center" align-content="center">
|
|
1234
|
+
<v-col cols="12" class="text-h6 text-center">
|
|
1235
|
+
{{
|
|
1236
|
+
`Are you sure you want to ${approveRejectVisitorDialog.text} the ${approveRejectVisitorDialogTitle}?`
|
|
1237
|
+
}}
|
|
1238
|
+
</v-col>
|
|
1239
|
+
|
|
1240
|
+
<v-col cols="12" md="8" class="mt-4">
|
|
1241
|
+
<v-textarea
|
|
1242
|
+
v-model="visitorApproveRejectRemarks"
|
|
1243
|
+
label="Remarks"
|
|
1244
|
+
placeholder="Enter remarks"
|
|
1245
|
+
outlined
|
|
1246
|
+
rows="3"
|
|
1247
|
+
clearable
|
|
1248
|
+
/>
|
|
1249
|
+
</v-col>
|
|
1250
|
+
</v-row>
|
|
1251
|
+
</v-card-text>
|
|
1252
|
+
<v-toolbar class="pa-0" density="compact">
|
|
1253
|
+
<v-row no-gutters>
|
|
1254
|
+
<v-col cols="6">
|
|
1255
|
+
<v-btn
|
|
1256
|
+
text="No"
|
|
1257
|
+
color="grey-lighten-3"
|
|
1258
|
+
variant="flat"
|
|
1259
|
+
height="48"
|
|
1260
|
+
tile
|
|
1261
|
+
block
|
|
1262
|
+
:disabled="isApprovingRejectingVisitor"
|
|
1263
|
+
@click="closeApproveRejectVisitorDialog"
|
|
1264
|
+
/>
|
|
1265
|
+
</v-col>
|
|
1266
|
+
<v-col cols="6">
|
|
1267
|
+
<v-btn
|
|
1268
|
+
text="Yes"
|
|
1269
|
+
color="success"
|
|
1270
|
+
variant="flat"
|
|
1271
|
+
height="48"
|
|
1272
|
+
tile
|
|
1273
|
+
block
|
|
1274
|
+
:disabled="!visitorApproveRejectRemarks || isApprovingRejectingVisitor"
|
|
1275
|
+
@click="updateVisitorApprovalStatus"
|
|
1276
|
+
:loading="isApprovingRejectingVisitor"
|
|
1277
|
+
/>
|
|
1278
|
+
</v-col>
|
|
1279
|
+
</v-row>
|
|
1280
|
+
</v-toolbar>
|
|
1281
|
+
</v-card>
|
|
1282
|
+
</v-dialog>
|
|
1283
|
+
|
|
1182
1284
|
<v-dialog v-model="hidQrDialog" max-width="440" persistent>
|
|
1183
1285
|
<v-card>
|
|
1184
1286
|
<v-toolbar color="transparent" density="comfortable" class="px-2">
|
|
@@ -1436,6 +1538,7 @@ const dialog = reactive({
|
|
|
1436
1538
|
showVisitorDataFromScannedQRCode: false,
|
|
1437
1539
|
approveRejectOvernightParkingRequestDialog: false,
|
|
1438
1540
|
returnNfcCard: false,
|
|
1541
|
+
approveRejectVisitorDialog: false,
|
|
1439
1542
|
});
|
|
1440
1543
|
|
|
1441
1544
|
const snapshotImageUrl = ref("");
|
|
@@ -1488,6 +1591,18 @@ const headers = computed(() => {
|
|
|
1488
1591
|
list.push({ title: "Action", value: "action" });
|
|
1489
1592
|
}
|
|
1490
1593
|
|
|
1594
|
+
// Guests is the only non-overnight-parking tab whose rows carry a
|
|
1595
|
+
// top-level status (approved/pending/rejected) and an Approve/Reject
|
|
1596
|
+
// action - without these two, the item.status/item.action template slots
|
|
1597
|
+
// have no header to render against and v-data-table never shows them,
|
|
1598
|
+
// no matter what their own v-if says.
|
|
1599
|
+
if (tab === "guests") {
|
|
1600
|
+
list.push(
|
|
1601
|
+
{ title: "Status", value: "status" },
|
|
1602
|
+
{ title: "Action", value: "action" },
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1491
1606
|
return list;
|
|
1492
1607
|
});
|
|
1493
1608
|
|
|
@@ -1590,6 +1705,77 @@ function closeApproveRejectOvernightParkingDialog() {
|
|
|
1590
1705
|
overNightParkingRequestApproveRejectRemarks.value = "";
|
|
1591
1706
|
}
|
|
1592
1707
|
|
|
1708
|
+
/** Self-service submissions arrive with `status: "pending"` when the site's
|
|
1709
|
+
Self-Service Auto-Approvals toggle is off - Approve/Reject here is what
|
|
1710
|
+
actually decides the visitor's outcome, mirroring the Overnight Parking
|
|
1711
|
+
approve/reject pattern above but against the top-level visitor status
|
|
1712
|
+
(via the existing staff update endpoint) rather than a nested field. */
|
|
1713
|
+
function getVisitorStatusColor(status: string) {
|
|
1714
|
+
switch (status) {
|
|
1715
|
+
case "approved":
|
|
1716
|
+
return "success";
|
|
1717
|
+
case "rejected":
|
|
1718
|
+
return "error";
|
|
1719
|
+
case "pending":
|
|
1720
|
+
return "warning";
|
|
1721
|
+
default:
|
|
1722
|
+
return "grey";
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function getVisitorStatusLabel(status: string) {
|
|
1727
|
+
return status === "pending" ? "Pending Approval" : status;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
const selectedVisitorForApproval = ref<Record<string, any>>({});
|
|
1731
|
+
const visitorApproveRejectRemarks = ref("");
|
|
1732
|
+
const approveRejectVisitorDialog = ref({ text: "", status: "" });
|
|
1733
|
+
const isApprovingRejectingVisitor = ref(false);
|
|
1734
|
+
|
|
1735
|
+
const approveRejectVisitorDialogTitle = computed(() => {
|
|
1736
|
+
const type = selectedVisitorForApproval.value?.type as string | undefined;
|
|
1737
|
+
const label = type ? type.replace("-", " ") : "Visitor";
|
|
1738
|
+
return `${label.charAt(0).toUpperCase()}${label.slice(1)} Registration Request`;
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
function openApproveRejectVisitorDialog(visitor: Record<string, any>, status: "approved" | "rejected") {
|
|
1742
|
+
selectedVisitorForApproval.value = visitor;
|
|
1743
|
+
approveRejectVisitorDialog.value = {
|
|
1744
|
+
text: status === "approved" ? "Approve" : "Reject",
|
|
1745
|
+
status,
|
|
1746
|
+
};
|
|
1747
|
+
visitorApproveRejectRemarks.value = "";
|
|
1748
|
+
dialog.approveRejectVisitorDialog = true;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function closeApproveRejectVisitorDialog() {
|
|
1752
|
+
dialog.approveRejectVisitorDialog = false;
|
|
1753
|
+
selectedVisitorForApproval.value = {};
|
|
1754
|
+
approveRejectVisitorDialog.value = { text: "", status: "" };
|
|
1755
|
+
visitorApproveRejectRemarks.value = "";
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
async function updateVisitorApprovalStatus() {
|
|
1759
|
+
isApprovingRejectingVisitor.value = true;
|
|
1760
|
+
try {
|
|
1761
|
+
await updateVisitor(selectedVisitorForApproval.value._id, {
|
|
1762
|
+
status: approveRejectVisitorDialog.value.status,
|
|
1763
|
+
remarks: visitorApproveRejectRemarks.value,
|
|
1764
|
+
updatedBy: currentUser.value?._id,
|
|
1765
|
+
} as any);
|
|
1766
|
+
showMessage(
|
|
1767
|
+
`Visitor registration successfully ${approveRejectVisitorDialog.value.status}`,
|
|
1768
|
+
"success"
|
|
1769
|
+
);
|
|
1770
|
+
await getVisitorRefresh();
|
|
1771
|
+
closeApproveRejectVisitorDialog();
|
|
1772
|
+
} catch (error) {
|
|
1773
|
+
showMessage(errorConverter(error), "error");
|
|
1774
|
+
} finally {
|
|
1775
|
+
isApprovingRejectingVisitor.value = false;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1593
1779
|
function mappedAttachments(attachments: string[]) {
|
|
1594
1780
|
return attachments.map((x, index) => {
|
|
1595
1781
|
return {
|
|
@@ -1665,17 +1851,12 @@ function toRoute(tab: any) {
|
|
|
1665
1851
|
|
|
1666
1852
|
const obj = tabOptions.value.find((x) => x.value === tab);
|
|
1667
1853
|
if (!obj) return;
|
|
1854
|
+
// Don't call getVisitorRefresh() here - activeTab is already in the
|
|
1855
|
+
// useLazyAsyncData `watch` array below, so changing it (which already
|
|
1856
|
+
// happened via v-model before this handler runs) already triggers a
|
|
1857
|
+
// refetch on its own. Calling refresh() again here fired the same
|
|
1858
|
+
// request a second time on every tab click.
|
|
1668
1859
|
page.value = 1;
|
|
1669
|
-
getVisitorRefresh();
|
|
1670
|
-
// navigateTo({
|
|
1671
|
-
// name: routeName,
|
|
1672
|
-
// params: {
|
|
1673
|
-
// org: orgId,
|
|
1674
|
-
// },
|
|
1675
|
-
// // query: {
|
|
1676
|
-
// // tab
|
|
1677
|
-
// // },
|
|
1678
|
-
// });
|
|
1679
1860
|
}
|
|
1680
1861
|
const {
|
|
1681
1862
|
data: getVisitorReq,
|
|
@@ -1702,8 +1883,15 @@ const {
|
|
|
1702
1883
|
};
|
|
1703
1884
|
|
|
1704
1885
|
if (activeTab.value === "guests") {
|
|
1705
|
-
|
|
1706
|
-
|
|
1886
|
+
// Self-service Contractor submissions land here too, not just Visitor
|
|
1887
|
+
// (type: "guest") - the "Registered" tab hard-filters status="registered",
|
|
1888
|
+
// which a pending/approved self-service contractor never has, so without
|
|
1889
|
+
// this it had no tab that would ever show it.
|
|
1890
|
+
params.type = "guest,contractor";
|
|
1891
|
+
// A self-service submission awaiting Approve/Reject (Self-Service
|
|
1892
|
+
// Auto-Approvals off) lands here as "pending" alongside already
|
|
1893
|
+
// "approved" guests, rather than being hidden until someone acts on it.
|
|
1894
|
+
params.status = "approved,pending";
|
|
1707
1895
|
} else if (activeTab.value === "resident-transactions") {
|
|
1708
1896
|
params.status = "registered";
|
|
1709
1897
|
params.type = "resident,tenant";
|
|
@@ -2237,8 +2425,11 @@ function buildReportParams(): any {
|
|
|
2237
2425
|
};
|
|
2238
2426
|
|
|
2239
2427
|
if (activeTab.value === "guests") {
|
|
2240
|
-
|
|
2241
|
-
|
|
2428
|
+
// Kept in sync with the Guests tab's own list query above - otherwise a
|
|
2429
|
+
// report generated from this tab would silently omit contractors and
|
|
2430
|
+
// pending rows the screen is actually showing.
|
|
2431
|
+
params.type = "guest,contractor";
|
|
2432
|
+
params.status = "approved,pending";
|
|
2242
2433
|
} else if (activeTab.value === "resident-transactions") {
|
|
2243
2434
|
params.status = "registered";
|
|
2244
2435
|
params.type = "resident,tenant";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps the browser's Contact Picker API ("or select contact in your phone
|
|
3
|
+
* book" on the self-service onboarding form). Support is narrow - Android
|
|
4
|
+
* Chrome/Edge over HTTPS only, not iOS Safari or desktop - so `isSupported`
|
|
5
|
+
* must gate whether the UI even offers this, rather than showing a button
|
|
6
|
+
* that silently does nothing everywhere else.
|
|
7
|
+
*/
|
|
8
|
+
export default function useContactPicker() {
|
|
9
|
+
const isSupported = computed(() => {
|
|
10
|
+
return (
|
|
11
|
+
typeof navigator !== "undefined" &&
|
|
12
|
+
"contacts" in navigator &&
|
|
13
|
+
"ContactsManager" in window
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function pickContact(): Promise<{ name?: string; tel?: string } | null> {
|
|
18
|
+
if (!isSupported.value) return null;
|
|
19
|
+
try {
|
|
20
|
+
const props = ["name", "tel"];
|
|
21
|
+
const contacts = await (navigator as any).contacts.select(props, { multiple: false });
|
|
22
|
+
const contact = contacts?.[0];
|
|
23
|
+
if (!contact) return null;
|
|
24
|
+
return {
|
|
25
|
+
name: contact.name?.[0],
|
|
26
|
+
tel: contact.tel?.[0],
|
|
27
|
+
};
|
|
28
|
+
} catch {
|
|
29
|
+
// User cancelled the picker, or the browser refused - either way this
|
|
30
|
+
// is not an error worth surfacing, the visitor can just type instead.
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { isSupported, pickContact };
|
|
36
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public, unauthenticated calls for the self-service visitor onboarding page
|
|
3
|
+
* (reached by scanning the gate QR code printed from Site Settings > Entry
|
|
4
|
+
* Pass > Self-Service). Every endpoint here is deliberately a separate,
|
|
5
|
+
* narrower public route from its staff-facing equivalent in `useBuilding`/
|
|
6
|
+
* `useVisitor`/`useSiteEntryPassSettings` - see the server-side route
|
|
7
|
+
* comments for why each one is safe to leave unauthenticated.
|
|
8
|
+
*/
|
|
9
|
+
export default function usePublicVisitorOnboarding() {
|
|
10
|
+
function getOnboardingSettings(siteId: string) {
|
|
11
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
12
|
+
`/api/access-management/settings/${siteId}/onboarding`,
|
|
13
|
+
{ method: "GET" },
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getBlocks(siteId: string) {
|
|
18
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
19
|
+
`/api/buildings/list/site/resident/${siteId}`,
|
|
20
|
+
{ method: "GET" },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getLevels(siteId: string, blockId: string) {
|
|
25
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
26
|
+
`/api/building-levels/list/site/resident/${siteId}`,
|
|
27
|
+
{ method: "GET", query: { block: blockId } },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getUnits(siteId: string, block: string, level: string) {
|
|
32
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
33
|
+
`/api/building-units/resident/site/${siteId}/block/${block}/level/${level}`,
|
|
34
|
+
{ method: "GET" },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createVisitor(siteId: string, payload: Record<string, any>) {
|
|
39
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
40
|
+
`/api/visitor-transactions/self-service/${siteId}`,
|
|
41
|
+
{ method: "POST", body: payload },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The "2" collection is the one actually populated/used by
|
|
46
|
+
// OvernightParkingAvailability.vue - the un-suffixed v1 collection this
|
|
47
|
+
// used to read from is a separate, unpopulated legacy store.
|
|
48
|
+
function getOvernightParkingHours(siteId: string) {
|
|
49
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
50
|
+
`/api/overnight-parking-approval-settings2/site/${siteId}/public`,
|
|
51
|
+
{ method: "GET" },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Backs the visitor's own preview/QR page, linked from their registration
|
|
56
|
+
// email so it still works after they've closed the browser.
|
|
57
|
+
function getSelfServicePreview(id: string) {
|
|
58
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
59
|
+
`/api/visitor-transactions/self-service/preview/${id}`,
|
|
60
|
+
{ method: "GET" },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
getOnboardingSettings,
|
|
66
|
+
getBlocks,
|
|
67
|
+
getLevels,
|
|
68
|
+
getUnits,
|
|
69
|
+
createVisitor,
|
|
70
|
+
getOvernightParkingHours,
|
|
71
|
+
getSelfServicePreview,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -3,26 +3,52 @@ import { useCommonPermissions } from "./useCommonPermission";
|
|
|
3
3
|
export function useServiceProviderPermission() {
|
|
4
4
|
const { hasPermission } = usePermission();
|
|
5
5
|
const { serviceProviderPermissions } = useCommonPermissions();
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
// The resource the estate actually stores. `constants/permissions.ts` settles
|
|
8
|
+
// `service-provider-mgmt` as canonical on rule 1 -- the server reads it at
|
|
9
|
+
// `core/utils/notification-category.util.ts:185` -- and it is what
|
|
10
|
+
// `-property-management`'s own `useLocalPermission` has always checked.
|
|
11
|
+
//
|
|
12
|
+
// This gate shipped asking for `service-provider:<action>` instead, a spelling
|
|
13
|
+
// that exists in no role document: on the staging role catalogue (428 roles)
|
|
14
|
+
// it is held by 0, so every role except the 71 holding `*` was refused. The
|
|
15
|
+
// strings below are held by 79 roles each.
|
|
16
|
+
const permissions: TPermissions = {
|
|
17
|
+
"service-provider-mgmt": {
|
|
18
|
+
...serviceProviderPermissions,
|
|
19
|
+
// `view-service-providers` is the spelling all 79 roles were granted;
|
|
20
|
+
// `see-all-service-providers` is canonical and nobody holds it yet. Both
|
|
21
|
+
// are accepted, so the gate is correct before AND after a role migration
|
|
22
|
+
// -- switching to the canonical string alone is what left pest-control's
|
|
23
|
+
// work-order gate stranding 92 roles. Recorded in
|
|
24
|
+
// `LEGACY_PERMISSION_ALIASES`.
|
|
25
|
+
"view-service-providers": serviceProviderPermissions["see-all-service-providers"],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
7
28
|
|
|
8
29
|
const { userAppRole } = useLocalSetup();
|
|
9
30
|
|
|
31
|
+
// Every gate fails closed while the role is still resolving: `userAppRole` is
|
|
32
|
+
// null on the first tick and a null role must never read as "allowed".
|
|
10
33
|
const canAddServiceProvider = computed(() => {
|
|
11
34
|
if (!userAppRole.value) return false;
|
|
12
35
|
if (userAppRole.value.permissions.includes("*")) return true;
|
|
13
|
-
return hasPermission(userAppRole.value, permissions, "service-provider", "add-service-provider");
|
|
36
|
+
return hasPermission(userAppRole.value, permissions, "service-provider-mgmt", "add-service-provider");
|
|
14
37
|
});
|
|
15
38
|
|
|
16
39
|
const canInviteServiceProvider = computed(() => {
|
|
17
40
|
if (!userAppRole.value) return false;
|
|
18
41
|
if (userAppRole.value.permissions.includes("*")) return true;
|
|
19
|
-
return hasPermission(userAppRole.value, permissions, "service-provider", "invite-service-provider");
|
|
42
|
+
return hasPermission(userAppRole.value, permissions, "service-provider-mgmt", "invite-service-provider");
|
|
20
43
|
});
|
|
21
44
|
|
|
22
45
|
const canViewServiceProviders = computed(() => {
|
|
23
46
|
if (!userAppRole.value) return false;
|
|
24
47
|
if (userAppRole.value.permissions.includes("*")) return true;
|
|
25
|
-
return
|
|
48
|
+
return (
|
|
49
|
+
hasPermission(userAppRole.value, permissions, "service-provider-mgmt", "see-all-service-providers") ||
|
|
50
|
+
hasPermission(userAppRole.value, permissions, "service-provider-mgmt", "view-service-providers")
|
|
51
|
+
);
|
|
26
52
|
});
|
|
27
53
|
|
|
28
54
|
return {
|
package/composables/useWebUsb.ts
CHANGED
|
@@ -227,6 +227,7 @@ export default function useWebUsb() {
|
|
|
227
227
|
address?: string,
|
|
228
228
|
qrHeader?: string,
|
|
229
229
|
qrSubText?: string,
|
|
230
|
+
layout?: "receipt" | "onboarding",
|
|
230
231
|
) => {
|
|
231
232
|
if (!isWebUsbSupported.value) {
|
|
232
233
|
throw new Error("Web USB is not supported in this browser");
|
|
@@ -245,7 +246,9 @@ export default function useWebUsb() {
|
|
|
245
246
|
};
|
|
246
247
|
|
|
247
248
|
let printTestResult;
|
|
248
|
-
if (forQr && urlImage) {
|
|
249
|
+
if (forQr && urlImage && layout === "onboarding") {
|
|
250
|
+
printTestResult = await printOnboardingQrCode(device, urlImage, qrHeader, qrSubText);
|
|
251
|
+
} else if (forQr && urlImage) {
|
|
249
252
|
printTestResult = await printQrCode(
|
|
250
253
|
device,
|
|
251
254
|
urlImage,
|
|
@@ -399,6 +402,66 @@ export default function useWebUsb() {
|
|
|
399
402
|
return canvas;
|
|
400
403
|
}
|
|
401
404
|
|
|
405
|
+
/**
|
|
406
|
+
* The self-service onboarding QR: one large QR code, a "SCAN QR CODE"-style
|
|
407
|
+
* subtext, and a dark site-name banner along the bottom - a single-purpose
|
|
408
|
+
* gate sign, not the multi-copy visitor-pass receipt `createReceiptLayout`
|
|
409
|
+
* produces (that one prints several QRs to be cut apart and handed out).
|
|
410
|
+
*/
|
|
411
|
+
async function createOnboardingQrLayout(
|
|
412
|
+
qrUrlImage: string,
|
|
413
|
+
header?: string,
|
|
414
|
+
subText?: string,
|
|
415
|
+
): Promise<HTMLCanvasElement> {
|
|
416
|
+
const canvas = document.createElement("canvas");
|
|
417
|
+
canvas.width = 576;
|
|
418
|
+
canvas.height = 760;
|
|
419
|
+
const ctx = canvas.getContext("2d")!;
|
|
420
|
+
ctx.fillStyle = "#fff";
|
|
421
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
422
|
+
ctx.textAlign = "center";
|
|
423
|
+
|
|
424
|
+
const qrSize = 400;
|
|
425
|
+
const qrCanvas = await QRCode.toCanvas(qrUrlImage, { width: qrSize, margin: 1 });
|
|
426
|
+
ctx.drawImage(qrCanvas, (canvas.width - qrSize) / 2, 40, qrSize, qrSize);
|
|
427
|
+
|
|
428
|
+
ctx.fillStyle = "#000";
|
|
429
|
+
ctx.font = "bold 26px Arial";
|
|
430
|
+
ctx.fillText((subText || "SCAN QR CODE").toUpperCase(), canvas.width / 2, 480);
|
|
431
|
+
|
|
432
|
+
const bannerHeight = 110;
|
|
433
|
+
const bannerY = canvas.height - bannerHeight;
|
|
434
|
+
ctx.fillStyle = "#000";
|
|
435
|
+
ctx.fillRect(0, bannerY, canvas.width, bannerHeight);
|
|
436
|
+
ctx.fillStyle = "#fff";
|
|
437
|
+
ctx.font = "bold 34px Arial";
|
|
438
|
+
ctx.fillText((header || "").toUpperCase(), canvas.width / 2, bannerY + bannerHeight / 2 + 12);
|
|
439
|
+
|
|
440
|
+
return canvas;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const printOnboardingQrCode = async (
|
|
444
|
+
device: USBDevice,
|
|
445
|
+
urlImage: string,
|
|
446
|
+
header?: string,
|
|
447
|
+
subText?: string,
|
|
448
|
+
) => {
|
|
449
|
+
try {
|
|
450
|
+
const { endpointNumber } = getPrinterConnection(device);
|
|
451
|
+
const canvas = await createOnboardingQrLayout(urlImage, header, subText);
|
|
452
|
+
const rasterData = canvasToRaster(canvas);
|
|
453
|
+
await device.transferOut(endpointNumber, rasterData.buffer);
|
|
454
|
+
|
|
455
|
+
const CUT = new Uint8Array([0x1d, 0x56, 0x41, 0x10]);
|
|
456
|
+
await device.transferOut(endpointNumber, CUT.buffer);
|
|
457
|
+
|
|
458
|
+
return { success: true, message: "Onboarding QR print sent successfully" };
|
|
459
|
+
} catch (error: unknown) {
|
|
460
|
+
console.error("[WebUSB] printOnboardingQrCode error:", error);
|
|
461
|
+
return { success: false, error: getErrorMessage(error, "Unable to print onboarding QR code") };
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
402
465
|
function canvasToRaster(canvas: HTMLCanvasElement): Uint8Array {
|
|
403
466
|
const ctx = canvas.getContext("2d");
|
|
404
467
|
if (!ctx) throw new Error("Canvas 2D context not available");
|
package/constants/permissions.ts
CHANGED
|
@@ -592,16 +592,21 @@ export const LEGACY_PERMISSION_ALIASES: Readonly<
|
|
|
592
592
|
"roles-and-permissions:delete-role": "roles:delete-role",
|
|
593
593
|
|
|
594
594
|
// --- service providers ---
|
|
595
|
-
// Needed by:
|
|
596
|
-
// `-mgmt` the server's notification filter expects
|
|
595
|
+
// Needed by: layer-common 4.0.3 and 4.1.0, where `useServiceProviderPermission`
|
|
596
|
+
// dropped the `-mgmt` the server's notification filter expects and asked for a
|
|
597
|
+
// resource 0 of 428 roles hold. The composable checks `service-provider-mgmt`
|
|
598
|
+
// from 4.1.1 on; these three stay because both published versions are still
|
|
599
|
+
// installed across the estate.
|
|
597
600
|
"service-provider:see-all-service-providers":
|
|
598
601
|
"service-provider-mgmt:see-all-service-providers",
|
|
599
602
|
"service-provider:add-service-provider":
|
|
600
603
|
"service-provider-mgmt:add-service-provider",
|
|
601
604
|
"service-provider:invite-service-provider":
|
|
602
605
|
"service-provider-mgmt:invite-service-provider",
|
|
603
|
-
// Needed by: `-property-management
|
|
604
|
-
// `see-all-`.
|
|
606
|
+
// Needed by: `-property-management` AND `useServiceProviderPermission` in this
|
|
607
|
+
// package, which both say `view-` where the canonical spelling says `see-all-`.
|
|
608
|
+
// This is the spelling all 79 granted roles actually store, so it cannot be
|
|
609
|
+
// retired until those roles are migrated.
|
|
605
610
|
"service-provider-mgmt:view-service-providers":
|
|
606
611
|
"service-provider-mgmt:see-all-service-providers",
|
|
607
612
|
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.0.4-staging.
|
|
5
|
+
"version": "4.0.4-staging.243",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|