@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;
@@ -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.452",
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
+ }
@@ -877,20 +877,25 @@ export function mapRobots(
877
877
  });
878
878
  }
879
879
 
880
- /** Robots that answer but cannot report a floor - listed, never placed (a guessed floor is worse than none). */
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 report a floor, so they are listed but not placed on the map." */
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 report a floor, so ${n === 1 ? "it is" : "they are"} listed but not placed on the map.`;
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 */