@7365admin1/layer-common 4.73.1-staging.453 → 4.73.1-staging.455
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/components/MemberCrossSiteFix.vue +170 -0
- package/components/MemberMain.vue +89 -14
- package/composables/useMember.ts +29 -0
- package/package.json +1 -1
- package/utils/cross-site-fix.ts +45 -0
- package/utils/role.ts +37 -5
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card class="screen-modal" data-test="cross-site-fix">
|
|
3
|
+
<v-card-title>Fix roles from other sites</v-card-title>
|
|
4
|
+
<v-card-text class="screen-modal__body">
|
|
5
|
+
<p class="text-body-2 mb-3">
|
|
6
|
+
These members hold a role created for another site. Each ticked member gets the role shown, at their own
|
|
7
|
+
site. Nobody's site changes. Rows with a warning are not ticked - check them first.
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<div v-if="loading" class="py-4 text-center"><v-progress-circular indeterminate /></div>
|
|
11
|
+
<p v-else-if="loadError" class="text-error">{{ loadError }}</p>
|
|
12
|
+
<template v-else-if="result">
|
|
13
|
+
<p class="text-body-2" data-test="cross-site-fix-result">
|
|
14
|
+
Changed {{ result.applied.length }} member(s).<span v-if="result.skipped.length"> Skipped {{ result.skipped.length }}.</span>
|
|
15
|
+
<span v-if="undone"> Undone: {{ undone }} restored.</span>
|
|
16
|
+
</p>
|
|
17
|
+
<p v-if="result.applied.length" class="text-caption">An undo file was downloaded. Keep it: it puts these roles back.</p>
|
|
18
|
+
<ul v-if="result.skipped.length" class="text-caption">
|
|
19
|
+
<li v-for="s in result.skipped" :key="s.member">{{ nameOf(s.member) }}: {{ s.reason }}</li>
|
|
20
|
+
</ul>
|
|
21
|
+
</template>
|
|
22
|
+
<template v-else>
|
|
23
|
+
<p v-if="!choices.length" class="text-body-2" data-test="cross-site-fix-empty">
|
|
24
|
+
No members here hold a role from another site.
|
|
25
|
+
</p>
|
|
26
|
+
<v-table v-else density="compact">
|
|
27
|
+
<thead>
|
|
28
|
+
<tr>
|
|
29
|
+
<th><span class="d-sr-only">Fix</span></th>
|
|
30
|
+
<th>Member</th>
|
|
31
|
+
<th>Site</th>
|
|
32
|
+
<th>From</th>
|
|
33
|
+
<th>To</th>
|
|
34
|
+
<th>Warning</th>
|
|
35
|
+
</tr>
|
|
36
|
+
</thead>
|
|
37
|
+
<tbody>
|
|
38
|
+
<tr v-for="(c, i) in choices" :key="c.member" data-test="cross-site-fix-row">
|
|
39
|
+
<td>
|
|
40
|
+
<v-checkbox v-model="choices[i].on" density="compact" hide-details :aria-label="`Fix ${c.name || c.email}`" />
|
|
41
|
+
</td>
|
|
42
|
+
<td>{{ c.name || c.email }}</td>
|
|
43
|
+
<td>{{ c.siteName }}</td>
|
|
44
|
+
<td>{{ c.from.name }}<span class="text-caption"> - {{ c.from.siteName }}</span></td>
|
|
45
|
+
<td style="min-width: 200px">
|
|
46
|
+
<span v-if="c.to">{{ c.to.name }}</span>
|
|
47
|
+
<v-select
|
|
48
|
+
v-else
|
|
49
|
+
v-model="choices[i].role"
|
|
50
|
+
:items="fixRoleOptions(c)"
|
|
51
|
+
item-title="name"
|
|
52
|
+
item-value="_id"
|
|
53
|
+
label="Role"
|
|
54
|
+
density="compact"
|
|
55
|
+
hide-details
|
|
56
|
+
/>
|
|
57
|
+
</td>
|
|
58
|
+
<td class="text-caption" :class="c.status === 'auto' ? '' : 'text-warning'">{{ fixWarning(c) }}</td>
|
|
59
|
+
</tr>
|
|
60
|
+
</tbody>
|
|
61
|
+
</v-table>
|
|
62
|
+
<p v-if="applyError" class="text-error mt-2">{{ applyError }}</p>
|
|
63
|
+
</template>
|
|
64
|
+
</v-card-text>
|
|
65
|
+
<div class="screen-modal__footer">
|
|
66
|
+
<template v-if="result">
|
|
67
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="!result.applied.length" @click="downloadUndo()">
|
|
68
|
+
Download undo file
|
|
69
|
+
</v-btn>
|
|
70
|
+
<v-btn
|
|
71
|
+
class="screen-btn-ghost"
|
|
72
|
+
variant="outlined"
|
|
73
|
+
:loading="undoing"
|
|
74
|
+
:disabled="!result.applied.length || undone > 0"
|
|
75
|
+
@click="runUndo()"
|
|
76
|
+
>
|
|
77
|
+
Undo
|
|
78
|
+
</v-btn>
|
|
79
|
+
<v-btn class="screen-btn-primary" variant="flat" @click="emit('done')">Close</v-btn>
|
|
80
|
+
</template>
|
|
81
|
+
<template v-else>
|
|
82
|
+
<v-btn class="screen-btn-ghost" variant="outlined" @click="emit('cancel')">Cancel</v-btn>
|
|
83
|
+
<v-btn
|
|
84
|
+
class="screen-btn-primary"
|
|
85
|
+
variant="flat"
|
|
86
|
+
:loading="applying"
|
|
87
|
+
:disabled="loading || !changes.length"
|
|
88
|
+
data-test="cross-site-fix-apply"
|
|
89
|
+
@click="apply()"
|
|
90
|
+
>
|
|
91
|
+
Apply to {{ changes.length }} member(s)
|
|
92
|
+
</v-btn>
|
|
93
|
+
</template>
|
|
94
|
+
</div>
|
|
95
|
+
</v-card>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<script setup lang="ts">
|
|
99
|
+
// Relative import: this layer's `utils/` is not auto-imported into consumers.
|
|
100
|
+
import { fixRoleOptions, fixWarning, initialFixChoices, toFixChanges, type FixChoice } from "../utils/cross-site-fix";
|
|
101
|
+
|
|
102
|
+
const props = defineProps({ org: { type: String, required: true } });
|
|
103
|
+
const emit = defineEmits<{ (e: "cancel"): void; (e: "done"): void }>();
|
|
104
|
+
|
|
105
|
+
const { getCrossSiteRoleFix, applyCrossSiteRoleFix, undoCrossSiteRoleFix } = useMember();
|
|
106
|
+
|
|
107
|
+
type TResult = { applied: any[]; skipped: Array<{ member: string; reason: string }>; undo: any };
|
|
108
|
+
|
|
109
|
+
const loading = ref(true);
|
|
110
|
+
const loadError = ref("");
|
|
111
|
+
const applying = ref(false);
|
|
112
|
+
const applyError = ref("");
|
|
113
|
+
const undoing = ref(false);
|
|
114
|
+
const undone = ref(0);
|
|
115
|
+
const choices = ref<FixChoice[]>([]);
|
|
116
|
+
const result = ref<TResult | null>(null);
|
|
117
|
+
const changes = computed(() => toFixChanges(choices.value));
|
|
118
|
+
const nameOf = (member: string) => {
|
|
119
|
+
const c = choices.value.find((x) => x.member === member);
|
|
120
|
+
return c?.name || c?.email || member;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
async function load() {
|
|
124
|
+
loading.value = true;
|
|
125
|
+
loadError.value = "";
|
|
126
|
+
try {
|
|
127
|
+
const res = await getCrossSiteRoleFix(props.org);
|
|
128
|
+
choices.value = initialFixChoices(res?.rows ?? []);
|
|
129
|
+
} catch (e: any) {
|
|
130
|
+
loadError.value = e?.data?.message || "Could not load. Only the organisation's owner or an organisation admin can do this.";
|
|
131
|
+
}
|
|
132
|
+
loading.value = false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function downloadUndo() {
|
|
136
|
+
if (!result.value) return;
|
|
137
|
+
const blob = new Blob([JSON.stringify(result.value.undo, null, 2)], { type: "application/json" });
|
|
138
|
+
const a = document.createElement("a");
|
|
139
|
+
a.href = URL.createObjectURL(blob);
|
|
140
|
+
a.download = `undo-roles-from-other-sites-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, "-")}.json`;
|
|
141
|
+
a.click();
|
|
142
|
+
URL.revokeObjectURL(a.href);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function apply() {
|
|
146
|
+
applying.value = true;
|
|
147
|
+
applyError.value = "";
|
|
148
|
+
try {
|
|
149
|
+
result.value = (await applyCrossSiteRoleFix(props.org, changes.value)) as TResult;
|
|
150
|
+
if (result.value?.applied?.length) downloadUndo();
|
|
151
|
+
} catch (e: any) {
|
|
152
|
+
applyError.value = e?.data?.message || "The roles could not be changed.";
|
|
153
|
+
}
|
|
154
|
+
applying.value = false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function runUndo() {
|
|
158
|
+
if (!result.value) return;
|
|
159
|
+
undoing.value = true;
|
|
160
|
+
try {
|
|
161
|
+
const res: any = await undoCrossSiteRoleFix(result.value.undo);
|
|
162
|
+
undone.value = res?.restored?.length ?? 0;
|
|
163
|
+
} catch (e: any) {
|
|
164
|
+
applyError.value = e?.data?.message || "Undo failed.";
|
|
165
|
+
}
|
|
166
|
+
undoing.value = false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
load();
|
|
170
|
+
</script>
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
their access; the company assigns each one a role for this site.
|
|
32
32
|
Shown while any remain (server count), gone at 0. -->
|
|
33
33
|
<v-alert
|
|
34
|
-
v-if="props.assignRole && otherSiteBanner"
|
|
34
|
+
v-if="props.assignRole && otherSiteBanner && !orgLevel"
|
|
35
35
|
type="warning"
|
|
36
36
|
variant="tonal"
|
|
37
37
|
density="compact"
|
|
@@ -46,9 +46,26 @@
|
|
|
46
46
|
>
|
|
47
47
|
{{ onlyOtherSite ? "Show all members" : "Show them" }}
|
|
48
48
|
</AppButton>
|
|
49
|
+
<AppButton
|
|
50
|
+
v-if="props.orgId"
|
|
51
|
+
data-test="member-other-site-fix"
|
|
52
|
+
@click="crossSiteFixDialog = true"
|
|
53
|
+
>
|
|
54
|
+
Fix roles from other sites
|
|
55
|
+
</AppButton>
|
|
49
56
|
</div>
|
|
50
57
|
</v-alert>
|
|
51
58
|
|
|
59
|
+
<!-- 2026-09-29: preview -> apply -> undo; the server decides who may. -->
|
|
60
|
+
<v-dialog v-model="crossSiteFixDialog" max-width="960">
|
|
61
|
+
<MemberCrossSiteFix
|
|
62
|
+
v-if="crossSiteFixDialog"
|
|
63
|
+
:org="props.orgId"
|
|
64
|
+
@cancel="crossSiteFixDialog = false"
|
|
65
|
+
@done="onCrossSiteFixDone"
|
|
66
|
+
/>
|
|
67
|
+
</v-dialog>
|
|
68
|
+
|
|
52
69
|
<AppCard class="table-card">
|
|
53
70
|
<CardToolbar :count="pageRange">
|
|
54
71
|
<template #tabs>
|
|
@@ -83,6 +100,8 @@
|
|
|
83
100
|
|
|
84
101
|
<div class="app-table-scroll">
|
|
85
102
|
<v-data-table
|
|
103
|
+
class="member-table"
|
|
104
|
+
:class="{ 'member-table--pin-action': actionLast }"
|
|
86
105
|
:headers="props.headers"
|
|
87
106
|
:items="items"
|
|
88
107
|
item-value="_id"
|
|
@@ -101,15 +120,21 @@
|
|
|
101
120
|
(memberRoleChip in utils/role.ts, server roleState). -->
|
|
102
121
|
<template #item.roleName="{ item }">
|
|
103
122
|
<StatusChip
|
|
104
|
-
v-if="memberRoleChip(item)"
|
|
123
|
+
v-if="memberRoleChip(item, orgLevel)"
|
|
105
124
|
:dot="false"
|
|
106
125
|
pill
|
|
107
|
-
:tone="memberRoleChip(item)?.tone"
|
|
126
|
+
:tone="memberRoleChip(item, orgLevel)?.tone"
|
|
127
|
+
class="member-role-chip"
|
|
108
128
|
data-test="member-role-chip"
|
|
109
129
|
>
|
|
110
|
-
{{ memberRoleChip(item)?.text }}
|
|
130
|
+
{{ memberRoleChip(item, orgLevel)?.text }}
|
|
111
131
|
</StatusChip>
|
|
112
132
|
</template>
|
|
133
|
+
<!-- Organization app: every person, and where they may go
|
|
134
|
+
(owner report 2026-09-29: a site-limited person stays listed). -->
|
|
135
|
+
<template v-if="orgLevel" #item.siteName="{ item }">
|
|
136
|
+
<span data-test="member-site-scope">{{ siteAccessLabel(item) }}</span>
|
|
137
|
+
</template>
|
|
113
138
|
<template #item.dateInvited="{ item }">
|
|
114
139
|
{{ formatDateInvited(item.dateInvited) }}
|
|
115
140
|
</template>
|
|
@@ -117,6 +142,18 @@
|
|
|
117
142
|
<span data-test="member-last-active">{{ formatLastActive(item.lastLoginAt) }}</span>
|
|
118
143
|
</template>
|
|
119
144
|
<template #item.action-table="{ item }">
|
|
145
|
+
<div class="d-flex align-center justify-end ga-1">
|
|
146
|
+
<!-- The main task on the organisation's Members list, so it is a
|
|
147
|
+
button on every row rather than only inside the menu. -->
|
|
148
|
+
<AppButton
|
|
149
|
+
v-if="siteAccessAllowed && item.user"
|
|
150
|
+
variant="ghost"
|
|
151
|
+
icon="mdi-map-marker-multiple-outline"
|
|
152
|
+
data-test="member-site-access-button"
|
|
153
|
+
@click="openSiteAccess(item)"
|
|
154
|
+
>
|
|
155
|
+
Site access
|
|
156
|
+
</AppButton>
|
|
120
157
|
<!--
|
|
121
158
|
`props.assignRole` belongs in this test. The menu's only item that
|
|
122
159
|
is not status-dependent is Assign Role, but the menu itself opened
|
|
@@ -125,12 +162,12 @@
|
|
|
125
162
|
and the permission was unreachable.
|
|
126
163
|
-->
|
|
127
164
|
<v-menu
|
|
128
|
-
v-if="
|
|
165
|
+
v-if="!isLimited(item) && (
|
|
129
166
|
props.assignRole ||
|
|
130
167
|
(currentStatus === 'active' &&
|
|
131
168
|
(canSuspendMembers || canDeleteMembers)) ||
|
|
132
169
|
(currentStatus === 'suspended' &&
|
|
133
|
-
(canActivateMembers || canDeleteMembers))
|
|
170
|
+
(canActivateMembers || canDeleteMembers)))
|
|
134
171
|
"
|
|
135
172
|
v-model="item.menuOpen"
|
|
136
173
|
:close-on-content-click="false"
|
|
@@ -199,6 +236,7 @@
|
|
|
199
236
|
</v-list-item>
|
|
200
237
|
</v-list>
|
|
201
238
|
</v-menu>
|
|
239
|
+
</div>
|
|
202
240
|
</template>
|
|
203
241
|
</v-data-table>
|
|
204
242
|
</div>
|
|
@@ -332,6 +370,8 @@ import {
|
|
|
332
370
|
otherSiteBannerText,
|
|
333
371
|
roleUsableHere,
|
|
334
372
|
safeReturnTo,
|
|
373
|
+
siteAccessLabel,
|
|
374
|
+
limitedSites,
|
|
335
375
|
withCurrentRole,
|
|
336
376
|
} from "../utils/role";
|
|
337
377
|
import { formatLastActive } from "../utils/last-active";
|
|
@@ -440,6 +480,11 @@ const props = defineProps({
|
|
|
440
480
|
},
|
|
441
481
|
});
|
|
442
482
|
|
|
483
|
+
/* The Organization app's Members list: no site, organisation memberships. It
|
|
484
|
+
lists every person (site-limited ones as one row with `limitedTo`), labels
|
|
485
|
+
where each may go, and has no "role from another site" banner - there is no
|
|
486
|
+
"this site" here. */
|
|
487
|
+
const orgLevel = computed(() => !props.siteId && props.type === "organization");
|
|
443
488
|
const items = ref<Array<Record<string, any>>>([]);
|
|
444
489
|
const onlyOtherSite = ref(false);
|
|
445
490
|
const page = ref(1);
|
|
@@ -516,6 +561,8 @@ const {
|
|
|
516
561
|
type: props.type,
|
|
517
562
|
siteId: props.siteId,
|
|
518
563
|
roleState: onlyOtherSite.value ? "other-site" : "",
|
|
564
|
+
// Organization app: site-limited people too, one row each (`limitedTo`).
|
|
565
|
+
people: orgLevel.value,
|
|
519
566
|
}),
|
|
520
567
|
{
|
|
521
568
|
watch: [() => props.status, () => props.orgId, () => props.siteId, headerSearch, onlyOtherSite],
|
|
@@ -531,7 +578,7 @@ const {
|
|
|
531
578
|
const { data: otherSiteReq, refresh: refreshOtherSite } = useAsyncData(
|
|
532
579
|
`members-other-site-${props.type}-${props.orgId}-${props.siteId}-${props.status}`,
|
|
533
580
|
() =>
|
|
534
|
-
props.assignRole && props.orgId
|
|
581
|
+
props.assignRole && props.orgId && !orgLevel.value
|
|
535
582
|
? _getAll({
|
|
536
583
|
status: props.status,
|
|
537
584
|
org: props.orgId,
|
|
@@ -551,6 +598,12 @@ watch(otherSiteBanner, (text) => {
|
|
|
551
598
|
// the last one fixed: back to the full list
|
|
552
599
|
if (!text && onlyOtherSite.value) onlyOtherSite.value = false;
|
|
553
600
|
});
|
|
601
|
+
const crossSiteFixDialog = ref(false);
|
|
602
|
+
function onCrossSiteFixDone() {
|
|
603
|
+
crossSiteFixDialog.value = false;
|
|
604
|
+
getAll();
|
|
605
|
+
refreshOtherSite();
|
|
606
|
+
}
|
|
554
607
|
function toggleOtherSite() {
|
|
555
608
|
page.value = 1;
|
|
556
609
|
onlyOtherSite.value = !onlyOtherSite.value;
|
|
@@ -609,6 +662,12 @@ function setMember({
|
|
|
609
662
|
screen, whoever may assign roles can choose a member's sites. The server
|
|
610
663
|
decides again (owner / org-wide admin only). */
|
|
611
664
|
const siteAccessAllowed = computed(() => props.assignRole && !props.siteId);
|
|
665
|
+
/* A site-limited person's row stands for several per-site memberships, so the
|
|
666
|
+
one-membership actions (Assign Role, Suspend, Delete) are not offered on it;
|
|
667
|
+
Site access manages them. */
|
|
668
|
+
const isLimited = (item: Record<string, any>) => orgLevel.value && limitedSites(item).length > 0;
|
|
669
|
+
/* Keep the actions column on screen however wide the table gets. */
|
|
670
|
+
const actionLast = computed(() => props.headers[props.headers.length - 1]?.value === "action-table");
|
|
612
671
|
const siteAccessDialog = ref(false);
|
|
613
672
|
const siteAccessUser = ref("");
|
|
614
673
|
const siteAccessName = ref("");
|
|
@@ -754,20 +813,16 @@ function formatDateInvited(value?: string | null) {
|
|
|
754
813
|
if (Number.isNaN(date.getTime())) return "";
|
|
755
814
|
|
|
756
815
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
757
|
-
month: "
|
|
816
|
+
month: "short",
|
|
758
817
|
day: "numeric",
|
|
759
818
|
year: "numeric",
|
|
760
|
-
hour: "2-digit",
|
|
761
|
-
minute: "2-digit",
|
|
762
|
-
hour12: true,
|
|
763
819
|
}).formatToParts(date);
|
|
764
820
|
|
|
765
821
|
const getPart = (type: Intl.DateTimeFormatPartTypes) =>
|
|
766
822
|
parts.find((part) => part.type === type)?.value ?? "";
|
|
767
823
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
)}:${getPart("minute")}${getPart("dayPeriod").toUpperCase()}`;
|
|
824
|
+
// Short ("Sep 29, 2026") so the table fits and the actions stay in view.
|
|
825
|
+
return `${getPart("month")} ${getPart("day")}, ${getPart("year")}`;
|
|
771
826
|
}
|
|
772
827
|
|
|
773
828
|
async function handleUpdateMemberStatus() {
|
|
@@ -803,3 +858,23 @@ const updateActionText = computed(() => {
|
|
|
803
858
|
return map[updateAction.value] || updateAction.value;
|
|
804
859
|
});
|
|
805
860
|
</script>
|
|
861
|
+
|
|
862
|
+
<style scoped>
|
|
863
|
+
/* The actions column stays pinned to the right edge (owner report 2026-09-29:
|
|
864
|
+
at 1151px the menu sat off-screen behind a sideways scroll). */
|
|
865
|
+
.member-table--pin-action :deep(th:last-child),
|
|
866
|
+
.member-table--pin-action :deep(td:last-child) {
|
|
867
|
+
position: sticky;
|
|
868
|
+
right: 0;
|
|
869
|
+
z-index: 1;
|
|
870
|
+
background: rgb(var(--v-theme-surface));
|
|
871
|
+
box-shadow: -1px 0 0 rgba(var(--v-border-color), var(--v-border-opacity));
|
|
872
|
+
}
|
|
873
|
+
.member-table--pin-action :deep(thead th:last-child) {
|
|
874
|
+
z-index: 3;
|
|
875
|
+
}
|
|
876
|
+
.member-table :deep(.member-role-chip) {
|
|
877
|
+
white-space: normal;
|
|
878
|
+
height: auto;
|
|
879
|
+
}
|
|
880
|
+
</style>
|
package/composables/useMember.ts
CHANGED
|
@@ -72,9 +72,13 @@ export default function useMember() {
|
|
|
72
72
|
roleState = "",
|
|
73
73
|
/** A building unit id, or "none": members on no unit yet. */
|
|
74
74
|
unit = "",
|
|
75
|
+
people = false,
|
|
75
76
|
} = {}) {
|
|
76
77
|
const query: Record<string, any> = { page, limit, type, status };
|
|
77
78
|
|
|
79
|
+
// Organization app Members: every person, site-limited ones as one row
|
|
80
|
+
// with `limitedTo` (core 2026-09-29). Only sent when asked for.
|
|
81
|
+
if (people) query.people = 1;
|
|
78
82
|
if (search) query.search = search;
|
|
79
83
|
// "other-site": only members holding a role pinned to another site.
|
|
80
84
|
if (roleState) query.roleState = roleState;
|
|
@@ -205,7 +209,32 @@ export default function useMember() {
|
|
|
205
209
|
});
|
|
206
210
|
}
|
|
207
211
|
|
|
212
|
+
/** "Fix roles from other sites": preview (read only), apply, undo. */
|
|
213
|
+
function getCrossSiteRoleFix(org: string) {
|
|
214
|
+
return useNuxtApp().$api<Record<string, any>>("/api/members/cross-site-roles/fix", {
|
|
215
|
+
method: "GET",
|
|
216
|
+
query: { org },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function applyCrossSiteRoleFix(org: string, changes: Array<{ member: string; to: string }>) {
|
|
221
|
+
return useNuxtApp().$api<Record<string, any>>("/api/members/cross-site-roles/fix", {
|
|
222
|
+
method: "POST",
|
|
223
|
+
body: { org, changes },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function undoCrossSiteRoleFix(body: { org: string; changes: Array<{ member: string; from: string; to: string }> }) {
|
|
228
|
+
return useNuxtApp().$api<Record<string, any>>("/api/members/cross-site-roles/undo", {
|
|
229
|
+
method: "POST",
|
|
230
|
+
body,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
208
234
|
return {
|
|
235
|
+
getCrossSiteRoleFix,
|
|
236
|
+
applyCrossSiteRoleFix,
|
|
237
|
+
undoCrossSiteRoleFix,
|
|
209
238
|
getSiteAccess,
|
|
210
239
|
saveSiteAccess,
|
|
211
240
|
updateMemberUnit,
|
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.73.1-staging.
|
|
5
|
+
"version": "4.73.1-staging.455",
|
|
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,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Fix roles from other sites" (owner request 2026-09-29, Aigle Security: 17
|
|
3
|
+
* members still held The Loft's "Security" role although each site has its
|
|
4
|
+
* own). The server proposes one role per member (`GET
|
|
5
|
+
* /api/members/cross-site-roles/fix`); these are the dialog's rules.
|
|
6
|
+
*
|
|
7
|
+
* Only "auto" rows start ticked. A "review" row (the new role grants FEWER
|
|
8
|
+
* permissions) and a "choose"/"none" row (several or no candidates) are never
|
|
9
|
+
* applied unless somebody ticks them and, for choose/none, picks a role.
|
|
10
|
+
*/
|
|
11
|
+
export type FixRole = { _id: string; name: string };
|
|
12
|
+
export type FixRow = {
|
|
13
|
+
member: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
email?: string;
|
|
16
|
+
siteName?: string;
|
|
17
|
+
from: { _id: string; name: string; siteName?: string };
|
|
18
|
+
to: FixRole | null;
|
|
19
|
+
status: "auto" | "review" | "choose" | "none";
|
|
20
|
+
lost?: string[];
|
|
21
|
+
options?: Array<FixRole | null>;
|
|
22
|
+
};
|
|
23
|
+
export type FixChoice = FixRow & { on: boolean; role: string };
|
|
24
|
+
|
|
25
|
+
export function initialFixChoices(rows: FixRow[]): FixChoice[] {
|
|
26
|
+
return (rows ?? []).map((r) => ({ ...r, on: r.status === "auto", role: r.to?._id ?? "" }));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The roles offered for a row: the proposal, else the options the server listed. */
|
|
30
|
+
export function fixRoleOptions(row: FixRow): FixRole[] {
|
|
31
|
+
return row.to ? [row.to] : (row.options ?? []).filter((o): o is FixRole => !!o);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What one row's warning says, in plain words ("" when none). */
|
|
35
|
+
export function fixWarning(row: FixRow): string {
|
|
36
|
+
if (row.status === "review") return `Fewer permissions: loses ${(row.lost ?? []).join(", ")}`;
|
|
37
|
+
if (row.status === "choose") return "Several roles match - choose one";
|
|
38
|
+
if (row.status === "none") return "No matching role at this site - choose one, or create it first";
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The POST body's changes: ticked rows with a role. */
|
|
43
|
+
export function toFixChanges(choices: FixChoice[]): Array<{ member: string; to: string }> {
|
|
44
|
+
return choices.filter((c) => c.on && c.role).map((c) => ({ member: c.member, to: c.role }));
|
|
45
|
+
}
|
package/utils/role.ts
CHANGED
|
@@ -36,12 +36,28 @@ export function withCurrentRole<T extends { _id?: unknown; name?: unknown }>(
|
|
|
36
36
|
* a member showed "Security" and Assign Role could not fix it). Rows from an
|
|
37
37
|
* older server carry no `roleState` and render exactly as before.
|
|
38
38
|
*/
|
|
39
|
-
export function memberRoleChip(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
export function memberRoleChip(
|
|
40
|
+
item: {
|
|
41
|
+
roleName?: unknown;
|
|
42
|
+
roleState?: unknown;
|
|
43
|
+
roleSiteName?: unknown;
|
|
44
|
+
limitedTo?: unknown;
|
|
45
|
+
},
|
|
46
|
+
/** The Organization app's Members list: there is no "this site" there. */
|
|
47
|
+
orgLevel = false,
|
|
48
|
+
): { text: string; tone: "info" | "warn" } | null {
|
|
44
49
|
const name = typeof item?.roleName === "string" ? item.roleName : "";
|
|
50
|
+
const limited = limitedSites(item);
|
|
51
|
+
if (orgLevel && limited.length) {
|
|
52
|
+
return {
|
|
53
|
+
text: limited.map((l) => `${l.roleName || "No role"} (${l.siteName || "a site"})`).join(", "),
|
|
54
|
+
tone: "info",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (orgLevel && item?.roleState === "other-site") {
|
|
58
|
+
const site = typeof item?.roleSiteName === "string" && item.roleSiteName ? item.roleSiteName : "";
|
|
59
|
+
return { text: site ? `${name || "Role"} (${site})` : name || "Role", tone: "info" };
|
|
60
|
+
}
|
|
45
61
|
switch (item?.roleState) {
|
|
46
62
|
case "missing":
|
|
47
63
|
return { text: "No role assigned - assign one", tone: "warn" };
|
|
@@ -95,3 +111,19 @@ export function safeReturnTo(value: unknown): string {
|
|
|
95
111
|
const path = Array.isArray(value) ? value[0] : value;
|
|
96
112
|
return typeof path === "string" && /^\/(?!\/)[^\s\\]*$/.test(path) ? path : "";
|
|
97
113
|
}
|
|
114
|
+
|
|
115
|
+
type TLimitedSite = { siteId?: string; siteName?: string; roleName?: string };
|
|
116
|
+
|
|
117
|
+
/** The sites a site-limited person may open (server `limitedTo`; org Members). */
|
|
118
|
+
export function limitedSites(item: { limitedTo?: unknown } | null | undefined): TLimitedSite[] {
|
|
119
|
+
return Array.isArray(item?.limitedTo) ? (item!.limitedTo as TLimitedSite[]) : [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The Organization app's Members "Sites" cell (owner report 2026-09-29, Aigle
|
|
124
|
+
* / Kanesan): a person limited to sites stays on the list and says where.
|
|
125
|
+
*/
|
|
126
|
+
export function siteAccessLabel(item: { limitedTo?: unknown } | null | undefined): string {
|
|
127
|
+
const names = limitedSites(item).map((l) => l.siteName || "a site");
|
|
128
|
+
return names.length ? `Limited to: ${names.join(", ")}` : "All sites";
|
|
129
|
+
}
|