@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,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
|
+
});
|