@7365admin1/layer-common 4.84.0 → 4.86.0
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/components/AttendanceDetailsDialog.vue +48 -1
- package/components/AttendanceMain.vue +104 -10
- package/components/AttendanceProxyDialog.vue +391 -0
- package/components/AttendanceSettingsDialog.vue +53 -1
- package/components/BulletinBoardView.vue +1 -1
- package/components/MyAttendanceMain.vue +60 -2
- package/composables/useAttendance.ts +53 -0
- package/composables/useAttendancePermission.ts +25 -0
- package/composables/useCleaningPermission.ts +5 -0
- package/composables/useLandscapingPermission.ts +5 -0
- package/composables/usePestControlPermission.ts +5 -0
- package/composables/usePoolMaintenancePermission.ts +5 -0
- package/composables/useSecurityPermission.ts +5 -0
- package/constants/permissions.ts +1 -0
- package/nuxt.config.ts +18 -0
- package/package.json +4 -2
- package/server/plugins/no-powered-by.ts +7 -0
- package/types/attendance.d.ts +13 -1
- package/utils/attendance-proxy.ts +110 -0
- package/utils/sanitize-html.ts +16 -0
- package/utils/security-headers.ts +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.86.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4891c16: Security headers on every app (HSTS, nosniff, frame-ancestors/X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP, minimal enforced CSP + full Report-Only CSP), drop `x-powered-by`, explicit `SameSite=Lax` on the session cookies, `sanitizeHtml` for rich text rendered with `v-html`, html2pdf.js 0.14 (jsPDF 4.2.1).
|
|
8
|
+
|
|
9
|
+
## 4.85.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- e46f7cb: Attendance: Site device mode. Settings switch (new permission attendance-mgmt:manage-site-device-mode, or manage-attendance-settings), "Check in / Check out for someone" on My Attendance with live photo, name and NRIC masked in the browser (only the last 4 characters are sent), private photos in the details dialog, person/NRIC column with duplicate warning, person search and CSV export on the Attendance page.
|
|
14
|
+
|
|
3
15
|
## 4.84.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1974,11 +1986,11 @@
|
|
|
1974
1986
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1975
1987
|
own rule from two endpoints the console already calls, unprojected:
|
|
1976
1988
|
|
|
1977
|
-
|
|
1978
|
-
|
|
1989
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1990
|
+
GET /api/roles/id/:role that membership's role document
|
|
1979
1991
|
|
|
1980
|
-
|
|
1981
|
-
|
|
1992
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1993
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1982
1994
|
|
|
1983
1995
|
`role.default` is the marker because it is the only property of a platform
|
|
1984
1996
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -12,6 +12,24 @@
|
|
|
12
12
|
<v-card-title class="text-capitalize">Attendance Details</v-card-title>
|
|
13
13
|
|
|
14
14
|
<v-card-text class="screen-modal__body pa-6">
|
|
15
|
+
<!-- Site device mode: who was actually checked in, and which account's
|
|
16
|
+
shared phone took it. Only the masked NRIC is ever shown. -->
|
|
17
|
+
<v-alert
|
|
18
|
+
v-if="attendance?.isProxy"
|
|
19
|
+
type="info"
|
|
20
|
+
variant="tonal"
|
|
21
|
+
density="compact"
|
|
22
|
+
class="mb-4"
|
|
23
|
+
data-test="attendance-proxy-details"
|
|
24
|
+
>
|
|
25
|
+
<div class="font-weight-medium">{{ attendance.proxy?.name }}</div>
|
|
26
|
+
<div>
|
|
27
|
+
NRIC {{ attendance.proxy?.nricMasked }}<span v-if="attendance.proxy?.post"> · {{ attendance.proxy.post }}</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="text-caption">
|
|
30
|
+
Taken on site device by {{ attendance.takenByName || attendance.userName || "-" }}
|
|
31
|
+
</div>
|
|
32
|
+
</v-alert>
|
|
15
33
|
<v-row v-if="attendance" dense>
|
|
16
34
|
<v-col :cols="attendance.checkOut?.timestamp ? 6 : 12">
|
|
17
35
|
<v-row dense>
|
|
@@ -48,6 +66,9 @@
|
|
|
48
66
|
cover
|
|
49
67
|
></v-img>
|
|
50
68
|
</div>
|
|
69
|
+
<div v-else-if="photoUrls.checkIn" class="att-dlg__frame">
|
|
70
|
+
<v-img :src="photoUrls.checkIn" height="280" width="100%" cover></v-img>
|
|
71
|
+
</div>
|
|
51
72
|
<div class="att-dlg__frame att-dlg__frame--empty pa-8 text-center" v-else>
|
|
52
73
|
<span class="screen-hint att-dlg__meta">No image</span>
|
|
53
74
|
</div>
|
|
@@ -84,6 +105,9 @@
|
|
|
84
105
|
cover
|
|
85
106
|
></v-img>
|
|
86
107
|
</div>
|
|
108
|
+
<div v-else-if="photoUrls.checkOut" class="att-dlg__frame">
|
|
109
|
+
<v-img :src="photoUrls.checkOut" height="280" width="100%" cover></v-img>
|
|
110
|
+
</div>
|
|
87
111
|
<div class="att-dlg__frame att-dlg__frame--empty pa-8 text-center" v-else>
|
|
88
112
|
<span class="screen-hint att-dlg__meta">No image</span>
|
|
89
113
|
</div>
|
|
@@ -119,7 +143,27 @@ const props = defineProps({
|
|
|
119
143
|
const emit = defineEmits(["close"]);
|
|
120
144
|
|
|
121
145
|
const attendance = ref<Record<string, any> | null>(null);
|
|
122
|
-
const { getMyAttendanceById } = useAttendance();
|
|
146
|
+
const { getMyAttendanceById, getAttendancePhotoUrl } = useAttendance();
|
|
147
|
+
|
|
148
|
+
// Site device mode photos are private: fetched with the session and shown from
|
|
149
|
+
// a local object URL, never from a public link.
|
|
150
|
+
const photoUrls = reactive<{ checkIn: string; checkOut: string }>({ checkIn: "", checkOut: "" });
|
|
151
|
+
function clearPhotos() {
|
|
152
|
+
for (const k of ["checkIn", "checkOut"] as const) {
|
|
153
|
+
if (photoUrls[k]) URL.revokeObjectURL(photoUrls[k]);
|
|
154
|
+
photoUrls[k] = "";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function loadPrivatePhotos(id: string, row: Record<string, any> | null) {
|
|
158
|
+
for (const k of ["checkIn", "checkOut"] as const) {
|
|
159
|
+
if (!row?.[k]?.privatePhoto) continue;
|
|
160
|
+
try {
|
|
161
|
+
photoUrls[k] = await getAttendancePhotoUrl(id, k);
|
|
162
|
+
} catch {
|
|
163
|
+
photoUrls[k] = "";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
123
167
|
const { formatDate } = useUtils();
|
|
124
168
|
const { getFileUrl } = useFile();
|
|
125
169
|
|
|
@@ -136,6 +180,8 @@ watch(
|
|
|
136
180
|
try {
|
|
137
181
|
const res = await getMyAttendanceById(newId);
|
|
138
182
|
attendance.value = res?.data || res;
|
|
183
|
+
clearPhotos();
|
|
184
|
+
void loadPrivatePhotos(newId, attendance.value);
|
|
139
185
|
} catch (err: any) {
|
|
140
186
|
console.error("Failed to load attendance by id", err);
|
|
141
187
|
}
|
|
@@ -147,6 +193,7 @@ watch(
|
|
|
147
193
|
watch(showDialog, (newVal) => {
|
|
148
194
|
if (!newVal) {
|
|
149
195
|
attendance.value = null;
|
|
196
|
+
clearPhotos();
|
|
150
197
|
}
|
|
151
198
|
});
|
|
152
199
|
</script>
|
|
@@ -17,15 +17,68 @@
|
|
|
17
17
|
<template #actions>
|
|
18
18
|
<!-- Was `color="black"`: a black slab in both themes, and the only
|
|
19
19
|
black surface on the page. Same button, the design's ghost. -->
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
<div class="d-flex flex-wrap align-center ga-2 w-100">
|
|
21
|
+
<div class="filter-field">
|
|
22
|
+
<v-text-field
|
|
23
|
+
v-model="searchInput"
|
|
24
|
+
density="compact"
|
|
25
|
+
placeholder="Search name or NRIC last 4"
|
|
26
|
+
clearable
|
|
27
|
+
width="280"
|
|
28
|
+
append-inner-icon="mdi-magnify"
|
|
29
|
+
hide-details
|
|
30
|
+
data-test="attendance-person-search"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
<v-spacer />
|
|
34
|
+
<v-btn
|
|
35
|
+
variant="outlined"
|
|
36
|
+
class="text-none screen-btn-ghost"
|
|
37
|
+
:loading="exporting"
|
|
38
|
+
data-test="attendance-export-csv"
|
|
39
|
+
@click="exportCsv"
|
|
40
|
+
>
|
|
41
|
+
<v-icon class="mr-2">mdi-download</v-icon>
|
|
42
|
+
Export CSV
|
|
43
|
+
</v-btn>
|
|
44
|
+
<v-btn
|
|
45
|
+
v-if="canManageAttendanceSettings || canManageSiteDeviceMode"
|
|
46
|
+
variant="outlined"
|
|
47
|
+
class="text-none screen-btn-ghost"
|
|
48
|
+
@click="dialogShowSettings = true"
|
|
49
|
+
>
|
|
50
|
+
<v-icon class="mr-2">mdi-cog</v-icon>
|
|
51
|
+
Settings
|
|
52
|
+
</v-btn>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
55
|
+
<!-- Site device mode rows name the PERSON checked in, their masked
|
|
56
|
+
NRIC, and the shared phone's account that took it. -->
|
|
57
|
+
<template #item.userName="{ item }">
|
|
58
|
+
<div v-if="item.isProxy" class="py-1">
|
|
59
|
+
<div class="font-weight-medium">
|
|
60
|
+
{{ item.proxyName }}
|
|
61
|
+
<v-chip
|
|
62
|
+
v-if="item.suspicious"
|
|
63
|
+
size="x-small"
|
|
64
|
+
color="warning"
|
|
65
|
+
variant="tonal"
|
|
66
|
+
class="ml-1"
|
|
67
|
+
prepend-icon="mdi-alert"
|
|
68
|
+
:title="item.suspiciousReason"
|
|
69
|
+
data-test="attendance-suspicious"
|
|
70
|
+
>
|
|
71
|
+
Check this
|
|
72
|
+
</v-chip>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="text-caption text-medium-emphasis">
|
|
75
|
+
NRIC {{ item.nricMasked }}<span v-if="item.post"> · {{ item.post }}</span>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="text-caption text-medium-emphasis">
|
|
78
|
+
Taken on site device by {{ item.userName || "-" }}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<span v-else>{{ item.userName }}</span>
|
|
29
82
|
</template>
|
|
30
83
|
<template #item.checkInTimestamp="{ item }">
|
|
31
84
|
<span>{{ formatDate(item.checkInTimestamp) }}</span>
|
|
@@ -64,6 +117,12 @@
|
|
|
64
117
|
import useAttendance from "../composables/useAttendance";
|
|
65
118
|
import useWorkerTerm from "../composables/useWorkerTerm";
|
|
66
119
|
import useUtils from "../composables/useUtils";
|
|
120
|
+
import { useAttendancePermission } from "../composables/useAttendancePermission";
|
|
121
|
+
import {
|
|
122
|
+
ATTENDANCE_CSV_HEADERS,
|
|
123
|
+
attendanceCsvRow,
|
|
124
|
+
toCsv,
|
|
125
|
+
} from "../utils/attendance-proxy";
|
|
67
126
|
|
|
68
127
|
const props = defineProps({
|
|
69
128
|
orgId: { type: String, default: "" },
|
|
@@ -77,6 +136,7 @@ const props = defineProps({
|
|
|
77
136
|
const canViewAllAttendance = computed(() => props.canViewAllAttendance);
|
|
78
137
|
const canViewAttendanceDetails = computed(() => props.canViewAttendanceDetails);
|
|
79
138
|
const canManageAttendanceSettings = computed(() => props.canManageAttendanceSettings);
|
|
139
|
+
const { canManageSiteDeviceMode } = useAttendancePermission();
|
|
80
140
|
|
|
81
141
|
const submitting = ref(false);
|
|
82
142
|
const page = ref(1);
|
|
@@ -93,7 +153,7 @@ const headers = [
|
|
|
93
153
|
{ title: "Total Hours", value: "totalHours" },
|
|
94
154
|
];
|
|
95
155
|
|
|
96
|
-
const { getAttendances } = useAttendance();
|
|
156
|
+
const { getAttendances, attendancePhotoPath } = useAttendance();
|
|
97
157
|
const { formatDate } = useUtils();
|
|
98
158
|
|
|
99
159
|
function calculateTotalHours(attendance: any): string {
|
|
@@ -154,6 +214,40 @@ async function handleRowClick(data: any) {
|
|
|
154
214
|
dialogShowMoreActions.value = true;
|
|
155
215
|
}
|
|
156
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Export: every row matching the current search (up to 2000), with the person,
|
|
219
|
+
* masked NRIC, the photo links (they open only for a signed-in account with the
|
|
220
|
+
* right to see them) and the duplicate warning.
|
|
221
|
+
*/
|
|
222
|
+
const exporting = ref(false);
|
|
223
|
+
async function exportCsv() {
|
|
224
|
+
exporting.value = true;
|
|
225
|
+
try {
|
|
226
|
+
const res: any = await getAttendances({
|
|
227
|
+
page: 1,
|
|
228
|
+
limit: 2000,
|
|
229
|
+
search: searchInput.value ?? "",
|
|
230
|
+
site: props.site,
|
|
231
|
+
serviceType: props.serviceType,
|
|
232
|
+
});
|
|
233
|
+
const rows = (res?.items ?? []).map((item: any) =>
|
|
234
|
+
attendanceCsvRow(
|
|
235
|
+
{ ...item, totalHours: calculateTotalHours(item) },
|
|
236
|
+
(id, which) => `${window.location.origin}${attendancePhotoPath(id, which)}`,
|
|
237
|
+
(d) => formatDate(d as any)
|
|
238
|
+
)
|
|
239
|
+
);
|
|
240
|
+
const blob = new Blob([toCsv(ATTENDANCE_CSV_HEADERS, rows)], { type: "text/csv;charset=utf-8" });
|
|
241
|
+
const link = document.createElement("a");
|
|
242
|
+
link.href = URL.createObjectURL(blob);
|
|
243
|
+
link.download = `attendance-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
244
|
+
link.click();
|
|
245
|
+
URL.revokeObjectURL(link.href);
|
|
246
|
+
} finally {
|
|
247
|
+
exporting.value = false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
157
251
|
const onSettingsSaved = async (payload: any) => {
|
|
158
252
|
// TODO: Implement API call to save settings
|
|
159
253
|
await getAttendancesRefresh();
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-dialog v-model="showDialog" max-width="520" persistent>
|
|
3
|
+
<v-card class="screen-modal" data-test="attendance-proxy-dialog">
|
|
4
|
+
<v-card-title>
|
|
5
|
+
{{ action === "checkIn" ? "Check in for someone" : "Check out for someone" }}
|
|
6
|
+
</v-card-title>
|
|
7
|
+
|
|
8
|
+
<v-card-text class="screen-modal__body">
|
|
9
|
+
<!-- 1. What are we doing? Two big choices. -->
|
|
10
|
+
<v-btn-toggle
|
|
11
|
+
v-model="action"
|
|
12
|
+
mandatory
|
|
13
|
+
divided
|
|
14
|
+
class="w-100 mb-4 proxy__toggle"
|
|
15
|
+
:disabled="submitting"
|
|
16
|
+
>
|
|
17
|
+
<v-btn value="checkIn" class="flex-grow-1 text-none" prepend-icon="mdi-login">Check in</v-btn>
|
|
18
|
+
<v-btn value="checkOut" class="flex-grow-1 text-none" prepend-icon="mdi-logout">Check out</v-btn>
|
|
19
|
+
</v-btn-toggle>
|
|
20
|
+
|
|
21
|
+
<!-- Check-out: today's open check-ins on this device, tap to pick. -->
|
|
22
|
+
<div v-if="action === 'checkOut'" class="mb-4">
|
|
23
|
+
<InputLabel title="Checked in today on this device" />
|
|
24
|
+
<div v-if="openLoading" class="screen-hint">Loading...</div>
|
|
25
|
+
<div v-else-if="openItems.length === 0" class="screen-hint">
|
|
26
|
+
Nobody is checked in on this device today. You can still type the name and NRIC below.
|
|
27
|
+
</div>
|
|
28
|
+
<v-list v-else density="compact" class="proxy__open" data-test="attendance-proxy-open">
|
|
29
|
+
<v-list-item
|
|
30
|
+
v-for="item in openItems"
|
|
31
|
+
:key="item._id"
|
|
32
|
+
:active="picked === item._id"
|
|
33
|
+
@click="pick(item)"
|
|
34
|
+
>
|
|
35
|
+
<v-list-item-title>{{ item.name }} · {{ item.nricMasked }}</v-list-item-title>
|
|
36
|
+
<v-list-item-subtitle>
|
|
37
|
+
In at {{ formatTime(item.checkInTimestamp) }}<span v-if="item.post"> · {{ item.post }}</span>
|
|
38
|
+
</v-list-item-subtitle>
|
|
39
|
+
</v-list-item>
|
|
40
|
+
</v-list>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- 2. Live photo (required). -->
|
|
44
|
+
<InputLabel title="Photo of the person" required />
|
|
45
|
+
<v-card variant="outlined" class="mb-1 proxy__stage">
|
|
46
|
+
<video
|
|
47
|
+
v-show="cameraOn && !photoPreview"
|
|
48
|
+
ref="videoEl"
|
|
49
|
+
autoplay
|
|
50
|
+
playsinline
|
|
51
|
+
muted
|
|
52
|
+
class="proxy__media"
|
|
53
|
+
/>
|
|
54
|
+
<img v-if="photoPreview" :src="photoPreview" class="proxy__media" alt="Captured photo" />
|
|
55
|
+
<div v-if="!cameraOn && !photoPreview" class="proxy__placeholder">
|
|
56
|
+
<v-icon size="48" class="mb-2">mdi-camera</v-icon>
|
|
57
|
+
<span>{{ cameraError || "Starting the camera..." }}</span>
|
|
58
|
+
</div>
|
|
59
|
+
</v-card>
|
|
60
|
+
<div class="d-flex ga-2 mb-4">
|
|
61
|
+
<v-btn
|
|
62
|
+
v-if="!photoPreview"
|
|
63
|
+
class="text-none screen-btn-primary flex-grow-1"
|
|
64
|
+
variant="flat"
|
|
65
|
+
prepend-icon="mdi-camera"
|
|
66
|
+
:disabled="!cameraOn || submitting"
|
|
67
|
+
data-test="attendance-proxy-capture"
|
|
68
|
+
@click="capture"
|
|
69
|
+
>
|
|
70
|
+
Take photo
|
|
71
|
+
</v-btn>
|
|
72
|
+
<v-btn
|
|
73
|
+
v-else
|
|
74
|
+
class="text-none screen-btn-ghost flex-grow-1"
|
|
75
|
+
variant="flat"
|
|
76
|
+
prepend-icon="mdi-camera-retake"
|
|
77
|
+
:disabled="submitting"
|
|
78
|
+
@click="retake"
|
|
79
|
+
>
|
|
80
|
+
Retake
|
|
81
|
+
</v-btn>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<!-- 3. Who. -->
|
|
85
|
+
<InputLabel title="Name" required />
|
|
86
|
+
<v-text-field
|
|
87
|
+
v-model="name"
|
|
88
|
+
density="comfortable"
|
|
89
|
+
placeholder="e.g. Tan Ah Kow"
|
|
90
|
+
:error-messages="touched ? nameError(name) : ''"
|
|
91
|
+
:disabled="submitting"
|
|
92
|
+
data-test="attendance-proxy-name"
|
|
93
|
+
/>
|
|
94
|
+
|
|
95
|
+
<InputLabel title="NRIC / FIN" required />
|
|
96
|
+
<v-text-field
|
|
97
|
+
v-model="nric"
|
|
98
|
+
density="comfortable"
|
|
99
|
+
placeholder="Full number or just the last 4 (e.g. 932P)"
|
|
100
|
+
autocomplete="off"
|
|
101
|
+
:error-messages="touched && !parsed.ok ? parsed.error : ''"
|
|
102
|
+
:hint="parsed.ok ? `Will be saved as ${parsed.masked}` : ''"
|
|
103
|
+
persistent-hint
|
|
104
|
+
:disabled="submitting"
|
|
105
|
+
data-test="attendance-proxy-nric"
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
<template v-if="action === 'checkIn'">
|
|
109
|
+
<InputLabel title="Shift / post (optional)" class="mt-2" />
|
|
110
|
+
<v-text-field
|
|
111
|
+
v-model="post"
|
|
112
|
+
density="comfortable"
|
|
113
|
+
placeholder="e.g. Main gate, night shift"
|
|
114
|
+
:error-messages="touched ? postError(post) : ''"
|
|
115
|
+
:disabled="submitting"
|
|
116
|
+
/>
|
|
117
|
+
</template>
|
|
118
|
+
|
|
119
|
+
<v-alert type="info" variant="tonal" density="compact" icon="mdi-shield-lock-outline" class="mt-2">
|
|
120
|
+
Only the last 4 characters of the NRIC are saved. The photo is kept private and
|
|
121
|
+
only people allowed to see attendance can open it.
|
|
122
|
+
</v-alert>
|
|
123
|
+
|
|
124
|
+
<v-alert v-if="error" type="error" variant="tonal" density="compact" class="mt-3" closable @click:close="error = ''">
|
|
125
|
+
{{ error }}
|
|
126
|
+
</v-alert>
|
|
127
|
+
</v-card-text>
|
|
128
|
+
|
|
129
|
+
<div class="screen-modal__footer">
|
|
130
|
+
<v-btn class="text-none screen-btn-ghost" variant="flat" :disabled="submitting" @click="close">
|
|
131
|
+
Cancel
|
|
132
|
+
</v-btn>
|
|
133
|
+
<v-btn
|
|
134
|
+
class="text-none screen-btn-primary"
|
|
135
|
+
variant="flat"
|
|
136
|
+
:prepend-icon="action === 'checkIn' ? 'mdi-login' : 'mdi-logout'"
|
|
137
|
+
:loading="submitting"
|
|
138
|
+
data-test="attendance-proxy-submit"
|
|
139
|
+
@click="submit"
|
|
140
|
+
>
|
|
141
|
+
{{ action === "checkIn" ? "Check in" : "Check out" }}
|
|
142
|
+
</v-btn>
|
|
143
|
+
</div>
|
|
144
|
+
</v-card>
|
|
145
|
+
</v-dialog>
|
|
146
|
+
</template>
|
|
147
|
+
|
|
148
|
+
<script setup lang="ts">
|
|
149
|
+
import useAttendance from "../composables/useAttendance";
|
|
150
|
+
import { nameError, parseNric, postError, proxyPayload } from "../utils/attendance-proxy";
|
|
151
|
+
|
|
152
|
+
const showDialog = defineModel({ type: Boolean, default: false });
|
|
153
|
+
const props = defineProps({
|
|
154
|
+
site: { type: String, required: true },
|
|
155
|
+
serviceType: { type: String, default: "" },
|
|
156
|
+
initialAction: { type: String as () => "checkIn" | "checkOut", default: "checkIn" },
|
|
157
|
+
});
|
|
158
|
+
const emit = defineEmits<{ (e: "saved", message: string): void; (e: "close"): void }>();
|
|
159
|
+
|
|
160
|
+
const { uploadAttendancePhoto, checkIn, proxyCheckOut, getOpenProxyCheckIns } = useAttendance();
|
|
161
|
+
|
|
162
|
+
const action = ref<"checkIn" | "checkOut">(props.initialAction);
|
|
163
|
+
const name = ref("");
|
|
164
|
+
const nric = ref("");
|
|
165
|
+
const post = ref("");
|
|
166
|
+
const touched = ref(false);
|
|
167
|
+
const submitting = ref(false);
|
|
168
|
+
const error = ref("");
|
|
169
|
+
const parsed = computed(() => parseNric(nric.value));
|
|
170
|
+
|
|
171
|
+
const openItems = ref<Array<Record<string, any>>>([]);
|
|
172
|
+
const openLoading = ref(false);
|
|
173
|
+
const picked = ref("");
|
|
174
|
+
|
|
175
|
+
const videoEl = ref<HTMLVideoElement | null>(null);
|
|
176
|
+
const stream = ref<MediaStream | null>(null);
|
|
177
|
+
const cameraOn = ref(false);
|
|
178
|
+
const cameraError = ref("");
|
|
179
|
+
const photoFile = ref<File | null>(null);
|
|
180
|
+
const photoPreview = ref("");
|
|
181
|
+
|
|
182
|
+
function formatTime(ts: unknown) {
|
|
183
|
+
return ts ? new Date(String(ts)).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "-";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function pick(item: Record<string, any>) {
|
|
187
|
+
picked.value = item._id;
|
|
188
|
+
name.value = item.name ?? "";
|
|
189
|
+
nric.value = item.nricLast4 ?? "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function loadOpen() {
|
|
193
|
+
openLoading.value = true;
|
|
194
|
+
try {
|
|
195
|
+
const res = await getOpenProxyCheckIns(props.site, props.serviceType);
|
|
196
|
+
openItems.value = res?.items ?? [];
|
|
197
|
+
} catch {
|
|
198
|
+
openItems.value = [];
|
|
199
|
+
} finally {
|
|
200
|
+
openLoading.value = false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function startCamera() {
|
|
205
|
+
cameraError.value = "";
|
|
206
|
+
try {
|
|
207
|
+
if (!navigator.mediaDevices?.getUserMedia) throw new Error("unsupported");
|
|
208
|
+
stream.value = await navigator.mediaDevices.getUserMedia({
|
|
209
|
+
video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } },
|
|
210
|
+
audio: false,
|
|
211
|
+
});
|
|
212
|
+
cameraOn.value = true;
|
|
213
|
+
await nextTick();
|
|
214
|
+
if (videoEl.value) {
|
|
215
|
+
videoEl.value.srcObject = stream.value;
|
|
216
|
+
await videoEl.value.play().catch(() => undefined);
|
|
217
|
+
}
|
|
218
|
+
} catch (err: any) {
|
|
219
|
+
cameraOn.value = false;
|
|
220
|
+
cameraError.value =
|
|
221
|
+
err?.name === "NotAllowedError"
|
|
222
|
+
? "Camera access is blocked. Allow the camera in your browser settings, then reopen this window."
|
|
223
|
+
: "No camera could be started on this device.";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function stopCamera() {
|
|
228
|
+
stream.value?.getTracks().forEach((t) => t.stop());
|
|
229
|
+
stream.value = null;
|
|
230
|
+
cameraOn.value = false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function capture() {
|
|
234
|
+
const video = videoEl.value;
|
|
235
|
+
if (!video) return;
|
|
236
|
+
const canvas = document.createElement("canvas");
|
|
237
|
+
canvas.width = video.videoWidth;
|
|
238
|
+
canvas.height = video.videoHeight;
|
|
239
|
+
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
|
240
|
+
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/jpeg", 0.8));
|
|
241
|
+
if (!blob) return;
|
|
242
|
+
photoFile.value = new File([blob], "attendance.jpg", { type: "image/jpeg" });
|
|
243
|
+
photoPreview.value = URL.createObjectURL(blob);
|
|
244
|
+
stopCamera();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function retake() {
|
|
248
|
+
if (photoPreview.value) URL.revokeObjectURL(photoPreview.value);
|
|
249
|
+
photoPreview.value = "";
|
|
250
|
+
photoFile.value = null;
|
|
251
|
+
void startCamera();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Location if the browser gives one within 4 s; never blocks the check-in. */
|
|
255
|
+
function bestEffortLocation(): Promise<{ latitude: number; longitude: number } | undefined> {
|
|
256
|
+
return new Promise((resolve) => {
|
|
257
|
+
if (!navigator.geolocation) return resolve(undefined);
|
|
258
|
+
const timer = setTimeout(() => resolve(undefined), 4000);
|
|
259
|
+
navigator.geolocation.getCurrentPosition(
|
|
260
|
+
(p) => {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
resolve({ latitude: p.coords.latitude, longitude: p.coords.longitude });
|
|
263
|
+
},
|
|
264
|
+
() => {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
resolve(undefined);
|
|
267
|
+
},
|
|
268
|
+
{ enableHighAccuracy: true, timeout: 4000, maximumAge: 60000 }
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function submit() {
|
|
274
|
+
touched.value = true;
|
|
275
|
+
error.value = "";
|
|
276
|
+
if (!photoFile.value) return void (error.value = "Take the person's photo first.");
|
|
277
|
+
if (nameError(name.value) || !parsed.value.ok || (action.value === "checkIn" && postError(post.value))) return;
|
|
278
|
+
|
|
279
|
+
// Masked HERE: from this line on only the last 4 (+ prefix) exist.
|
|
280
|
+
const proxy = proxyPayload({ name: name.value, nric: nric.value, post: post.value });
|
|
281
|
+
const shown = parsed.value.ok ? parsed.value.masked : "";
|
|
282
|
+
nric.value = "";
|
|
283
|
+
|
|
284
|
+
submitting.value = true;
|
|
285
|
+
try {
|
|
286
|
+
const [{ id: privatePhoto }, location] = await Promise.all([
|
|
287
|
+
uploadAttendancePhoto(props.site, photoFile.value, props.serviceType),
|
|
288
|
+
bestEffortLocation(),
|
|
289
|
+
]);
|
|
290
|
+
const timestamp = new Date().toISOString();
|
|
291
|
+
if (action.value === "checkIn") {
|
|
292
|
+
await checkIn(
|
|
293
|
+
props.site,
|
|
294
|
+
{ checkIn: { timestamp, privatePhoto, ...(location ? { location } : {}) }, proxy } as any,
|
|
295
|
+
props.serviceType
|
|
296
|
+
);
|
|
297
|
+
} else {
|
|
298
|
+
await proxyCheckOut(
|
|
299
|
+
props.site,
|
|
300
|
+
{
|
|
301
|
+
proxy: { name: proxy.name, nricLast4: proxy.nricLast4 },
|
|
302
|
+
checkOut: { timestamp, privatePhoto, ...(location ? { location } : {}) },
|
|
303
|
+
},
|
|
304
|
+
props.serviceType
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
emit(
|
|
308
|
+
"saved",
|
|
309
|
+
`${proxy.name} (${shown}) ${action.value === "checkIn" ? "checked in" : "checked out"} at ${formatTime(timestamp)}.`
|
|
310
|
+
);
|
|
311
|
+
close();
|
|
312
|
+
} catch (err: any) {
|
|
313
|
+
error.value = err?.data?.message || "Could not save. Please check the connection and try again.";
|
|
314
|
+
} finally {
|
|
315
|
+
submitting.value = false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function reset() {
|
|
320
|
+
name.value = "";
|
|
321
|
+
nric.value = "";
|
|
322
|
+
post.value = "";
|
|
323
|
+
touched.value = false;
|
|
324
|
+
error.value = "";
|
|
325
|
+
picked.value = "";
|
|
326
|
+
if (photoPreview.value) URL.revokeObjectURL(photoPreview.value);
|
|
327
|
+
photoPreview.value = "";
|
|
328
|
+
photoFile.value = null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function close() {
|
|
332
|
+
stopCamera();
|
|
333
|
+
reset();
|
|
334
|
+
showDialog.value = false;
|
|
335
|
+
emit("close");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
watch(showDialog, (open) => {
|
|
339
|
+
if (open) {
|
|
340
|
+
action.value = props.initialAction;
|
|
341
|
+
void startCamera();
|
|
342
|
+
void loadOpen();
|
|
343
|
+
} else {
|
|
344
|
+
stopCamera();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
watch(action, (next) => {
|
|
349
|
+
if (next === "checkOut") void loadOpen();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
onBeforeUnmount(stopCamera);
|
|
353
|
+
</script>
|
|
354
|
+
|
|
355
|
+
<style scoped>
|
|
356
|
+
.proxy__stage {
|
|
357
|
+
position: relative;
|
|
358
|
+
height: 260px;
|
|
359
|
+
overflow: hidden;
|
|
360
|
+
border-radius: var(--r-inner);
|
|
361
|
+
border-color: var(--border);
|
|
362
|
+
}
|
|
363
|
+
.proxy__media {
|
|
364
|
+
width: 100%;
|
|
365
|
+
height: 100%;
|
|
366
|
+
object-fit: cover;
|
|
367
|
+
display: block;
|
|
368
|
+
background: #000;
|
|
369
|
+
}
|
|
370
|
+
.proxy__placeholder {
|
|
371
|
+
height: 100%;
|
|
372
|
+
display: flex;
|
|
373
|
+
flex-direction: column;
|
|
374
|
+
align-items: center;
|
|
375
|
+
justify-content: center;
|
|
376
|
+
text-align: center;
|
|
377
|
+
padding: 16px;
|
|
378
|
+
background: var(--thead);
|
|
379
|
+
color: var(--muted);
|
|
380
|
+
font-size: var(--fs-cell);
|
|
381
|
+
}
|
|
382
|
+
.proxy__open {
|
|
383
|
+
border: 1px solid var(--border);
|
|
384
|
+
border-radius: var(--r-inner);
|
|
385
|
+
max-height: 200px;
|
|
386
|
+
overflow-y: auto;
|
|
387
|
+
}
|
|
388
|
+
.proxy__toggle .v-btn {
|
|
389
|
+
height: 48px;
|
|
390
|
+
}
|
|
391
|
+
</style>
|
|
@@ -18,6 +18,29 @@
|
|
|
18
18
|
{{ error }}
|
|
19
19
|
</v-alert>
|
|
20
20
|
|
|
21
|
+
<!-- Site device mode (owner request 2026-10-01). Saved on its own,
|
|
22
|
+
straight away, through its own endpoint - the Update button below
|
|
23
|
+
never touches it. -->
|
|
24
|
+
<div v-if="canManageSiteDeviceMode" class="attendance-settings__device mb-4">
|
|
25
|
+
<v-switch
|
|
26
|
+
v-model="siteDeviceMode"
|
|
27
|
+
label="Site device mode"
|
|
28
|
+
color="accent"
|
|
29
|
+
inset
|
|
30
|
+
hide-details
|
|
31
|
+
:loading="deviceModeSaving"
|
|
32
|
+
:disabled="deviceModeSaving || loading"
|
|
33
|
+
data-test="site-device-mode-switch"
|
|
34
|
+
@update:model-value="onDeviceModeToggle"
|
|
35
|
+
/>
|
|
36
|
+
<span class="attendance-settings__hint">
|
|
37
|
+
{{ siteDeviceMode ? "ON" : "OFF" }} for this site. When on, one shared
|
|
38
|
+
phone at this site can check staff in and out: each person takes a photo
|
|
39
|
+
and types their name and NRIC. Only the last 4 characters of the NRIC are
|
|
40
|
+
saved. This switch saves straight away.
|
|
41
|
+
</span>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
21
44
|
<v-form ref="formRef" v-model="valid" @submit.prevent="onSubmit">
|
|
22
45
|
<v-row dense>
|
|
23
46
|
<v-col cols="12">
|
|
@@ -158,7 +181,28 @@ const isMapSearchVisible = ref(false);
|
|
|
158
181
|
|
|
159
182
|
const radiusInMeters = computed(() => radius.value * 1609.34);
|
|
160
183
|
|
|
161
|
-
const { getAttendanceSettings, updateAttendanceSettings } = useAttendance();
|
|
184
|
+
const { getAttendanceSettings, updateAttendanceSettings, setSiteDeviceMode } = useAttendance();
|
|
185
|
+
const { canManageSiteDeviceMode } = useAttendancePermission();
|
|
186
|
+
|
|
187
|
+
const siteDeviceMode = ref(false);
|
|
188
|
+
const deviceModeSaving = ref(false);
|
|
189
|
+
|
|
190
|
+
async function onDeviceModeToggle(next: boolean | null) {
|
|
191
|
+
const enabled = next === true;
|
|
192
|
+
deviceModeSaving.value = true;
|
|
193
|
+
try {
|
|
194
|
+
const res = await setSiteDeviceMode(props.site, enabled, props.serviceType);
|
|
195
|
+
siteDeviceMode.value = res?.siteDeviceMode === true;
|
|
196
|
+
snackbarText.value = res?.message || (enabled ? "Site device mode is ON." : "Site device mode is OFF.");
|
|
197
|
+
snackbarColor.value = "success";
|
|
198
|
+
snackbar.value = true;
|
|
199
|
+
} catch (err: any) {
|
|
200
|
+
siteDeviceMode.value = !enabled;
|
|
201
|
+
error.value = err?.data?.message || "Could not change Site device mode. Please try again.";
|
|
202
|
+
} finally {
|
|
203
|
+
deviceModeSaving.value = false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
162
206
|
|
|
163
207
|
const postalCode = ref("");
|
|
164
208
|
const country = ref("");
|
|
@@ -197,6 +241,7 @@ watchEffect(() => {
|
|
|
197
241
|
}
|
|
198
242
|
|
|
199
243
|
if (getAttendanceSettingsReq.value) {
|
|
244
|
+
siteDeviceMode.value = getAttendanceSettingsReq.value.siteDeviceMode === true;
|
|
200
245
|
isGeofencingEnabled.value =
|
|
201
246
|
getAttendanceSettingsReq.value.isGeofencingEnabled || false;
|
|
202
247
|
|
|
@@ -391,6 +436,13 @@ watch(showDialog, (newVal) => {
|
|
|
391
436
|
color: var(--text2);
|
|
392
437
|
}
|
|
393
438
|
|
|
439
|
+
/* Site device mode: its own framed block, because it saves on its own. */
|
|
440
|
+
.attendance-settings__device {
|
|
441
|
+
padding: 12px 14px;
|
|
442
|
+
border: 1px solid var(--border);
|
|
443
|
+
border-radius: var(--r-inner);
|
|
444
|
+
}
|
|
445
|
+
|
|
394
446
|
/* Was a `variant="flat"` v-card, i.e. a second card inside the modal card. It
|
|
395
447
|
is a frame around the QR, so it takes the design's inner surface. */
|
|
396
448
|
.attendance-settings__qr {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
<v-col class="font-weight-bold">{{ label }}:</v-col>
|
|
20
20
|
|
|
21
21
|
<v-col cols="9" class="px-10">
|
|
22
|
-
<span v-if="key === 'content'" v-html="getFieldValue(key)" />
|
|
22
|
+
<span v-if="key === 'content'" v-html="sanitizeHtml(getFieldValue(key))" />
|
|
23
23
|
<span v-else-if="key === 'noExpiration'">
|
|
24
24
|
<BulletinExpirationChip
|
|
25
25
|
:value="getFieldValue(key) as boolean"
|
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-row no-gutters align="center" justify="center">
|
|
3
|
+
<!-- Site device mode is ON for this site: this shared phone can check other
|
|
4
|
+
people in and out (owner request 2026-10-01). -->
|
|
5
|
+
<v-col v-if="siteDeviceMode && canCheckInOut" cols="12" class="mb-4">
|
|
6
|
+
<v-alert
|
|
7
|
+
type="info"
|
|
8
|
+
variant="tonal"
|
|
9
|
+
icon="mdi-cellphone-check"
|
|
10
|
+
data-test="site-device-mode-banner"
|
|
11
|
+
>
|
|
12
|
+
<div class="font-weight-medium">Site device mode is on</div>
|
|
13
|
+
<div class="mb-3">
|
|
14
|
+
This phone can check staff in and out for them: take their photo, type their
|
|
15
|
+
name and NRIC. Only the last 4 characters of the NRIC are saved.
|
|
16
|
+
</div>
|
|
17
|
+
<v-btn
|
|
18
|
+
class="text-none screen-btn-primary"
|
|
19
|
+
size="large"
|
|
20
|
+
variant="flat"
|
|
21
|
+
prepend-icon="mdi-account-clock"
|
|
22
|
+
data-test="attendance-proxy-open-btn"
|
|
23
|
+
@click="dialogShowProxy = true"
|
|
24
|
+
>
|
|
25
|
+
Check in / Check out for someone
|
|
26
|
+
</v-btn>
|
|
27
|
+
</v-alert>
|
|
28
|
+
</v-col>
|
|
3
29
|
<v-col cols="12" lg="12">
|
|
4
30
|
<TableMain
|
|
5
31
|
title="Attendance Records"
|
|
@@ -71,6 +97,14 @@
|
|
|
71
97
|
@close="dialogShowMoreActions = false"
|
|
72
98
|
/>
|
|
73
99
|
|
|
100
|
+
<AttendanceProxyDialog
|
|
101
|
+
v-if="siteDeviceMode"
|
|
102
|
+
v-model="dialogShowProxy"
|
|
103
|
+
:site="props.site"
|
|
104
|
+
:service-type="props.serviceType"
|
|
105
|
+
@saved="onProxySaved"
|
|
106
|
+
/>
|
|
107
|
+
|
|
74
108
|
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
75
109
|
</template>
|
|
76
110
|
|
|
@@ -107,7 +141,31 @@ const headers = [
|
|
|
107
141
|
{ title: "Total Hours", value: "totalHours" },
|
|
108
142
|
];
|
|
109
143
|
|
|
110
|
-
const { getMyAttendances, checkIn, checkOut } = useAttendance();
|
|
144
|
+
const { getMyAttendances, checkIn, checkOut, getAttendanceSettings } = useAttendance();
|
|
145
|
+
|
|
146
|
+
// Site device mode: read once per site. A failed read means OFF (today's flow).
|
|
147
|
+
const siteDeviceMode = ref(false);
|
|
148
|
+
const dialogShowProxy = ref(false);
|
|
149
|
+
watch(
|
|
150
|
+
() => [props.site, props.serviceType],
|
|
151
|
+
async () => {
|
|
152
|
+
siteDeviceMode.value = false;
|
|
153
|
+
if (!props.site) return;
|
|
154
|
+
try {
|
|
155
|
+
const res: any = await getAttendanceSettings(props.site, props.serviceType);
|
|
156
|
+
siteDeviceMode.value = res?.siteDeviceMode === true;
|
|
157
|
+
} catch {
|
|
158
|
+
siteDeviceMode.value = false;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{ immediate: true }
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
function onProxySaved(text: string) {
|
|
165
|
+
message.value = text;
|
|
166
|
+
messageColor.value = "success";
|
|
167
|
+
messageSnackbar.value = true;
|
|
168
|
+
}
|
|
111
169
|
const { formatDate } = useUtils();
|
|
112
170
|
|
|
113
171
|
const dialogShowMoreActions = ref(false);
|
|
@@ -170,7 +228,7 @@ watchEffect(() => {
|
|
|
170
228
|
const today = new Date().toISOString().split("T")[0];
|
|
171
229
|
todayAttendance.value =
|
|
172
230
|
getMyAttendancesReq.value.items.find((item: any) => {
|
|
173
|
-
if (!item.checkInTimestamp) return false;
|
|
231
|
+
if (!item.checkInTimestamp || item.isProxy) return false;
|
|
174
232
|
const itemDate = new Date(item.checkInTimestamp)
|
|
175
233
|
.toISOString()
|
|
176
234
|
.split("T")[0];
|
|
@@ -81,7 +81,60 @@ export default function useAttendance() {
|
|
|
81
81
|
);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// ---- Site device mode (check in/out for someone on a shared site phone) ----
|
|
85
|
+
|
|
86
|
+
function setSiteDeviceMode(site: string, enabled: boolean, serviceType: string = "") {
|
|
87
|
+
return useNuxtApp().$api<{ message: string; siteDeviceMode: boolean }>(
|
|
88
|
+
`/api/attendance-settings/site/${site}/device-mode`,
|
|
89
|
+
{ method: "PUT", body: { serviceType, enabled } }
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The person's photo, stored PRIVATELY by the server (never a public URL). */
|
|
94
|
+
function uploadAttendancePhoto(site: string, file: File, serviceType: string = "") {
|
|
95
|
+
const form = new FormData();
|
|
96
|
+
form.append("file", file);
|
|
97
|
+
return useNuxtApp().$api<{ id: string }>(`/api/attendances/site/${site}/photo`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
query: { serviceType },
|
|
100
|
+
body: form,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function proxyCheckOut(site: string, body: Record<string, any>, serviceType: string = "") {
|
|
105
|
+
return useNuxtApp().$api<{ message: string; id: string }>(
|
|
106
|
+
`/api/attendances/site/${site}/proxy-check-out`,
|
|
107
|
+
{ method: "POST", body: { ...body, serviceType } }
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getOpenProxyCheckIns(site: string, serviceType: string = "") {
|
|
112
|
+
return useNuxtApp().$api<{ items: Array<Record<string, any>> }>(
|
|
113
|
+
`/api/attendances/site/${site}/proxy-open`,
|
|
114
|
+
{ method: "GET", query: { serviceType } }
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Path of the private photo; read through $api (session), never a public link. */
|
|
119
|
+
function attendancePhotoPath(id: string, which: "checkIn" | "checkOut") {
|
|
120
|
+
return `/api/attendances/id/${id}/photo/${which}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function getAttendancePhotoUrl(id: string, which: "checkIn" | "checkOut") {
|
|
124
|
+
const blob = await useNuxtApp().$api<Blob>(attendancePhotoPath(id, which), {
|
|
125
|
+
method: "GET",
|
|
126
|
+
responseType: "blob",
|
|
127
|
+
});
|
|
128
|
+
return URL.createObjectURL(blob);
|
|
129
|
+
}
|
|
130
|
+
|
|
84
131
|
return {
|
|
132
|
+
setSiteDeviceMode,
|
|
133
|
+
uploadAttendancePhoto,
|
|
134
|
+
proxyCheckOut,
|
|
135
|
+
getOpenProxyCheckIns,
|
|
136
|
+
attendancePhotoPath,
|
|
137
|
+
getAttendancePhotoUrl,
|
|
85
138
|
getAttendances,
|
|
86
139
|
getMyAttendances,
|
|
87
140
|
getMyAttendanceById,
|
|
@@ -57,6 +57,30 @@ export function useAttendancePermission() {
|
|
|
57
57
|
);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
// Site device mode switch. Widened, never swapped: the new key OR the existing
|
|
61
|
+
// settings key OR `*` - the same three the server accepts (core
|
|
62
|
+
// `SITE_DEVICE_MODE_PERMISSIONS`), so an admin who manages attendance
|
|
63
|
+
// settings today can use it without a role change.
|
|
64
|
+
const canManageSiteDeviceMode = computed(() => {
|
|
65
|
+
if (!userAppRole.value) return false;
|
|
66
|
+
if (moduleDenied("attendance-mgmt")) return false;
|
|
67
|
+
if (userAppRole.value.permissions.includes("*")) return true;
|
|
68
|
+
return (
|
|
69
|
+
hasPermission(
|
|
70
|
+
userAppRole.value,
|
|
71
|
+
permissions,
|
|
72
|
+
"attendance-mgmt",
|
|
73
|
+
"manage-site-device-mode"
|
|
74
|
+
) ||
|
|
75
|
+
hasPermission(
|
|
76
|
+
userAppRole.value,
|
|
77
|
+
permissions,
|
|
78
|
+
"attendance-mgmt",
|
|
79
|
+
"manage-attendance-settings"
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
60
84
|
const canViewOwnAttendance = computed(() => {
|
|
61
85
|
if (!userAppRole.value) return false;
|
|
62
86
|
if (moduleDenied("my-attendance")) return false;
|
|
@@ -85,6 +109,7 @@ export function useAttendancePermission() {
|
|
|
85
109
|
canViewAllAttendance,
|
|
86
110
|
canViewAttendanceDetails,
|
|
87
111
|
canManageAttendanceSettings,
|
|
112
|
+
canManageSiteDeviceMode,
|
|
88
113
|
canViewOwnAttendance,
|
|
89
114
|
canCheckInOut,
|
|
90
115
|
};
|
|
@@ -221,6 +221,11 @@ export default function useCleaningPermission() {
|
|
|
221
221
|
description:
|
|
222
222
|
"Allows the user to configure and manage attendance settings.",
|
|
223
223
|
},
|
|
224
|
+
"manage-site-device-mode": {
|
|
225
|
+
check: true,
|
|
226
|
+
description:
|
|
227
|
+
"Allows the user to turn Site device mode on or off: one shared phone at the site checks staff in and out with a photo, their name and the last 4 characters of their NRIC.",
|
|
228
|
+
},
|
|
224
229
|
},
|
|
225
230
|
// my attendance permissions
|
|
226
231
|
"my-attendance": {
|
|
@@ -198,6 +198,11 @@ export default function useLandscapingPermission() {
|
|
|
198
198
|
check: true,
|
|
199
199
|
description: "Allows the user to configure and manage attendance settings.",
|
|
200
200
|
},
|
|
201
|
+
"manage-site-device-mode": {
|
|
202
|
+
check: true,
|
|
203
|
+
description:
|
|
204
|
+
"Allows the user to turn Site device mode on or off: one shared phone at the site checks staff in and out with a photo, their name and the last 4 characters of their NRIC.",
|
|
205
|
+
},
|
|
201
206
|
},
|
|
202
207
|
// my attendance permissions
|
|
203
208
|
"my-attendance": {
|
|
@@ -213,6 +213,11 @@ export default function usePestControlPermission() {
|
|
|
213
213
|
check: true,
|
|
214
214
|
description: "Allows the user to configure and manage attendance settings.",
|
|
215
215
|
},
|
|
216
|
+
"manage-site-device-mode": {
|
|
217
|
+
check: true,
|
|
218
|
+
description:
|
|
219
|
+
"Allows the user to turn Site device mode on or off: one shared phone at the site checks staff in and out with a photo, their name and the last 4 characters of their NRIC.",
|
|
220
|
+
},
|
|
216
221
|
},
|
|
217
222
|
// my attendance permissions
|
|
218
223
|
"my-attendance": {
|
|
@@ -213,6 +213,11 @@ export default function usePoolMaintenancePermission() {
|
|
|
213
213
|
check: true,
|
|
214
214
|
description: "Allows the user to configure and manage attendance settings.",
|
|
215
215
|
},
|
|
216
|
+
"manage-site-device-mode": {
|
|
217
|
+
check: true,
|
|
218
|
+
description:
|
|
219
|
+
"Allows the user to turn Site device mode on or off: one shared phone at the site checks staff in and out with a photo, their name and the last 4 characters of their NRIC.",
|
|
220
|
+
},
|
|
216
221
|
},
|
|
217
222
|
// my attendance permissions
|
|
218
223
|
"my-attendance": {
|
|
@@ -491,6 +491,11 @@ export default function useSecurityPermission() {
|
|
|
491
491
|
description:
|
|
492
492
|
"Allows the user to configure and manage attendance settings.",
|
|
493
493
|
},
|
|
494
|
+
"manage-site-device-mode": {
|
|
495
|
+
check: true,
|
|
496
|
+
description:
|
|
497
|
+
"Allows the user to turn Site device mode on or off: one shared phone at the site checks staff in and out with a photo, their name and the last 4 characters of their NRIC.",
|
|
498
|
+
},
|
|
494
499
|
},
|
|
495
500
|
|
|
496
501
|
"my-attendance": {
|
package/constants/permissions.ts
CHANGED
|
@@ -206,6 +206,7 @@ export const PERMISSIONS = {
|
|
|
206
206
|
"see-all-attendance",
|
|
207
207
|
"see-attendance-details",
|
|
208
208
|
"manage-attendance-settings",
|
|
209
|
+
"manage-site-device-mode",
|
|
209
210
|
],
|
|
210
211
|
"my-attendance": ["see-own-attendance", "check-in-out"],
|
|
211
212
|
toilet_locations: ["create-toilet-location"],
|
package/nuxt.config.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
|
2
2
|
import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
|
|
3
|
+
import { SECURITY_HEADERS } from "./utils/security-headers";
|
|
3
4
|
export default defineNuxtConfig({
|
|
4
5
|
ssr: false,
|
|
5
6
|
devtools: { enabled: false },
|
|
7
|
+
|
|
8
|
+
// Security headers for every app that extends this layer: the HTML shell gets
|
|
9
|
+
// them from Nitro, the static files from the `_headers` file the Cloudflare
|
|
10
|
+
// preset writes from these same rules. See utils/security-headers.ts.
|
|
11
|
+
routeRules: {
|
|
12
|
+
"/**": { headers: SECURITY_HEADERS },
|
|
13
|
+
// Stored files read with the signed-in session (API-core
|
|
14
|
+
// GET /api/files/object/<key>) instead of the public CDN. Unused until an
|
|
15
|
+
// app's API_DO_STORAGE_ENDPOINT points at /api/storage/<folder>; that switch
|
|
16
|
+
// goes with SPACES_PRIVATE_PREFIXES - see the storage runbook.
|
|
17
|
+
...(process.env.API_CORE
|
|
18
|
+
? { "/api/storage/**": { proxy: `${process.env.API_CORE}/api/files/object/**` } }
|
|
19
|
+
: {}),
|
|
20
|
+
},
|
|
6
21
|
build: {
|
|
7
22
|
transpile: ["vuetify"],
|
|
8
23
|
},
|
|
@@ -48,6 +63,9 @@ export default defineNuxtConfig({
|
|
|
48
63
|
cookieConfig: {
|
|
49
64
|
domain: (process.env.DOMAIN as string) ?? "localhost",
|
|
50
65
|
secure: true,
|
|
66
|
+
// Explicit, not the browser default: the API also reads `sid` from the
|
|
67
|
+
// cookie, so a cross-site form post must never carry it (CSRF).
|
|
68
|
+
sameSite: "lax",
|
|
51
69
|
maxAge: 30 * 24 * 60 * 60,
|
|
52
70
|
},
|
|
53
71
|
APP: (process.env.APP as string) ?? "App",
|
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.
|
|
5
|
+
"version": "4.86.0",
|
|
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.",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"pages",
|
|
23
23
|
"plugins",
|
|
24
24
|
"public",
|
|
25
|
+
"server",
|
|
25
26
|
"services",
|
|
26
27
|
"types",
|
|
27
28
|
"utils",
|
|
@@ -57,8 +58,9 @@
|
|
|
57
58
|
"@mdi/font": "^7.4.47",
|
|
58
59
|
"@types/qrcode": "^1.5.6",
|
|
59
60
|
"ckeditor5": "47.4.0",
|
|
61
|
+
"dompurify": "^3.3.1",
|
|
60
62
|
"hls.js": "^1.6.13",
|
|
61
|
-
"html2pdf.js": "^0.
|
|
63
|
+
"html2pdf.js": "^0.14.0",
|
|
62
64
|
"html5-qrcode": "^2.3.8",
|
|
63
65
|
"moment-timezone": "^0.6.0",
|
|
64
66
|
"nuxt-signature-pad": "^1.8.0",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Nuxt's renderer stamps `x-powered-by: Nuxt` on the HTML shell. It tells a
|
|
2
|
+
// scanner the framework for free and helps nobody, so drop it.
|
|
3
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
4
|
+
nitroApp.hooks.hook("render:response", (response) => {
|
|
5
|
+
if (response?.headers) delete response.headers["x-powered-by"];
|
|
6
|
+
});
|
|
7
|
+
});
|
package/types/attendance.d.ts
CHANGED
|
@@ -11,9 +11,11 @@ declare type TAttendance = {
|
|
|
11
11
|
declare type TAttendanceCheckIn = {
|
|
12
12
|
checkIn: {
|
|
13
13
|
timestamp: string;
|
|
14
|
-
location
|
|
14
|
+
location?: TAttendanceLocation;
|
|
15
15
|
img?: string;
|
|
16
|
+
privatePhoto?: string;
|
|
16
17
|
};
|
|
18
|
+
proxy?: TAttendanceProxy;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
declare type TAttendanceCheckOut = {
|
|
@@ -35,4 +37,14 @@ declare type TAttendanceSettings = {
|
|
|
35
37
|
location?: TAttendanceLocation;
|
|
36
38
|
isGeofencingEnabled?: boolean;
|
|
37
39
|
mile?: number;
|
|
40
|
+
/** Site device mode: one shared phone may check staff in/out (default OFF). */
|
|
41
|
+
siteDeviceMode?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Who a Site device mode check-in is for. Never the full NRIC. */
|
|
45
|
+
declare type TAttendanceProxy = {
|
|
46
|
+
name: string;
|
|
47
|
+
nricLast4: string;
|
|
48
|
+
nricPrefix?: string;
|
|
49
|
+
post?: string;
|
|
38
50
|
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site device mode (owner request 2026-10-01): one shared phone at a site checks
|
|
3
|
+
* a person in or out with a photo, their name and their NRIC/FIN.
|
|
4
|
+
*
|
|
5
|
+
* Privacy (Singapore PDPA): the full NRIC is masked HERE, in the browser,
|
|
6
|
+
* before anything is sent. Only the last 4 characters leave the device, plus
|
|
7
|
+
* the prefix letter when the full number was typed, so the record can be shown
|
|
8
|
+
* as "G****932P" (owner's display rule); last 4 only is shown as "****932P".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const FULL_NRIC_RE = /^[STFGM]\d{7}[A-Z]$/;
|
|
12
|
+
export const NRIC_LAST4_RE = /^\d{3}[A-Z]$/;
|
|
13
|
+
const FULL_NRIC_ANYWHERE_RE = /[STFGM]\d{7}[A-Z]/i;
|
|
14
|
+
|
|
15
|
+
export type TParsedNric =
|
|
16
|
+
| { ok: true; nricLast4: string; nricPrefix: string; masked: string; typedFull: boolean }
|
|
17
|
+
| { ok: false; error: string };
|
|
18
|
+
|
|
19
|
+
/** Full NRIC/FIN or just its last 4, any case, spaces ignored. */
|
|
20
|
+
export function parseNric(input: unknown): TParsedNric {
|
|
21
|
+
const value = String(input ?? "").replace(/\s+/g, "").toUpperCase();
|
|
22
|
+
if (!value) return { ok: false, error: "Type the NRIC/FIN, or just its last 4 characters (e.g. 932P)." };
|
|
23
|
+
if (FULL_NRIC_RE.test(value)) {
|
|
24
|
+
const nricLast4 = value.slice(-4);
|
|
25
|
+
const nricPrefix = value[0];
|
|
26
|
+
return { ok: true, nricLast4, nricPrefix, masked: `${nricPrefix}****${nricLast4}`, typedFull: true };
|
|
27
|
+
}
|
|
28
|
+
if (NRIC_LAST4_RE.test(value)) {
|
|
29
|
+
return { ok: true, nricLast4: value, nricPrefix: "", masked: `****${value}`, typedFull: false };
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
error: "That doesn't look like an NRIC/FIN. Type the full number (e.g. S1234567D) or just the last 4 (e.g. 567D).",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Empty string when fine, otherwise the sentence to show. */
|
|
38
|
+
export function nameError(input: unknown): string {
|
|
39
|
+
const value = String(input ?? "").trim();
|
|
40
|
+
if (!value) return "Type the person's name.";
|
|
41
|
+
if (FULL_NRIC_ANYWHERE_RE.test(value)) return "Please type the NRIC in the NRIC box, not in the name.";
|
|
42
|
+
if (value.length < 2) return "Type the person's full name.";
|
|
43
|
+
if (value.length > 80) return "The name is too long (80 characters at most).";
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function postError(input: unknown): string {
|
|
48
|
+
const value = String(input ?? "").trim();
|
|
49
|
+
if (FULL_NRIC_ANYWHERE_RE.test(value)) return "Please don't type an NRIC in Shift / post.";
|
|
50
|
+
if (value.length > 80) return "Shift / post is too long (80 characters at most).";
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What is sent to the server. Never contains the full NRIC. */
|
|
55
|
+
export function proxyPayload(input: { name: string; nric: string; post?: string }) {
|
|
56
|
+
const parsed = parseNric(input.nric);
|
|
57
|
+
if (!parsed.ok) throw new Error(parsed.error);
|
|
58
|
+
const name = String(input.name).trim().replace(/\s+/g, " ");
|
|
59
|
+
const post = String(input.post ?? "").trim();
|
|
60
|
+
return {
|
|
61
|
+
name,
|
|
62
|
+
nricLast4: parsed.nricLast4,
|
|
63
|
+
...(parsed.nricPrefix ? { nricPrefix: parsed.nricPrefix } : {}),
|
|
64
|
+
...(post ? { post } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** CSV cell: quoted, and a leading = + - @ neutralised so a spreadsheet never runs it. */
|
|
69
|
+
function csvCell(value: unknown): string {
|
|
70
|
+
let text = value === null || value === undefined ? "" : String(value);
|
|
71
|
+
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
|
|
72
|
+
return `"${text.replace(/"/g, '""')}"`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function toCsv(headers: string[], rows: unknown[][]): string {
|
|
76
|
+
return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One export row for an attendance list item (web Attendance page). */
|
|
80
|
+
export function attendanceCsvRow(
|
|
81
|
+
item: Record<string, any>,
|
|
82
|
+
photoLink: (id: string, which: "checkIn" | "checkOut") => string,
|
|
83
|
+
formatDate: (d: unknown) => string,
|
|
84
|
+
): unknown[] {
|
|
85
|
+
return [
|
|
86
|
+
item.isProxy ? item.proxyName : item.userName,
|
|
87
|
+
item.isProxy ? item.nricMasked : "",
|
|
88
|
+
item.isProxy ? item.post ?? "" : "",
|
|
89
|
+
item.isProxy ? `Site device (${item.userName ?? ""})` : "Own account",
|
|
90
|
+
item.checkInTimestamp ? formatDate(item.checkInTimestamp) : "",
|
|
91
|
+
item.checkOutTimestamp ? formatDate(item.checkOutTimestamp) : "",
|
|
92
|
+
item.totalHours ?? "",
|
|
93
|
+
item.hasCheckInPhoto ? photoLink(String(item._id), "checkIn") : "",
|
|
94
|
+
item.hasCheckOutPhoto ? photoLink(String(item._id), "checkOut") : "",
|
|
95
|
+
item.suspicious ? item.suspiciousReason : "",
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const ATTENDANCE_CSV_HEADERS = [
|
|
100
|
+
"Person",
|
|
101
|
+
"NRIC (last 4)",
|
|
102
|
+
"Shift / post",
|
|
103
|
+
"Taken on",
|
|
104
|
+
"Check in",
|
|
105
|
+
"Check out",
|
|
106
|
+
"Total hours",
|
|
107
|
+
"Check-in photo",
|
|
108
|
+
"Check-out photo",
|
|
109
|
+
"Check this",
|
|
110
|
+
];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import DOMPurify from "dompurify";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one way rich text typed by a user (announcements, facility descriptions,
|
|
5
|
+
* policies) reaches `v-html`. Keeps formatting, drops scripts, event handlers,
|
|
6
|
+
* `javascript:` links and embedded frames/forms. Browser-only (the apps are
|
|
7
|
+
* `ssr: false`); without a DOM it returns "" rather than unsanitised HTML.
|
|
8
|
+
*/
|
|
9
|
+
export function sanitizeHtml(html: unknown): string {
|
|
10
|
+
if (html === null || html === undefined || html === "") return "";
|
|
11
|
+
if (typeof window === "undefined" || !DOMPurify.isSupported) return "";
|
|
12
|
+
return DOMPurify.sanitize(String(html), {
|
|
13
|
+
USE_PROFILES: { html: true },
|
|
14
|
+
FORBID_TAGS: ["style", "form", "input", "button", "iframe", "object", "embed"],
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response headers every web app sends, on the HTML shell (Nitro route rules)
|
|
3
|
+
* and on the static files (nitro writes the same rules into Cloudflare's
|
|
4
|
+
* `_headers`). One list, so the eleven apps cannot drift apart.
|
|
5
|
+
*
|
|
6
|
+
* The ENFORCED Content-Security-Policy only carries the directives that cannot
|
|
7
|
+
* break a screen: who may frame us (clickjacking), no plugins, no <base> swap.
|
|
8
|
+
* The full policy ships as Report-Only first: the apps load Google Fonts,
|
|
9
|
+
* camera players and documents in iframes, sockets and the Spaces CDN, and a
|
|
10
|
+
* wrong guess there blanks a page. Violations show in the browser console;
|
|
11
|
+
* once a release runs clean, move it to the enforced header.
|
|
12
|
+
*
|
|
13
|
+
* HSTS deliberately has no `preload`: preload is decided for the whole
|
|
14
|
+
* iservice365.org domain at the apex and is hard to undo (owner/leads action).
|
|
15
|
+
*/
|
|
16
|
+
export const CSP_ENFORCED = [
|
|
17
|
+
"frame-ancestors 'self'",
|
|
18
|
+
"object-src 'none'",
|
|
19
|
+
"base-uri 'self'",
|
|
20
|
+
].join("; ");
|
|
21
|
+
|
|
22
|
+
export const CSP_REPORT_ONLY = [
|
|
23
|
+
"default-src 'self'",
|
|
24
|
+
"script-src 'self'",
|
|
25
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
26
|
+
"font-src 'self' data: https://fonts.gstatic.com",
|
|
27
|
+
"img-src 'self' data: blob: https:",
|
|
28
|
+
"media-src 'self' blob: https:",
|
|
29
|
+
"connect-src 'self' https: wss:",
|
|
30
|
+
"frame-src 'self' https:",
|
|
31
|
+
"worker-src 'self' blob:",
|
|
32
|
+
"object-src 'none'",
|
|
33
|
+
"base-uri 'self'",
|
|
34
|
+
"frame-ancestors 'self'",
|
|
35
|
+
"form-action 'self' https:",
|
|
36
|
+
"upgrade-insecure-requests",
|
|
37
|
+
].join("; ");
|
|
38
|
+
|
|
39
|
+
export const SECURITY_HEADERS: Record<string, string> = {
|
|
40
|
+
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
|
41
|
+
"x-content-type-options": "nosniff",
|
|
42
|
+
"x-frame-options": "SAMEORIGIN",
|
|
43
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
44
|
+
// camera/mic: QR scan, photo capture; geolocation: attendance;
|
|
45
|
+
// usb/serial/hid: card readers (useWebUsb). Everything else is off.
|
|
46
|
+
"permissions-policy":
|
|
47
|
+
"camera=(self), microphone=(self), geolocation=(self), usb=(self), serial=(self), hid=(self), payment=(self), display-capture=(self), bluetooth=(), midi=(), magnetometer=(), gyroscope=(), accelerometer=()",
|
|
48
|
+
// allow-popups: payment and document windows we open keep working.
|
|
49
|
+
"cross-origin-opener-policy": "same-origin-allow-popups",
|
|
50
|
+
"content-security-policy": CSP_ENFORCED,
|
|
51
|
+
"content-security-policy-report-only": CSP_REPORT_ONLY,
|
|
52
|
+
};
|