@7365admin1/layer-common 4.78.0 → 4.79.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,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.79.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 324da82: Site access lists "Organisation roles" and "<Site> roles" in every site's Role list and in "Same role for all selected sites" (organisation roles offered before any site is ticked), with a "Create an organisation role" link. RolePermissionMain takes `scope` ("organisation" = roles with no site); getRoles sends it only when set.
8
+
3
9
  ## 4.78.0
4
10
 
5
11
  ### Minor Changes
@@ -1926,11 +1932,11 @@
1926
1932
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1927
1933
  own rule from two endpoints the console already calls, unprojected:
1928
1934
 
1929
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1930
- GET /api/roles/id/:role that membership's role document
1935
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1936
+ GET /api/roles/id/:role that membership's role document
1931
1937
 
1932
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1933
- staff = member.type === "admin" && role.type === "admin"
1938
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1939
+ staff = member.type === "admin" && role.type === "admin"
1934
1940
 
1935
1941
  `role.default` is the marker because it is the only property of a platform
1936
1942
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -4,6 +4,12 @@
4
4
  <v-card-text class="screen-modal__body">
5
5
  <p class="text-body-2 mb-3">
6
6
  Choose the sites this person can open and their role at each. They will see only these sites.
7
+ A role can be one made for that site, or an <strong>organisation role</strong> (made once in Roles &amp;
8
+ Permissions) - either way it gives its permissions at that site only.
9
+ </p>
10
+ <p class="text-body-2 mb-3">
11
+ <a href="#" data-test="site-access-create-org-role" @click.prevent="createOrgRole()">Create an organisation role</a>
12
+ to use at many sites.
7
13
  </p>
8
14
 
9
15
  <div v-if="loading" class="py-4 text-center"><v-progress-circular indeterminate /></div>
@@ -31,7 +37,9 @@
31
37
  <div class="d-flex align-center ga-2 mb-2">
32
38
  <v-select
33
39
  v-model="sameRole"
34
- :items="sharedNames"
40
+ :items="sharedItems"
41
+ item-title="title"
42
+ item-value="value"
35
43
  label="Same role for all selected sites"
36
44
  density="compact"
37
45
  hide-details
@@ -39,27 +47,28 @@
39
47
  class="filter-field"
40
48
  data-test="site-access-same-role"
41
49
  />
42
- <v-btn variant="outlined" :disabled="!sameRole" @click="applySame()">Apply</v-btn>
50
+ <v-btn variant="outlined" :disabled="!sameRole || !anyOn" @click="applySame()">Apply</v-btn>
43
51
  </div>
52
+ <p v-if="sameRole && !anyOn" class="text-caption mb-2">Tick the sites first, then Apply.</p>
44
53
 
45
54
  <div v-for="(c, i) in choices" :key="c.site" class="d-flex align-center ga-2 py-1" data-test="site-access-row">
46
55
  <v-checkbox v-model="choices[i].on" density="compact" hide-details :label="c.name" style="min-width: 220px" />
47
56
  <v-select
48
57
  v-if="c.roles.length"
49
58
  v-model="choices[i].role"
50
- :items="c.roles"
51
- item-title="name"
52
- item-value="_id"
59
+ :items="roleItems(c)"
60
+ item-title="title"
61
+ item-value="value"
53
62
  :disabled="!c.on"
54
63
  label="Role"
55
64
  density="compact"
56
65
  hide-details
57
66
  class="filter-field"
58
67
  />
59
- <span v-else class="text-caption">No roles created for {{ c.name }} yet — create one in that site's Roles &amp; Permissions.</span>
68
+ <span v-else class="text-caption">No roles for {{ c.name }} yet — create an organisation role (link above) or one in that site's Roles &amp; Permissions.</span>
60
69
  </div>
61
70
  <p v-for="c in choices.filter((x) => x.on && !x.role && x.replacedRole)" :key="`r-${c.site}`" class="text-caption">
62
- {{ c.name }}: "{{ c.replacedRole }}" is not one of this site's own roles — choose one of {{ c.name }}'s roles.
71
+ {{ c.name }}: "{{ c.replacedRole }}" is not a role for this site — choose an organisation role or one of {{ c.name }}'s roles.
63
72
  </p>
64
73
  </div>
65
74
 
@@ -78,7 +87,7 @@
78
87
 
79
88
  <script setup lang="ts">
80
89
  // Relative import: this layer's `utils/` is not auto-imported into consumers.
81
- import { applySameRole, initialChoices, sharedRoleNames, toPayload, type SiteChoice } from "../utils/site-access";
90
+ import { applySameRole, initialChoices, roleItems, sharedRoleItems, toPayload, type SiteChoice } from "../utils/site-access";
82
91
 
83
92
  const props = defineProps({
84
93
  org: { type: String, required: true },
@@ -98,7 +107,16 @@ const orgWide = ref<Array<{ roleName?: string }>>([]);
98
107
  const removeOrgAccess = ref(false);
99
108
  const sameRole = ref<string | null>(null);
100
109
 
101
- const sharedNames = computed(() => sharedRoleNames(choices.value));
110
+ const sharedItems = computed(() => sharedRoleItems(choices.value));
111
+ const anyOn = computed(() => choices.value.some((c) => c.on));
112
+
113
+ // Organization app -> Roles & Permissions, on the organisation's own service;
114
+ // Create there comes back here (`returnTo`), and the box reloads its roles on open.
115
+ function createOrgRole() {
116
+ emit("cancel");
117
+ const back = useRoute().fullPath;
118
+ navigateTo({ path: `/org/${props.org}/roles-permissions`, query: { service: "own", returnTo: back } });
119
+ }
102
120
  const missing = computed(() => toPayload(choices.value).missing);
103
121
 
104
122
  function applySame() {
@@ -227,6 +227,11 @@ const props = defineProps({
227
227
  type: String,
228
228
  default: "",
229
229
  },
230
+ /** "organisation": list only roles with no site (Organization app service roles). */
231
+ scope: {
232
+ type: String,
233
+ default: "",
234
+ },
230
235
  /*
231
236
  * Whether the Create-role dialog shows its (read-only) Site field.
232
237
  * The dialog defaulted this to `true` but this component never declared or
@@ -429,6 +434,7 @@ const {
429
434
  type: props.listType ?? props.type,
430
435
  org: props.orgId,
431
436
  site: props.siteId,
437
+ scope: props.scope,
432
438
  }),
433
439
  {
434
440
  watch: [page, headerSearch],
@@ -24,10 +24,12 @@ export default function useRole() {
24
24
  type = "",
25
25
  org = "",
26
26
  site = "",
27
+ /** "organisation": only roles with no site (needs core 3.134.0+ - an older API refuses the key with 400) */
28
+ scope = "",
27
29
  } = {}) {
28
30
  return useNuxtApp().$api<Record<string, any>>("/api/roles", {
29
31
  method: "GET",
30
- query: { search, page, limit, type, org, site },
32
+ query: { search, page, limit, type, org, site, ...(scope ? { scope } : {}) },
31
33
  });
32
34
  }
33
35
 
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.78.0",
5
+ "version": "4.79.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.",
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Site access editor state (owner request 2026-09-28). One row per site of the
3
- * organisation; a chosen site needs one of ITS OWN roles (the server refuses an
4
- * organisation-level role for a site, because it would reach every site).
3
+ * organisation; a chosen site needs a role: one created FOR that site, or one of
4
+ * the organisation's service roles (owner request 2026-09-30, `scope:
5
+ * "organisation"`). Either way the server confines the membership to that site.
5
6
  */
6
- export type SiteAccessRole = { _id: string; name: string; type?: string };
7
+ export type SiteAccessRole = { _id: string; name: string; type?: string; scope?: "site" | "organisation" };
7
8
  export type SiteAccessRow = {
8
9
  site: string;
9
10
  name: string;
@@ -11,33 +12,57 @@ export type SiteAccessRow = {
11
12
  roles: SiteAccessRole[];
12
13
  };
13
14
  export type SiteChoice = { site: string; name: string; on: boolean; role: string; roles: SiteAccessRole[]; replacedRole?: string };
15
+ /** A v-select entry: a role, or a group heading. */
16
+ export type RoleItem = { title: string; value?: string; type?: "subheader" };
14
17
 
15
- /** Start from what the member has now. A current role that is not one of the site's roles is dropped. */
18
+ const isOrgRole = (r: SiteAccessRole) => r.scope === "organisation";
19
+
20
+ /** Start from what the member has now. A current role the site does not offer is dropped. */
16
21
  export function initialChoices(rows: SiteAccessRow[]): SiteChoice[] {
17
22
  return (rows ?? []).map((r) => {
18
23
  const current = r.membership?.role ?? "";
19
24
  const valid = r.roles.some((x) => x._id === current);
20
25
  return {
21
26
  site: r.site, name: r.name, on: !!r.membership, role: valid ? current : "", roles: r.roles ?? [],
22
- // their current role here is not one of this site's own roles (e.g. an
23
- // organisation role from an invitation) - say so rather than blank it silently
27
+ // their current role here is not one this site offers (e.g. another site's
28
+ // role) - say so rather than blank it silently
24
29
  ...(r.membership && !valid ? { replacedRole: r.membership.roleName || "their current role" } : {}),
25
30
  };
26
31
  });
27
32
  }
28
33
 
29
- /** Role names offered by "Same role for all selected sites". */
30
- export function sharedRoleNames(choices: SiteChoice[]): string[] {
31
- const names = new Set<string>();
32
- for (const c of choices) if (c.on) for (const r of c.roles) names.add(r.name);
33
- return [...names].sort((a, b) => a.localeCompare(b));
34
+ /** One site's Role list: "Organisation roles" first, then "<Site> roles". */
35
+ export function roleItems(choice: Pick<SiteChoice, "name" | "roles">): RoleItem[] {
36
+ const org = choice.roles.filter(isOrgRole);
37
+ const own = choice.roles.filter((r) => !isOrgRole(r));
38
+ const items: RoleItem[] = [];
39
+ if (org.length) items.push({ title: "Organisation roles", type: "subheader" }, ...org.map((r) => ({ title: r.name, value: r._id })));
40
+ if (own.length) items.push({ title: `${choice.name} roles`, type: "subheader" }, ...own.map((r) => ({ title: r.name, value: r._id })));
41
+ return items;
42
+ }
43
+
44
+ /**
45
+ * "Same role for all selected sites", grouped: the organisation's roles (they
46
+ * work at every site, so they are listed even before a site is ticked), then
47
+ * the names of the ticked sites' own roles.
48
+ */
49
+ export function sharedRoleItems(choices: SiteChoice[]): RoleItem[] {
50
+ const org = new Set<string>();
51
+ for (const c of choices) for (const r of c.roles) if (isOrgRole(r)) org.add(r.name);
52
+ const own = new Set<string>();
53
+ for (const c of choices) if (c.on) for (const r of c.roles) if (!isOrgRole(r) && !org.has(r.name)) own.add(r.name);
54
+ const sorted = (s: Set<string>) => [...s].sort((a, b) => a.localeCompare(b)).map((n) => ({ title: n, value: n }));
55
+ const items: RoleItem[] = [];
56
+ if (org.size) items.push({ title: "Organisation roles", type: "subheader" }, ...sorted(org));
57
+ if (own.size) items.push({ title: "Roles of the selected sites", type: "subheader" }, ...sorted(own));
58
+ return items;
34
59
  }
35
60
 
36
- /** Give every selected site the role with this name, where that site has one. */
61
+ /** Give every selected site the role with this name (an organisation role first), where that site has one. */
37
62
  export function applySameRole(choices: SiteChoice[], roleName: string): SiteChoice[] {
38
63
  return choices.map((c) => {
39
64
  if (!c.on) return c;
40
- const match = c.roles.find((r) => r.name === roleName);
65
+ const match = c.roles.find((r) => r.name === roleName && isOrgRole(r)) ?? c.roles.find((r) => r.name === roleName);
41
66
  return match ? { ...c, role: match._id } : c;
42
67
  });
43
68
  }