@7365admin1/layer-common 4.74.0 → 4.75.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.75.0
4
+
5
+ ### Minor Changes
6
+
7
+ - aa8c658: Organization app Members: lists every person of the organisation, including people limited to specific sites (one row each, labelled "Limited to: Site A, Site B"; organisation-wide people read "All sites"); a Site access button on every row; the actions column stays pinned on the right; shorter dates; at organisation level the "role from another site" banner is gone and such a role reads "Security (The Loft)". Needs core with `GET /members?people=1` (older servers refuse the flag).
8
+
3
9
  ## 4.74.0
4
10
 
5
11
  ### Minor Changes
@@ -1896,11 +1902,11 @@
1896
1902
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1897
1903
  own rule from two endpoints the console already calls, unprojected:
1898
1904
 
1899
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1900
- GET /api/roles/id/:role that membership's role document
1905
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1906
+ GET /api/roles/id/:role that membership's role document
1901
1907
 
1902
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1903
- staff = member.type === "admin" && role.type === "admin"
1908
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1909
+ staff = member.type === "admin" && role.type === "admin"
1904
1910
 
1905
1911
  `role.default` is the marker because it is the only property of a platform
1906
1912
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -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"
@@ -100,6 +100,8 @@
100
100
 
101
101
  <div class="app-table-scroll">
102
102
  <v-data-table
103
+ class="member-table"
104
+ :class="{ 'member-table--pin-action': actionLast }"
103
105
  :headers="props.headers"
104
106
  :items="items"
105
107
  item-value="_id"
@@ -118,15 +120,21 @@
118
120
  (memberRoleChip in utils/role.ts, server roleState). -->
119
121
  <template #item.roleName="{ item }">
120
122
  <StatusChip
121
- v-if="memberRoleChip(item)"
123
+ v-if="memberRoleChip(item, orgLevel)"
122
124
  :dot="false"
123
125
  pill
124
- :tone="memberRoleChip(item)?.tone"
126
+ :tone="memberRoleChip(item, orgLevel)?.tone"
127
+ class="member-role-chip"
125
128
  data-test="member-role-chip"
126
129
  >
127
- {{ memberRoleChip(item)?.text }}
130
+ {{ memberRoleChip(item, orgLevel)?.text }}
128
131
  </StatusChip>
129
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>
130
138
  <template #item.dateInvited="{ item }">
131
139
  {{ formatDateInvited(item.dateInvited) }}
132
140
  </template>
@@ -134,6 +142,18 @@
134
142
  <span data-test="member-last-active">{{ formatLastActive(item.lastLoginAt) }}</span>
135
143
  </template>
136
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>
137
157
  <!--
138
158
  `props.assignRole` belongs in this test. The menu's only item that
139
159
  is not status-dependent is Assign Role, but the menu itself opened
@@ -142,12 +162,12 @@
142
162
  and the permission was unreachable.
143
163
  -->
144
164
  <v-menu
145
- v-if="
165
+ v-if="!isLimited(item) && (
146
166
  props.assignRole ||
147
167
  (currentStatus === 'active' &&
148
168
  (canSuspendMembers || canDeleteMembers)) ||
149
169
  (currentStatus === 'suspended' &&
150
- (canActivateMembers || canDeleteMembers))
170
+ (canActivateMembers || canDeleteMembers)))
151
171
  "
152
172
  v-model="item.menuOpen"
153
173
  :close-on-content-click="false"
@@ -216,6 +236,7 @@
216
236
  </v-list-item>
217
237
  </v-list>
218
238
  </v-menu>
239
+ </div>
219
240
  </template>
220
241
  </v-data-table>
221
242
  </div>
@@ -349,6 +370,8 @@ import {
349
370
  otherSiteBannerText,
350
371
  roleUsableHere,
351
372
  safeReturnTo,
373
+ siteAccessLabel,
374
+ limitedSites,
352
375
  withCurrentRole,
353
376
  } from "../utils/role";
354
377
  import { formatLastActive } from "../utils/last-active";
@@ -457,6 +480,11 @@ const props = defineProps({
457
480
  },
458
481
  });
459
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");
460
488
  const items = ref<Array<Record<string, any>>>([]);
461
489
  const onlyOtherSite = ref(false);
462
490
  const page = ref(1);
@@ -533,6 +561,8 @@ const {
533
561
  type: props.type,
534
562
  siteId: props.siteId,
535
563
  roleState: onlyOtherSite.value ? "other-site" : "",
564
+ // Organization app: site-limited people too, one row each (`limitedTo`).
565
+ people: orgLevel.value,
536
566
  }),
537
567
  {
538
568
  watch: [() => props.status, () => props.orgId, () => props.siteId, headerSearch, onlyOtherSite],
@@ -548,7 +578,7 @@ const {
548
578
  const { data: otherSiteReq, refresh: refreshOtherSite } = useAsyncData(
549
579
  `members-other-site-${props.type}-${props.orgId}-${props.siteId}-${props.status}`,
550
580
  () =>
551
- props.assignRole && props.orgId
581
+ props.assignRole && props.orgId && !orgLevel.value
552
582
  ? _getAll({
553
583
  status: props.status,
554
584
  org: props.orgId,
@@ -632,6 +662,12 @@ function setMember({
632
662
  screen, whoever may assign roles can choose a member's sites. The server
633
663
  decides again (owner / org-wide admin only). */
634
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");
635
671
  const siteAccessDialog = ref(false);
636
672
  const siteAccessUser = ref("");
637
673
  const siteAccessName = ref("");
@@ -777,20 +813,16 @@ function formatDateInvited(value?: string | null) {
777
813
  if (Number.isNaN(date.getTime())) return "";
778
814
 
779
815
  const parts = new Intl.DateTimeFormat("en-US", {
780
- month: "long",
816
+ month: "short",
781
817
  day: "numeric",
782
818
  year: "numeric",
783
- hour: "2-digit",
784
- minute: "2-digit",
785
- hour12: true,
786
819
  }).formatToParts(date);
787
820
 
788
821
  const getPart = (type: Intl.DateTimeFormatPartTypes) =>
789
822
  parts.find((part) => part.type === type)?.value ?? "";
790
823
 
791
- return `${getPart("month")} ${getPart("day")}, ${getPart("year")} ${getPart(
792
- "hour"
793
- )}:${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")}`;
794
826
  }
795
827
 
796
828
  async function handleUpdateMemberStatus() {
@@ -826,3 +858,23 @@ const updateActionText = computed(() => {
826
858
  return map[updateAction.value] || updateAction.value;
827
859
  });
828
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>
@@ -70,9 +70,13 @@ export default function useMember() {
70
70
  status = "active",
71
71
  siteId = "",
72
72
  roleState = "",
73
+ people = false,
73
74
  } = {}) {
74
75
  const query: Record<string, any> = { page, limit, type, status };
75
76
 
77
+ // Organization app Members: every person, site-limited ones as one row
78
+ // with `limitedTo` (core 2026-09-29). Only sent when asked for.
79
+ if (people) query.people = 1;
76
80
  if (search) query.search = search;
77
81
  // "other-site": only members holding a role pinned to another site.
78
82
  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.74.0",
5
+ "version": "4.75.0",
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" };
@@ -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
+ }