@7365admin1/layer-common 4.37.0 → 4.38.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,16 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.38.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 59ce7ed: Passes & Keys permissions and the membership the app is working in (DV-0409).
8
+
9
+ - Passes And Keys Management: new `add-passes-and-keys`, `update-passes-and-keys` and `delete-passes-and-keys` permissions in both the Security and the Property Management role catalogues (`useSecurityPermission`, `usePropertyPermission`). API-core now enforces them on `/api/keys` and `/api/qr-code-templates`; "See All Passes And Keys" is what shows the Templates and Passes & Keys pages.
10
+ - `plugins/API.ts` sends `X-App` (this app's member type, from `runtimeConfig.public.APP`) and `X-Org` (the organisation in the address) with every request, so core checks the role of the membership the screen is using when a person holds several memberships at one site. Only chooses among the user's own memberships; grants nothing. A server that does not read them is unaffected.
11
+
12
+ - 27c17b7: Service-provider invitations can be answered in the app: the notification bell opens "<Company> has invited you to <site>" on the organisation app's Invitations page (other apps send the click there by URL), the new `ServiceProviderInvitationsReceived` section lists invitations from other organisations with Accept / Decline, `useServiceProviderInvitation` gains `acceptInvitation` and `getReceivedInvitations`, and the invitation card shows a failed accept or decline instead of swallowing it.
13
+
3
14
  ## 4.37.0
4
15
 
5
16
  ### Minor Changes
@@ -1430,11 +1441,11 @@
1430
1441
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1431
1442
  own rule from two endpoints the console already calls, unprojected:
1432
1443
 
1433
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1434
- GET /api/roles/id/:role that membership's role document
1444
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1445
+ GET /api/roles/id/:role that membership's role document
1435
1446
 
1436
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1437
- staff = member.type === "admin" && role.type === "admin"
1447
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1448
+ staff = member.type === "admin" && role.type === "admin"
1438
1449
 
1439
1450
  `role.default` is the marker because it is the only property of a platform
1440
1451
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -134,7 +134,7 @@ import {
134
134
  timeAgo,
135
135
  type TInboxItem,
136
136
  } from "../utils/notification-inbox";
137
- import { notificationTarget } from "../utils/notification-target";
137
+ import { externalTargetUrl, notificationTarget } from "../utils/notification-target";
138
138
 
139
139
  /**
140
140
  * The top-bar notification feed for the web, in every signed-in app.
@@ -168,6 +168,7 @@ const { getAll, getUnreadCount, markAsRead, markAllAsRead } = useNotification();
168
168
  const { currentUser } = useLocalAuth();
169
169
  const route = useRoute();
170
170
  const router = useRouter();
171
+ const appConfig = useRuntimeConfig();
171
172
 
172
173
  const open = ref(false);
173
174
  const loading = ref(false);
@@ -305,14 +306,24 @@ function markRead(item: Record<string, any>) {
305
306
  */
306
307
  async function goToSubject(item: Record<string, any>) {
307
308
  const target = notificationTarget(item as any, {
308
- organization: String(route.params?.organization ?? ""),
309
+ // The org app nests under `organization`; the service apps under `org`.
310
+ organization: String(route.params?.organization ?? route.params?.org ?? ""),
309
311
  });
310
312
  if (!target) return;
311
- if (typeof router.hasRoute !== "function" || !router.hasRoute(target.name)) return;
313
+
314
+ if (typeof router.hasRoute !== "function" || !router.hasRoute(target.name)) {
315
+ // The page lives in another web app (the invitation is answered in the
316
+ // organisation app): go there by URL, or stay put if it is not configured.
317
+ const href = externalTargetUrl(target, { org: String(appConfig?.public?.APP_ORG ?? "") });
318
+ if (!href) return;
319
+ open.value = false;
320
+ window.location.assign(href);
321
+ return;
322
+ }
312
323
 
313
324
  open.value = false;
314
325
  try {
315
- await router.push(target);
326
+ await router.push({ name: target.name, params: target.params, query: target.query });
316
327
  } catch {
317
328
  // A cancelled or duplicate navigation is not worth an error in the top bar.
318
329
  }
@@ -57,6 +57,11 @@
57
57
  :label="invitation.statusLabel"
58
58
  />
59
59
  </div>
60
+
61
+ <!-- An accept or decline that failed: said on the card, not swallowed. -->
62
+ <div v-if="error" class="px-4 pb-4 text-error sp-invite-prompt__body" role="alert">
63
+ {{ error }}
64
+ </div>
60
65
  </AppCard>
61
66
 
62
67
  <AppCard v-else-if="error">
@@ -82,8 +87,8 @@ const props = defineProps({
82
87
 
83
88
  const emit = defineEmits<{ (e: "answered", status: string): void }>();
84
89
 
85
- const { getInvitation, declineInvitation } = useServiceProviderInvitation();
86
- const { addViaInvite } = useCustomerSite();
90
+ const { getInvitation, declineInvitation, acceptInvitation } =
91
+ useServiceProviderInvitation();
87
92
 
88
93
  const invitation = ref<any>(null);
89
94
  const error = ref("");
@@ -112,9 +117,10 @@ async function load() {
112
117
  }
113
118
 
114
119
  async function accept() {
120
+ error.value = "";
115
121
  working.value = "accept";
116
122
  try {
117
- await addViaInvite(props.invitationId);
123
+ await acceptInvitation(props.invitationId);
118
124
  await load();
119
125
  emit("answered", "complete");
120
126
  } catch (err: any) {
@@ -125,6 +131,7 @@ async function accept() {
125
131
  }
126
132
 
127
133
  async function decline() {
134
+ error.value = "";
128
135
  working.value = "decline";
129
136
  try {
130
137
  await declineInvitation(props.invitationId);
@@ -0,0 +1,113 @@
1
+ <template>
2
+ <!-- Nothing to answer and nothing linked: the section stays out of the way. -->
3
+ <section v-if="ids.length" class="sp-received mb-4" aria-labelledby="sp-received-title">
4
+ <div class="mb-2">
5
+ <div id="sp-received-title" class="screen-card-title">
6
+ Invitations from other organisations
7
+ </div>
8
+ <div class="screen-hint">
9
+ Another organisation has asked your company to provide services at one
10
+ of its sites. Accept to add the site to your Sites list, or decline.
11
+ </div>
12
+ </div>
13
+
14
+ <div class="d-flex flex-column ga-3">
15
+ <div
16
+ v-for="id in ids"
17
+ :key="id"
18
+ :class="{ 'sp-received__linked': id === linked }"
19
+ >
20
+ <Suspense>
21
+ <ServiceProviderInvitationPrompt
22
+ :invitation-id="id"
23
+ @answered="(status: string) => onAnswered(id, status)"
24
+ />
25
+ </Suspense>
26
+
27
+ <div v-if="answered[id] === 'complete'" class="sp-received__done mt-2">
28
+ {{ doneLine(id) }}
29
+ <NuxtLink v-if="sitesLink(id)" :to="sitesLink(id)">Open Sites</NuxtLink>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </section>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ /**
38
+ * "Invitations from other organisations" — where a service provider answers an
39
+ * invitation another organisation sent it (owner report 2026-09-24: the bell
40
+ * said "Open the invitation to accept or decline" and there was nowhere to do
41
+ * either).
42
+ *
43
+ * The list comes from the server keyed on the signed-in account, so it is the
44
+ * same whichever organisation is on screen. The row the bell linked to
45
+ * (`?invite=<id>`) is always shown — even once answered or expired, so the
46
+ * person sees what happened to it — and is outlined.
47
+ */
48
+ import type { TReceivedServiceProviderInvitation } from "../composables/useServiceProviderInvitation";
49
+
50
+ const route = useRoute();
51
+ const { getReceivedInvitations } = useServiceProviderInvitation();
52
+
53
+ const items = ref<TReceivedServiceProviderInvitation[]>([]);
54
+ const answered = reactive<Record<string, string>>({});
55
+
56
+ const linked = computed(() => {
57
+ const value = String(route.query?.invite ?? "").trim();
58
+ return /^[a-f0-9]{24}$/i.test(value) ? value : "";
59
+ });
60
+
61
+ const ids = computed(() => {
62
+ const listed = items.value.map((i) => String(i._id));
63
+ return linked.value && !listed.includes(linked.value)
64
+ ? [linked.value, ...listed]
65
+ : listed;
66
+ });
67
+
68
+ function itemFor(id: string) {
69
+ return items.value.find((i) => String(i._id) === id);
70
+ }
71
+
72
+ function doneLine(id: string) {
73
+ const item = itemFor(id);
74
+ const whose = item?.providerOrgName ? `${item.providerOrgName}'s` : "your";
75
+ return item?.siteName
76
+ ? `${item.siteName} is now on ${whose} Sites list.`
77
+ : `The site is now on ${whose} Sites list.`;
78
+ }
79
+
80
+ function sitesLink(id: string) {
81
+ const org = itemFor(id)?.providerOrgId || String(route.params?.organization ?? "");
82
+ return org ? `/org/${org}/sites` : "";
83
+ }
84
+
85
+ function onAnswered(id: string, status: string) {
86
+ answered[id] = status;
87
+ }
88
+
89
+ async function load() {
90
+ try {
91
+ const res = await getReceivedInvitations();
92
+ items.value = Array.isArray(res?.items) ? res.items : [];
93
+ } catch {
94
+ // The section is an addition to the Invitations page; if it cannot load,
95
+ // the rest of the page must still work. A linked row still renders and
96
+ // reports its own error.
97
+ items.value = [];
98
+ }
99
+ }
100
+
101
+ await load();
102
+ </script>
103
+
104
+ <style scoped>
105
+ .sp-received__linked :deep(.app-card) {
106
+ outline: 2px solid rgb(var(--v-theme-primary));
107
+ outline-offset: 2px;
108
+ }
109
+
110
+ .sp-received__done {
111
+ font-size: var(--fs-cell);
112
+ }
113
+ </style>
@@ -579,6 +579,23 @@ export default function usePropertyPermission() {
579
579
  description:
580
580
  "Allows the user to view the list of all passes and keys on mobile. Checked by the iMA365 mobile app.",
581
581
  },
582
+ // DV-0396: the same three writes Security offers, enforced by API-core on
583
+ // /api/keys and /api/qr-code-templates for every app that calls them.
584
+ "add-passes-and-keys": {
585
+ check: true,
586
+ description:
587
+ "Allows the user to add pass templates and to create or generate passes and keys.",
588
+ },
589
+ "update-passes-and-keys": {
590
+ check: true,
591
+ description:
592
+ "Allows the user to edit pass templates, passes and keys, restore them, and change a pass or key back to Available.",
593
+ },
594
+ "delete-passes-and-keys": {
595
+ check: true,
596
+ description:
597
+ "Allows the user to delete pass templates, passes and keys.",
598
+ },
582
599
  },
583
600
  "online-forms": {
584
601
  "see-all-online-forms": {
@@ -669,12 +669,31 @@ export default function useSecurityPermission() {
669
669
  },
670
670
  // Property's descriptions minus its "Checked by the iMA365 mobile app."
671
671
  // sentence, which is not true of this application.
672
+ // Add / update / delete (DV-0396), also offered by Property Management and
673
+ // enforced by API-core on /api/keys and /api/qr-code-templates. The security web app's
674
+ // Templates and Passes & Keys pages and the guard app's pass & key screen
675
+ // read them. See All is also what shows those two web pages.
672
676
  "passes-and-keys-mgmt": {
673
677
  "see-all-passes-and-keys": {
674
678
  check: true,
675
679
  description:
676
680
  "Allows the user to view the list of all passes and keys on mobile.",
677
681
  },
682
+ "add-passes-and-keys": {
683
+ check: true,
684
+ description:
685
+ "Allows the user to add pass templates and to create or generate passes and keys.",
686
+ },
687
+ "update-passes-and-keys": {
688
+ check: true,
689
+ description:
690
+ "Allows the user to edit pass templates, passes and keys, restore them, and change a pass or key back to Available.",
691
+ },
692
+ "delete-passes-and-keys": {
693
+ check: true,
694
+ description:
695
+ "Allows the user to delete pass templates, passes and keys.",
696
+ },
678
697
  },
679
698
  "resident-transactions": {
680
699
  "see-all-resident-transactions": {
@@ -26,6 +26,19 @@ export type TServiceProviderInvitation = {
26
26
  }>;
27
27
  };
28
28
 
29
+ /** One row of "Invitations from other organisations". */
30
+ export type TReceivedServiceProviderInvitation = {
31
+ _id: string;
32
+ orgName: string;
33
+ siteName: string;
34
+ status: string;
35
+ statusLabel: string;
36
+ expireAt?: string;
37
+ /** The invited company's own organisation (the one whose list gains the site). */
38
+ providerOrgId?: string;
39
+ providerOrgName?: string;
40
+ };
41
+
29
42
  /** The words on screen. Kept beside the server's own list on purpose — a
30
43
  * status the server invents must never render as a raw stored value. */
31
44
  export const SERVICE_PROVIDER_INVITE_LABELS: Record<string, string> = {
@@ -130,6 +143,23 @@ export default function useServiceProviderInvitation() {
130
143
  });
131
144
  }
132
145
 
146
+ /** Adds the site to the invited company's list. The invited account only. */
147
+ function acceptInvitation(id: string) {
148
+ return useNuxtApp().$api<{ message: string }>(`${base}/${id}/accept`, {
149
+ method: "PUT",
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Invitations from other organisations still waiting for the signed-in
155
+ * account's answer — keyed on the account, not on the organisation on screen.
156
+ */
157
+ function getReceivedInvitations(): Promise<{
158
+ items: TReceivedServiceProviderInvitation[];
159
+ }> {
160
+ return useNuxtApp().$api(`${base}/received`, { method: "GET" });
161
+ }
162
+
133
163
  return {
134
164
  getInvitations,
135
165
  cancelInvitation,
@@ -141,5 +171,7 @@ export default function useServiceProviderInvitation() {
141
171
  rejectInvitation,
142
172
  getInvitation,
143
173
  declineInvitation,
174
+ acceptInvitation,
175
+ getReceivedInvitations,
144
176
  };
145
177
  }
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.37.0",
5
+ "version": "4.38.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.",
package/plugins/API.ts CHANGED
@@ -11,6 +11,17 @@ export default defineNuxtPlugin((nuxtApp) => {
11
11
  onRequest({ options }) {
12
12
  const sid = useCookie("sid", cookieConfig).value ?? "";
13
13
  options.headers.set("Authorization", sid);
14
+ // Which of the user's memberships this screen is working in (DV-0396):
15
+ // this app's member type and the organisation in the address. With
16
+ // several memberships pinned to one site, the server then checks the
17
+ // same role the screen was drawn from (core `requireSiteReach`). Only
18
+ // chooses among the user's own memberships; it grants nothing.
19
+ const app = useRuntimeConfig().public.APP;
20
+ if (app) options.headers.set("X-App", String(app));
21
+ const org = (nuxtApp as any).$router?.currentRoute?.value?.params?.org;
22
+ if (typeof org === "string" && /^[0-9a-f]{24}$/i.test(org)) {
23
+ options.headers.set("X-Org", org);
24
+ }
14
25
  },
15
26
  // "You do not have permission to ..." - the role changed since the page
16
27
  // read it. Re-read it in the background (plugins/secure-member.client.ts)
@@ -31,7 +31,8 @@
31
31
  /** Only the fields a destination is decided from. */
32
32
  export type TTargetRow = {
33
33
  module?: string;
34
- params?: { id?: string } | null;
34
+ screen?: string;
35
+ params?: { id?: string; org?: string } | null;
35
36
  };
36
37
 
37
38
  /** What the surrounding app knows that the stored row does not. */
@@ -43,6 +44,13 @@ export type TTargetContext = {
43
44
  export type TNotificationTarget = {
44
45
  name: string;
45
46
  params: Record<string, string>;
47
+ query?: Record<string, string>;
48
+ /**
49
+ * Set when the page lives in another web app. The bell opens
50
+ * `<that app's URL><path>` when its own router has no route by `name`.
51
+ */
52
+ app?: "org";
53
+ path?: string;
46
54
  };
47
55
 
48
56
  /** Nuxt's generated names for `pages/org/[organization]/marketplace/orders/`. */
@@ -52,12 +60,42 @@ export const ORDER_DETAIL_ROUTE = "org-organization-marketplace-orders-id";
52
60
  /** The stored `module` tag on a row sent to a vendor's staff. */
53
61
  export const VENDOR_ORDER_MODULE = "marketplace-vendor-order";
54
62
 
63
+ /**
64
+ * "<Company> has invited you to <site>" — core `serviceProviderInvited` stores
65
+ * `module: "other"` (the apps route on it; it never changes) with this
66
+ * `screen`, the invitation id in `params.id`, and, since 2026-09-24, the
67
+ * invited company's own organisation in `params.org`.
68
+ *
69
+ * It opens the organisation app's Invitations page, where "Invitations from
70
+ * other organisations" lists it with Accept / Decline. Only the organisation
71
+ * app has that page; every other app sends the click there by URL.
72
+ */
73
+ export const SP_INVITATION_SCREEN = "/service-provider-invitation";
74
+ export const INVITATIONS_ROUTE = "org-organization-invitations-status-status";
75
+
55
76
  export function notificationTarget(
56
77
  item: TTargetRow | null | undefined,
57
78
  context: TTargetContext = {},
58
79
  ): TNotificationTarget | null {
59
80
  const organization = String(context.organization ?? "").trim();
60
81
 
82
+ if (String(item?.screen ?? "") === SP_INVITATION_SCREEN) {
83
+ // The invited company's organisation when the row names it (the invitation
84
+ // belongs there), otherwise the one on screen. The list on that page is
85
+ // keyed on the signed-in account, so either shows the invitation.
86
+ const org = String(item?.params?.org ?? "").trim() || organization;
87
+ if (!org) return null;
88
+ const id = String(item?.params?.id ?? "").trim();
89
+ const query: Record<string, string> = id ? { invite: id } : {};
90
+ return {
91
+ name: INVITATIONS_ROUTE,
92
+ params: { organization: org, status: "pending" },
93
+ query,
94
+ app: "org",
95
+ path: `/org/${encodeURIComponent(org)}/invitations/status/pending`,
96
+ };
97
+ }
98
+
61
99
  if (String(item?.module ?? "") !== VENDOR_ORDER_MODULE) return null;
62
100
 
63
101
  // Every order route is nested under the organization. Without one there is no
@@ -73,3 +111,18 @@ export function notificationTarget(
73
111
 
74
112
  return { name: ORDER_DETAIL_ROUTE, params: { organization, id } };
75
113
  }
114
+
115
+ /**
116
+ * The full URL for a target whose page lives in another web app, or "" when
117
+ * there is none to build (no path, or that app's URL is not configured).
118
+ */
119
+ export function externalTargetUrl(
120
+ target: TNotificationTarget | null | undefined,
121
+ appUrls: { org?: string } = {},
122
+ ): string {
123
+ if (!target?.app || !target.path) return "";
124
+ const base = String(appUrls[target.app] ?? "").trim().replace(/\/+$/, "");
125
+ if (!base) return "";
126
+ const qs = new URLSearchParams(target.query ?? {}).toString();
127
+ return `${base}${target.path}${qs ? `?${qs}` : ""}`;
128
+ }