@7365admin1/layer-common 4.67.2 → 4.68.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 +18 -4
- package/components/MemberMain.vue +37 -0
- package/components/MemberSiteAccess.vue +135 -0
- package/composables/useMember.ts +23 -0
- package/package.json +1 -1
- package/utils/role.ts +3 -2
- package/utils/site-access.ts +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.68.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f07fc1e: Members → Site access (organisation-level Members screen, owner request 2026-09-28):
|
|
8
|
+
the organisation's owner/admin chooses which of the organisation's sites a member
|
|
9
|
+
can open and the role at each (only roles created for that site), with "Same role
|
|
10
|
+
for all selected sites", and can remove the member's organisation-wide access so
|
|
11
|
+
they see only the chosen sites. New `MemberSiteAccess.vue`, `utils/site-access.ts`,
|
|
12
|
+
`useMember().getSiteAccess/saveSiteAccess`.
|
|
13
|
+
|
|
14
|
+
Fix: Assign Role pre-fills the member's current role again when the server marks it
|
|
15
|
+
usable (`roleState: "ok"`); 4.65.3 treated "ok" as unusable.
|
|
16
|
+
|
|
3
17
|
## 4.67.2
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
|
@@ -1817,11 +1831,11 @@
|
|
|
1817
1831
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1818
1832
|
own rule from two endpoints the console already calls, unprojected:
|
|
1819
1833
|
|
|
1820
|
-
|
|
1821
|
-
|
|
1834
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1835
|
+
GET /api/roles/id/:role that membership's role document
|
|
1822
1836
|
|
|
1823
|
-
|
|
1824
|
-
|
|
1837
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1838
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1825
1839
|
|
|
1826
1840
|
`role.default` is the marker because it is the only property of a platform
|
|
1827
1841
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -168,6 +168,14 @@
|
|
|
168
168
|
Assign Role
|
|
169
169
|
</v-list-item>
|
|
170
170
|
|
|
171
|
+
<v-list-item
|
|
172
|
+
v-if="siteAccessAllowed && item.user"
|
|
173
|
+
data-test="member-site-access"
|
|
174
|
+
@click="openSiteAccess(item)"
|
|
175
|
+
>
|
|
176
|
+
Site access
|
|
177
|
+
</v-list-item>
|
|
178
|
+
|
|
171
179
|
<v-list-item
|
|
172
180
|
v-if="currentStatus === 'active' && canSuspendMembers"
|
|
173
181
|
@click="openUpdateDialog(item._id, 'suspended')"
|
|
@@ -241,6 +249,17 @@
|
|
|
241
249
|
INSIDE the `v-form` because Submit is a `type="submit"` and moving it out
|
|
242
250
|
would break the form's own submit.
|
|
243
251
|
-->
|
|
252
|
+
<v-dialog v-model="siteAccessDialog" max-width="640">
|
|
253
|
+
<MemberSiteAccess
|
|
254
|
+
v-if="siteAccessDialog && siteAccessUser"
|
|
255
|
+
:org="props.orgId"
|
|
256
|
+
:user="siteAccessUser"
|
|
257
|
+
:name="siteAccessName"
|
|
258
|
+
@cancel="siteAccessDialog = false"
|
|
259
|
+
@saved="onSiteAccessSaved"
|
|
260
|
+
/>
|
|
261
|
+
</v-dialog>
|
|
262
|
+
|
|
244
263
|
<v-dialog v-model="assignRoleDialog" max-width="520">
|
|
245
264
|
<v-card class="screen-modal">
|
|
246
265
|
<v-card-title>Assign Role</v-card-title>
|
|
@@ -578,6 +597,24 @@ function setMember({
|
|
|
578
597
|
if (dialog) refreshRoles();
|
|
579
598
|
}
|
|
580
599
|
|
|
600
|
+
/* Site access (owner request 2026-09-28): on the organisation-level Members
|
|
601
|
+
screen, whoever may assign roles can choose a member's sites. The server
|
|
602
|
+
decides again (owner / org-wide admin only). */
|
|
603
|
+
const siteAccessAllowed = computed(() => props.assignRole && !props.siteId);
|
|
604
|
+
const siteAccessDialog = ref(false);
|
|
605
|
+
const siteAccessUser = ref("");
|
|
606
|
+
const siteAccessName = ref("");
|
|
607
|
+
function openSiteAccess(item: Record<string, any>) {
|
|
608
|
+
siteAccessUser.value = String(item.user ?? "");
|
|
609
|
+
siteAccessName.value = String(item.name || item.email || "");
|
|
610
|
+
siteAccessDialog.value = true;
|
|
611
|
+
}
|
|
612
|
+
function onSiteAccessSaved(msg: string) {
|
|
613
|
+
siteAccessDialog.value = false;
|
|
614
|
+
showMessage(msg, "success");
|
|
615
|
+
getAll();
|
|
616
|
+
}
|
|
617
|
+
|
|
581
618
|
const roles = ref<Array<Record<string, any>>>([]);
|
|
582
619
|
// Web audit M1: the member's own role is always offered, even when the
|
|
583
620
|
// site-scoped list below does not return it (widen-only, `utils/role.ts`).
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card class="screen-modal" data-test="site-access">
|
|
3
|
+
<v-card-title>Site access{{ props.name ? ` — ${props.name}` : "" }}</v-card-title>
|
|
4
|
+
<v-card-text class="screen-modal__body">
|
|
5
|
+
<p class="text-body-2 mb-3">
|
|
6
|
+
Choose the sites this person can open and their role at each. They will see only these sites.
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<div v-if="loading" class="py-4 text-center"><v-progress-circular indeterminate /></div>
|
|
10
|
+
<p v-else-if="loadError" class="text-error">{{ loadError }}</p>
|
|
11
|
+
<template v-else>
|
|
12
|
+
<div v-if="orgWide.length" class="mb-4" data-test="site-access-org-wide">
|
|
13
|
+
<p class="text-body-2 mb-1">
|
|
14
|
+
<strong>Organisation access:</strong>
|
|
15
|
+
{{ orgWide.map((m) => m.roleName || "role").join(", ") }} — this reaches <strong>every</strong> site.
|
|
16
|
+
</p>
|
|
17
|
+
<v-checkbox
|
|
18
|
+
v-model="removeOrgAccess"
|
|
19
|
+
density="compact"
|
|
20
|
+
hide-details
|
|
21
|
+
label="Remove organisation access and limit this person to the sites below"
|
|
22
|
+
/>
|
|
23
|
+
<p v-if="removeOrgAccess" class="text-caption">
|
|
24
|
+
They will no longer appear on the Organization app's Members list. You can change their sites again here from any site's member, or invite them again.
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<p v-if="!choices.length" class="text-body-2">This organisation has no sites yet.</p>
|
|
29
|
+
|
|
30
|
+
<div v-else>
|
|
31
|
+
<div class="d-flex align-center ga-2 mb-2">
|
|
32
|
+
<v-select
|
|
33
|
+
v-model="sameRole"
|
|
34
|
+
:items="sharedNames"
|
|
35
|
+
label="Same role for all selected sites"
|
|
36
|
+
density="compact"
|
|
37
|
+
hide-details
|
|
38
|
+
clearable
|
|
39
|
+
class="filter-field"
|
|
40
|
+
data-test="site-access-same-role"
|
|
41
|
+
/>
|
|
42
|
+
<v-btn variant="outlined" :disabled="!sameRole" @click="applySame()">Apply</v-btn>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<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
|
+
<v-checkbox v-model="choices[i].on" density="compact" hide-details :label="c.name" style="min-width: 220px" />
|
|
47
|
+
<v-select
|
|
48
|
+
v-if="c.roles.length"
|
|
49
|
+
v-model="choices[i].role"
|
|
50
|
+
:items="c.roles"
|
|
51
|
+
item-title="name"
|
|
52
|
+
item-value="_id"
|
|
53
|
+
:disabled="!c.on"
|
|
54
|
+
label="Role"
|
|
55
|
+
density="compact"
|
|
56
|
+
hide-details
|
|
57
|
+
class="filter-field"
|
|
58
|
+
/>
|
|
59
|
+
<span v-else class="text-caption">No roles created for {{ c.name }} yet — create one in that site's Roles & Permissions.</span>
|
|
60
|
+
</div>
|
|
61
|
+
<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.
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<p v-if="missing.length" class="text-error text-caption mt-2">Choose a role for: {{ missing.join(", ") }}</p>
|
|
67
|
+
<p v-if="saveError" class="text-error mt-2">{{ saveError }}</p>
|
|
68
|
+
</template>
|
|
69
|
+
</v-card-text>
|
|
70
|
+
<div class="screen-modal__footer">
|
|
71
|
+
<v-btn class="screen-btn-ghost" variant="outlined" @click="emit('cancel')">Cancel</v-btn>
|
|
72
|
+
<v-btn class="screen-btn-primary" variant="flat" :loading="saving" :disabled="loading || !!loadError || missing.length > 0" @click="save()">
|
|
73
|
+
Save
|
|
74
|
+
</v-btn>
|
|
75
|
+
</div>
|
|
76
|
+
</v-card>
|
|
77
|
+
</template>
|
|
78
|
+
|
|
79
|
+
<script setup lang="ts">
|
|
80
|
+
// Relative import: this layer's `utils/` is not auto-imported into consumers.
|
|
81
|
+
import { applySameRole, initialChoices, sharedRoleNames, toPayload, type SiteChoice } from "../utils/site-access";
|
|
82
|
+
|
|
83
|
+
const props = defineProps({
|
|
84
|
+
org: { type: String, required: true },
|
|
85
|
+
user: { type: String, required: true },
|
|
86
|
+
name: { type: String, default: "" },
|
|
87
|
+
});
|
|
88
|
+
const emit = defineEmits<{ (e: "cancel"): void; (e: "saved", message: string): void }>();
|
|
89
|
+
|
|
90
|
+
const { getSiteAccess, saveSiteAccess } = useMember();
|
|
91
|
+
|
|
92
|
+
const loading = ref(true);
|
|
93
|
+
const loadError = ref("");
|
|
94
|
+
const saving = ref(false);
|
|
95
|
+
const saveError = ref("");
|
|
96
|
+
const choices = ref<SiteChoice[]>([]);
|
|
97
|
+
const orgWide = ref<Array<{ roleName?: string }>>([]);
|
|
98
|
+
const removeOrgAccess = ref(false);
|
|
99
|
+
const sameRole = ref<string | null>(null);
|
|
100
|
+
|
|
101
|
+
const sharedNames = computed(() => sharedRoleNames(choices.value));
|
|
102
|
+
const missing = computed(() => toPayload(choices.value).missing);
|
|
103
|
+
|
|
104
|
+
function applySame() {
|
|
105
|
+
if (sameRole.value) choices.value = applySameRole(choices.value, sameRole.value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function load() {
|
|
109
|
+
loading.value = true;
|
|
110
|
+
loadError.value = "";
|
|
111
|
+
try {
|
|
112
|
+
const res = await getSiteAccess(props.org, props.user);
|
|
113
|
+
choices.value = initialChoices(res?.sites ?? []);
|
|
114
|
+
orgWide.value = res?.orgWide ?? [];
|
|
115
|
+
} catch (e: any) {
|
|
116
|
+
loadError.value = e?.data?.message || "Site access could not be loaded. Only the organisation's owner or an admin can change it.";
|
|
117
|
+
}
|
|
118
|
+
loading.value = false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function save() {
|
|
122
|
+
saving.value = true;
|
|
123
|
+
saveError.value = "";
|
|
124
|
+
try {
|
|
125
|
+
const { sites } = toPayload(choices.value);
|
|
126
|
+
await saveSiteAccess({ org: props.org, user: props.user, sites, removeOrgAccess: removeOrgAccess.value });
|
|
127
|
+
emit("saved", sites.length ? `Site access saved: ${sites.length} site(s).` : "Site access saved: no sites.");
|
|
128
|
+
} catch (e: any) {
|
|
129
|
+
saveError.value = e?.data?.message || "Site access could not be saved.";
|
|
130
|
+
}
|
|
131
|
+
saving.value = false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
load();
|
|
135
|
+
</script>
|
package/composables/useMember.ts
CHANGED
|
@@ -173,7 +173,30 @@ export default function useMember() {
|
|
|
173
173
|
);
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/** One member's sites and the role at each (org owner / admin only). */
|
|
177
|
+
function getSiteAccess(org: string, user: string) {
|
|
178
|
+
return useNuxtApp().$api<Record<string, any>>("/api/members/site-access", {
|
|
179
|
+
method: "GET",
|
|
180
|
+
query: { org, user },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Replace one member's sites: [{ site, role }]; optionally drop org-wide access. */
|
|
185
|
+
function saveSiteAccess(body: {
|
|
186
|
+
org: string;
|
|
187
|
+
user: string;
|
|
188
|
+
sites: Array<{ site: string; role: string }>;
|
|
189
|
+
removeOrgAccess?: boolean;
|
|
190
|
+
}) {
|
|
191
|
+
return useNuxtApp().$api<Record<string, any>>("/api/members/site-access", {
|
|
192
|
+
method: "PUT",
|
|
193
|
+
body,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
176
197
|
return {
|
|
198
|
+
getSiteAccess,
|
|
199
|
+
saveSiteAccess,
|
|
177
200
|
members,
|
|
178
201
|
member,
|
|
179
202
|
page,
|
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.68.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/role.ts
CHANGED
|
@@ -76,12 +76,13 @@ export function otherSiteBannerText(count: unknown): string | null {
|
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
78
|
* Can this member's CURRENT role be used on the screen's site? The server's
|
|
79
|
-
* `roleState` is
|
|
79
|
+
* `roleState` is "ok" (or absent, older servers) for a usable role and names the problem otherwise
|
|
80
80
|
* ("other-site", "other-org", "deleted", "missing"). An unusable role is never
|
|
81
81
|
* pre-filled in Assign Role nor offered in its list (owner report 2026-09-28).
|
|
82
82
|
*/
|
|
83
83
|
export function roleUsableHere(member: { role?: unknown; roleState?: unknown }): boolean {
|
|
84
|
-
|
|
84
|
+
// The server sends "ok" for a usable role (older servers send nothing).
|
|
85
|
+
return !!member?.role && (!member?.roleState || member.roleState === "ok");
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
/** Assign Role's empty state when the site has no role of its own yet. */
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
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).
|
|
5
|
+
*/
|
|
6
|
+
export type SiteAccessRole = { _id: string; name: string; type?: string };
|
|
7
|
+
export type SiteAccessRow = {
|
|
8
|
+
site: string;
|
|
9
|
+
name: string;
|
|
10
|
+
membership: { role: string; roleName?: string } | null;
|
|
11
|
+
roles: SiteAccessRole[];
|
|
12
|
+
};
|
|
13
|
+
export type SiteChoice = { site: string; name: string; on: boolean; role: string; roles: SiteAccessRole[]; replacedRole?: string };
|
|
14
|
+
|
|
15
|
+
/** Start from what the member has now. A current role that is not one of the site's roles is dropped. */
|
|
16
|
+
export function initialChoices(rows: SiteAccessRow[]): SiteChoice[] {
|
|
17
|
+
return (rows ?? []).map((r) => {
|
|
18
|
+
const current = r.membership?.role ?? "";
|
|
19
|
+
const valid = r.roles.some((x) => x._id === current);
|
|
20
|
+
return {
|
|
21
|
+
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
|
|
24
|
+
...(r.membership && !valid ? { replacedRole: r.membership.roleName || "their current role" } : {}),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
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
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Give every selected site the role with this name, where that site has one. */
|
|
37
|
+
export function applySameRole(choices: SiteChoice[], roleName: string): SiteChoice[] {
|
|
38
|
+
return choices.map((c) => {
|
|
39
|
+
if (!c.on) return c;
|
|
40
|
+
const match = c.roles.find((r) => r.name === roleName);
|
|
41
|
+
return match ? { ...c, role: match._id } : c;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What to send, and what still blocks saving (a chosen site with no role). */
|
|
46
|
+
export function toPayload(choices: SiteChoice[]): { sites: Array<{ site: string; role: string }>; missing: string[] } {
|
|
47
|
+
const chosen = choices.filter((c) => c.on);
|
|
48
|
+
return {
|
|
49
|
+
sites: chosen.filter((c) => c.role).map((c) => ({ site: c.site, role: c.role })),
|
|
50
|
+
missing: chosen.filter((c) => !c.role).map((c) => c.name),
|
|
51
|
+
};
|
|
52
|
+
}
|