@7365admin1/layer-common 4.115.0 → 4.117.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 +17 -4
- package/components/VehicleManagement.vue +58 -7
- package/components/VehicleQrStickerDialog.vue +15 -8
- package/components/VehicleStickerCode.vue +27 -0
- package/composables/useTermsGate.ts +65 -0
- package/composables/useVehicle.ts +7 -0
- package/middleware/01.auth.ts +11 -0
- package/package.json +1 -1
- package/pages/accept-terms.vue +126 -0
- package/utils/terms-gate.ts +45 -0
- package/utils/vehicle-qr-blank.ts +21 -0
- package/utils/vehicle-qr-sticker.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.117.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 48657c4: Post-login "Accept Terms & Privacy Policy" screen for every web app. `01-auth` asks `GET /api/users/terms` once per page load; if the account has never accepted the live version (or a newer one was published) it goes to `/accept-terms`, which shows the Super Admin text and records acceptance with `POST /api/users/terms/accept`. An account that accepted is never asked again on any device or app. Fails open on any error or timeout. Print, PDF, preview, kiosk and public pages (plain layouts) and headless browsers are never interrupted.
|
|
8
|
+
|
|
9
|
+
## 4.116.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 66b0224: Vehicle Management: plate menu gets "Print sticker again" (same code) and "Undo re-issue"; the re-issue confirm explains it; the Print QR stickers dialog labels each row "Has sticker - prints the same code" / "New sticker" and can list only vehicles with a sticker. Reprinting a plate linked to a blank sticker prints that blank code.
|
|
14
|
+
- b9429e8: Vehicle Management: the QR sticker column and the Preview plate table show each linked plate's printed sticker code (with a copy button) when the server sends it, so staff can match a blank sticker to a vehicle.
|
|
15
|
+
|
|
3
16
|
## 4.115.0
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
|
@@ -3104,11 +3117,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3104
3117
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3105
3118
|
own rule from two endpoints the console already calls, unprojected:
|
|
3106
3119
|
|
|
3107
|
-
|
|
3108
|
-
|
|
3120
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3121
|
+
GET /api/roles/id/:role that membership's role document
|
|
3109
3122
|
|
|
3110
|
-
|
|
3111
|
-
|
|
3123
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3124
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3112
3125
|
|
|
3113
3126
|
`role.default` is the marker because it is the only property of a platform
|
|
3114
3127
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -61,6 +61,9 @@
|
|
|
61
61
|
<v-chip size="small" variant="tonal" :color="qrStickerSummary(rowPlates(item)).color" data-test="vehicle-qr-cell">
|
|
62
62
|
{{ qrStickerSummary(rowPlates(item)).label }}
|
|
63
63
|
</v-chip>
|
|
64
|
+
<div v-for="plate in rowPlates(item).filter((p: any) => p.qrCode)" :key="plate._id ?? plate.plateNumber">
|
|
65
|
+
<VehicleStickerCode :code="plate.qrCode" :label="rowPlates(item).length > 1 ? plate.plateNumber : ''" />
|
|
66
|
+
</div>
|
|
64
67
|
</template>
|
|
65
68
|
|
|
66
69
|
<template #item.category="{ value }">
|
|
@@ -111,8 +114,12 @@
|
|
|
111
114
|
<v-list-subheader v-if="rowPlates(item).length > 1">
|
|
112
115
|
{{ plate.plateNumber }}
|
|
113
116
|
</v-list-subheader>
|
|
114
|
-
<v-list-item v-if="qrStickersOn" title="
|
|
117
|
+
<v-list-item v-if="qrStickersOn && plate.hasQrSticker" title="Print sticker again" prepend-icon="mdi-printer"
|
|
118
|
+
data-test="vehicle-qr-print-again" @click="openStickerDialog([String(plate._id)])" />
|
|
119
|
+
<v-list-item v-if="qrStickersOn" title="Re-issue sticker (lost or stolen)" prepend-icon="mdi-qrcode-edit"
|
|
115
120
|
@click="askReissue(plate as TPlateNumber)" />
|
|
121
|
+
<v-list-item v-if="qrStickersOn" title="Undo re-issue" prepend-icon="mdi-undo"
|
|
122
|
+
data-test="vehicle-qr-undo" @click="askUndo(plate as TPlateNumber)" />
|
|
116
123
|
<v-list-item v-if="qrStickersOn" title="Link QR sticker" prepend-icon="mdi-link-variant"
|
|
117
124
|
@click="askLink(plate as TPlateNumber)" />
|
|
118
125
|
<v-list-item v-if="qrStickersOn" title="Remove QR sticker" prepend-icon="mdi-link-variant-off"
|
|
@@ -221,6 +228,7 @@
|
|
|
221
228
|
<template #item.plateNumber="{ value, item: plateNumberItem }">
|
|
222
229
|
<span class="d-inline-flex align-center ga-2">
|
|
223
230
|
{{ value }}
|
|
231
|
+
<VehicleStickerCode v-if="qrEnabled && (plateNumberItem as any).qrCode" :code="(plateNumberItem as any).qrCode" />
|
|
224
232
|
<AnprCameraBadge :cameras="(plateNumberItem as any).anprCameras ?? []" :plate-number="value"
|
|
225
233
|
:plate-id="(plateNumberItem as any)._id ?? ''" :status="(plateNumberItem as any).status ?? ''"
|
|
226
234
|
:type="(plateNumberItem as any).type ?? ''" :end="(plateNumberItem as any).end ?? null"
|
|
@@ -241,8 +249,12 @@
|
|
|
241
249
|
<v-btn icon="mdi-dots-vertical" v-bind="menuProps" flat size="x-small" />
|
|
242
250
|
</template>
|
|
243
251
|
<v-list density="compact">
|
|
244
|
-
<v-list-item v-if="qrStickersOn" title="
|
|
245
|
-
@click="
|
|
252
|
+
<v-list-item v-if="qrStickersOn && (plateNumberItem as any).hasQrSticker" title="Print sticker again" prepend-icon="mdi-printer"
|
|
253
|
+
data-test="vehicle-qr-print-again" @click="openStickerDialog([String((plateNumberItem as any)._id)])" />
|
|
254
|
+
<v-list-item v-if="qrStickersOn" title="Re-issue sticker (lost or stolen)" prepend-icon="mdi-qrcode-edit"
|
|
255
|
+
@click="askReissue((plateNumberItem as any) as TPlateNumber)" />
|
|
256
|
+
<v-list-item v-if="qrStickersOn" title="Undo re-issue" prepend-icon="mdi-undo"
|
|
257
|
+
data-test="vehicle-qr-undo" @click="askUndo((plateNumberItem as any) as TPlateNumber)" />
|
|
246
258
|
<v-list-item v-if="qrStickersOn" title="Link QR sticker" prepend-icon="mdi-link-variant"
|
|
247
259
|
@click="askLink(plateNumberItem as TPlateNumber)" />
|
|
248
260
|
<v-list-item v-if="qrStickersOn" title="Remove QR sticker" prepend-icon="mdi-link-variant-off"
|
|
@@ -378,8 +390,9 @@
|
|
|
378
390
|
<v-card>
|
|
379
391
|
<v-card-title>Re-issue sticker?</v-card-title>
|
|
380
392
|
<v-card-text>
|
|
381
|
-
A new QR sticker will be made for {{ qrReissuePlate?.plateNumber }}.
|
|
382
|
-
the new one must be pasted on the car.
|
|
393
|
+
Use this when a sticker is lost or stolen. A new QR sticker will be made for {{ qrReissuePlate?.plateNumber }}.
|
|
394
|
+
The old sticker stops working, so the new one must be pasted on the car. You can undo this afterwards
|
|
395
|
+
(Undo re-issue) if the old sticker turns up. To print the same sticker again, use Print sticker again instead.
|
|
383
396
|
<v-alert v-if="qrReissueError" type="error" variant="tonal" density="compact" class="mt-3">{{ qrReissueError }}</v-alert>
|
|
384
397
|
</v-card-text>
|
|
385
398
|
<v-card-actions>
|
|
@@ -392,6 +405,21 @@
|
|
|
392
405
|
</v-card>
|
|
393
406
|
</v-dialog>
|
|
394
407
|
|
|
408
|
+
<v-dialog v-model="dialog.qrUndo" width="460">
|
|
409
|
+
<v-card data-test="vehicle-qr-undo-dialog">
|
|
410
|
+
<v-card-title>Undo re-issue?</v-card-title>
|
|
411
|
+
<v-card-text>
|
|
412
|
+
{{ qrPlate?.plateNumber }}: {{ UNDO_REISSUE_NOTE }}
|
|
413
|
+
<v-alert v-if="qrError" type="error" variant="tonal" density="compact" class="mt-3">{{ qrError }}</v-alert>
|
|
414
|
+
</v-card-text>
|
|
415
|
+
<v-card-actions>
|
|
416
|
+
<v-spacer />
|
|
417
|
+
<v-btn variant="text" class="text-none" @click="dialog.qrUndo = false">Cancel</v-btn>
|
|
418
|
+
<v-btn color="primary" variant="flat" class="text-none" :loading="qrBusy" @click="confirmUndo">Undo re-issue</v-btn>
|
|
419
|
+
</v-card-actions>
|
|
420
|
+
</v-card>
|
|
421
|
+
</v-dialog>
|
|
422
|
+
|
|
395
423
|
<v-dialog v-model="dialog.qrLink" width="460">
|
|
396
424
|
<v-card data-test="vehicle-qr-link-dialog">
|
|
397
425
|
<v-card-title>Link QR sticker</v-card-title>
|
|
@@ -456,7 +484,7 @@ import {
|
|
|
456
484
|
// `error.message` put ofetch's own developer string in the delete dialog:
|
|
457
485
|
// `[PUT] "/api/vehicles/6a9691f25648d7633171c9e3": 502`.
|
|
458
486
|
import { errorConverter } from '../utils/data';
|
|
459
|
-
import { BLANK_LIST_NOTE, blankSummaryText, qrStickerSummary, stickerCodeHint, type BlankSummary } from '../utils/vehicle-qr-blank';
|
|
487
|
+
import { BLANK_LIST_NOTE, UNDO_REISSUE_NOTE, blankSummaryText, qrStickerSummary, stickerCodeHint, type BlankSummary } from '../utils/vehicle-qr-blank';
|
|
460
488
|
import { isPermissionRefusalError } from '../utils/permission-refusal';
|
|
461
489
|
|
|
462
490
|
|
|
@@ -560,7 +588,7 @@ const activeTab = ref<TVehicleTab>("residents");
|
|
|
560
588
|
|
|
561
589
|
|
|
562
590
|
const { formatCamelCaseToWords, formatDate, debounce, maskNRIC } = useUtils();
|
|
563
|
-
const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts, getVehicleQrSettings, regenerateVehicleQr, linkVehicleQr, unlinkVehicleQr, getAvailableBlankVehicleQr } = useVehicle();
|
|
591
|
+
const { getVehicles, deleteVehicle, formatVehicleStatus, approveVehicle, blockVehicle, unblockVehicle, getSpecificVehicleById, getActiveCameraCounts, getVehicleQrSettings, regenerateVehicleQr, undoReissueVehicleQr, linkVehicleQr, unlinkVehicleQr, getAvailableBlankVehicleQr } = useVehicle();
|
|
564
592
|
|
|
565
593
|
// Vehicle QR stickers: the site's switch (Settings) decides whether the print
|
|
566
594
|
// and re-issue actions exist; only people who may update vehicles get them.
|
|
@@ -674,6 +702,28 @@ async function confirmUnlink() {
|
|
|
674
702
|
}
|
|
675
703
|
}
|
|
676
704
|
|
|
705
|
+
function askUndo(plate: TPlateNumber) {
|
|
706
|
+
qrPlate.value = plate || null;
|
|
707
|
+
qrError.value = "";
|
|
708
|
+
dialog.qrUndo = true;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function confirmUndo() {
|
|
712
|
+
const id = qrPlate.value?._id;
|
|
713
|
+
if (!id) return;
|
|
714
|
+
qrBusy.value = true;
|
|
715
|
+
qrError.value = "";
|
|
716
|
+
try {
|
|
717
|
+
await undoReissueVehicleQr(String(id));
|
|
718
|
+
dialog.qrUndo = false;
|
|
719
|
+
showMessage(`Earlier QR sticker restored for ${qrPlate.value?.plateNumber}`, "success");
|
|
720
|
+
} catch (e: any) {
|
|
721
|
+
qrError.value = errorConverter(e);
|
|
722
|
+
} finally {
|
|
723
|
+
qrBusy.value = false;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
677
727
|
function openStickerDialog(ids: string[] = []) {
|
|
678
728
|
qrVehicleIds.value = ids;
|
|
679
729
|
dialog.qrStickers = true;
|
|
@@ -786,6 +836,7 @@ const dialog = reactive({
|
|
|
786
836
|
bulkVehicle: false,
|
|
787
837
|
qrStickers: false,
|
|
788
838
|
qrReissue: false,
|
|
839
|
+
qrUndo: false,
|
|
789
840
|
qrLink: false,
|
|
790
841
|
qrUnlink: false,
|
|
791
842
|
});
|
|
@@ -33,27 +33,32 @@
|
|
|
33
33
|
</div>
|
|
34
34
|
<div v-if="mode === 'vehicles'" class="d-flex flex-wrap align-center ga-2 mb-2">
|
|
35
35
|
<v-checkbox-btn :model-value="allOnPageTicked" :indeterminate="someOnPageTicked && !allOnPageTicked"
|
|
36
|
-
aria-label="Select all on this page" @update:model-value="(v: any) => tickGroup(
|
|
36
|
+
aria-label="Select all on this page" @update:model-value="(v: any) => tickGroup(shownPlates.map((p) => p.id), !!v)" />
|
|
37
37
|
<span class="text-body-2">Select all on this page</span>
|
|
38
38
|
<v-spacer />
|
|
39
39
|
<v-btn variant="tonal" size="small" class="text-none" :loading="loadingAll" @click="loadAll">
|
|
40
40
|
Select all matching the current tab and search
|
|
41
41
|
</v-btn>
|
|
42
42
|
</div>
|
|
43
|
+
<v-checkbox v-if="mode === 'vehicles' && !fromReissue" v-model="onlyWithSticker" density="compact" hide-details
|
|
44
|
+
label="Only vehicles with a sticker (reprint)" data-test="vehicle-qr-only-sticker" />
|
|
43
45
|
<v-table v-if="mode === 'vehicles'" density="compact" fixed-header height="360">
|
|
44
46
|
<thead>
|
|
45
|
-
<tr><th style="width: 48px" /><th>Blk-Level-Unit</th><th>Owner</th><th>Plate</th><th>Status</th></tr>
|
|
47
|
+
<tr><th style="width: 48px" /><th>Blk-Level-Unit</th><th>Owner</th><th>Plate</th><th>Status</th><th>Sticker</th></tr>
|
|
46
48
|
</thead>
|
|
47
49
|
<tbody>
|
|
48
|
-
<tr v-for="p in
|
|
50
|
+
<tr v-for="p in shownPlates" :key="p.id">
|
|
49
51
|
<td><v-checkbox-btn :model-value="selected.includes(p.id)" :aria-label="`Select ${p.plateNumber}`"
|
|
50
52
|
@update:model-value="(v: any) => tickGroup([p.id], !!v)" /></td>
|
|
51
53
|
<td>{{ p.unitLabel || "-" }}</td>
|
|
52
54
|
<td>{{ p.ownerName || "-" }}</td>
|
|
53
55
|
<td class="font-weight-bold">{{ p.plateNumber }}</td>
|
|
54
56
|
<td>{{ p.status || "-" }}</td>
|
|
57
|
+
<td class="text-caption" :class="p.hasSticker ? 'text-success' : 'text-medium-emphasis'" data-test="vehicle-qr-row-note">
|
|
58
|
+
{{ p.hasSticker ? STICKER_SAME_NOTE : STICKER_NEW_NOTE }}
|
|
59
|
+
</td>
|
|
55
60
|
</tr>
|
|
56
|
-
<tr v-if="!
|
|
61
|
+
<tr v-if="!shownPlates.length"><td colspan="6" class="text-center text-medium-emphasis py-6">No vehicles to list</td></tr>
|
|
57
62
|
</tbody>
|
|
58
63
|
</v-table>
|
|
59
64
|
<p v-if="mode === 'vehicles'" class="text-caption text-medium-emphasis mt-2 mb-0">
|
|
@@ -227,7 +232,7 @@
|
|
|
227
232
|
<script lang="ts" setup>
|
|
228
233
|
import QRCode from "qrcode";
|
|
229
234
|
import useVehicle from "../composables/useVehicle";
|
|
230
|
-
import { BLANK_DEFAULT_COUNT, BLANK_MAX_COUNT, BLANK_STRIP_NOTE, blankCount, blankStickerItems, blankSummaryText, type BlankSummary } from "../utils/vehicle-qr-blank";
|
|
235
|
+
import { BLANK_DEFAULT_COUNT, BLANK_MAX_COUNT, BLANK_STRIP_NOTE, STICKER_NEW_NOTE, STICKER_SAME_NOTE, blankCount, blankStickerItems, blankSummaryText, reprintItems, type BlankSummary } from "../utils/vehicle-qr-blank";
|
|
231
236
|
import { errorConverter } from "../utils/data";
|
|
232
237
|
import { saveFile, stickerBundleName, stickerFileNames, svgToImage, zipFiles, type ImageFormat } from "../utils/vehicle-qr-export";
|
|
233
238
|
import {
|
|
@@ -370,8 +375,10 @@ const sizeItems = computed(() => {
|
|
|
370
375
|
});
|
|
371
376
|
const sizeLines = computed(() => stickerSizeLines(normalizeDesign(design)));
|
|
372
377
|
|
|
373
|
-
const
|
|
374
|
-
const
|
|
378
|
+
const onlyWithSticker = ref(false);
|
|
379
|
+
const shownPlates = computed(() => (onlyWithSticker.value ? plates.value.filter((p) => p.hasSticker) : plates.value));
|
|
380
|
+
const allOnPageTicked = computed(() => shownPlates.value.length > 0 && shownPlates.value.every((p) => selected.value.includes(p.id)));
|
|
381
|
+
const someOnPageTicked = computed(() => shownPlates.value.some((p) => selected.value.includes(p.id)));
|
|
375
382
|
|
|
376
383
|
function tickGroup(ids: string[], on: boolean) {
|
|
377
384
|
selected.value = setIds(selected.value, ids, on);
|
|
@@ -444,7 +451,7 @@ async function goReview() {
|
|
|
444
451
|
isBlank.value = false;
|
|
445
452
|
const { ids, dropped: over } = idsForRequest(selected.value);
|
|
446
453
|
const res = await getVehicleQrStickers(props.site, ids);
|
|
447
|
-
items.value = (res?.items ?? []) as StickerItem[];
|
|
454
|
+
items.value = reprintItems((res?.items ?? []) as StickerItem[]);
|
|
448
455
|
siteName.value = String((res as any)?.site?.name ?? "");
|
|
449
456
|
missing.value = res?.missing ?? [];
|
|
450
457
|
dropped.value = over;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span class="d-inline-flex align-center ga-1 text-caption" data-test="vehicle-sticker-code">
|
|
3
|
+
<span v-if="label" class="text-medium-emphasis">{{ label }}</span>
|
|
4
|
+
<code class="vehicle-sticker-code">{{ code }}</code>
|
|
5
|
+
<v-btn :icon="copied ? 'mdi-check' : 'mdi-content-copy'" flat size="x-small" density="compact"
|
|
6
|
+
aria-label="Copy sticker code" @click.stop="copy" />
|
|
7
|
+
</span>
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
<script setup lang="ts">
|
|
11
|
+
// A blank sticker's printed code, with a copy button. Shown only when the server sent it.
|
|
12
|
+
const props = defineProps<{ code: string; label?: string }>();
|
|
13
|
+
const copied = ref(false);
|
|
14
|
+
async function copy() {
|
|
15
|
+
try {
|
|
16
|
+
await navigator.clipboard.writeText(props.code);
|
|
17
|
+
copied.value = true;
|
|
18
|
+
setTimeout(() => (copied.value = false), 1500);
|
|
19
|
+
} catch {
|
|
20
|
+
/* clipboard blocked: the code is still readable on screen */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<style scoped>
|
|
26
|
+
.vehicle-sticker-code { font-size: 0.75rem; letter-spacing: 0.02em; }
|
|
27
|
+
</style>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { shouldPrompt, type TermsGateReply } from "../utils/terms-gate";
|
|
2
|
+
|
|
3
|
+
const TIMEOUT_MS = 5000;
|
|
4
|
+
const FAIL_RETRY_MS = 60_000;
|
|
5
|
+
|
|
6
|
+
type GateState = { sid: string; mustAccept: boolean; reply: TermsGateReply | null; failedAt: number };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The post-login "Accept Terms and Privacy Policy" gate.
|
|
10
|
+
*
|
|
11
|
+
* Acceptance is stored per ACCOUNT on the server, so an account that has
|
|
12
|
+
* accepted the live version is never asked again (any device, any app); only a
|
|
13
|
+
* newly published version asks again. FAILS OPEN: any error, timeout or empty
|
|
14
|
+
* answer lets the user in and asks again later - this is a consent screen, not
|
|
15
|
+
* an API lock.
|
|
16
|
+
*/
|
|
17
|
+
export function useTermsGate() {
|
|
18
|
+
const { cookieConfig } = useRuntimeConfig().public;
|
|
19
|
+
const state = useState<GateState | null>("termsGate", () => null);
|
|
20
|
+
|
|
21
|
+
async function load(): Promise<TermsGateReply | null> {
|
|
22
|
+
try {
|
|
23
|
+
return await useNuxtApp().$api<TermsGateReply>("/api/users/terms", { timeout: TIMEOUT_MS, retry: 0 });
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True when this account must accept before continuing. */
|
|
30
|
+
async function check(force = false): Promise<boolean> {
|
|
31
|
+
const sid = String(useCookie("sid", cookieConfig).value ?? "");
|
|
32
|
+
if (!sid) return false;
|
|
33
|
+
if (import.meta.client && navigator.onLine === false) return false;
|
|
34
|
+
|
|
35
|
+
const s = state.value;
|
|
36
|
+
if (!force && s && s.sid === sid) {
|
|
37
|
+
if (!s.failedAt || Date.now() - s.failedAt < FAIL_RETRY_MS) return s.mustAccept;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const reply = await load();
|
|
41
|
+
state.value = {
|
|
42
|
+
sid,
|
|
43
|
+
reply,
|
|
44
|
+
mustAccept: shouldPrompt(reply),
|
|
45
|
+
failedAt: reply ? 0 : Date.now(),
|
|
46
|
+
};
|
|
47
|
+
return state.value.mustAccept;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function accept(): Promise<void> {
|
|
51
|
+
const id = state.value?.reply?.latest?._id;
|
|
52
|
+
if (!id) throw new Error("No terms to accept.");
|
|
53
|
+
await useNuxtApp().$api("/api/users/terms/accept", { method: "POST", body: { terms: id } });
|
|
54
|
+
const sid = String(useCookie("sid", cookieConfig).value ?? "");
|
|
55
|
+
state.value = { sid, reply: state.value?.reply ?? null, mustAccept: false, failedAt: 0 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** "Not now" after the server could not record it: let them in, ask again soon. */
|
|
59
|
+
function deferAfterFailure() {
|
|
60
|
+
const sid = String(useCookie("sid", cookieConfig).value ?? "");
|
|
61
|
+
state.value = { sid, reply: state.value?.reply ?? null, mustAccept: false, failedAt: Date.now() };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { state, check, accept, deferAfterFailure };
|
|
65
|
+
}
|
|
@@ -307,6 +307,12 @@ function getSpecificVehicleById(id: string){
|
|
|
307
307
|
);
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
function undoReissueVehicleQr(id: string) {
|
|
311
|
+
return useNuxtApp().$api<{ vehicleId: string; restored: boolean }>(`/api/vehicles/qr/undo-reissue/id/${id}`, {
|
|
312
|
+
method: "PUT",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
310
316
|
// Blank (pre-generated) stickers: print codes first, link them to vehicles later.
|
|
311
317
|
function getBlankVehicleQrStickers(site: string, count: number) {
|
|
312
318
|
return useNuxtApp().$api<{
|
|
@@ -363,6 +369,7 @@ function getSpecificVehicleById(id: string){
|
|
|
363
369
|
setVehicleQrSettings,
|
|
364
370
|
getVehicleQrStickers,
|
|
365
371
|
regenerateVehicleQr,
|
|
372
|
+
undoReissueVehicleQr,
|
|
366
373
|
getBlankVehicleQrStickers,
|
|
367
374
|
getAvailableBlankVehicleQr,
|
|
368
375
|
linkVehicleQr,
|
package/middleware/01.auth.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { termsGateSkips } from "../utils/terms-gate";
|
|
2
|
+
|
|
1
3
|
export default defineNuxtRouteMiddleware(async (route) => {
|
|
2
4
|
const { cookieConfig } = useRuntimeConfig().public;
|
|
3
5
|
|
|
@@ -12,4 +14,13 @@ export default defineNuxtRouteMiddleware(async (route) => {
|
|
|
12
14
|
// Redirect to login page if no access token
|
|
13
15
|
return navigateTo({ name: "index" });
|
|
14
16
|
}
|
|
17
|
+
|
|
18
|
+
// Terms and Privacy Policy: ask once per account per published version.
|
|
19
|
+
// Fails open; print/PDF/preview/kiosk/public (plain layout) pages are skipped.
|
|
20
|
+
if (import.meta.client && !termsGateSkips(route, navigator.userAgent)) {
|
|
21
|
+
const { check } = useTermsGate();
|
|
22
|
+
if (await check()) {
|
|
23
|
+
return navigateTo({ path: "/accept-terms", query: { redirect: route.fullPath } });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
15
26
|
});
|
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.117.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.",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-container class="py-8" style="max-width: 860px">
|
|
3
|
+
<v-card class="pa-4 pa-sm-6" variant="flat" border>
|
|
4
|
+
<h1 class="text-h5 font-weight-bold mb-1">Terms & Privacy Policy</h1>
|
|
5
|
+
<p class="text-body-2 text-medium-emphasis mb-4">
|
|
6
|
+
Please read and accept our Terms & Conditions and Privacy Policy to continue.
|
|
7
|
+
<template v-if="version">(Version {{ version }})</template>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<div v-if="loading" class="d-flex justify-center py-8">
|
|
11
|
+
<v-progress-circular indeterminate />
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<template v-else>
|
|
15
|
+
<div class="terms-scroll mb-4">
|
|
16
|
+
<h2 v-if="termsHtml" class="text-subtitle-1 font-weight-bold mb-2">Terms & Conditions</h2>
|
|
17
|
+
<!-- Published by Seven365 in Super Admin -> Terms & Privacy Policy. -->
|
|
18
|
+
<div v-if="termsHtml" class="platform-terms mb-6" v-html="termsHtml" />
|
|
19
|
+
<h2 v-if="policiesHtml" class="text-subtitle-1 font-weight-bold mb-2">Privacy Policy</h2>
|
|
20
|
+
<div v-if="policiesHtml" class="platform-terms" v-html="policiesHtml" />
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<v-checkbox
|
|
24
|
+
v-model="agreed"
|
|
25
|
+
hide-details
|
|
26
|
+
density="comfortable"
|
|
27
|
+
label="I have read and accept the Terms & Conditions and Privacy Policy"
|
|
28
|
+
/>
|
|
29
|
+
|
|
30
|
+
<v-alert v-if="errorText" type="error" variant="tonal" class="mt-3" density="compact">
|
|
31
|
+
{{ errorText }}
|
|
32
|
+
<div v-if="failed" class="mt-2">
|
|
33
|
+
<a href="#" @click.prevent="notNow">Continue for now (we will ask again next time)</a>
|
|
34
|
+
</div>
|
|
35
|
+
</v-alert>
|
|
36
|
+
|
|
37
|
+
<div class="d-flex flex-wrap ga-3 mt-4">
|
|
38
|
+
<v-btn color="primary" :disabled="!agreed" :loading="saving" @click="onAccept">I accept</v-btn>
|
|
39
|
+
<v-btn variant="text" :disabled="saving" @click="onSignOut">Sign out</v-btn>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
42
|
+
</v-card>
|
|
43
|
+
</v-container>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<script setup lang="ts">
|
|
47
|
+
import { safeRedirect } from "../utils/terms-gate";
|
|
48
|
+
|
|
49
|
+
// No 01-auth here (no redirect loop); signed-out visitors are sent to sign-in.
|
|
50
|
+
definePageMeta({ layout: "plain", skipTermsGate: true });
|
|
51
|
+
useHead({ title: "Terms & Privacy Policy" });
|
|
52
|
+
|
|
53
|
+
const route = useRoute();
|
|
54
|
+
const { cookieConfig } = useRuntimeConfig().public;
|
|
55
|
+
const { state, check, accept, deferAfterFailure } = useTermsGate();
|
|
56
|
+
const { logout } = useLocalAuth();
|
|
57
|
+
|
|
58
|
+
const loading = ref(true);
|
|
59
|
+
const saving = ref(false);
|
|
60
|
+
const agreed = ref(false);
|
|
61
|
+
const failed = ref(false);
|
|
62
|
+
const errorText = ref("");
|
|
63
|
+
|
|
64
|
+
const latest = computed(() => state.value?.reply?.latest ?? null);
|
|
65
|
+
const version = computed(() => latest.value?.version);
|
|
66
|
+
const termsHtml = computed(() => sanitizeHtml(latest.value?.terms));
|
|
67
|
+
const policiesHtml = computed(() => sanitizeHtml(latest.value?.policies));
|
|
68
|
+
|
|
69
|
+
function goBack() {
|
|
70
|
+
return navigateTo(safeRedirect(route.query.redirect), { replace: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
onMounted(async () => {
|
|
74
|
+
if (!useCookie("sid", cookieConfig).value) {
|
|
75
|
+
await navigateTo({ name: "index" });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Re-ask the server: already accepted (another tab/device/app), nothing
|
|
79
|
+
// published, or the server is down -> straight through.
|
|
80
|
+
if (!(await check(true))) {
|
|
81
|
+
await goBack();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
loading.value = false;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
async function onAccept() {
|
|
88
|
+
saving.value = true;
|
|
89
|
+
errorText.value = "";
|
|
90
|
+
failed.value = false;
|
|
91
|
+
try {
|
|
92
|
+
await accept();
|
|
93
|
+
await goBack();
|
|
94
|
+
} catch (e: any) {
|
|
95
|
+
failed.value = true;
|
|
96
|
+
errorText.value =
|
|
97
|
+
e?.statusCode === 400
|
|
98
|
+
? "A newer version was just published. Please reload this page."
|
|
99
|
+
: "We could not save your acceptance. Please try again.";
|
|
100
|
+
} finally {
|
|
101
|
+
saving.value = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function notNow() {
|
|
106
|
+
deferAfterFailure();
|
|
107
|
+
await goBack();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function onSignOut() {
|
|
111
|
+
await logout();
|
|
112
|
+
await navigateTo({ name: "index" });
|
|
113
|
+
}
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<style scoped>
|
|
117
|
+
.terms-scroll {
|
|
118
|
+
max-height: 55vh;
|
|
119
|
+
overflow-y: auto;
|
|
120
|
+
padding-right: 8px;
|
|
121
|
+
}
|
|
122
|
+
.platform-terms {
|
|
123
|
+
line-height: 1.6;
|
|
124
|
+
overflow-wrap: anywhere;
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rules for the post-login "Accept Terms and Privacy Policy" gate, kept
|
|
3
|
+
* apart from Nuxt so they can be unit-tested. The server decides who must
|
|
4
|
+
* accept (GET /api/users/terms -> mustAccept, per ACCOUNT); an account that has
|
|
5
|
+
* accepted the live version is never asked again on any device or app.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type TermsGateReply = {
|
|
9
|
+
latest?: { _id?: string; version?: number; terms?: string; policies?: string } | null;
|
|
10
|
+
mustAccept?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Gate only when the server positively says so AND sent text to accept. */
|
|
14
|
+
export function shouldPrompt(reply: TermsGateReply | null | undefined): boolean {
|
|
15
|
+
if (!reply || reply.mustAccept !== true) return false;
|
|
16
|
+
const latest = reply.latest;
|
|
17
|
+
return Boolean(latest?._id && (latest.terms || latest.policies));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NEVER_GATED_LAYOUTS = ["plain", "plain-dark"];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pages that must never be interrupted: print/PDF/preview/kiosk/public pages
|
|
24
|
+
* (they use the plain layouts), the accept page itself, an explicit opt-out,
|
|
25
|
+
* and a headless browser (the PDF service).
|
|
26
|
+
*/
|
|
27
|
+
export function termsGateSkips(
|
|
28
|
+
to: { path?: string; meta?: Record<string, unknown> },
|
|
29
|
+
userAgent = "",
|
|
30
|
+
): boolean {
|
|
31
|
+
if (to.path === "/accept-terms") return true;
|
|
32
|
+
const meta = to.meta ?? {};
|
|
33
|
+
if (meta.skipTermsGate) return true;
|
|
34
|
+
if (typeof meta.layout === "string" && NEVER_GATED_LAYOUTS.includes(meta.layout)) return true;
|
|
35
|
+
return /HeadlessChrome|Puppeteer/i.test(userAgent);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Only ever go back to a path on this site. */
|
|
39
|
+
export function safeRedirect(value: unknown): string {
|
|
40
|
+
const path = Array.isArray(value) ? value[0] : value;
|
|
41
|
+
if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.includes("\\")) {
|
|
42
|
+
return "/";
|
|
43
|
+
}
|
|
44
|
+
return path;
|
|
45
|
+
}
|
|
@@ -53,6 +53,27 @@ export function plateLines(plate: string): string[] {
|
|
|
53
53
|
return isBlankCode(plate) ? [plate.slice(0, 14), plate.slice(15)] : [plate];
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
const BLANK_TOKEN = /^[0-9A-HJKMNP-TV-Z]{24}$/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reprinting a plate whose sticker was a blank one: print that same code in the
|
|
60
|
+
* plate slot (as the original blank sticker did) so the new sticker matches the
|
|
61
|
+
* old one. Any other sticker keeps its plate number.
|
|
62
|
+
*/
|
|
63
|
+
export function reprintItems<T extends { token?: string; plateNumber?: string }>(items: T[]): T[] {
|
|
64
|
+
return items.map((i) =>
|
|
65
|
+
i.token && BLANK_TOKEN.test(i.token) ? { ...i, plateNumber: (i.token.match(/.{4}/g) ?? []).join("-") } : i,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The print dialog's per-row note: a plate that has a sticker prints the same code again. */
|
|
70
|
+
export const STICKER_SAME_NOTE = "Has sticker - prints the same code";
|
|
71
|
+
export const STICKER_NEW_NOTE = "New sticker";
|
|
72
|
+
|
|
73
|
+
/** Shown before undoing a re-issue: stickers re-issued before 11 Oct 2026 saved nothing to go back to. */
|
|
74
|
+
export const UNDO_REISSUE_NOTE =
|
|
75
|
+
"Puts back the sticker this vehicle had before its last re-issue (the newer one stops working). Only possible for re-issues made after 11 Oct 2026; if the earlier sticker was a blank one, its code is back in the available list and can be linked again.";
|
|
76
|
+
|
|
56
77
|
/** The list's "QR sticker" cell for one row: how many of its plates already hold a sticker. */
|
|
57
78
|
export function qrStickerSummary(plates: Array<{ hasQrSticker?: boolean }>): { label: string; color: string } {
|
|
58
79
|
const total = plates.length;
|
|
@@ -181,6 +181,8 @@ export type StickerPlateRow = {
|
|
|
181
181
|
status: string;
|
|
182
182
|
ownerName: string;
|
|
183
183
|
unitLabel: string;
|
|
184
|
+
/** The list says this plate already has a sticker (printing it again prints the same code). */
|
|
185
|
+
hasSticker: boolean;
|
|
184
186
|
};
|
|
185
187
|
|
|
186
188
|
const text = (v: unknown) => (v == null ? "" : String(v).trim());
|
|
@@ -242,6 +244,7 @@ export function stickerPlateRows(rows: Array<Record<string, any>>): StickerPlate
|
|
|
242
244
|
plateNumber: text(plate.plateNumber),
|
|
243
245
|
status: text(plate.status).toLowerCase(),
|
|
244
246
|
ownerName: text(row?.name ?? plate.name),
|
|
247
|
+
hasSticker: plate.hasQrSticker === true,
|
|
245
248
|
unitLabel: stickerUnitLabel({
|
|
246
249
|
block: row?.block?.name ?? row?.block,
|
|
247
250
|
level: row?.level?.name ?? row?.level,
|