@7365admin1/layer-common 4.80.1 → 4.81.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.81.0
4
+
5
+ ### Minor Changes
6
+
7
+ - db2b9e9: Members table: one compact "Role & access" cell (each role once; one site by name, several as "N sites" opening the full site list), name with email underneath, one-line dates, clean role labels.
8
+
3
9
  ## 4.80.1
4
10
 
5
11
  ### Patch Changes
@@ -1944,11 +1950,11 @@
1944
1950
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1945
1951
  own rule from two endpoints the console already calls, unprojected:
1946
1952
 
1947
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1948
- GET /api/roles/id/:role that membership's role document
1953
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1954
+ GET /api/roles/id/:role that membership's role document
1949
1955
 
1950
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1951
- staff = member.type === "admin" && role.type === "admin"
1956
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1957
+ staff = member.type === "admin" && role.type === "admin"
1952
1958
 
1953
1959
  `role.default` is the marker because it is the only property of a platform
1954
1960
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -115,32 +115,86 @@
115
115
  <template #item.nature="{ item }">
116
116
  {{ replaceMatch(item.nature, "_", " ") }}
117
117
  </template>
118
- <!-- The role is a soft pill in the design, not a plain word. The
119
- member's REAL role name; a deleted / other-organisation / missing
120
- role says so instead of reading as a working grant
121
- (memberRoleChip in utils/role.ts, server roleState). -->
122
- <template #item.roleName="{ item }">
123
- <StatusChip
124
- v-if="memberRoleChip(item, orgLevel)"
125
- :dot="false"
126
- pill
127
- :tone="memberRoleChip(item, orgLevel)?.tone"
128
- class="member-role-chip"
129
- data-test="member-role-chip"
130
- >
131
- {{ memberRoleChip(item, orgLevel)?.text }}
132
- </StatusChip>
118
+ <!-- Name on top, email underneath in grey - only when it is not the
119
+ name again (owner report 2026-09-30). Search still matches the
120
+ email (server side). -->
121
+ <template #item.name="{ item }">
122
+ <div class="member-person" data-test="member-person">
123
+ <div class="member-person__name" :title="memberPerson(item).name">{{ memberPerson(item).name }}</div>
124
+ <div
125
+ v-if="memberPerson(item).email"
126
+ class="member-person__email"
127
+ :title="memberPerson(item).email"
128
+ >{{ memberPerson(item).email }}</div>
129
+ </div>
133
130
  </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>
131
+ <!-- ONE compact "Role & access" cell (owner report 2026-09-30, Aigle:
132
+ an 18-site person's roles and sites were two long paragraphs).
133
+ Each role once; one site by name, several as "N sites" that opens
134
+ the full site -> role list (utils/member-row.ts). A deleted /
135
+ other-organisation / missing role still says so (memberRoleChip,
136
+ server roleState). -->
137
+ <template #item.roleName="{ item }">
138
+ <div class="member-access" data-test="member-access">
139
+ <StatusChip
140
+ v-for="r in memberAccess(item, orgLevel).roles.slice(0, 2)"
141
+ :key="r"
142
+ :dot="false"
143
+ pill
144
+ :tone="memberAccess(item, orgLevel).tone"
145
+ class="member-role-chip"
146
+ :class="{ 'member-role-chip--warn': memberAccess(item, orgLevel).tone === 'warn' }"
147
+ :title="r"
148
+ data-test="member-role-chip"
149
+ >
150
+ {{ r }}
151
+ </StatusChip>
152
+ <span
153
+ v-if="memberAccess(item, orgLevel).roles.length > 2"
154
+ class="member-access__more"
155
+ >+{{ memberAccess(item, orgLevel).roles.length - 2 }}</span>
156
+ <v-menu
157
+ v-if="memberAccess(item, orgLevel).sites.length > 1"
158
+ location="bottom start"
159
+ :close-on-content-click="false"
160
+ >
161
+ <template #activator="{ props: siteProps }">
162
+ <button
163
+ v-bind="siteProps"
164
+ type="button"
165
+ class="member-access__sites"
166
+ data-test="member-site-count"
167
+ >
168
+ {{ memberAccess(item, orgLevel).scope }}
169
+ </button>
170
+ </template>
171
+ <v-card class="member-sites-card" data-test="member-site-list">
172
+ <v-card-title class="text-subtitle-2">
173
+ {{ memberPerson(item).name }} - {{ memberAccess(item, orgLevel).scope }}
174
+ </v-card-title>
175
+ <v-list density="compact">
176
+ <v-list-item
177
+ v-for="s in memberAccess(item, orgLevel).sites"
178
+ :key="s.siteName"
179
+ :title="s.siteName"
180
+ :subtitle="s.roleName"
181
+ />
182
+ </v-list>
183
+ </v-card>
184
+ </v-menu>
185
+ <span
186
+ v-else-if="memberAccess(item, orgLevel).scope"
187
+ class="member-access__scope"
188
+ :title="memberAccess(item, orgLevel).scope"
189
+ data-test="member-site-scope"
190
+ >{{ memberAccess(item, orgLevel).scope }}</span>
191
+ </div>
138
192
  </template>
139
193
  <template #item.dateInvited="{ item }">
140
- {{ formatDateInvited(item.dateInvited) }}
194
+ <span class="member-nowrap">{{ formatMemberDate(item.dateInvited) }}</span>
141
195
  </template>
142
196
  <template #item.lastLoginAt="{ item }">
143
- <span data-test="member-last-active">{{ formatLastActive(item.lastLoginAt) }}</span>
197
+ <span class="member-nowrap" data-test="member-last-active">{{ formatLastActive(item.lastLoginAt) }}</span>
144
198
  </template>
145
199
  <template #item.action-table="{ item }">
146
200
  <div class="d-flex align-center justify-end ga-1">
@@ -371,11 +425,11 @@ import {
371
425
  otherSiteBannerText,
372
426
  roleUsableHere,
373
427
  safeReturnTo,
374
- siteAccessLabel,
375
428
  limitedSites,
376
429
  withCurrentRole,
377
430
  } from "../utils/role";
378
431
  import { formatLastActive } from "../utils/last-active";
432
+ import { formatMemberDate, memberAccess, memberPerson } from "../utils/member-row";
379
433
 
380
434
  const props = defineProps({
381
435
  /** The design's page title. Every app that mounts this screen calls it
@@ -434,40 +488,14 @@ const props = defineProps({
434
488
  },
435
489
  headers: {
436
490
  type: Array as PropType<Array<Record<string, string>>>,
491
+ // Email sits under the name and the sites inside "Role & access" (owner
492
+ // report 2026-09-30): two long columns became one compact cell.
437
493
  default: () => [
438
- {
439
- title: "Name",
440
-
441
- value: "name",
442
- },
443
- {
444
- title: "Email",
445
-
446
- value: "email",
447
- },
448
- {
449
- title: "Roles and Permission",
450
-
451
- value: "roleName",
452
- },
453
- {
454
- title: "Site Invited To",
455
-
456
- value: "siteName",
457
- },
458
- {
459
- title: "Date",
460
-
461
- value: "dateInvited",
462
- },
463
- {
464
- title: "Last active",
465
- value: "lastLoginAt",
466
- },
467
- {
468
- title: "Action",
469
- value: "action-table",
470
- },
494
+ { title: "Name", value: "name" },
495
+ { title: "Role & access", value: "roleName" },
496
+ { title: "Date", value: "dateInvited" },
497
+ { title: "Last active", value: "lastLoginAt" },
498
+ { title: "Action", value: "action-table" },
471
499
  ],
472
500
  },
473
501
  /**
@@ -809,25 +837,6 @@ function showMessage(msg: string, color: string) {
809
837
  messageSnackbar.value = true;
810
838
  }
811
839
 
812
- function formatDateInvited(value?: string | null) {
813
- if (!value) return "";
814
-
815
- const date = new Date(value);
816
- if (Number.isNaN(date.getTime())) return "";
817
-
818
- const parts = new Intl.DateTimeFormat("en-US", {
819
- month: "short",
820
- day: "numeric",
821
- year: "numeric",
822
- }).formatToParts(date);
823
-
824
- const getPart = (type: Intl.DateTimeFormatPartTypes) =>
825
- parts.find((part) => part.type === type)?.value ?? "";
826
-
827
- // Short ("Sep 29, 2026") so the table fits and the actions stay in view.
828
- return `${getPart("month")} ${getPart("day")}, ${getPart("year")}`;
829
- }
830
-
831
840
  async function handleUpdateMemberStatus() {
832
841
  if (!selectedMemberId.value) return;
833
842
  updateLoading.value = true;
@@ -876,8 +885,74 @@ const updateActionText = computed(() => {
876
885
  .member-table--pin-action :deep(thead th:last-child) {
877
886
  z-index: 3;
878
887
  }
888
+ /* One line per cell, the same height on every row (owner report 2026-09-30). */
889
+ .member-table :deep(td) {
890
+ height: 60px;
891
+ vertical-align: middle;
892
+ }
893
+ .member-table :deep(th) {
894
+ white-space: nowrap;
895
+ }
896
+ .member-nowrap {
897
+ white-space: nowrap;
898
+ }
899
+ .member-person {
900
+ max-width: 260px;
901
+ min-width: 160px;
902
+ }
903
+ .member-person__name,
904
+ .member-person__email {
905
+ overflow: hidden;
906
+ text-overflow: ellipsis;
907
+ white-space: nowrap;
908
+ }
909
+ .member-person__email {
910
+ font-size: 12px;
911
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
912
+ }
913
+ .member-access {
914
+ display: flex;
915
+ align-items: center;
916
+ flex-wrap: nowrap;
917
+ gap: 6px;
918
+ max-width: 380px;
919
+ min-width: 180px;
920
+ }
879
921
  .member-table :deep(.member-role-chip) {
922
+ max-width: 220px;
923
+ overflow: hidden;
924
+ text-overflow: ellipsis;
925
+ white-space: nowrap;
926
+ display: inline-block;
927
+ flex-shrink: 1;
928
+ }
929
+ /* A warning names the fix; it may take two lines rather than be cut off. */
930
+ .member-table :deep(.member-role-chip--warn) {
880
931
  white-space: normal;
881
- height: auto;
932
+ max-width: 300px;
933
+ }
934
+ .member-access__more,
935
+ .member-access__scope {
936
+ font-size: 12px;
937
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
938
+ white-space: nowrap;
939
+ overflow: hidden;
940
+ text-overflow: ellipsis;
941
+ }
942
+ .member-access__sites {
943
+ font-size: 12px;
944
+ font-weight: 600;
945
+ white-space: nowrap;
946
+ color: rgb(var(--v-theme-primary));
947
+ text-decoration: underline;
948
+ background: none;
949
+ border: 0;
950
+ padding: 0;
951
+ cursor: pointer;
952
+ }
953
+ .member-sites-card {
954
+ max-height: 360px;
955
+ overflow-y: auto;
956
+ min-width: 280px;
882
957
  }
883
958
  </style>
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.80.1",
5
+ "version": "4.81.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.",
@@ -1,3 +1,5 @@
1
+ import { formatMemberDate } from "./member-row.ts";
2
+
1
3
  /**
2
4
  * "Last active" on the Members lists: the last time the person signed in
3
5
  * (server field `lastLoginAt`). Never signed in since the field existed -> "—".
@@ -12,5 +14,5 @@ export function formatLastActive(value?: string | Date | null, now: Date = new D
12
14
  const days = Math.round((dayStart(now) - dayStart(date)) / 86400000);
13
15
  if (days === 0) return `Today, ${time}`;
14
16
  if (days === 1) return `Yesterday, ${time}`;
15
- return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
17
+ return formatMemberDate(date);
16
18
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * How one row of the Members table reads (owner report 2026-09-30, Aigle
3
+ * Members: an 18-site person's row was two ~250px paragraphs, role names were
4
+ * printed twice, the date wrapped onto three lines and the name was repeated
5
+ * as the email). Display only: nothing here changes what anybody may do.
6
+ */
7
+ import { limitedSites, memberRoleChip } from "./role.ts";
8
+
9
+ /** "Org - Admin ( For Site)" -> "Org Admin (per site)". Display only. */
10
+ export function cleanRoleName(name: unknown): string {
11
+ if (typeof name !== "string") return "";
12
+ return name
13
+ .replace(/\s+/g, " ")
14
+ .replace(/\(\s+/g, "(")
15
+ .replace(/\s+\)/g, ")")
16
+ .replace(/\(for site\)/gi, "(per site)")
17
+ .replace(/(\S) - (\S)/g, "$1 $2")
18
+ .trim();
19
+ }
20
+
21
+ /** One site's role(s), each named once: "Security, Security" -> "Security". */
22
+ function distinctRoles(roleName: unknown): string[] {
23
+ // ponytail: the server joins a site's roles with ", " (core peoplePage); a
24
+ // role name that itself holds ", " would be split here - display only.
25
+ const names = typeof roleName === "string" ? roleName.split(", ") : [];
26
+ return [...new Set(names.map(cleanRoleName).filter(Boolean))];
27
+ }
28
+
29
+ export type TMemberSite = { siteName: string; roleName: string };
30
+ export type TMemberAccess = {
31
+ /** Role chips, each name once. */
32
+ roles: string[];
33
+ tone: "info" | "warn";
34
+ /** Where the person may go: "All sites", one site's name, "18 sites", or "". */
35
+ scope: string;
36
+ /** Every site with its role(s) - the popover behind a "N sites" count. */
37
+ sites: TMemberSite[];
38
+ };
39
+
40
+ /**
41
+ * The Members table's one "Role & access" cell.
42
+ * - Organisation level, limited to sites: the distinct roles once each, plus
43
+ * the site's name (one site) or "N sites" (several; `sites` lists them all).
44
+ * - Organisation level, organisation-wide: the role, "All sites".
45
+ * - Site level: the role chip as before (it still warns about a role from
46
+ * another site), with the row's site name.
47
+ */
48
+ export function memberAccess(item: Record<string, any> | null | undefined, orgLevel = false): TMemberAccess {
49
+ const limited = limitedSites(item);
50
+ if (orgLevel && limited.length) {
51
+ const sites = limited.map((l) => ({
52
+ siteName: l.siteName || "A site",
53
+ roleName: distinctRoles(l.roleName).join(", ") || "No role",
54
+ }));
55
+ const roles = [...new Set(limited.flatMap((l) => distinctRoles(l.roleName)))];
56
+ return {
57
+ roles: roles.length ? roles : ["No role"],
58
+ tone: "info",
59
+ scope: sites.length === 1 ? sites[0].siteName : `${sites.length} sites`,
60
+ sites,
61
+ };
62
+ }
63
+ const chip = memberRoleChip(item ?? {}, orgLevel);
64
+ const text = chip ? (chip.tone === "info" ? cleanRoleName(chip.text) : chip.text) : "";
65
+ const scope = orgLevel ? "All sites" : typeof item?.siteName === "string" ? item.siteName : "";
66
+ return { roles: text ? [text] : [], tone: chip?.tone ?? "info", scope, sites: [] };
67
+ }
68
+
69
+ /** Name on top, email underneath - the email only when it adds something. */
70
+ export function memberPerson(item: Record<string, any> | null | undefined): { name: string; email: string } {
71
+ const name = typeof item?.name === "string" ? item.name.trim() : "";
72
+ const email = typeof item?.email === "string" ? item.email.trim() : "";
73
+ if (!name) return { name: email, email: "" };
74
+ return { name, email: email.toLowerCase() === name.toLowerCase() ? "" : email };
75
+ }
76
+
77
+ /** "30 Sep 2026" - one line. Bad or empty -> "". */
78
+ export function formatMemberDate(value?: string | Date | null): string {
79
+ if (!value) return "";
80
+ const date = new Date(value);
81
+ if (Number.isNaN(date.getTime())) return "";
82
+ // Not en-GB's "short" month: newer ICU prints "Sept".
83
+ return `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
84
+ }
85
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];