@7365admin1/layer-common 4.73.1-staging.454 → 4.73.1-staging.456

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.
@@ -41,6 +41,7 @@
41
41
  <div class="d-flex align-center flex-wrap ga-2">
42
42
  <span class="flex-grow-1">{{ otherSiteBanner }}</span>
43
43
  <AppButton
44
+ v-if="!orgLevel"
44
45
  data-test="member-other-site-filter"
45
46
  @click="toggleOtherSite()"
46
47
  >
@@ -100,6 +101,8 @@
100
101
 
101
102
  <div class="app-table-scroll">
102
103
  <v-data-table
104
+ class="member-table"
105
+ :class="{ 'member-table--pin-action': actionLast }"
103
106
  :headers="props.headers"
104
107
  :items="items"
105
108
  item-value="_id"
@@ -118,15 +121,21 @@
118
121
  (memberRoleChip in utils/role.ts, server roleState). -->
119
122
  <template #item.roleName="{ item }">
120
123
  <StatusChip
121
- v-if="memberRoleChip(item)"
124
+ v-if="memberRoleChip(item, orgLevel)"
122
125
  :dot="false"
123
126
  pill
124
- :tone="memberRoleChip(item)?.tone"
127
+ :tone="memberRoleChip(item, orgLevel)?.tone"
128
+ class="member-role-chip"
125
129
  data-test="member-role-chip"
126
130
  >
127
- {{ memberRoleChip(item)?.text }}
131
+ {{ memberRoleChip(item, orgLevel)?.text }}
128
132
  </StatusChip>
129
133
  </template>
134
+ <!-- Organization app: every person, and where they may go
135
+ (owner report 2026-09-29: a site-limited person stays listed). -->
136
+ <template v-if="orgLevel" #item.siteName="{ item }">
137
+ <span data-test="member-site-scope">{{ siteAccessLabel(item) }}</span>
138
+ </template>
130
139
  <template #item.dateInvited="{ item }">
131
140
  {{ formatDateInvited(item.dateInvited) }}
132
141
  </template>
@@ -134,6 +143,18 @@
134
143
  <span data-test="member-last-active">{{ formatLastActive(item.lastLoginAt) }}</span>
135
144
  </template>
136
145
  <template #item.action-table="{ item }">
146
+ <div class="d-flex align-center justify-end ga-1">
147
+ <!-- The main task on the organisation's Members list, so it is a
148
+ button on every row rather than only inside the menu. -->
149
+ <AppButton
150
+ v-if="siteAccessAllowed && item.user"
151
+ variant="ghost"
152
+ icon="mdi-map-marker-multiple-outline"
153
+ data-test="member-site-access-button"
154
+ @click="openSiteAccess(item)"
155
+ >
156
+ Site access
157
+ </AppButton>
137
158
  <!--
138
159
  `props.assignRole` belongs in this test. The menu's only item that
139
160
  is not status-dependent is Assign Role, but the menu itself opened
@@ -142,12 +163,12 @@
142
163
  and the permission was unreachable.
143
164
  -->
144
165
  <v-menu
145
- v-if="
166
+ v-if="!isLimited(item) && (
146
167
  props.assignRole ||
147
168
  (currentStatus === 'active' &&
148
169
  (canSuspendMembers || canDeleteMembers)) ||
149
170
  (currentStatus === 'suspended' &&
150
- (canActivateMembers || canDeleteMembers))
171
+ (canActivateMembers || canDeleteMembers)))
151
172
  "
152
173
  v-model="item.menuOpen"
153
174
  :close-on-content-click="false"
@@ -216,6 +237,7 @@
216
237
  </v-list-item>
217
238
  </v-list>
218
239
  </v-menu>
240
+ </div>
219
241
  </template>
220
242
  </v-data-table>
221
243
  </div>
@@ -349,6 +371,8 @@ import {
349
371
  otherSiteBannerText,
350
372
  roleUsableHere,
351
373
  safeReturnTo,
374
+ siteAccessLabel,
375
+ limitedSites,
352
376
  withCurrentRole,
353
377
  } from "../utils/role";
354
378
  import { formatLastActive } from "../utils/last-active";
@@ -457,6 +481,11 @@ const props = defineProps({
457
481
  },
458
482
  });
459
483
 
484
+ /* The Organization app's Members list: no site, organisation memberships. It
485
+ lists every person (site-limited ones as one row with `limitedTo`), labels
486
+ where each may go, and has no "role from another site" banner - there is no
487
+ "this site" here. */
488
+ const orgLevel = computed(() => !props.siteId && props.type === "organization");
460
489
  const items = ref<Array<Record<string, any>>>([]);
461
490
  const onlyOtherSite = ref(false);
462
491
  const page = ref(1);
@@ -533,6 +562,8 @@ const {
533
562
  type: props.type,
534
563
  siteId: props.siteId,
535
564
  roleState: onlyOtherSite.value ? "other-site" : "",
565
+ // Organization app: site-limited people too, one row each (`limitedTo`).
566
+ people: orgLevel.value,
536
567
  }),
537
568
  {
538
569
  watch: [() => props.status, () => props.orgId, () => props.siteId, headerSearch, onlyOtherSite],
@@ -561,8 +592,10 @@ const { data: otherSiteReq, refresh: refreshOtherSite } = useAsyncData(
561
592
  : Promise.resolve(null),
562
593
  { watch: [() => props.status, () => props.orgId, () => props.siteId] }
563
594
  );
595
+ // At organisation level there is no "this site": the count says what is wrong
596
+ // and points to "Fix roles from other sites" (no per-site filter there).
564
597
  const otherSiteBanner = computed(() =>
565
- otherSiteBannerText(otherSiteReq.value?.total)
598
+ otherSiteBannerText(otherSiteReq.value?.total, orgLevel.value)
566
599
  );
567
600
  watch(otherSiteBanner, (text) => {
568
601
  // the last one fixed: back to the full list
@@ -632,6 +665,12 @@ function setMember({
632
665
  screen, whoever may assign roles can choose a member's sites. The server
633
666
  decides again (owner / org-wide admin only). */
634
667
  const siteAccessAllowed = computed(() => props.assignRole && !props.siteId);
668
+ /* A site-limited person's row stands for several per-site memberships, so the
669
+ one-membership actions (Assign Role, Suspend, Delete) are not offered on it;
670
+ Site access manages them. */
671
+ const isLimited = (item: Record<string, any>) => orgLevel.value && limitedSites(item).length > 0;
672
+ /* Keep the actions column on screen however wide the table gets. */
673
+ const actionLast = computed(() => props.headers[props.headers.length - 1]?.value === "action-table");
635
674
  const siteAccessDialog = ref(false);
636
675
  const siteAccessUser = ref("");
637
676
  const siteAccessName = ref("");
@@ -777,20 +816,16 @@ function formatDateInvited(value?: string | null) {
777
816
  if (Number.isNaN(date.getTime())) return "";
778
817
 
779
818
  const parts = new Intl.DateTimeFormat("en-US", {
780
- month: "long",
819
+ month: "short",
781
820
  day: "numeric",
782
821
  year: "numeric",
783
- hour: "2-digit",
784
- minute: "2-digit",
785
- hour12: true,
786
822
  }).formatToParts(date);
787
823
 
788
824
  const getPart = (type: Intl.DateTimeFormatPartTypes) =>
789
825
  parts.find((part) => part.type === type)?.value ?? "";
790
826
 
791
- return `${getPart("month")} ${getPart("day")}, ${getPart("year")} ${getPart(
792
- "hour"
793
- )}:${getPart("minute")}${getPart("dayPeriod").toUpperCase()}`;
827
+ // Short ("Sep 29, 2026") so the table fits and the actions stay in view.
828
+ return `${getPart("month")} ${getPart("day")}, ${getPart("year")}`;
794
829
  }
795
830
 
796
831
  async function handleUpdateMemberStatus() {
@@ -826,3 +861,23 @@ const updateActionText = computed(() => {
826
861
  return map[updateAction.value] || updateAction.value;
827
862
  });
828
863
  </script>
864
+
865
+ <style scoped>
866
+ /* The actions column stays pinned to the right edge (owner report 2026-09-29:
867
+ at 1151px the menu sat off-screen behind a sideways scroll). */
868
+ .member-table--pin-action :deep(th:last-child),
869
+ .member-table--pin-action :deep(td:last-child) {
870
+ position: sticky;
871
+ right: 0;
872
+ z-index: 1;
873
+ background: rgb(var(--v-theme-surface));
874
+ box-shadow: -1px 0 0 rgba(var(--v-border-color), var(--v-border-opacity));
875
+ }
876
+ .member-table--pin-action :deep(thead th:last-child) {
877
+ z-index: 3;
878
+ }
879
+ .member-table :deep(.member-role-chip) {
880
+ white-space: normal;
881
+ height: auto;
882
+ }
883
+ </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;
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.454",
5
+ "version": "4.73.1-staging.456",
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
@@ -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" };
@@ -66,9 +82,14 @@ export function memberRoleChip(item: {
66
82
  * re-assign a role for the right site, nobody's access is cut). `null` at 0 so
67
83
  * the banner disappears once every member here has this site's role.
68
84
  */
69
- export function otherSiteBannerText(count: unknown): string | null {
85
+ export function otherSiteBannerText(count: unknown, orgLevel = false): string | null {
70
86
  const n = typeof count === "number" && Number.isFinite(count) ? Math.floor(count) : 0;
71
87
  if (n <= 0) return null;
88
+ if (orgLevel) {
89
+ return n === 1
90
+ ? "1 member holds a role created for one particular site. Fix roles from other sites gives them the right role."
91
+ : `${n} members hold a role created for one particular site. Fix roles from other sites gives each one the right role.`;
92
+ }
72
93
  return n === 1
73
94
  ? "1 member here has a role from another site. Assign them a role for this site."
74
95
  : `${n} members here have a role from another site. Assign each one a role for this site.`;
@@ -95,3 +116,19 @@ export function safeReturnTo(value: unknown): string {
95
116
  const path = Array.isArray(value) ? value[0] : value;
96
117
  return typeof path === "string" && /^\/(?!\/)[^\s\\]*$/.test(path) ? path : "";
97
118
  }
119
+
120
+ type TLimitedSite = { siteId?: string; siteName?: string; roleName?: string };
121
+
122
+ /** The sites a site-limited person may open (server `limitedTo`; org Members). */
123
+ export function limitedSites(item: { limitedTo?: unknown } | null | undefined): TLimitedSite[] {
124
+ return Array.isArray(item?.limitedTo) ? (item!.limitedTo as TLimitedSite[]) : [];
125
+ }
126
+
127
+ /**
128
+ * The Organization app's Members "Sites" cell (owner report 2026-09-29, Aigle
129
+ * / Kanesan): a person limited to sites stays on the list and says where.
130
+ */
131
+ export function siteAccessLabel(item: { limitedTo?: unknown } | null | undefined): string {
132
+ const names = limitedSites(item).map((l) => l.siteName || "a site");
133
+ return names.length ? `Limited to: ${names.join(", ")}` : "All sites";
134
+ }