@7365admin1/layer-common 4.44.0 → 4.45.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,12 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.45.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c6a8606: Invite member: from inside a site the Site field is fixed to that site (read-only), and the Role list shows only the organisation's own active roles for the invited app and that site, each labelled "Site role" or "Organisation role". Org-less template roles are no longer offered on an invitation into an organisation.
8
+ - 1c0aea4: Settings → Site Information is read-only on a site the organisation serves but does not own: a note says "This site belongs to <Client>. Only they can change its details.", cover photo, description and documents are inert and Save is hidden. Blocks and guard posts stay editable. Driven by `detailsEditable`/`ownerOrgName` on the site read (core); absent on an older API = unchanged.
9
+
3
10
  ## 4.44.0
4
11
 
5
12
  ### Minor Changes
@@ -1502,11 +1509,11 @@
1502
1509
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1503
1510
  own rule from two endpoints the console already calls, unprojected:
1504
1511
 
1505
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1506
- GET /api/roles/id/:role that membership's role document
1512
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1513
+ GET /api/roles/id/:role that membership's role document
1507
1514
 
1508
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1509
- staff = member.type === "admin" && role.type === "admin"
1515
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1516
+ staff = member.type === "admin" && role.type === "admin"
1510
1517
 
1511
1518
  `role.default` is the marker because it is the only property of a platform
1512
1519
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -46,9 +46,10 @@
46
46
  <v-col cols="12">
47
47
  <v-autocomplete
48
48
  v-model="invite.role"
49
- :items="roles"
50
- item-title="name"
49
+ :items="roleOptions"
50
+ item-title="title"
51
51
  item-value="_id"
52
+ :item-props="(item: any) => ({ subtitle: item.subtitle })"
52
53
  :rules="[requiredRule]"
53
54
  class="filter-field"
54
55
  density="comfortable"
@@ -61,7 +62,23 @@
61
62
  <v-row no-gutters>
62
63
  <InputLabel class="text-capitalize" title="Site" />
63
64
  <v-col cols="12">
65
+ <!--
66
+ Inviting from inside a site invites INTO that site. The owner
67
+ (2026-09-25): other sites in this list were confusing, so the
68
+ site is shown, not chosen.
69
+ -->
70
+ <v-text-field
71
+ v-if="fixedSite"
72
+ :model-value="invite.siteName || 'This site'"
73
+ class="filter-field"
74
+ density="comfortable"
75
+ readonly
76
+ hint="Invitations from this site are for this site."
77
+ persistent-hint
78
+ data-test="invite-fixed-site"
79
+ ></v-text-field>
64
80
  <v-autocomplete
81
+ v-else
65
82
  v-model="invite.site"
66
83
  :items="sites"
67
84
  class="filter-field"
@@ -133,6 +150,8 @@ import useCustomerSite from '../composables/useCustomerSite';
133
150
  import useLocal from '../composables/useLocal';
134
151
  import { useLocalSetup } from '../composables/useLocalSetup';
135
152
  import useRole from '../composables/useRole';
153
+ import useSite from '../composables/useSite';
154
+ import { inviteRoleOptions } from '../utils/invite-role-options';
136
155
  import useUser from '../composables/useUser';
137
156
  import useUtils from '../composables/useUtils';
138
157
 
@@ -169,6 +188,16 @@ const props = defineProps({
169
188
  * which is about the site created in step 4) had no way to say so. It is
170
189
  * wired to `invite` below.
171
190
  */
191
+ /*
192
+ * The site the invitation is being made FROM (every site-level Invitations
193
+ * page passes it through InvitationMain). When set, the Site field is fixed
194
+ * to it and the Role list is that site's roles plus the organisation-wide
195
+ * ones.
196
+ */
197
+ siteId: {
198
+ type: String,
199
+ default: "",
200
+ },
172
201
  site: {
173
202
  type: Object as PropType<TCustomerSite>,
174
203
  default: () => ({
@@ -262,6 +291,18 @@ watch(
262
291
  },
263
292
  { immediate: true, deep: true }
264
293
  )
294
+
295
+ /* The site this form is fixed to, if any: the page's site, or onboarding's. */
296
+ const fixedSite = computed(() => props.siteId || props.site?.site || "")
297
+
298
+ watch(
299
+ () => props.siteId,
300
+ (siteId) => {
301
+ if (!siteId) return
302
+ invite.value.site = siteId
303
+ },
304
+ { immediate: true }
305
+ )
265
306
  // invite.value.app = props.app;
266
307
  // invite.value.org = props.org ?? "";
267
308
 
@@ -357,6 +398,27 @@ watchEffect(() => {
357
398
  }
358
399
  });
359
400
 
401
+ /* Name the fixed site: from the organisation's site list, else read it. */
402
+ const { getSiteById } = useSite();
403
+ watch(
404
+ [fixedSite, sites],
405
+ async ([siteId]) => {
406
+ if (!siteId || invite.value.siteName) return
407
+ const listed = sites.value.find((x) => x?.value === siteId)
408
+ if (listed?.title) {
409
+ invite.value.siteName = listed.title
410
+ return
411
+ }
412
+ try {
413
+ const site: any = await getSiteById(siteId)
414
+ invite.value.siteName = site?.name || site?.data?.name || ""
415
+ } catch {
416
+ // The field still reads "This site"; the invitation carries the id.
417
+ }
418
+ },
419
+ { immediate: true }
420
+ )
421
+
360
422
  watchEffect(() => {
361
423
  if (hasSite.value) {
362
424
  refreshSiteData();
@@ -384,26 +446,47 @@ const roles = ref<Array<Record<string, any>>>([]);
384
446
  */
385
447
  const { getInvitableRoles } = useRole();
386
448
 
449
+ /*
450
+ * 2026-09-25 (owner report, Wing Tai / Winsland House I): the list now asks for
451
+ * the app actually being invited into (`invite.app`, which the App field can
452
+ * change - it used to keep asking for `props.app`) and for the chosen site, so
453
+ * it holds only this organisation's roles for this app and site. The org-less
454
+ * templates are gone from any invitation that names an organisation - the
455
+ * server refuses them there (`core roleInvitable`).
456
+ */
387
457
  const { data: RolesData, refresh: refreshRoles } = await useLazyAsyncData(
388
- "get-roles-by-type-" + props.org + "-" + props.app,
458
+ "get-roles-by-type-" + props.org + "-" + props.app + "-" + fixedSite.value,
389
459
  () =>
390
460
  getInvitableRoles({
391
461
  org: props.org,
392
- type: props.app,
393
- limit: 50,
462
+ type: invite.value.app || props.app,
463
+ site: invite.value.site,
464
+ limit: 100,
394
465
  }),
395
- { watch: [() => props.app] }
466
+ { watch: [() => props.app, () => invite.value.app, () => invite.value.site] }
396
467
  );
397
468
 
398
469
  watchEffect(() => {
399
470
  if (RolesData.value) {
400
471
  roles.value = RolesData.value.items;
472
+ // A role picked for a different site is not valid for this one.
473
+ if (
474
+ invite.value.role &&
475
+ !roles.value.some((r: any) => String(r._id) === String(invite.value.role))
476
+ ) {
477
+ invite.value.role = "";
478
+ }
401
479
  }
402
480
  });
403
481
 
482
+ const roleOptions = computed(() => inviteRoleOptions(roles.value));
483
+
404
484
  async function handleUpdateApp(value: string) {
405
485
  invite.value.role = "";
406
- invite.value.site = "";
486
+ if (!fixedSite.value) {
487
+ invite.value.site = "";
488
+ invite.value.siteName = "";
489
+ }
407
490
  await refreshRoles()
408
491
  }
409
492
 
@@ -124,6 +124,7 @@
124
124
  <InvitationForm
125
125
  title="Invite member"
126
126
  :org="org"
127
+ :site-id="props.siteId"
127
128
  :invite-member="true"
128
129
  @cancel="dialogMember = false"
129
130
  @success="handleSuccess"
@@ -28,7 +28,17 @@
28
28
  <v-expansion-panel-text>
29
29
  <v-row no-gutters class="d-flex justify-center py-10">
30
30
  <v-form class="w-100">
31
- <v-row>
31
+ <!--
32
+ Owner decision 2026-09-25: a site's details are its owning
33
+ client's. A provider that serves the site sees them read-only;
34
+ the server refuses the save too. Blocks and guard posts below
35
+ stay editable - they are the provider's operational settings.
36
+ -->
37
+ <v-alert v-if="siteDetailsLocked" type="info" variant="tonal" density="compact" class="mb-4"
38
+ data-test="site-info-owner-only">
39
+ {{ siteOwnerNote }}
40
+ </v-alert>
41
+ <v-row :inert="siteDetailsLocked" :class="{ 'opacity-60': siteDetailsLocked }">
32
42
  <!-- Cover Photo + Preview -->
33
43
  <v-col cols="12" lg="5" class="pt-0">
34
44
  <InputLabel title="Cover Photo (displayed in resident app)" class="d-block mb-2" />
@@ -58,10 +68,12 @@
58
68
  </v-col>
59
69
 
60
70
  <!-- Submit -->
61
- <v-col cols="12" class="pt-0 d-flex justify-end">
71
+ <v-col v-if="!siteDetailsLocked" cols="12" class="pt-0 d-flex justify-end">
62
72
  <v-btn color="primary-button" class="text-none" size="large" variant="flat" text="Save"
63
73
  :loading="savingSiteInfo" @click="saveSiteInfo" />
64
74
  </v-col>
75
+ </v-row>
76
+ <v-row>
65
77
 
66
78
  <!-- Divider -->
67
79
  <v-col cols="12" class="my-2">
@@ -290,6 +302,13 @@ const orgId = computed(
290
302
  () => props.orgId || String((siteData.value as TSite | null)?.orgId ?? "")
291
303
  );
292
304
 
305
+ // The site read says whether this caller may change the site's details (core
306
+ // 3.103). Absent on an older API = unknown, so nothing is locked here.
307
+ const siteDetailsLocked = computed(
308
+ () => (siteData.value as any)?.detailsEditable === false
309
+ );
310
+ const siteOwnerNote = computed(() => siteOwnerOnlyNote((siteData.value as any)?.ownerOrgName));
311
+
293
312
  watch(
294
313
  siteData,
295
314
  (val: TSite) => {
@@ -73,17 +73,34 @@ export default function useRole() {
73
73
  limit?: number;
74
74
  }
75
75
  ) {
76
+ /*
77
+ * An invitation INTO an organisation offers that organisation's own roles
78
+ * only (owner, 2026-09-25: the org-less templates "Org Owner", "Org - Admin
79
+ * Doan623"... in a site's Invite member list were never this client's).
80
+ * The server refuses a template on such an invitation (`core
81
+ * roleInvitable`). Templates are still what an invitation naming NO
82
+ * organisation offers, e.g. the console's new-client flows.
83
+ *
84
+ * A role pinned to a site is offered only for THAT site: the server's site
85
+ * filter returns this site's roles plus the org-wide ones, and with no site
86
+ * chosen yet only the org-wide ones are valid.
87
+ */
76
88
  const [tenant, templates] = await Promise.all([
77
89
  org
78
90
  ? getRoles({ search, org, site, type, limit })
79
91
  : Promise.resolve({ items: [] } as Record<string, any>),
80
- getRoles({ search, type: "organization", limit }),
92
+ org
93
+ ? Promise.resolve({ items: [] } as Record<string, any>)
94
+ : getRoles({ search, type: "organization", limit }),
81
95
  ]);
82
96
 
83
97
  const seen = new Set<string>();
84
98
  const items = [...(tenant?.items ?? []), ...(templates?.items ?? [])].filter(
85
99
  (role: Record<string, any>) => {
86
100
  if (role?.type === "admin") return false;
101
+ if (role?.status && role.status !== "active") return false;
102
+ const roleSite = String(role?.site ?? "");
103
+ if (roleSite && roleSite !== String(site ?? "")) return false;
87
104
  const id = String(role?._id ?? "");
88
105
  if (!id || seen.has(id)) return false;
89
106
  seen.add(id);
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.44.0",
5
+ "version": "4.45.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,22 @@
1
+ /**
2
+ * The Invite member Role list, labelled so an inviter can tell a role made for
3
+ * this site from one that applies across the organisation (owner report
4
+ * 2026-09-25). The roles themselves are already scoped by
5
+ * `useRole.getInvitableRoles`.
6
+ */
7
+ export type TInviteRoleOption = { _id: string; title: string; subtitle: string };
8
+
9
+ export const SITE_ROLE_LABEL = "Site role";
10
+ export const ORG_ROLE_LABEL = "Organisation role";
11
+
12
+ export function inviteRoleOptions(
13
+ roles: Array<Record<string, any>> | null | undefined,
14
+ ): TInviteRoleOption[] {
15
+ return (roles ?? [])
16
+ .filter((role) => role && role._id)
17
+ .map((role) => ({
18
+ _id: String(role._id),
19
+ title: String(role.name ?? ""),
20
+ subtitle: role.site ? SITE_ROLE_LABEL : ORG_ROLE_LABEL,
21
+ }));
22
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * What a service provider is told on a site it serves but does not own
3
+ * (owner decision 2026-09-25: only the owning client changes a site's
4
+ * details). Same words as the server's refusal.
5
+ */
6
+ export function siteOwnerOnlyNote(ownerName?: string | null): string {
7
+ const name = String(ownerName ?? "").trim() || "another organisation";
8
+ return `This site belongs to ${name}. Only they can change its details.`;
9
+ }