@7365admin1/layer-common 4.78.0 → 4.80.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 +16 -4
- package/components/MemberSiteAccess.vue +27 -9
- package/components/RolePermissionMain.vue +6 -0
- package/components/ServiceProviderMain.vue +5 -1
- package/composables/useRole.ts +3 -1
- package/package.json +1 -1
- package/utils/site-access.ts +38 -13
- package/utils/sp-inviter-row.ts +26 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.80.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 200cb0b: Service Providers > Invitations: the status line says who must act while an invitation waits ("Waiting for a Seven365 Super Admin to approve - the email is sent after approval") and, once approved, whether the email went out ("Email sent 30 Sept, 14:14" or "Email failed: <reason> - use Resend", in red). Reads `emailStatus` from core 3.134+; older rows keep "Sent - waiting for their answer".
|
|
8
|
+
|
|
9
|
+
## 4.79.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 4.78.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1926,11 +1938,11 @@
|
|
|
1926
1938
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1927
1939
|
own rule from two endpoints the console already calls, unprojected:
|
|
1928
1940
|
|
|
1929
|
-
|
|
1930
|
-
|
|
1941
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1942
|
+
GET /api/roles/id/:role that membership's role document
|
|
1931
1943
|
|
|
1932
|
-
|
|
1933
|
-
|
|
1944
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1945
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1934
1946
|
|
|
1935
1947
|
`role.default` is the marker because it is the only property of a platform
|
|
1936
1948
|
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 &
|
|
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="
|
|
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
|
|
51
|
-
item-title="
|
|
52
|
-
item-value="
|
|
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
|
|
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 & 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
|
|
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,
|
|
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
|
|
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],
|
|
@@ -140,7 +140,11 @@
|
|
|
140
140
|
:status="item.viewStatus || item.statusRaw || item.status"
|
|
141
141
|
:label="item.status"
|
|
142
142
|
/>
|
|
143
|
-
<div
|
|
143
|
+
<div
|
|
144
|
+
v-if="item.statusLine"
|
|
145
|
+
class="mt-1 sp-sub"
|
|
146
|
+
:class="{ 'text-error': item.statusLine.startsWith('Email failed') }"
|
|
147
|
+
>{{ item.statusLine }}</div>
|
|
144
148
|
<!-- Was `text-caption text-medium-emphasis` - Roboto 12px at
|
|
145
149
|
Vuetify's 60% opacity. The design's sub-line is `--muted`. -->
|
|
146
150
|
<div v-if="item.rejectionReason" class="mt-1 sp-reject-reason">
|
package/composables/useRole.ts
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "4.80.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/utils/site-access.ts
CHANGED
|
@@ -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
|
|
4
|
-
* organisation
|
|
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
|
-
|
|
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
|
|
23
|
-
//
|
|
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
|
-
/**
|
|
30
|
-
export function
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
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
|
}
|
package/utils/sp-inviter-row.ts
CHANGED
|
@@ -19,8 +19,19 @@ export type TSentInvitation = {
|
|
|
19
19
|
createdAt?: string | null;
|
|
20
20
|
answeredAt?: string | null;
|
|
21
21
|
onSite?: boolean;
|
|
22
|
+
/** The latest invitation-email attempt, from the server (core 3.134+). */
|
|
23
|
+
emailStatus?: { state?: "sent" | "failed"; at?: string | null; reason?: string } | null;
|
|
22
24
|
};
|
|
23
25
|
|
|
26
|
+
/** "30 Sep, 14:14" - when the email went out. */
|
|
27
|
+
export function spDayTime(value?: string | null): string {
|
|
28
|
+
if (!value) return "";
|
|
29
|
+
const d = new Date(value);
|
|
30
|
+
return Number.isNaN(d.getTime())
|
|
31
|
+
? ""
|
|
32
|
+
: d.toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
const ANSWERED: Record<string, string> = {
|
|
25
36
|
complete: "Accepted",
|
|
26
37
|
declined: "Declined",
|
|
@@ -91,7 +102,20 @@ export function sentStatusLine(row: TSentInvitation): string {
|
|
|
91
102
|
return `Accepted${when ? ` on ${when}` : ""} · no longer on this site`;
|
|
92
103
|
}
|
|
93
104
|
if (ANSWERED[status]) return when ? `${ANSWERED[status]} on ${when}` : "";
|
|
94
|
-
|
|
95
|
-
if (status === "
|
|
105
|
+
// Who has to act, and that nothing is emailed until they do.
|
|
106
|
+
if (status === "awaiting-approval") {
|
|
107
|
+
return "Waiting for a Seven365 Super Admin to approve - the email is sent after approval";
|
|
108
|
+
}
|
|
109
|
+
if (status === "pending") {
|
|
110
|
+
const email = row.emailStatus;
|
|
111
|
+
if (email?.state === "failed") {
|
|
112
|
+
return `Email failed: ${email.reason || "could not be sent"} - use Resend`;
|
|
113
|
+
}
|
|
114
|
+
if (email?.state === "sent") {
|
|
115
|
+
const when = spDayTime(email.at);
|
|
116
|
+
return `Email sent${when ? ` ${when}` : ""} - waiting for their answer`;
|
|
117
|
+
}
|
|
118
|
+
return "Sent - waiting for their answer";
|
|
119
|
+
}
|
|
96
120
|
return "";
|
|
97
121
|
}
|