@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.
Files changed (48) hide show
  1. package/README.md +5 -0
  2. package/dist/components/AccessReviewPanel.d.ts +9 -0
  3. package/dist/components/AccountMenu.d.ts +8 -0
  4. package/dist/components/ApprovalPolicyEditor.d.ts +8 -0
  5. package/dist/components/AuditTrail.d.ts +6 -0
  6. package/dist/components/GroupPicker.d.ts +12 -0
  7. package/dist/components/IdentityCard.d.ts +14 -0
  8. package/dist/components/InviteUserPanel.d.ts +14 -0
  9. package/dist/components/OrganizationSwitcher.d.ts +9 -0
  10. package/dist/components/PermissionMatrix.d.ts +10 -0
  11. package/dist/components/PolicyRuleViewer.d.ts +6 -0
  12. package/dist/components/RoleBadge.d.ts +6 -0
  13. package/dist/components/SessionStatus.d.ts +9 -0
  14. package/dist/components/TeamList.d.ts +13 -0
  15. package/dist/components/UserPicker.d.ts +12 -0
  16. package/dist/components/types.d.ts +77 -0
  17. package/dist/index.cjs +1629 -0
  18. package/dist/index.cjs.map +1 -0
  19. package/dist/index.css +2238 -0
  20. package/dist/index.css.map +1 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +1610 -0
  23. package/dist/index.js.map +1 -0
  24. package/package.json +43 -0
  25. package/src/components/AccessReviewPanel.tsx +169 -0
  26. package/src/components/AccountMenu.tsx +144 -0
  27. package/src/components/ApprovalPolicyEditor.tsx +131 -0
  28. package/src/components/AuditTrail.tsx +105 -0
  29. package/src/components/GroupPicker.tsx +175 -0
  30. package/src/components/IdentityCard.tsx +78 -0
  31. package/src/components/InviteUserPanel.tsx +162 -0
  32. package/src/components/OrganizationSwitcher.test.tsx +59 -0
  33. package/src/components/OrganizationSwitcher.tsx +161 -0
  34. package/src/components/PermissionMatrix.test.tsx +96 -0
  35. package/src/components/PermissionMatrix.tsx +271 -0
  36. package/src/components/PolicyRuleViewer.test.tsx +29 -0
  37. package/src/components/PolicyRuleViewer.tsx +78 -0
  38. package/src/components/RoleBadge.test.tsx +35 -0
  39. package/src/components/RoleBadge.tsx +38 -0
  40. package/src/components/SessionStatus.test.tsx +40 -0
  41. package/src/components/SessionStatus.tsx +194 -0
  42. package/src/components/TeamList.test.tsx +48 -0
  43. package/src/components/TeamList.tsx +98 -0
  44. package/src/components/UserPicker.test.tsx +52 -0
  45. package/src/components/UserPicker.tsx +174 -0
  46. package/src/components/types.ts +89 -0
  47. package/src/index.tsx +35 -0
  48. package/src/styles.css +2578 -0
@@ -0,0 +1,59 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { OrganizationSwitcher } from "./OrganizationSwitcher";
4
+
5
+ const organizations = [
6
+ {
7
+ id: "echo",
8
+ label: "EchoThink, Inc.",
9
+ description: "Production tenant",
10
+ status: "active" as const
11
+ },
12
+ {
13
+ id: "acme",
14
+ label: "Acme Corp",
15
+ description: "Sandbox workspace",
16
+ status: "inactive" as const
17
+ }
18
+ ];
19
+
20
+ describe("@echothink-ui/identity OrganizationSwitcher", () => {
21
+ it("renders a menu trigger with active organization context", () => {
22
+ render(<OrganizationSwitcher activeId="echo" defaultOpen organizations={organizations} />);
23
+
24
+ const trigger = screen.getByRole("button", {
25
+ name: "Switch organization, current organization EchoThink, Inc."
26
+ });
27
+ expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
28
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
29
+ expect(screen.getAllByText("Production tenant").length).toBeGreaterThan(0);
30
+ expect(screen.getAllByText("Active").length).toBeGreaterThan(0);
31
+
32
+ const activeOption = screen.getByRole("menuitemradio", { name: /EchoThink, Inc./ });
33
+ expect(activeOption.getAttribute("aria-checked")).toBe("true");
34
+ expect(activeOption.getAttribute("aria-current")).toBe("true");
35
+ });
36
+
37
+ it("switches to the selected organization and closes the menu", () => {
38
+ const onSwitch = vi.fn();
39
+ render(
40
+ <OrganizationSwitcher
41
+ activeId="echo"
42
+ defaultOpen
43
+ onSwitch={onSwitch}
44
+ organizations={organizations}
45
+ />
46
+ );
47
+
48
+ fireEvent.click(screen.getByRole("menuitemradio", { name: /Acme Corp/ }));
49
+
50
+ expect(onSwitch).toHaveBeenCalledWith("acme");
51
+ expect(
52
+ screen
53
+ .getByRole("button", {
54
+ name: "Switch organization, current organization EchoThink, Inc."
55
+ })
56
+ .getAttribute("aria-expanded")
57
+ ).toBe("false");
58
+ });
59
+ });
@@ -0,0 +1,161 @@
1
+ import * as React from "react";
2
+ import { StatusDot, type SurfaceComponentProps } from "@echothink-ui/core";
3
+ import type { OrganizationRef } from "./types";
4
+
5
+ export interface OrganizationSwitcherProps extends Omit<SurfaceComponentProps, "children"> {
6
+ organizations: OrganizationRef[];
7
+ activeId?: string;
8
+ defaultOpen?: boolean;
9
+ onSwitch?: (id: string) => void;
10
+ }
11
+
12
+ export function OrganizationSwitcher({
13
+ organizations,
14
+ activeId,
15
+ defaultOpen = false,
16
+ onSwitch,
17
+ className
18
+ }: OrganizationSwitcherProps) {
19
+ const [open, setOpen] = React.useState(defaultOpen);
20
+ const [selectedId, setSelectedId] = React.useState(activeId ?? organizations[0]?.id);
21
+ const detailsRef = React.useRef<HTMLDetailsElement>(null);
22
+ const listRef = React.useRef<HTMLDivElement>(null);
23
+ const active =
24
+ organizations.find((organization) => organization.id === (activeId ?? selectedId)) ??
25
+ organizations[0];
26
+ const triggerLabel = active
27
+ ? `Switch organization, current organization ${active.label}`
28
+ : "Switch organization";
29
+
30
+ React.useEffect(() => {
31
+ if (activeId !== undefined) setSelectedId(activeId);
32
+ }, [activeId]);
33
+
34
+ React.useEffect(() => {
35
+ if (!organizations.some((organization) => organization.id === selectedId)) {
36
+ setSelectedId(organizations[0]?.id);
37
+ }
38
+ }, [organizations, selectedId]);
39
+
40
+ const closeMenu = React.useCallback(() => {
41
+ setOpen(false);
42
+ detailsRef.current?.querySelector("summary")?.focus();
43
+ }, []);
44
+
45
+ const selectOrganization = (organization: OrganizationRef) => {
46
+ if (activeId === undefined) setSelectedId(organization.id);
47
+ onSwitch?.(organization.id);
48
+ closeMenu();
49
+ };
50
+
51
+ return (
52
+ <div
53
+ className={["eth-identity-organization-switcher", className].filter(Boolean).join(" ")}
54
+ data-eth-component="OrganizationSwitcher"
55
+ >
56
+ <details
57
+ ref={detailsRef}
58
+ className="eth-popover"
59
+ open={open}
60
+ onToggle={(event) => {
61
+ const nextOpen = event.currentTarget.open;
62
+ setOpen(nextOpen);
63
+ if (nextOpen) window.setTimeout(() => listRef.current?.focus(), 0);
64
+ }}
65
+ >
66
+ <summary aria-haspopup="menu" aria-expanded={open} aria-label={triggerLabel}>
67
+ <span className="eth-identity-organization-switcher__trigger">
68
+ <span className="eth-identity-organization-switcher__avatar" aria-hidden="true">
69
+ {active ? initials(active.label) : "ORG"}
70
+ </span>
71
+ <span className="eth-identity-organization-switcher__copy">
72
+ <span className="eth-identity-organization-switcher__label">
73
+ {active?.label ?? "Select organization"}
74
+ </span>
75
+ <span className="eth-identity-organization-switcher__meta">
76
+ {active?.description ?? `${organizations.length} organizations`}
77
+ </span>
78
+ </span>
79
+ {active?.status ? (
80
+ <StatusDot status={active.status} label={formatStatus(active.status)} />
81
+ ) : null}
82
+ <span className="eth-identity-organization-switcher__chevron" aria-hidden="true" />
83
+ </span>
84
+ </summary>
85
+ <div
86
+ ref={listRef}
87
+ tabIndex={-1}
88
+ className="eth-identity-organization-switcher__list"
89
+ role="menu"
90
+ aria-label="Organizations"
91
+ onKeyDown={(event) => {
92
+ if (event.key === "Escape") closeMenu();
93
+ }}
94
+ >
95
+ {organizations.length ? (
96
+ organizations.map((organization) => {
97
+ const selected = organization.id === active?.id;
98
+
99
+ return (
100
+ <button
101
+ key={organization.id}
102
+ type="button"
103
+ className="eth-identity-organization-switcher__option"
104
+ role="menuitemradio"
105
+ aria-checked={selected}
106
+ aria-current={selected ? "true" : undefined}
107
+ onClick={() => selectOrganization(organization)}
108
+ >
109
+ <span className="eth-identity-organization-switcher__option-main">
110
+ <span className="eth-identity-organization-switcher__option-label">
111
+ {organization.label}
112
+ </span>
113
+ {organization.description ? (
114
+ <span className="eth-identity-organization-switcher__option-meta">
115
+ {organization.description}
116
+ </span>
117
+ ) : null}
118
+ </span>
119
+ <span className="eth-identity-organization-switcher__option-state">
120
+ {organization.status ? (
121
+ <StatusDot
122
+ status={organization.status}
123
+ label={formatStatus(organization.status)}
124
+ />
125
+ ) : null}
126
+ <span
127
+ className="eth-identity-organization-switcher__option-check"
128
+ aria-hidden="true"
129
+ />
130
+ </span>
131
+ </button>
132
+ );
133
+ })
134
+ ) : (
135
+ <div className="eth-identity-organization-switcher__empty" role="none">
136
+ No organizations available
137
+ </div>
138
+ )}
139
+ </div>
140
+ </details>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ function initials(label: string) {
146
+ const value = label
147
+ .split(/\s+/)
148
+ .filter(Boolean)
149
+ .slice(0, 2)
150
+ .map((part) => part[0]?.toUpperCase())
151
+ .join("");
152
+
153
+ return value || "ORG";
154
+ }
155
+
156
+ function formatStatus(status: string) {
157
+ return status
158
+ .split("-")
159
+ .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
160
+ .join(" ");
161
+ }
@@ -0,0 +1,96 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { PermissionMatrix } from "./PermissionMatrix";
4
+ import type { PermissionAction } from "./types";
5
+
6
+ const subjects = [
7
+ { id: "workspace-admin", label: "Workspace admin", kind: "role" },
8
+ { id: "qa-reviewer", label: "QA reviewer", kind: "role" }
9
+ ];
10
+
11
+ const actions = [
12
+ {
13
+ id: "dataset.read",
14
+ label: "Read",
15
+ group: "Datasets",
16
+ resource: "Dataset",
17
+ scope: "workspace"
18
+ },
19
+ {
20
+ id: "dataset.write",
21
+ label: "Write",
22
+ group: "Datasets",
23
+ resource: "Dataset",
24
+ scope: "project"
25
+ },
26
+ {
27
+ id: "review.approve",
28
+ label: "Approve",
29
+ group: "Reviews",
30
+ resource: "Review queue",
31
+ scope: "tenant"
32
+ }
33
+ ] satisfies PermissionAction[];
34
+
35
+ describe("PermissionMatrix", () => {
36
+ it("renders a matrix summary with grouped actions and resource context", () => {
37
+ render(
38
+ <PermissionMatrix
39
+ title="Workspace permissions"
40
+ description="Review the effective grants before applying the policy bundle."
41
+ subjects={subjects}
42
+ actions={actions}
43
+ assignments={{
44
+ "workspace-admin": {
45
+ "dataset.read": "allow",
46
+ "dataset.write": "allow",
47
+ "review.approve": "allow"
48
+ },
49
+ "qa-reviewer": {
50
+ "dataset.read": "allow",
51
+ "dataset.write": "inherit",
52
+ "review.approve": "deny"
53
+ }
54
+ }}
55
+ />
56
+ );
57
+
58
+ expect(screen.getByText("Workspace permissions")).toBeTruthy();
59
+ expect(screen.getByText("2 subjects")).toBeTruthy();
60
+ expect(screen.getByText("3 actions")).toBeTruthy();
61
+ expect(screen.getByText("2 resources")).toBeTruthy();
62
+ expect(screen.getByRole("table", { name: "Workspace permissions matrix" })).toBeTruthy();
63
+ expect(screen.getByText("Datasets")).toBeTruthy();
64
+ expect(screen.getByText("Reviews")).toBeTruthy();
65
+ expect(screen.getAllByText("Dataset").length).toBeGreaterThan(0);
66
+ expect(screen.getByText("Review queue")).toBeTruthy();
67
+ expect(screen.getByText("workspace")).toBeTruthy();
68
+ expect(screen.getByText("tenant")).toBeTruthy();
69
+ expect(screen.getAllByText("Allow").length).toBeGreaterThan(0);
70
+ expect(screen.getAllByText("Deny").length).toBeGreaterThan(0);
71
+ expect(screen.getAllByText("Inherit").length).toBeGreaterThan(0);
72
+ });
73
+
74
+ it("marks editable cells with permission values and compact controls", () => {
75
+ const { container } = render(
76
+ <PermissionMatrix
77
+ subjects={[
78
+ { id: "jd", label: "Jane Doe", kind: "user" },
79
+ { id: "svc", label: "Import service", kind: "service-account" }
80
+ ]}
81
+ actions={[
82
+ { id: "read", label: "Read", group: "Resource" },
83
+ { id: "approve", label: "Approve", group: "Workflow" }
84
+ ]}
85
+ assignments={{ jd: { read: "allow" } }}
86
+ mode="edit"
87
+ onChange={() => undefined}
88
+ />
89
+ );
90
+
91
+ expect(screen.getByRole("table", { name: "Permission matrix" })).toBeTruthy();
92
+ expect(screen.getByLabelText("Jane Doe Read permission")).toBeTruthy();
93
+ expect(container.querySelector('[data-permission-value="inherit"]')).toBeTruthy();
94
+ expect(container.querySelector(".eth-identity-permission-matrix__select")).toBeTruthy();
95
+ });
96
+ });
@@ -0,0 +1,271 @@
1
+ import {
2
+ Badge,
3
+ EmptyState,
4
+ Select,
5
+ Surface,
6
+ type EthSeverity,
7
+ type SurfaceComponentProps
8
+ } from "@echothink-ui/core";
9
+ import type * as React from "react";
10
+ import type { IdentitySubject, PermissionAction, PermissionValue } from "./types";
11
+
12
+ export interface PermissionMatrixProps
13
+ extends Omit<SurfaceComponentProps, "children" | "onChange"> {
14
+ subjects: IdentitySubject[];
15
+ actions: PermissionAction[];
16
+ assignments: Record<string, Record<string, PermissionValue>>;
17
+ onChange?: (subjectId: string, actionId: string, value: PermissionValue) => void;
18
+ mode?: "read" | "edit";
19
+ }
20
+
21
+ export function PermissionMatrix({
22
+ subjects,
23
+ actions,
24
+ assignments,
25
+ onChange,
26
+ mode = "read",
27
+ title,
28
+ description,
29
+ metadata,
30
+ className,
31
+ "aria-label": ariaLabel,
32
+ ...props
33
+ }: PermissionMatrixProps) {
34
+ const groups = groupActions(actions);
35
+ const permissionCounts = countPermissionValues(subjects, actions, assignments);
36
+ const resourceCount = countResources(actions);
37
+ const tableLabel = ariaLabel ?? matrixLabelForTitle(title);
38
+ const matrixMetadata = [
39
+ { label: "Subjects", value: countLabel(subjects.length, "subject") },
40
+ { label: "Actions", value: countLabel(actions.length, "action") },
41
+ { label: "Resources", value: countLabel(resourceCount, "resource") },
42
+ ...(metadata ?? [])
43
+ ];
44
+
45
+ return (
46
+ <Surface
47
+ {...props}
48
+ title={title}
49
+ description={description}
50
+ metadata={matrixMetadata}
51
+ className={["eth-identity-permission-matrix", className].filter(Boolean).join(" ")}
52
+ data-eth-component="PermissionMatrix"
53
+ >
54
+ {subjects.length > 0 && actions.length > 0 ? (
55
+ <>
56
+ <div className="eth-identity-permission-matrix__summary" aria-label="Permission summary">
57
+ <SummaryItem value={permissionCounts.allow} label="Allowed" tone="allow" />
58
+ <SummaryItem value={permissionCounts.deny} label="Denied" tone="deny" />
59
+ <SummaryItem value={permissionCounts.inherit} label="Inherited" tone="inherit" />
60
+ <span className="eth-identity-permission-matrix__mode">
61
+ {mode === "edit" ? "Editable" : "Read only"}
62
+ </span>
63
+ </div>
64
+ <div className="eth-identity-permission-matrix__table-wrap">
65
+ <table aria-label={tableLabel} className="eth-identity-permission-matrix__table">
66
+ <caption className="eth-identity-permission-matrix__caption">
67
+ Permission assignments across subjects, resources, scopes, and actions.
68
+ </caption>
69
+ <thead>
70
+ <tr>
71
+ <th
72
+ scope="col"
73
+ rowSpan={2}
74
+ className={[
75
+ "eth-identity-permission-matrix__subject-col",
76
+ "eth-identity-permission-matrix__subject-heading"
77
+ ].join(" ")}
78
+ >
79
+ Subject
80
+ </th>
81
+ {groups.map((group) => (
82
+ <th key={group.label} scope="colgroup" colSpan={group.actions.length}>
83
+ {group.label}
84
+ </th>
85
+ ))}
86
+ </tr>
87
+ <tr>
88
+ {groups.flatMap((group) =>
89
+ group.actions.map((action) => (
90
+ <th key={action.id} scope="col">
91
+ <span className="eth-identity-permission-matrix__action-label">
92
+ {action.label}
93
+ </span>
94
+ {action.resource || action.scope ? (
95
+ <span className="eth-identity-permission-matrix__action-meta">
96
+ {action.resource ? <span>{action.resource}</span> : null}
97
+ {action.scope ? <span>{action.scope}</span> : null}
98
+ </span>
99
+ ) : null}
100
+ </th>
101
+ ))
102
+ )}
103
+ </tr>
104
+ </thead>
105
+ <tbody>
106
+ {subjects.map((subject) => {
107
+ const subjectMeta = formatSubjectMeta(subject);
108
+
109
+ return (
110
+ <tr key={subject.id}>
111
+ <th scope="row" className="eth-identity-permission-matrix__subject">
112
+ <span className="eth-identity-permission-matrix__subject-label">
113
+ {subject.label}
114
+ </span>
115
+ {subjectMeta ? <small>{subjectMeta}</small> : null}
116
+ </th>
117
+ {actions.map((action) => {
118
+ const value = assignments[subject.id]?.[action.id] ?? "inherit";
119
+ const valueLabel = labelForPermission(value);
120
+
121
+ return (
122
+ <td
123
+ key={action.id}
124
+ className="eth-identity-permission-matrix__cell"
125
+ data-permission={value}
126
+ data-permission-value={value}
127
+ title={`${subject.label} / ${action.label}: ${valueLabel}`}
128
+ >
129
+ {mode === "edit" ? (
130
+ <Select
131
+ aria-label={`${subject.label} ${action.label} permission`}
132
+ className={[
133
+ "eth-identity-permission-matrix__select",
134
+ `eth-identity-permission-matrix__select--${value}`
135
+ ].join(" ")}
136
+ density="compact"
137
+ disabled={!onChange}
138
+ value={value}
139
+ options={permissionOptions}
140
+ onChange={(event) =>
141
+ onChange?.(
142
+ subject.id,
143
+ action.id,
144
+ event.currentTarget.value as PermissionValue
145
+ )
146
+ }
147
+ />
148
+ ) : (
149
+ <Badge
150
+ severity={severityForPermission(value)}
151
+ aria-label={`${subject.label} ${action.label}: ${valueLabel}`}
152
+ className={[
153
+ "eth-identity-permission-matrix__badge",
154
+ `eth-identity-permission-matrix__badge--${value}`
155
+ ].join(" ")}
156
+ >
157
+ <span
158
+ className="eth-identity-permission-matrix__status-mark"
159
+ aria-hidden="true"
160
+ />
161
+ {valueLabel}
162
+ </Badge>
163
+ )}
164
+ </td>
165
+ );
166
+ })}
167
+ </tr>
168
+ );
169
+ })}
170
+ </tbody>
171
+ </table>
172
+ </div>
173
+ </>
174
+ ) : (
175
+ <EmptyState
176
+ title="No permissions configured"
177
+ description="Add at least one subject and one action to render the permission matrix."
178
+ />
179
+ )}
180
+ </Surface>
181
+ );
182
+ }
183
+
184
+ function SummaryItem({
185
+ value,
186
+ label,
187
+ tone
188
+ }: {
189
+ value: number;
190
+ label: string;
191
+ tone: PermissionValue;
192
+ }) {
193
+ return (
194
+ <span
195
+ className={`eth-identity-permission-matrix__summary-item eth-identity-permission-matrix__summary-item--${tone}`}
196
+ >
197
+ <strong>{value}</strong>
198
+ {label}
199
+ </span>
200
+ );
201
+ }
202
+
203
+ const permissionOptions: Array<{ value: PermissionValue; label: string }> = [
204
+ { value: "inherit", label: "Inherit" },
205
+ { value: "allow", label: "Allow" },
206
+ { value: "deny", label: "Deny" }
207
+ ];
208
+
209
+ function groupActions(actions: PermissionAction[]) {
210
+ const labels = Array.from(new Set(actions.map((action) => action.group ?? "Permissions")));
211
+ return labels.map((label) => ({
212
+ label,
213
+ actions: actions.filter((action) => (action.group ?? "Permissions") === label)
214
+ }));
215
+ }
216
+
217
+ function severityForPermission(value: PermissionValue): EthSeverity {
218
+ if (value === "allow") return "success";
219
+ if (value === "deny") return "danger";
220
+ return "neutral";
221
+ }
222
+
223
+ function labelForPermission(value: PermissionValue) {
224
+ return permissionOptions.find((option) => option.value === value)?.label ?? value;
225
+ }
226
+
227
+ function countPermissionValues(
228
+ subjects: IdentitySubject[],
229
+ actions: PermissionAction[],
230
+ assignments: Record<string, Record<string, PermissionValue>>
231
+ ) {
232
+ return subjects.reduce(
233
+ (counts, subject) => {
234
+ actions.forEach((action) => {
235
+ const value = assignments[subject.id]?.[action.id] ?? "inherit";
236
+ counts[value] += 1;
237
+ });
238
+ return counts;
239
+ },
240
+ { allow: 0, deny: 0, inherit: 0 } satisfies Record<PermissionValue, number>
241
+ );
242
+ }
243
+
244
+ function countResources(actions: PermissionAction[]) {
245
+ return new Set(actions.map((action) => action.resource ?? action.group ?? action.label)).size;
246
+ }
247
+
248
+ function countLabel(value: number, noun: string) {
249
+ return `${value} ${value === 1 ? noun : `${noun}s`}`;
250
+ }
251
+
252
+ function formatSubjectKind(kind: string) {
253
+ return kind.replace(/-/g, " ");
254
+ }
255
+
256
+ function formatSubjectMeta(subject: IdentitySubject) {
257
+ return [formatSubjectKind(subject.kind), subject.role, subject.description]
258
+ .filter(Boolean)
259
+ .join(" / ");
260
+ }
261
+
262
+ function nodeToText(node: React.ReactNode, fallback: string) {
263
+ if (typeof node === "string") return node;
264
+ if (typeof node === "number") return String(node);
265
+ return fallback;
266
+ }
267
+
268
+ function matrixLabelForTitle(title: React.ReactNode) {
269
+ const label = nodeToText(title, "Permission");
270
+ return label.toLowerCase().includes("matrix") ? label : `${label} matrix`;
271
+ }
@@ -0,0 +1,29 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { PolicyRuleViewer } from "./PolicyRuleViewer";
4
+
5
+ const rule = {
6
+ id: "p1",
7
+ effect: "allow" as const,
8
+ subjects: ["group:marketing"],
9
+ actions: ["read", "comment"],
10
+ resources: ["resource:campaign/*"],
11
+ conditions: "context.environment == 'prod'"
12
+ };
13
+
14
+ describe("@echothink-ui/identity PolicyRuleViewer", () => {
15
+ it("renders the rule effect and facets as a readable read-only summary", () => {
16
+ const { container } = render(<PolicyRuleViewer rule={rule} />);
17
+
18
+ expect(screen.getByText("Policy rule p1")).toBeTruthy();
19
+ expect(screen.getByLabelText("Rule effect: Allow").textContent).toContain("Allow");
20
+ expect(
21
+ container.querySelector(".eth-identity-policy-rule-viewer__facet-list")
22
+ ).toBeTruthy();
23
+ expect(screen.getByText("group:marketing")).toBeTruthy();
24
+ expect(screen.getByText("read")).toBeTruthy();
25
+ expect(screen.getByText("comment")).toBeTruthy();
26
+ expect(screen.getByText("resource:campaign/*")).toBeTruthy();
27
+ expect(screen.getByText("context.environment == 'prod'")).toBeTruthy();
28
+ });
29
+ });