@7365admin1/layer-common 4.85.0 → 4.87.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.87.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4be2d7c: Notification bell: robot alarms open the site's Robot alerts page, inventory alerts open their store on the right tab (Requests / Stock / My Stock), older "work-order" and "other" inventory rows route, and a click with nowhere to go now says why instead of doing nothing.
8
+
9
+ ## 4.86.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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).
14
+
3
15
  ## 4.85.0
4
16
 
5
17
  ### Minor Changes
@@ -1980,11 +1992,11 @@
1980
1992
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1981
1993
  own rule from two endpoints the console already calls, unprojected:
1982
1994
 
1983
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1984
- GET /api/roles/id/:role that membership's role document
1995
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1996
+ GET /api/roles/id/:role that membership's role document
1985
1997
 
1986
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1987
- staff = member.type === "admin" && role.type === "admin"
1998
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1999
+ staff = member.type === "admin" && role.type === "admin"
1988
2000
 
1989
2001
  `role.default` is the marker because it is the only property of a platform
1990
2002
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -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"
@@ -149,6 +149,9 @@ const props = defineProps({
149
149
  });
150
150
 
151
151
  const movedFromEquipment = useRoute().query.moved === "equipment";
152
+ // A notification link (`?storeId=&tab=`, NotificationBell): open that store on that tab, once.
153
+ const linkQuery = useRoute().query;
154
+ const linked = { storeId: String(linkQuery.storeId ?? ""), tab: String(linkQuery.tab ?? "") };
152
155
 
153
156
  const { getStores, createStore: apiCreateStore, getRequests, getReport } = useInventory();
154
157
  const { getSites } = useSite();
@@ -227,7 +230,10 @@ async function loadStores() {
227
230
  loadError.value = "";
228
231
  try {
229
232
  stores.value = (await getStores(props.siteId)) ?? [];
230
- if (!stores.value.some((s) => s._id === storeId.value)) {
233
+ const fromLink = stores.value.find((s) => s._id === linked.storeId);
234
+ linked.storeId = "";
235
+ if (fromLink) storeId.value = fromLink._id;
236
+ else if (!stores.value.some((s) => s._id === storeId.value)) {
231
237
  // A service provider working at a property store sees it first.
232
238
  storeId.value = stores.value[0]?._id ?? "";
233
239
  }
@@ -295,7 +301,10 @@ async function createStore() {
295
301
  }
296
302
 
297
303
  watch(storeId, () => {
298
- tab.value = "stock";
304
+ // The linked tab only when this person has it (tabs are hidden, never greyed).
305
+ const wanted = linked.tab;
306
+ linked.tab = "";
307
+ tab.value = wanted && tabs.value.some((t) => t.value === wanted) ? wanted : "stock";
299
308
  refreshCounts();
300
309
  probeCompanyUsage();
301
310
  });
@@ -127,6 +127,13 @@
127
127
  <v-btn variant="text" class="text-none" @click="viewFromToast">View</v-btn>
128
128
  </template>
129
129
  </v-snackbar>
130
+
131
+ <v-snackbar v-model="nowhere.show" location="top right" :timeout="6000" color="surface">
132
+ <div class="d-flex align-center ga-3">
133
+ <v-icon icon="mdi-information-outline" color="primary" />
134
+ <div class="text-body-2">{{ nowhere.text }}</div>
135
+ </div>
136
+ </v-snackbar>
130
137
  </div>
131
138
  </template>
132
139
 
@@ -137,7 +144,7 @@ import {
137
144
  timeAgo,
138
145
  type TInboxItem,
139
146
  } from "../utils/notification-inbox";
140
- import { appUrlsFrom, chooseNavigation, notificationTarget } from "../utils/notification-target";
147
+ import { appUrlsFrom, chooseNavigation, noTargetMessage, notificationTarget } from "../utils/notification-target";
141
148
 
142
149
  /**
143
150
  * The top-bar notification feed for the web, in every signed-in app.
@@ -180,6 +187,8 @@ const markingAll = ref(false);
180
187
  const items = ref<Array<Record<string, any>>>([]);
181
188
  const unread = ref(0);
182
189
  const toast = reactive({ show: false, title: "", body: "" });
190
+ /** Said when a clicked row has no page to open - a click is never a silent nothing. */
191
+ const nowhere = reactive({ show: false, text: "" });
183
192
 
184
193
  const userId = computed(() => String(currentUser.value?._id ?? ""));
185
194
  const badgeLabel = computed(() => badgeText(unread.value));
@@ -341,7 +350,10 @@ async function goToSubject(item: Record<string, any>) {
341
350
  hasPage: hasLocalPage,
342
351
  appUrls: appUrls(),
343
352
  });
344
- if (!nav) return;
353
+ if (!nav) {
354
+ Object.assign(nowhere, { show: true, text: noTargetMessage(item as any) });
355
+ return;
356
+ }
345
357
 
346
358
  open.value = false;
347
359
  if (nav.type === "href") {
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.85.0",
5
+ "version": "4.87.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.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",
@@ -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
+ });
@@ -45,7 +45,7 @@ export type TTargetRow = {
45
45
  * (core `withNotificationKind`); `site` stands in for `siteId` on rows kept
46
46
  * unscoped on purpose. Older rows have neither and are read from `module`.
47
47
  */
48
- params?: { id?: string; org?: string; kind?: string; site?: string } | null;
48
+ params?: { id?: string; org?: string; kind?: string; site?: string; storeId?: string } | null;
49
49
  };
50
50
 
51
51
  /** What the surrounding app knows that the stored row does not. */
@@ -127,9 +127,26 @@ export function notificationKind(item: TTargetRow | null | undefined): string {
127
127
  return "";
128
128
  }
129
129
  if (module === "other") {
130
- return /(accepted|declined) your invitation$/i.test(title) ? "serviceProviderInviteAnswer" : "";
130
+ if (/(accepted|declined) your invitation$/i.test(title)) return "serviceProviderInviteAnswer";
131
+ // Inventory alerts sent before core had an inventory module were stored as "other".
132
+ return /request waiting|request (was )?(approved|rejected)|low (on )?stock|stock handed to you|hand-over (confirmed|declined)/i.test(title)
133
+ ? "inventory"
134
+ : "";
131
135
  }
132
- return module;
136
+ // Older rows spelled the work order module with a hyphen.
137
+ return module === "work-order" ? "workOrder" : module;
138
+ }
139
+
140
+ /**
141
+ * Which inventory tab an alert is about. Alerts carry the store id but no tab,
142
+ * so the tab follows the alert's fixed title (API-core inventory-notify.service)
143
+ * - the same rule the mobile apps use. Values are the web tabs' own names.
144
+ */
145
+ export function inventoryTabForAlert(title: string): "requests" | "stock" | "mine" | "" {
146
+ if (/stock handed to you|hand-over (confirmed|declined)/i.test(title)) return "mine";
147
+ if (/request waiting|request (was )?(approved|rejected)/i.test(title)) return "requests";
148
+ if (/low (on )?stock/i.test(title)) return "stock";
149
+ return "";
133
150
  }
134
151
 
135
152
  /**
@@ -182,6 +199,8 @@ const SITE_PAGES: Record<string, TSitePage[]> = {
182
199
  { apps: ["security"], page: () => "virtual-patrol/cctv" },
183
200
  { apps: ["property-management"], page: () => "cctv" },
184
201
  ],
202
+ // A robot at the site raised an alarm (core safety alert, category robotAlert).
203
+ robotAlert: [{ apps: ["security", "property-management", "hygiene"], page: () => "robot-management/alerts" }],
185
204
  // hygiene has its Service Providers page too since 2026-09-28
186
205
  serviceProviderInviteAnswer: [{ apps: SERVICE_APPS, page: () => "service-providers" }],
187
206
  };
@@ -214,7 +233,12 @@ function sitePageTarget(
214
233
  const list = apps === "any" ? [home, ...SERVICE_APPS.filter((a) => a !== home)] : apps;
215
234
  for (const app of list) candidates.push({ app, path: base + page(id) });
216
235
  }
217
- return { candidates };
236
+ if (kind !== "inventory") return { candidates };
237
+ // Open the alert's own store on the tab it is about (InventoryMain reads ?storeId=&tab=).
238
+ const storeId = String(item.params?.storeId ?? item.params?.id ?? "").trim();
239
+ const tab = inventoryTabForAlert(String(item.title ?? ""));
240
+ const query: Record<string, string> = { ...(storeId ? { storeId } : {}), ...(tab ? { tab } : {}) };
241
+ return Object.keys(query).length ? { candidates, query } : { candidates };
218
242
  }
219
243
 
220
244
  /** Seven365 staff pages, in the organisation app. */
@@ -294,6 +318,21 @@ export function notificationTarget(
294
318
  };
295
319
  }
296
320
 
321
+ /** Kinds that only the iService365 phone apps can open (residents' own screens). */
322
+ const PHONE_ONLY_KINDS = new Set(["prelovedMarketplace", "marketplace-order"]);
323
+
324
+ /**
325
+ * What the bell says when a click has nowhere to go, so a click is never a
326
+ * silent nothing. A link grants nothing either way: the page opened still
327
+ * checks the person may see it.
328
+ */
329
+ export function noTargetMessage(item: TTargetRow | null | undefined): string {
330
+ if (PHONE_ONLY_KINDS.has(notificationKind(item))) {
331
+ return "This notification opens in the iService365 app on your phone.";
332
+ }
333
+ return "There is no page to open for this notification here. It has been marked as read.";
334
+ }
335
+
297
336
  /**
298
337
  * The full URL for a target whose page lives in another web app, or "" when
299
338
  * there is none to build (no path, or that app's URL is not configured).
@@ -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
+ };