@echothink-ui/identity 0.1.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/README.md +5 -0
- package/dist/components/AccessReviewPanel.d.ts +9 -0
- package/dist/components/AccountMenu.d.ts +8 -0
- package/dist/components/ApprovalPolicyEditor.d.ts +8 -0
- package/dist/components/AuditTrail.d.ts +6 -0
- package/dist/components/GroupPicker.d.ts +12 -0
- package/dist/components/IdentityCard.d.ts +14 -0
- package/dist/components/InviteUserPanel.d.ts +14 -0
- package/dist/components/OrganizationSwitcher.d.ts +9 -0
- package/dist/components/PermissionMatrix.d.ts +10 -0
- package/dist/components/PolicyRuleViewer.d.ts +6 -0
- package/dist/components/RoleBadge.d.ts +6 -0
- package/dist/components/SessionStatus.d.ts +9 -0
- package/dist/components/TeamList.d.ts +13 -0
- package/dist/components/UserPicker.d.ts +12 -0
- package/dist/components/types.d.ts +77 -0
- package/dist/index.cjs +1629 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +2238 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +1610 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
- package/src/components/AccessReviewPanel.tsx +169 -0
- package/src/components/AccountMenu.tsx +144 -0
- package/src/components/ApprovalPolicyEditor.tsx +131 -0
- package/src/components/AuditTrail.tsx +105 -0
- package/src/components/GroupPicker.tsx +175 -0
- package/src/components/IdentityCard.tsx +78 -0
- package/src/components/InviteUserPanel.tsx +162 -0
- package/src/components/OrganizationSwitcher.test.tsx +59 -0
- package/src/components/OrganizationSwitcher.tsx +161 -0
- package/src/components/PermissionMatrix.test.tsx +96 -0
- package/src/components/PermissionMatrix.tsx +271 -0
- package/src/components/PolicyRuleViewer.test.tsx +29 -0
- package/src/components/PolicyRuleViewer.tsx +78 -0
- package/src/components/RoleBadge.test.tsx +35 -0
- package/src/components/RoleBadge.tsx +38 -0
- package/src/components/SessionStatus.test.tsx +40 -0
- package/src/components/SessionStatus.tsx +194 -0
- package/src/components/TeamList.test.tsx +48 -0
- package/src/components/TeamList.tsx +98 -0
- package/src/components/UserPicker.test.tsx +52 -0
- package/src/components/UserPicker.tsx +174 -0
- package/src/components/types.ts +89 -0
- package/src/index.tsx +35 -0
- package/src/styles.css +2578 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Badge, Surface, type SurfaceComponentProps } from "@echothink-ui/core";
|
|
2
|
+
import type { PolicyRule } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface PolicyRuleViewerProps extends Omit<SurfaceComponentProps, "children"> {
|
|
5
|
+
rule: PolicyRule;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function effectLabel(effect: PolicyRule["effect"]) {
|
|
9
|
+
return effect === "allow" ? "Allow" : "Deny";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function renderRuleValues(values: string[], label: string) {
|
|
13
|
+
if (!values.length) {
|
|
14
|
+
return <span className="eth-identity-policy-rule-viewer__empty">Not specified</span>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<ul className="eth-identity-policy-rule-viewer__values" aria-label={`${label} list`}>
|
|
19
|
+
{values.map((value) => (
|
|
20
|
+
<li key={value}>
|
|
21
|
+
<code>{value}</code>
|
|
22
|
+
</li>
|
|
23
|
+
))}
|
|
24
|
+
</ul>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PolicyRuleViewer({ rule, title, className, ...props }: PolicyRuleViewerProps) {
|
|
29
|
+
const effect = effectLabel(rule.effect);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Surface
|
|
33
|
+
{...props}
|
|
34
|
+
title={title ?? `Policy rule ${rule.id}`}
|
|
35
|
+
className={["eth-identity-policy-rule-viewer", className].filter(Boolean).join(" ")}
|
|
36
|
+
data-eth-component="PolicyRuleViewer"
|
|
37
|
+
>
|
|
38
|
+
<dl className="eth-identity-policy-rule-viewer__facet-list">
|
|
39
|
+
<div
|
|
40
|
+
className="eth-identity-policy-rule-viewer__facet eth-identity-policy-rule-viewer__facet--effect"
|
|
41
|
+
>
|
|
42
|
+
<dt>Effect</dt>
|
|
43
|
+
<dd>
|
|
44
|
+
<Badge
|
|
45
|
+
aria-label={`Rule effect: ${effect}`}
|
|
46
|
+
className="eth-identity-policy-rule-viewer__effect"
|
|
47
|
+
severity={rule.effect === "allow" ? "success" : "danger"}
|
|
48
|
+
>
|
|
49
|
+
{effect}
|
|
50
|
+
</Badge>
|
|
51
|
+
</dd>
|
|
52
|
+
</div>
|
|
53
|
+
<div>
|
|
54
|
+
<dt>Subjects</dt>
|
|
55
|
+
<dd>{renderRuleValues(rule.subjects, "Subjects")}</dd>
|
|
56
|
+
</div>
|
|
57
|
+
<div>
|
|
58
|
+
<dt>Actions</dt>
|
|
59
|
+
<dd>{renderRuleValues(rule.actions, "Actions")}</dd>
|
|
60
|
+
</div>
|
|
61
|
+
<div>
|
|
62
|
+
<dt>Resources</dt>
|
|
63
|
+
<dd>{renderRuleValues(rule.resources, "Resources")}</dd>
|
|
64
|
+
</div>
|
|
65
|
+
{rule.conditions ? (
|
|
66
|
+
<div>
|
|
67
|
+
<dt>Conditions</dt>
|
|
68
|
+
<dd>
|
|
69
|
+
<code className="eth-identity-policy-rule-viewer__condition">
|
|
70
|
+
{rule.conditions}
|
|
71
|
+
</code>
|
|
72
|
+
</dd>
|
|
73
|
+
</div>
|
|
74
|
+
) : null}
|
|
75
|
+
</dl>
|
|
76
|
+
</Surface>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { RoleBadge } from "./RoleBadge";
|
|
4
|
+
|
|
5
|
+
describe("RoleBadge", () => {
|
|
6
|
+
it("renders role indicators with semantic attributes and caller props", () => {
|
|
7
|
+
render(
|
|
8
|
+
<RoleBadge
|
|
9
|
+
role="workspace_admin"
|
|
10
|
+
tier="admin"
|
|
11
|
+
id="workspace-admin-role"
|
|
12
|
+
aria-label="Privileged workspace administrator role"
|
|
13
|
+
className="custom-role"
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const badge = screen
|
|
18
|
+
.getByLabelText("Privileged workspace administrator role")
|
|
19
|
+
.closest(".eth-identity-role-badge");
|
|
20
|
+
|
|
21
|
+
expect(badge?.getAttribute("id")).toBe("workspace-admin-role");
|
|
22
|
+
expect(badge?.getAttribute("data-eth-component")).toBe("RoleBadge");
|
|
23
|
+
expect(badge?.getAttribute("data-tier")).toBe("admin");
|
|
24
|
+
expect(badge?.className).toContain("custom-role");
|
|
25
|
+
expect(screen.getByText("workspace_admin").closest(".eth-badge")?.className).toContain(
|
|
26
|
+
"eth-badge--danger"
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("provides a default accessible label for the role", () => {
|
|
31
|
+
render(<RoleBadge role="read_only_viewer" tier="viewer" />);
|
|
32
|
+
|
|
33
|
+
expect(screen.getByLabelText("Role: read_only_viewer")).toBeTruthy();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
import { Badge, type EthSeverity } from "@echothink-ui/core";
|
|
3
|
+
|
|
4
|
+
export interface RoleBadgeProps extends Omit<HTMLAttributes<HTMLSpanElement>, "children" | "role"> {
|
|
5
|
+
role: string;
|
|
6
|
+
tier?: "admin" | "editor" | "viewer" | "custom";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function RoleBadge({
|
|
10
|
+
role,
|
|
11
|
+
tier = "custom",
|
|
12
|
+
className,
|
|
13
|
+
"aria-label": ariaLabel,
|
|
14
|
+
"aria-labelledby": ariaLabelledBy,
|
|
15
|
+
title,
|
|
16
|
+
...props
|
|
17
|
+
}: RoleBadgeProps) {
|
|
18
|
+
return (
|
|
19
|
+
<span
|
|
20
|
+
{...props}
|
|
21
|
+
aria-label={ariaLabel ?? (ariaLabelledBy ? undefined : `Role: ${role}`)}
|
|
22
|
+
aria-labelledby={ariaLabelledBy}
|
|
23
|
+
className={["eth-identity-role-badge", className].filter(Boolean).join(" ")}
|
|
24
|
+
data-eth-component="RoleBadge"
|
|
25
|
+
data-tier={tier}
|
|
26
|
+
title={title ?? role}
|
|
27
|
+
>
|
|
28
|
+
<Badge severity={severityForTier(tier)}>{role}</Badge>
|
|
29
|
+
</span>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function severityForTier(tier: NonNullable<RoleBadgeProps["tier"]>): EthSeverity {
|
|
34
|
+
if (tier === "admin") return "danger";
|
|
35
|
+
if (tier === "editor") return "warning";
|
|
36
|
+
if (tier === "viewer") return "info";
|
|
37
|
+
return "neutral";
|
|
38
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { SessionStatus } from "./SessionStatus";
|
|
4
|
+
|
|
5
|
+
describe("SessionStatus", () => {
|
|
6
|
+
it("renders a security summary with a semantic expiration time", () => {
|
|
7
|
+
render(
|
|
8
|
+
<SessionStatus
|
|
9
|
+
session={{
|
|
10
|
+
expiresAt: "2999-05-27T18:00:00Z",
|
|
11
|
+
mfaEnabled: true,
|
|
12
|
+
lastIp: "10.0.4.12"
|
|
13
|
+
}}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(screen.getByRole("heading", { name: "Session security" })).toBeTruthy();
|
|
18
|
+
expect(screen.getByText("Session active")).toBeTruthy();
|
|
19
|
+
|
|
20
|
+
const expiration = screen.getByLabelText("Session expiration");
|
|
21
|
+
expect(expiration.tagName).toBe("TIME");
|
|
22
|
+
expect(expiration.getAttribute("dateTime")).toBe("2999-05-27T18:00:00Z");
|
|
23
|
+
expect(screen.getByText("10.0.4.12").closest("code")).toBeTruthy();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("surfaces disabled MFA and missing last IP as reviewable security state", () => {
|
|
27
|
+
render(
|
|
28
|
+
<SessionStatus
|
|
29
|
+
session={{
|
|
30
|
+
expiresAt: "2999-05-27T18:00:00Z",
|
|
31
|
+
mfaEnabled: false
|
|
32
|
+
}}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(screen.getByText("Review required")).toBeTruthy();
|
|
37
|
+
expect(screen.getByText("No second factor is enforced for this session.")).toBeTruthy();
|
|
38
|
+
expect(screen.getByText("Not recorded")).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge,
|
|
3
|
+
Surface,
|
|
4
|
+
type EthOperationalStatus,
|
|
5
|
+
type EthSeverity,
|
|
6
|
+
type SurfaceComponentProps
|
|
7
|
+
} from "@echothink-ui/core";
|
|
8
|
+
|
|
9
|
+
const EXPIRING_SOON_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export interface SessionStatusProps extends Omit<SurfaceComponentProps, "children"> {
|
|
12
|
+
session: {
|
|
13
|
+
expiresAt: string;
|
|
14
|
+
mfaEnabled: boolean;
|
|
15
|
+
lastIp?: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function SessionStatus({
|
|
20
|
+
session,
|
|
21
|
+
title,
|
|
22
|
+
description,
|
|
23
|
+
className,
|
|
24
|
+
severity,
|
|
25
|
+
status,
|
|
26
|
+
...props
|
|
27
|
+
}: SessionStatusProps) {
|
|
28
|
+
const expires = new Date(session.expiresAt);
|
|
29
|
+
const hasValidExpiration = Number.isFinite(expires.valueOf());
|
|
30
|
+
const expiresAt = hasValidExpiration ? expires.getTime() : null;
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const expired = expiresAt !== null && expiresAt < now;
|
|
33
|
+
const expiringSoon = expiresAt !== null && !expired && expiresAt - now <= EXPIRING_SOON_MS;
|
|
34
|
+
const needsReview = !session.mfaEnabled || expiringSoon || !hasValidExpiration;
|
|
35
|
+
const tone: EthSeverity = expired ? "danger" : needsReview ? "warning" : "success";
|
|
36
|
+
const resolvedStatus: EthOperationalStatus =
|
|
37
|
+
status ?? (expired ? "stale" : needsReview ? "warning" : "active");
|
|
38
|
+
const sessionState = expired ? "Expired" : expiringSoon ? "Expiring soon" : "Active";
|
|
39
|
+
const summaryTitle = summaryTitleForSession({ expired, needsReview });
|
|
40
|
+
const summaryDetail = summaryForSession({
|
|
41
|
+
expired,
|
|
42
|
+
expiringSoon,
|
|
43
|
+
hasValidExpiration,
|
|
44
|
+
mfaEnabled: session.mfaEnabled
|
|
45
|
+
});
|
|
46
|
+
const summaryClassName = [
|
|
47
|
+
"eth-identity-session-status__summary",
|
|
48
|
+
`eth-identity-session-status__summary--${tone}`
|
|
49
|
+
].join(" ");
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Surface
|
|
53
|
+
{...props}
|
|
54
|
+
title={title === undefined ? "Session security" : title}
|
|
55
|
+
description={
|
|
56
|
+
description === undefined
|
|
57
|
+
? "Authentication freshness and recent access signal."
|
|
58
|
+
: description
|
|
59
|
+
}
|
|
60
|
+
severity={severity}
|
|
61
|
+
status={resolvedStatus}
|
|
62
|
+
className={["eth-identity-session-status", className].filter(Boolean).join(" ")}
|
|
63
|
+
data-eth-component="SessionStatus"
|
|
64
|
+
>
|
|
65
|
+
<div className={summaryClassName}>
|
|
66
|
+
<span className="eth-identity-session-status__summary-mark" aria-hidden="true" />
|
|
67
|
+
<div className="eth-identity-session-status__summary-copy">
|
|
68
|
+
<strong>{summaryTitle}</strong>
|
|
69
|
+
<span>{summaryDetail}</span>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
<dl aria-label="Session details">
|
|
73
|
+
<div>
|
|
74
|
+
<dt>Session</dt>
|
|
75
|
+
<dd className="eth-identity-session-status__value">
|
|
76
|
+
<span className="eth-identity-session-status__value-main">
|
|
77
|
+
<Badge severity={tone}>{sessionState}</Badge>
|
|
78
|
+
</span>
|
|
79
|
+
<span className="eth-identity-session-status__detail">
|
|
80
|
+
{expired ? "Credentials are no longer valid." : "Authenticated session lifecycle."}
|
|
81
|
+
</span>
|
|
82
|
+
</dd>
|
|
83
|
+
</div>
|
|
84
|
+
<div>
|
|
85
|
+
<dt>Expires</dt>
|
|
86
|
+
<dd className="eth-identity-session-status__value">
|
|
87
|
+
<span className="eth-identity-session-status__value-main">
|
|
88
|
+
{hasValidExpiration ? (
|
|
89
|
+
<time
|
|
90
|
+
aria-label="Session expiration"
|
|
91
|
+
dateTime={session.expiresAt}
|
|
92
|
+
title={session.expiresAt}
|
|
93
|
+
>
|
|
94
|
+
{formatSessionDate(expires)}
|
|
95
|
+
</time>
|
|
96
|
+
) : (
|
|
97
|
+
<span>Unknown</span>
|
|
98
|
+
)}
|
|
99
|
+
</span>
|
|
100
|
+
<span className="eth-identity-session-status__detail">
|
|
101
|
+
{expirationDetail({ expired, expiringSoon, hasValidExpiration })}
|
|
102
|
+
</span>
|
|
103
|
+
</dd>
|
|
104
|
+
</div>
|
|
105
|
+
<div>
|
|
106
|
+
<dt>MFA</dt>
|
|
107
|
+
<dd className="eth-identity-session-status__value">
|
|
108
|
+
<span className="eth-identity-session-status__value-main">
|
|
109
|
+
<Badge severity={session.mfaEnabled ? "success" : "warning"}>
|
|
110
|
+
{session.mfaEnabled ? "Enabled" : "Disabled"}
|
|
111
|
+
</Badge>
|
|
112
|
+
</span>
|
|
113
|
+
<span className="eth-identity-session-status__detail">
|
|
114
|
+
{session.mfaEnabled
|
|
115
|
+
? "Second factor challenge is enforced."
|
|
116
|
+
: "No second factor is enforced for this session."}
|
|
117
|
+
</span>
|
|
118
|
+
</dd>
|
|
119
|
+
</div>
|
|
120
|
+
<div>
|
|
121
|
+
<dt>Last IP</dt>
|
|
122
|
+
<dd className="eth-identity-session-status__value">
|
|
123
|
+
<span className="eth-identity-session-status__value-main">
|
|
124
|
+
{session.lastIp ? (
|
|
125
|
+
<code className="eth-identity-session-status__code">{session.lastIp}</code>
|
|
126
|
+
) : (
|
|
127
|
+
<span className="eth-identity-session-status__muted">Not recorded</span>
|
|
128
|
+
)}
|
|
129
|
+
</span>
|
|
130
|
+
<span className="eth-identity-session-status__detail">
|
|
131
|
+
Latest network signal captured for the session.
|
|
132
|
+
</span>
|
|
133
|
+
</dd>
|
|
134
|
+
</div>
|
|
135
|
+
</dl>
|
|
136
|
+
</Surface>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatSessionDate(value: Date) {
|
|
141
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
142
|
+
day: "2-digit",
|
|
143
|
+
hour: "2-digit",
|
|
144
|
+
minute: "2-digit",
|
|
145
|
+
month: "short",
|
|
146
|
+
timeZoneName: "short",
|
|
147
|
+
year: "numeric"
|
|
148
|
+
}).format(value);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function summaryTitleForSession({
|
|
152
|
+
expired,
|
|
153
|
+
needsReview
|
|
154
|
+
}: {
|
|
155
|
+
expired: boolean;
|
|
156
|
+
needsReview: boolean;
|
|
157
|
+
}) {
|
|
158
|
+
if (expired) return "Session expired";
|
|
159
|
+
if (needsReview) return "Review required";
|
|
160
|
+
return "Session active";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function summaryForSession({
|
|
164
|
+
expired,
|
|
165
|
+
expiringSoon,
|
|
166
|
+
hasValidExpiration,
|
|
167
|
+
mfaEnabled
|
|
168
|
+
}: {
|
|
169
|
+
expired: boolean;
|
|
170
|
+
expiringSoon: boolean;
|
|
171
|
+
hasValidExpiration: boolean;
|
|
172
|
+
mfaEnabled: boolean;
|
|
173
|
+
}) {
|
|
174
|
+
if (!hasValidExpiration) return "Expiration could not be verified from the provided timestamp.";
|
|
175
|
+
if (expired) return "Access should be refreshed or revoked before more work continues.";
|
|
176
|
+
if (!mfaEnabled) return "MFA is disabled, so this session needs security review.";
|
|
177
|
+
if (expiringSoon) return "The session is valid but expires within the next 24 hours.";
|
|
178
|
+
return "MFA is enabled and the session remains within its validity window.";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function expirationDetail({
|
|
182
|
+
expired,
|
|
183
|
+
expiringSoon,
|
|
184
|
+
hasValidExpiration
|
|
185
|
+
}: {
|
|
186
|
+
expired: boolean;
|
|
187
|
+
expiringSoon: boolean;
|
|
188
|
+
hasValidExpiration: boolean;
|
|
189
|
+
}) {
|
|
190
|
+
if (!hasValidExpiration) return "Timestamp could not be parsed.";
|
|
191
|
+
if (expired) return "Past the identity provider expiry.";
|
|
192
|
+
if (expiringSoon) return "Expires within 24 hours.";
|
|
193
|
+
return "Valid until scheduled expiry.";
|
|
194
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { TeamList } from "./TeamList";
|
|
4
|
+
|
|
5
|
+
const teams = [
|
|
6
|
+
{
|
|
7
|
+
id: "marketing",
|
|
8
|
+
label: "Marketing",
|
|
9
|
+
memberCount: 12,
|
|
10
|
+
lead: { id: "jd", label: "Jane Doe" }
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: "legal",
|
|
14
|
+
label: "Legal",
|
|
15
|
+
memberCount: 4,
|
|
16
|
+
lead: { id: "pr", label: "Pat R." }
|
|
17
|
+
}
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe("TeamList", () => {
|
|
21
|
+
it("renders teams as an accessible list with contextual open actions", () => {
|
|
22
|
+
const onSelect = vi.fn();
|
|
23
|
+
|
|
24
|
+
render(<TeamList title="Workspace teams" teams={teams} onSelect={onSelect} />);
|
|
25
|
+
|
|
26
|
+
const list = screen.getByRole("list", { name: "Workspace teams" });
|
|
27
|
+
expect(list.querySelectorAll('[role="listitem"]')).toHaveLength(2);
|
|
28
|
+
expect(screen.getByText("12 members").closest(".eth-badge")).toBeTruthy();
|
|
29
|
+
expect(screen.getByText("Jane Doe")).toBeTruthy();
|
|
30
|
+
|
|
31
|
+
fireEvent.click(screen.getByRole("button", { name: "Open Marketing team" }));
|
|
32
|
+
|
|
33
|
+
expect(onSelect).toHaveBeenCalledWith("marketing");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("omits row actions when the list is read-only", () => {
|
|
37
|
+
render(<TeamList teams={teams} />);
|
|
38
|
+
|
|
39
|
+
expect(screen.queryByRole("button", { name: /open/i })).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renders an empty state for teams without results", () => {
|
|
43
|
+
render(<TeamList title="Workspace teams" teams={[]} />);
|
|
44
|
+
|
|
45
|
+
expect(screen.getByText("No teams available")).toBeTruthy();
|
|
46
|
+
expect(screen.getByText("Add teams to see membership summaries.")).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Badge, Button, Surface, type SurfaceComponentProps } from "@echothink-ui/core";
|
|
2
|
+
import type { IdentityRef } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface TeamListTeam {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
memberCount: number;
|
|
8
|
+
lead?: IdentityRef;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TeamListProps extends Omit<SurfaceComponentProps, "children" | "onSelect"> {
|
|
12
|
+
teams: TeamListTeam[];
|
|
13
|
+
onSelect?: (id: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function TeamList({ teams, onSelect, title, className, ...props }: TeamListProps) {
|
|
17
|
+
const listLabel = typeof title === "string" && title.trim() ? title : "Teams";
|
|
18
|
+
const totalMembers = teams.reduce((total, team) => total + team.memberCount, 0);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Surface
|
|
22
|
+
{...props}
|
|
23
|
+
title={title}
|
|
24
|
+
className={["eth-identity-team-list", className].filter(Boolean).join(" ")}
|
|
25
|
+
data-eth-component="TeamList"
|
|
26
|
+
>
|
|
27
|
+
{teams.length ? (
|
|
28
|
+
<>
|
|
29
|
+
<dl className="eth-identity-team-list__summary" aria-label="Team summary">
|
|
30
|
+
<div>
|
|
31
|
+
<dt>Teams</dt>
|
|
32
|
+
<dd>{teams.length}</dd>
|
|
33
|
+
</div>
|
|
34
|
+
<div>
|
|
35
|
+
<dt>Members</dt>
|
|
36
|
+
<dd>{totalMembers}</dd>
|
|
37
|
+
</div>
|
|
38
|
+
</dl>
|
|
39
|
+
<div className="eth-identity-team-list__items" role="list" aria-label={listLabel}>
|
|
40
|
+
{teams.map((team) => (
|
|
41
|
+
<article key={team.id} className="eth-identity-team-list__item" role="listitem">
|
|
42
|
+
<span className="eth-identity-team-list__avatar" aria-hidden="true">
|
|
43
|
+
{initials(team.label)}
|
|
44
|
+
</span>
|
|
45
|
+
<div className="eth-identity-team-list__main">
|
|
46
|
+
<div className="eth-identity-team-list__heading">
|
|
47
|
+
<h3>{team.label}</h3>
|
|
48
|
+
<Badge severity="neutral">{formatMemberCount(team.memberCount)}</Badge>
|
|
49
|
+
</div>
|
|
50
|
+
<dl
|
|
51
|
+
className="eth-identity-team-list__meta"
|
|
52
|
+
aria-label={`${team.label} membership summary`}
|
|
53
|
+
>
|
|
54
|
+
<div>
|
|
55
|
+
<dt>Lead</dt>
|
|
56
|
+
<dd>{team.lead?.label ?? "Unassigned"}</dd>
|
|
57
|
+
</div>
|
|
58
|
+
</dl>
|
|
59
|
+
</div>
|
|
60
|
+
{onSelect ? (
|
|
61
|
+
<Button
|
|
62
|
+
type="button"
|
|
63
|
+
intent="secondary"
|
|
64
|
+
density="compact"
|
|
65
|
+
aria-label={`Open ${team.label} team`}
|
|
66
|
+
onClick={() => onSelect(team.id)}
|
|
67
|
+
>
|
|
68
|
+
Open
|
|
69
|
+
</Button>
|
|
70
|
+
) : null}
|
|
71
|
+
</article>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
</>
|
|
75
|
+
) : (
|
|
76
|
+
<div className="eth-identity-team-list__empty" role="status">
|
|
77
|
+
<h3>No teams available</h3>
|
|
78
|
+
<p>Add teams to see membership summaries.</p>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
</Surface>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatMemberCount(count: number) {
|
|
86
|
+
return `${count} ${count === 1 ? "member" : "members"}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function initials(label: string) {
|
|
90
|
+
const value = label
|
|
91
|
+
.split(/\s+/)
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.slice(0, 2)
|
|
94
|
+
.map((part) => part[0]?.toUpperCase())
|
|
95
|
+
.join("");
|
|
96
|
+
|
|
97
|
+
return value || "TM";
|
|
98
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { UserPicker } from "./UserPicker";
|
|
4
|
+
|
|
5
|
+
const users = [
|
|
6
|
+
{ id: "jd", label: "Jane Doe", email: "jane@echothink.example", kind: "user" as const },
|
|
7
|
+
{ id: "mk", label: "Mark K.", email: "mark@echothink.example", kind: "user" as const }
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
describe("@echothink-ui/identity UserPicker", () => {
|
|
11
|
+
it("renders selected users with accessible search suggestions", () => {
|
|
12
|
+
render(<UserPicker value={["jd"]} suggestions={users} defaultOpen multiple />);
|
|
13
|
+
|
|
14
|
+
expect(screen.getByLabelText("Selected users")).toBeTruthy();
|
|
15
|
+
expect(screen.getByPlaceholderText("Search users")).toBeTruthy();
|
|
16
|
+
expect(screen.getByRole("listbox", { name: "User suggestions" })).toBeTruthy();
|
|
17
|
+
|
|
18
|
+
const selected = screen.getByRole("option", {
|
|
19
|
+
name: /Jane Doe jane@echothink\.example/
|
|
20
|
+
});
|
|
21
|
+
const available = screen.getByRole("option", {
|
|
22
|
+
name: /Mark K\. mark@echothink\.example/
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(selected.getAttribute("aria-selected")).toBe("true");
|
|
26
|
+
expect(available.getAttribute("aria-selected")).toBe("false");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("filters suggestions and adds selected users", () => {
|
|
30
|
+
const onChange = vi.fn();
|
|
31
|
+
const onSearch = vi.fn();
|
|
32
|
+
|
|
33
|
+
render(
|
|
34
|
+
<UserPicker
|
|
35
|
+
value={["jd"]}
|
|
36
|
+
onChange={onChange}
|
|
37
|
+
onSearch={onSearch}
|
|
38
|
+
suggestions={users}
|
|
39
|
+
defaultOpen
|
|
40
|
+
multiple
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
fireEvent.change(screen.getByPlaceholderText("Search users"), {
|
|
45
|
+
target: { value: "mark" }
|
|
46
|
+
});
|
|
47
|
+
fireEvent.click(screen.getByRole("option", { name: /Mark K\. mark@echothink\.example/ }));
|
|
48
|
+
|
|
49
|
+
expect(onSearch).toHaveBeenCalledWith("mark");
|
|
50
|
+
expect(onChange).toHaveBeenCalledWith(["jd", "mk"]);
|
|
51
|
+
});
|
|
52
|
+
});
|