@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.
@@ -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: "long",
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
- return `${getPart("month")} ${getPart("day")}, ${getPart("year")} ${getPart(
769
- "hour"
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>
@@ -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.453",
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(item: {
40
- roleName?: unknown;
41
- roleState?: unknown;
42
- roleSiteName?: unknown;
43
- }): { text: string; tone: "info" | "warn" } | null {
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
+ }