@7365admin1/layer-common 4.92.0 → 4.93.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,15 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.93.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b763487: Invitations list shows ONE row per multi-site invitation (e-mail, role, date, "N sites" with a details dialog listing every site and its status). Row actions: Resend (one request, one e-mail for the whole group), Cancel all, and Cancel one site inside the details. Old single-site invitations look and behave as before. Asks the API for `group=true`; against an API that does not know it, falls back to the plain per-site list. Same component on the org console and every app's Invitations page.
8
+
9
+ ### Patch Changes
10
+
11
+ - 31c71b3: The auth middleware no longer saves `?email=` as a browser-wide `service-provider-email` cookie (30 days). Nothing reads it any more, and a leftover from another account sent the org app's sign-in to that account's create page. Redirect-to-sign-in behaviour is unchanged.
12
+
3
13
  ## 4.92.0
4
14
 
5
15
  ### Minor Changes
@@ -2044,11 +2054,11 @@
2044
2054
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
2045
2055
  own rule from two endpoints the console already calls, unprojected:
2046
2056
 
2047
- GET /api/members/user/:user/app/admin the Seven365 staff membership
2048
- GET /api/roles/id/:role that membership's role document
2057
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
2058
+ GET /api/roles/id/:role that membership's role document
2049
2059
 
2050
- owner = member.type === "admin" && role.type === "admin" && role.default === true
2051
- staff = member.type === "admin" && role.type === "admin"
2060
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
2061
+ staff = member.type === "admin" && role.type === "admin"
2052
2062
 
2053
2063
  `role.default` is the marker because it is the only property of a platform
2054
2064
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -61,19 +61,37 @@
61
61
  <template #item.createdAt="{ item }">
62
62
  {{ item.createdAt ? formatDate(item.createdAt) : "" }}
63
63
  </template>
64
+ <template #item.siteLabel="{ item }">
65
+ <a
66
+ v-if="siteCountOf(item) > 1"
67
+ href="#"
68
+ class="text-primary"
69
+ :title="item.sites?.map((s) => s.siteName).join(', ')"
70
+ @click.prevent="detailItem = item"
71
+ >
72
+ {{ siteLabel(item) }}
73
+ </a>
74
+ <span v-else>{{ siteLabel(item) }}</span>
75
+ </template>
64
76
  <template #item.action-table="{ item }">
65
77
  <v-menu
66
- :close-on-content-click="false"
78
+ :close-on-content-click="true"
67
79
  offset-y
68
- width="150"
69
- v-if="props.cancelInvitation"
80
+ width="170"
81
+ v-if="item.status === 'pending' && (props.cancelInvitation || props.inviteMember)"
70
82
  >
71
83
  <template v-slot:activator="{ props }">
72
84
  <v-icon v-bind="props">mdi-dots-vertical</v-icon>
73
85
  </template>
74
86
  <v-list>
75
- <v-list-item @click="openConfirmDialog(item._id ?? '')" v-if="item.status === 'pending'">
76
- Cancel Invite
87
+ <v-list-item v-if="props.inviteMember" @click="onResend(item)">
88
+ Resend
89
+ </v-list-item>
90
+ <v-list-item
91
+ v-if="props.cancelInvitation"
92
+ @click="openConfirmDialog(groupIds(item))"
93
+ >
94
+ {{ siteCountOf(item) > 1 ? "Cancel all" : "Cancel Invite" }}
77
95
  </v-list-item>
78
96
  </v-list>
79
97
  </v-menu>
@@ -85,6 +103,34 @@
85
103
  </AppCard>
86
104
  </v-col>
87
105
 
106
+ <v-dialog :model-value="!!detailItem" max-width="520" @update:model-value="detailItem = null">
107
+ <v-card v-if="detailItem" class="pa-4">
108
+ <div class="text-h6 mb-1">{{ siteLabel(detailItem) }}</div>
109
+ <div class="text-caption mb-3">
110
+ {{ detailItem.email }} - one invitation e-mail; accepting it adds every site below.
111
+ </div>
112
+ <v-list density="compact" style="max-height: 50vh; overflow-y: auto">
113
+ <v-list-item v-for="s in detailItem.sites" :key="s._id" :title="s.siteName || 'Site'">
114
+ <template #append>
115
+ <span class="text-caption me-3">{{ formatStatus(s.status || "") }}</span>
116
+ <v-btn
117
+ v-if="s.status === 'pending' && props.cancelInvitation"
118
+ size="small"
119
+ variant="text"
120
+ color="error"
121
+ @click="openConfirmDialog([s._id])"
122
+ >
123
+ Cancel
124
+ </v-btn>
125
+ </template>
126
+ </v-list-item>
127
+ </v-list>
128
+ <div class="d-flex justify-end mt-3">
129
+ <v-btn variant="text" @click="detailItem = null">Close</v-btn>
130
+ </div>
131
+ </v-card>
132
+ </v-dialog>
133
+
88
134
  <ConfirmDialog
89
135
  v-model="confirmDialog"
90
136
  :loading="cancelLoading"
@@ -95,8 +141,12 @@
95
141
  </template>
96
142
  <template #description>
97
143
  <p class="text-subtitle-2">
98
- Are you sure you want to cancel this invitation? This action cannot be
99
- undone.
144
+ {{
145
+ selectedInviteIds.length > 1
146
+ ? `Are you sure you want to cancel all ${selectedInviteIds.length} site invitations?`
147
+ : "Are you sure you want to cancel this invitation?"
148
+ }}
149
+ This action cannot be undone.
100
150
  </p>
101
151
  </template>
102
152
  <template #footer>
@@ -140,6 +190,13 @@
140
190
  <script setup lang="ts">
141
191
  // Relative import: this layer's `utils/` is not auto-imported into consumers.
142
192
  import { invitationsEmptyText } from "../utils/invitations-empty-text";
193
+ import {
194
+ groupIds,
195
+ resendArgs,
196
+ siteCountOf,
197
+ siteLabel,
198
+ } from "../utils/invite-group";
199
+ import useUser from "../composables/useUser";
143
200
 
144
201
  /** The same two tabs, same labels, same routes. */
145
202
  const invitationTabs = [
@@ -205,7 +262,8 @@ const headers = computed(() => {
205
262
  },
206
263
  {
207
264
  title: "Site Invited To",
208
- value: "metadata.siteName",
265
+ value: "siteLabel",
266
+ sortable: false,
209
267
  },
210
268
  {
211
269
  title: "Status",
@@ -214,7 +272,7 @@ const headers = computed(() => {
214
272
 
215
273
  ];
216
274
 
217
- if (props.status === "pending" && props.cancelInvitation) {
275
+ if (props.status === "pending" && (props.cancelInvitation || props.inviteMember)) {
218
276
  baseHeaders.push({
219
277
  title: "Action",
220
278
  value: "action-table",
@@ -226,6 +284,7 @@ const headers = computed(() => {
226
284
 
227
285
  const { getVerifications: _getVerifications, cancelUserInvitation } =
228
286
  useVerification();
287
+ const { inviteMemberClient } = useUser();
229
288
 
230
289
  const page = ref(1);
231
290
  const pages = ref(0);
@@ -255,8 +314,8 @@ const {
255
314
  pageRange: string;
256
315
  }>(
257
316
  `get-verifications-${props.app}-${props.org}-${props.siteId}-${props.status}`,
258
- () =>
259
- _getVerifications({
317
+ async () => {
318
+ const query = {
260
319
  page: page.value,
261
320
  status: props.status,
262
321
  search: headerSearch.value,
@@ -271,7 +330,15 @@ const {
271
330
  // caller reaches it (owner report 2026-09-28: LakeGarden's invitations
272
331
  // showed on every Wing Tai site). The org console passes no site.
273
332
  siteId: props.siteId,
274
- }),
333
+ };
334
+ // One row per multi-site invitation. An API without `group` refuses the
335
+ // key: fall back to the plain list (one row per site, as before).
336
+ try {
337
+ return await _getVerifications({ ...query, group: true });
338
+ } catch {
339
+ return await _getVerifications(query);
340
+ }
341
+ },
275
342
  {
276
343
  watch: [() => props.status]
277
344
  }
@@ -309,31 +376,63 @@ watch([headerSearch, () => props.status], () => {
309
376
  });
310
377
 
311
378
  const confirmDialog = ref(false);
312
- const selectedInviteId = ref<string | null>(null);
379
+ const selectedInviteIds = ref<string[]>([]);
313
380
  const cancelLoading = ref(false);
381
+ const detailItem = ref<any>(null);
382
+ const resending = ref(false);
314
383
 
315
- function openConfirmDialog(id: string) {
316
- selectedInviteId.value = id;
384
+ function openConfirmDialog(ids: string[]) {
385
+ selectedInviteIds.value = ids;
317
386
  confirmDialog.value = true;
318
387
  }
319
388
 
320
389
  async function onConfirmCancel() {
321
- if (!selectedInviteId.value) return;
390
+ if (!selectedInviteIds.value.length) return;
322
391
 
323
392
  cancelLoading.value = true;
324
393
  try {
325
- const res = await cancelUserInvitation(selectedInviteId.value);
326
-
327
- showMessage(res.message || "Invitation cancelled successfully", "success");
394
+ let res: Record<string, any> = {};
395
+ for (const id of selectedInviteIds.value) {
396
+ res = await cancelUserInvitation(id);
397
+ }
398
+
399
+ showMessage(
400
+ selectedInviteIds.value.length > 1
401
+ ? "All site invitations cancelled"
402
+ : res.message || "Invitation cancelled successfully",
403
+ "success",
404
+ );
405
+ detailItem.value = null;
328
406
  await getVerifications();
329
407
  confirmDialog.value = false;
330
- selectedInviteId.value = null;
408
+ selectedInviteIds.value = [];
331
409
  } catch (error: any) {
332
- showMessage(error?.message || "Failed to cancel invitation", "error");
410
+ showMessage(error?.data?.message || error?.message || "Failed to cancel invitation", "error");
411
+ await getVerifications();
333
412
  }
334
413
  cancelLoading.value = false;
335
414
  }
336
415
 
416
+ // ONE request = ONE e-mail for the whole group (the server re-uses the
417
+ // pending rows and names every site).
418
+ async function onResend(item: any) {
419
+ const args = resendArgs(item);
420
+ if (!args) {
421
+ showMessage("This invitation cannot be re-sent from here.", "error");
422
+ return;
423
+ }
424
+ if (resending.value) return;
425
+ resending.value = true;
426
+ try {
427
+ await inviteMemberClient(args);
428
+ showMessage("Invitation re-sent (one e-mail).", "success");
429
+ await getVerifications();
430
+ } catch (error: any) {
431
+ showMessage(error?.data?.message || error?.message || "Failed to resend invitation", "error");
432
+ }
433
+ resending.value = false;
434
+ }
435
+
337
436
  function handleSuccess(closeForm: boolean) {
338
437
  getVerifications();
339
438
  if (closeForm) {
@@ -11,6 +11,8 @@ export default function useVerification() {
11
11
  limit = 20,
12
12
  // "seven365": only the invitations Seven365 sent (the console's lists).
13
13
  origin = "",
14
+ // true: one entry per multi-site invitation group, with its `sites`.
15
+ group = false,
14
16
  } = {}): Promise<{
15
17
  items: TMiniVerification[];
16
18
  pages: number;
@@ -22,6 +24,7 @@ export default function useVerification() {
22
24
  status, search, page, type, email, app, siteId, orgId, limit,
23
25
  // Sent only when asked for: an older API refuses a key it does not know.
24
26
  ...(origin && { origin }),
27
+ ...(group && { group: "true" }),
25
28
  },
26
29
  });
27
30
  }
@@ -4,13 +4,9 @@ export default defineNuxtRouteMiddleware(async (route) => {
4
4
  // Get access token from cookies
5
5
  const sid = useCookie("sid", cookieConfig).value;
6
6
 
7
- const serviceProviderEmail = (route.query.email as string) ?? "";
8
-
9
-
10
- if (serviceProviderEmail) {
11
- useCookie("service-provider-email", cookieConfig).value =
12
- serviceProviderEmail;
13
- }
7
+ // No longer saves `?email=` as a browser-wide `service-provider-email` cookie
8
+ // (30 days): nothing reads it, and a leftover from another account sent the
9
+ // org app's sign-in to that account's create page.
14
10
 
15
11
  if (!sid) {
16
12
  // Redirect to login page if no access token
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.92.0",
5
+ "version": "4.93.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,61 @@
1
+ /**
2
+ * Invitations list, multi-site invitations (owner request 2026-10-02): the
3
+ * server (`GET /api/verifications?group=true`) returns ONE entry per invitation
4
+ * group with its `sites`; an old single-site invitation is a group of one.
5
+ */
6
+ export type InviteSiteRow = {
7
+ _id: string;
8
+ siteId?: string;
9
+ siteName?: string;
10
+ status?: string;
11
+ };
12
+
13
+ export type InviteEntry = {
14
+ _id?: string;
15
+ email?: string;
16
+ metadata?: Record<string, any>;
17
+ sites?: InviteSiteRow[];
18
+ siteCount?: number;
19
+ };
20
+
21
+ export function siteCountOf(item: InviteEntry): number {
22
+ return item.siteCount ?? item.sites?.length ?? 1;
23
+ }
24
+
25
+ /** "LakeGarden" for one site, "21 sites" for a group (details list them all). */
26
+ export function siteLabel(item: InviteEntry): string {
27
+ const n = siteCountOf(item);
28
+ if (n > 1) return `${n} sites`;
29
+ return item.sites?.[0]?.siteName || item.metadata?.siteName || "";
30
+ }
31
+
32
+ /** Every verification id the entry stands for (Cancel all). */
33
+ export function groupIds(item: InviteEntry): string[] {
34
+ const ids = item.sites?.length
35
+ ? item.sites.map((s) => s._id)
36
+ : [item._id ?? ""];
37
+ return ids.filter(Boolean).map(String);
38
+ }
39
+
40
+ /**
41
+ * Arguments of `inviteMemberClient` that re-send the invitation: ONE request,
42
+ * so ONE e-mail for the whole group. `null` when it cannot be rebuilt (no org).
43
+ */
44
+ export function resendArgs(item: InviteEntry) {
45
+ const m = item.metadata ?? {};
46
+ if (!item.email || !m.org) return null;
47
+ const sites = (item.sites ?? [])
48
+ .map((s) => s.siteId)
49
+ .filter(Boolean)
50
+ .map(String);
51
+ return {
52
+ email: item.email,
53
+ app: m.app ?? "",
54
+ role: m.role ? String(m.role) : "",
55
+ name: m.name ?? "",
56
+ org: String(m.org),
57
+ sites: sites.length > 1 ? sites : [],
58
+ site: sites.length === 1 ? sites[0] : m.siteId ? String(m.siteId) : "",
59
+ siteName: item.sites?.[0]?.siteName || m.siteName || "",
60
+ };
61
+ }