@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 +10 -4
- package/components/MemberMain.vue +150 -75
- package/package.json +1 -1
- package/utils/last-active.ts +3 -1
- package/utils/member-row.ts +85 -0
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
|
-
|
|
1948
|
-
|
|
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
|
-
|
|
1951
|
-
|
|
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
|
-
<!--
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
<!--
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
{{
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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.
|
|
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.",
|
package/utils/last-active.ts
CHANGED
|
@@ -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
|
|
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"];
|