@7365admin1/layer-common 4.84.0 → 4.84.1-staging.482

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +107 -0
  3. package/assets/css/screens.css +8 -4
  4. package/components/AccessCardDetailsDialog.vue +57 -4
  5. package/components/AccessCardPreviewDialog.vue +291 -50
  6. package/components/AppSelect.vue +143 -4
  7. package/components/AttendanceDetailsDialog.vue +48 -1
  8. package/components/AttendanceMain.vue +104 -10
  9. package/components/AttendanceProxyDialog.vue +391 -0
  10. package/components/AttendanceSettingsDialog.vue +53 -1
  11. package/components/BuildingUnitFormEdit.vue +6 -0
  12. package/components/BuildingUnitManagement.vue +298 -0
  13. package/components/BulletinBoardView.vue +1 -1
  14. package/components/DashboardEmptyState.vue +8 -0
  15. package/components/DashboardMain.vue +14 -0
  16. package/components/DashboardPanel.vue +10 -0
  17. package/components/Dialog/UpdateMoreAction.vue +11 -1
  18. package/components/DocumentForm.vue +407 -112
  19. package/components/DocumentManagement.vue +213 -12
  20. package/components/Facility/BookingSetup.vue +63 -0
  21. package/components/HidAccessLogDashboard.vue +74 -17
  22. package/components/HidAccessPermissions.vue +424 -0
  23. package/components/HidIntercomManagement.vue +183 -14
  24. package/components/HidProfileQrCode.vue +332 -0
  25. package/components/HidQrCodeConfiguration.vue +17 -195
  26. package/components/HidReaderManagement.vue +398 -0
  27. package/components/HidReaderUserRoster.vue +44 -3
  28. package/components/HidUserEnrollment.vue +2239 -276
  29. package/components/InventoryMain.vue +11 -2
  30. package/components/InvitationClientForm.vue +19 -1
  31. package/components/MemberServiceProviders.vue +43 -45
  32. package/components/MyAttendanceMain.vue +60 -2
  33. package/components/NotificationBell.vue +14 -2
  34. package/components/TableMain.vue +22 -7
  35. package/components/VisitorForm.vue +12 -0
  36. package/components/VisitorManagement.vue +279 -7
  37. package/composables/useAttendance.ts +53 -0
  38. package/composables/useAttendancePermission.ts +25 -0
  39. package/composables/useCleaningPermission.ts +5 -0
  40. package/composables/useDocument.ts +7 -0
  41. package/composables/useFacility.ts +11 -0
  42. package/composables/useHidAmico.ts +165 -0
  43. package/composables/useHidNavigation.ts +25 -1
  44. package/composables/useHidReaderSelection.ts +50 -0
  45. package/composables/useLandscapingPermission.ts +5 -0
  46. package/composables/useMember.ts +12 -0
  47. package/composables/useNFCPatrolReportFilters.ts +21 -3
  48. package/composables/useNFCPatrolRoute.ts +8 -1
  49. package/composables/usePeople.ts +19 -0
  50. package/composables/usePestControlPermission.ts +5 -0
  51. package/composables/usePoolMaintenancePermission.ts +5 -0
  52. package/composables/useSecurityPermission.ts +5 -0
  53. package/composables/useServiceProvider.ts +45 -2
  54. package/composables/useSiteActions.ts +29 -0
  55. package/composables/useSiteCategory.ts +45 -0
  56. package/constants/permissions.ts +1 -0
  57. package/nuxt.config.ts +18 -0
  58. package/package.json +4 -2
  59. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  60. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  61. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  62. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  63. package/plugins/vuetify.ts +6 -0
  64. package/server/plugins/no-powered-by.ts +7 -0
  65. package/types/attendance.d.ts +13 -1
  66. package/types/document.d.ts +14 -0
  67. package/types/facility.d.ts +4 -0
  68. package/types/member.d.ts +4 -0
  69. package/types/people.d.ts +28 -1
  70. package/types/service-provider.d.ts +5 -0
  71. package/types/site.d.ts +7 -1
  72. package/utils/attendance-proxy.ts +110 -0
  73. package/utils/hid-enrolment-subject.ts +344 -0
  74. package/utils/hid-permission-assignments.ts +198 -0
  75. package/utils/hid-reader-selection.ts +52 -0
  76. package/utils/notification-target.ts +43 -4
  77. package/utils/occupancy-role.ts +109 -0
  78. package/utils/sanitize-html.ts +16 -0
  79. package/utils/security-headers.ts +52 -0
  80. package/utils/service-type.ts +30 -0
  81. package/utils/site-actions.ts +18 -0
  82. package/utils/site-provider-members.ts +37 -0
@@ -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.84.0",
5
+ "version": "4.84.1-staging.482",
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.10.2",
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",
@@ -5,7 +5,7 @@
5
5
  :org="orgId"
6
6
  message="Enable HID as a service for this site before managing HID users."
7
7
  >
8
- <HidUserEnrollment :site="siteId" />
8
+ <HidUserEnrollment :site="siteId" :org="orgId" />
9
9
  </HidEnabledGate>
10
10
  </v-container>
11
11
  </template>
@@ -5,7 +5,7 @@
5
5
  :org="orgId"
6
6
  message="Enable HID as a service for this site before managing physical HID cards."
7
7
  >
8
- <HidUserEnrollment :site="siteId" card-management />
8
+ <HidUserEnrollment :site="siteId" :org="orgId" card-management />
9
9
  </HidEnabledGate>
10
10
  </v-container>
11
11
  </template>
@@ -0,0 +1,23 @@
1
+ <template>
2
+ <v-container fluid>
3
+ <HidEnabledGate
4
+ :site="siteId"
5
+ :org="orgId"
6
+ message="Enable HID as a service for this site before generating a QR code."
7
+ >
8
+ <HidProfileQrCode :site="siteId" />
9
+ </HidEnabledGate>
10
+ </v-container>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ definePageMeta({
15
+ layout: "default",
16
+ middleware: ["01-auth", "02-org"],
17
+ memberOnly: true,
18
+ });
19
+
20
+ const route = useRoute();
21
+ const siteId = computed(() => String(route.params.site ?? ""));
22
+ const orgId = computed(() => String(route.params.org ?? ""));
23
+ </script>
@@ -5,7 +5,7 @@
5
5
  :org="orgId"
6
6
  message="Enable HID as a service for this site before managing HID readers."
7
7
  >
8
- <HidReaderManagement :site="siteId" />
8
+ <HidReaderManagement :site="siteId" :org="orgId" />
9
9
  </HidEnabledGate>
10
10
  </v-container>
11
11
  </template>
@@ -114,6 +114,12 @@ export default defineNuxtPlugin((app) => {
114
114
 
115
115
  const vuetify = createVuetify({
116
116
  defaults: {
117
+ // Phones (owner 2026-10-02, "most pages are not mobile responsive"): below
118
+ // 600px every data table shows each row as a stacked card (label: value)
119
+ // instead of a wide grid scrolled sideways. Tablets and desktop unchanged.
120
+ VDataTable: { mobileBreakpoint: "sm" },
121
+ VDataTableServer: { mobileBreakpoint: "sm" },
122
+ VDataTableVirtual: { mobileBreakpoint: "sm" },
117
123
  VTextField: {
118
124
  variant: "outlined",
119
125
  density: "comfortable",
@@ -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
+ });
@@ -11,9 +11,11 @@ declare type TAttendance = {
11
11
  declare type TAttendanceCheckIn = {
12
12
  checkIn: {
13
13
  timestamp: string;
14
- location: TAttendanceLocation;
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
  };
@@ -3,4 +3,18 @@ declare type TDocument = {
3
3
  name: string;
4
4
  attachment: string;
5
5
  type: string;
6
+ /**
7
+ * Set at upload or renewal, absent when the document never expires.
8
+ *
9
+ * An ISO instant on the way out of the API and a `YYYY-MM-DD` string on the
10
+ * way back in, which is what `InputDatePicker` binds to - hence the loose
11
+ * type rather than `string`.
12
+ */
13
+ expiryDate?: string | Date | null;
14
+ remarks?: string;
15
+ size?: number;
16
+ parentId?: string | null;
17
+ createdAt?: string | Date;
18
+ updatedAt?: string | Date;
19
+ status?: string;
6
20
  };
@@ -73,6 +73,10 @@ declare type TFacility = {
73
73
  hourInterval?: number;
74
74
  isBookingFeeEnabled: boolean;
75
75
  isGuestEnabled: boolean;
76
+ /** Residents may complete this facility's checklist themselves. */
77
+ isResidentChecklistEnabled?: boolean;
78
+ /** Security may complete it. Mutually exclusive with the above. */
79
+ isSecurityChecklistEnabled?: boolean;
76
80
  bookingAmount: number | null;
77
81
  peakBookingFeeAmount?: number | null;
78
82
  bookingFeeGuestAmount?: number | null;
package/types/member.d.ts CHANGED
@@ -10,6 +10,10 @@ declare type TMember = {
10
10
  customerOrgId?: string;
11
11
  customerSiteId?: string;
12
12
  status?: string;
13
+ email?: string;
14
+ /** The building unit this member manages (a unit's Management tab). */
15
+ unit?: string | null;
16
+ unitName?: string;
13
17
  createdAt?: string;
14
18
  updatedAt?: string;
15
19
  deletedAt?: string;
package/types/people.d.ts CHANGED
@@ -22,11 +22,38 @@ declare type TPeople = {
22
22
  email?: string;
23
23
  files?: { name: string, id: string }[];
24
24
  isOwner?: boolean;
25
+ /**
26
+ * Owner / occupant / person-in-charge, as the BUSINESS people form asks it.
27
+ * Absent on everyone else - `isOwner` is still the flag that decides unit
28
+ * ownership, and the server derives it from this when it is sent. See
29
+ * `OccupancyRoles` in core's `person.model.ts`.
30
+ */
31
+ occupancyRole?: TOccupancyRole;
32
+ // Link to this person's `users` row. Empty for the person types that carry
33
+ // no login, and empty for a resident whose account was never written.
34
+ user?: string;
35
+ };
36
+
37
+ declare type TOccupancyRole = "owner" | "occupant" | "person-in-charge";
38
+
39
+ /** One standing and everything the product says about it. See `utils/occupancy-role.ts`. */
40
+ declare type TOccupancyRoleOption = {
41
+ value: TOccupancyRole;
42
+ /** The label a stored role is printed with. */
43
+ title: string;
44
+ /** The row menu's wording. */
45
+ action: string;
46
+ /** The confirmation's heading and its sentence. */
47
+ prompt: string;
48
+ description: string;
25
49
  };
26
50
 
27
51
  declare type TPlateNumber = { plateNumber: string, recNo?: string, _id?: string, status: TVehicleStatus, type: TVehicleType, anprCameras?: TVehicleAnprCamera[] };
28
52
 
29
53
 
30
- declare type TPeoplePayload = Pick<TGuest, "name" | "block" | "level" | "unit" | "unitName" | "contact" | "plateNumber" | "nric" | "contact" | "remarks" | "org" | "site" | "start" | "end" | "type" | "isOwner"> & { status?: string }
54
+ // `""` is the FORM's "nothing picked yet" - it is stripped from the payload
55
+ // before it is sent, because absence is what the server reads as "never
56
+ // asked". See `PeopleFormMgmt.vue`.
57
+ declare type TPeoplePayload = Pick<TGuest, "name" | "block" | "level" | "unit" | "unitName" | "contact" | "plateNumber" | "nric" | "contact" | "remarks" | "org" | "site" | "start" | "end" | "type" | "isOwner"> & { status?: string; user?: string; occupancyRole?: TOccupancyRole | "" }
31
58
 
32
59
  declare type TPeopleType = "guest" | "resident" | "tenant"
@@ -7,6 +7,11 @@ declare type TServiceProvider = {
7
7
  serviceProviderOrgId: string;
8
8
  type: string;
9
9
  nature: string;
10
+ status?: string;
11
+ email?: string;
12
+ /** The building unit this provider serves (a unit's Management tab). */
13
+ unit?: string | null;
14
+ unitName?: string;
10
15
  };
11
16
 
12
17
  declare type TServiceProviderName = {
package/types/site.d.ts CHANGED
@@ -5,7 +5,10 @@ declare type THidQrCodeFormat = "0" | "1" | "2";
5
5
  declare type THidPermissionCategory =
6
6
  | "resident"
7
7
  | "property_management"
8
- | "service_provider";
8
+ /** A provider COMPANY - granting one admits every active member of that org. */
9
+ | "service_provider"
10
+ /** ONE PERSON from a provider company, by their membership at this site. */
11
+ | "service_provider_member";
9
12
 
10
13
  declare type THidPermissionAssignment = {
11
14
  subjectId: string;
@@ -27,7 +30,10 @@ declare type THidSitePermissions = {
27
30
  counts: {
28
31
  resident: number;
29
32
  propertyManagement: number;
33
+ /** Provider COMPANIES granted. */
30
34
  serviceProvider: number;
35
+ /** Named provider people granted. Counted apart from the companies. */
36
+ serviceProviderMember: number;
31
37
  intercom: number;
32
38
  };
33
39
  };
@@ -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
+ ];