@7365admin1/layer-common 4.73.1-staging.452 → 4.73.1-staging.454
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.
|
@@ -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>
|
|
@@ -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>
|
|
@@ -551,6 +568,12 @@ watch(otherSiteBanner, (text) => {
|
|
|
551
568
|
// the last one fixed: back to the full list
|
|
552
569
|
if (!text && onlyOtherSite.value) onlyOtherSite.value = false;
|
|
553
570
|
});
|
|
571
|
+
const crossSiteFixDialog = ref(false);
|
|
572
|
+
function onCrossSiteFixDone() {
|
|
573
|
+
crossSiteFixDialog.value = false;
|
|
574
|
+
getAll();
|
|
575
|
+
refreshOtherSite();
|
|
576
|
+
}
|
|
554
577
|
function toggleOtherSite() {
|
|
555
578
|
page.value = 1;
|
|
556
579
|
onlyOtherSite.value = !onlyOtherSite.value;
|
package/composables/useMember.ts
CHANGED
|
@@ -205,7 +205,32 @@ export default function useMember() {
|
|
|
205
205
|
});
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/** "Fix roles from other sites": preview (read only), apply, undo. */
|
|
209
|
+
function getCrossSiteRoleFix(org: string) {
|
|
210
|
+
return useNuxtApp().$api<Record<string, any>>("/api/members/cross-site-roles/fix", {
|
|
211
|
+
method: "GET",
|
|
212
|
+
query: { org },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function applyCrossSiteRoleFix(org: string, changes: Array<{ member: string; to: string }>) {
|
|
217
|
+
return useNuxtApp().$api<Record<string, any>>("/api/members/cross-site-roles/fix", {
|
|
218
|
+
method: "POST",
|
|
219
|
+
body: { org, changes },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function undoCrossSiteRoleFix(body: { org: string; changes: Array<{ member: string; from: string; to: string }> }) {
|
|
224
|
+
return useNuxtApp().$api<Record<string, any>>("/api/members/cross-site-roles/undo", {
|
|
225
|
+
method: "POST",
|
|
226
|
+
body,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
208
230
|
return {
|
|
231
|
+
getCrossSiteRoleFix,
|
|
232
|
+
applyCrossSiteRoleFix,
|
|
233
|
+
undoCrossSiteRoleFix,
|
|
209
234
|
getSiteAccess,
|
|
210
235
|
saveSiteAccess,
|
|
211
236
|
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.454",
|
|
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/robot-display.ts
CHANGED
|
@@ -877,20 +877,25 @@ export function mapRobots(
|
|
|
877
877
|
});
|
|
878
878
|
}
|
|
879
879
|
|
|
880
|
-
/**
|
|
880
|
+
/**
|
|
881
|
+
* Robots that answer but cannot be drawn: no floor (a guessed floor is worse
|
|
882
|
+
* than none), or a floor but no position on the plan (Ounitech reports its
|
|
883
|
+
* floor since the 2025.10 API, but not a plan position). Listed, never placed.
|
|
884
|
+
* An offline robot with a plan position is simply not drawn (never a frozen dot).
|
|
885
|
+
*/
|
|
881
886
|
export function unplacedRobots<T extends { _id?: unknown }>(rows: T[], states: Record<string, TLiveRobotState>): T[] {
|
|
882
887
|
return rows.filter((r) => {
|
|
883
888
|
const live = states[String(r?._id ?? "")];
|
|
884
|
-
return !!live?.ok && live.monitorable !== false && live.floorKnown !== true;
|
|
889
|
+
return !!live?.ok && live.monitorable !== false && (live.floorKnown !== true || !planPoint(live as any));
|
|
885
890
|
});
|
|
886
891
|
}
|
|
887
892
|
|
|
888
|
-
/** "2 robots (Ounitech) can't
|
|
893
|
+
/** "2 robots (Ounitech) can't be placed on the map, so they are listed but not drawn." */
|
|
889
894
|
export function unplacedText(brandNames: string[]): string {
|
|
890
895
|
const n = brandNames.length;
|
|
891
896
|
if (!n) return "";
|
|
892
897
|
const brands = [...new Set(brandNames)].join(", ");
|
|
893
|
-
return `${n} ${n === 1 ? "robot" : "robots"} (${brands}) can't
|
|
898
|
+
return `${n} ${n === 1 ? "robot" : "robots"} (${brands}) can't be placed on the map, so ${n === 1 ? "it is" : "they are"} listed but not drawn.`;
|
|
894
899
|
}
|
|
895
900
|
|
|
896
901
|
/* -------------------------------------------------------- patrol routes */
|