@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,131 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
FormField,
|
|
5
|
+
NumberInput,
|
|
6
|
+
Surface,
|
|
7
|
+
Tag,
|
|
8
|
+
TextInput,
|
|
9
|
+
Textarea,
|
|
10
|
+
type SurfaceComponentProps
|
|
11
|
+
} from "@echothink-ui/core";
|
|
12
|
+
import { RuleBuilder, type RuleDefinition } from "@echothink-ui/forms";
|
|
13
|
+
import type { ApprovalPolicy, ApprovalPolicySchema } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface ApprovalPolicyEditorProps extends Omit<
|
|
16
|
+
SurfaceComponentProps,
|
|
17
|
+
"children" | "onChange"
|
|
18
|
+
> {
|
|
19
|
+
policy: ApprovalPolicy;
|
|
20
|
+
schema: ApprovalPolicySchema;
|
|
21
|
+
onChange?: (policy: ApprovalPolicy) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ApprovalPolicyEditor({
|
|
25
|
+
policy,
|
|
26
|
+
schema,
|
|
27
|
+
onChange,
|
|
28
|
+
title,
|
|
29
|
+
description,
|
|
30
|
+
actions,
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
}: ApprovalPolicyEditorProps) {
|
|
34
|
+
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
|
35
|
+
const policyDomId = `eth-identity-policy-${String(policy.id ?? generatedId).replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
36
|
+
const variables = schema.variables ?? Object.keys(schema.properties ?? {});
|
|
37
|
+
const rules = policy.rules ?? [];
|
|
38
|
+
const approvers = policy.approvers ?? [];
|
|
39
|
+
const requiredApprovals = policy.requiredApprovals ?? 1;
|
|
40
|
+
const update = (patch: Partial<ApprovalPolicy>) => onChange?.({ ...policy, ...patch });
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Surface
|
|
44
|
+
{...props}
|
|
45
|
+
title={title ?? "Approval policy"}
|
|
46
|
+
description={
|
|
47
|
+
description ??
|
|
48
|
+
schema.description ??
|
|
49
|
+
"Configure approval thresholds, reviewers, and workflow rules for governed actions."
|
|
50
|
+
}
|
|
51
|
+
actions={actions ?? policy.actions}
|
|
52
|
+
className={["eth-identity-approval-policy-editor", className].filter(Boolean).join(" ")}
|
|
53
|
+
data-eth-component="ApprovalPolicyEditor"
|
|
54
|
+
>
|
|
55
|
+
<div className="eth-identity-approval-policy-editor__layout">
|
|
56
|
+
<div className="eth-identity-approval-policy-editor__fields">
|
|
57
|
+
<FormField id={`${policyDomId}-name`} label="Policy name" required>
|
|
58
|
+
<TextInput
|
|
59
|
+
id={`${policyDomId}-name`}
|
|
60
|
+
value={policy.name ?? ""}
|
|
61
|
+
onChange={(event) => update({ name: event.currentTarget.value })}
|
|
62
|
+
/>
|
|
63
|
+
</FormField>
|
|
64
|
+
<FormField id={`${policyDomId}-description`} label="Description">
|
|
65
|
+
<Textarea
|
|
66
|
+
id={`${policyDomId}-description`}
|
|
67
|
+
value={policy.description ?? ""}
|
|
68
|
+
onChange={(event) => update({ description: event.currentTarget.value })}
|
|
69
|
+
/>
|
|
70
|
+
</FormField>
|
|
71
|
+
<FormField
|
|
72
|
+
id={`${policyDomId}-required`}
|
|
73
|
+
label="Required approvals"
|
|
74
|
+
helperText="Minimum number of reviewers who must approve before the workflow can continue."
|
|
75
|
+
>
|
|
76
|
+
<NumberInput
|
|
77
|
+
id={`${policyDomId}-required`}
|
|
78
|
+
min={1}
|
|
79
|
+
value={requiredApprovals}
|
|
80
|
+
onChange={(event) => update({ requiredApprovals: Number(event.currentTarget.value) })}
|
|
81
|
+
/>
|
|
82
|
+
</FormField>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<aside
|
|
86
|
+
className="eth-identity-approval-policy-editor__summary"
|
|
87
|
+
aria-label="Approval policy summary"
|
|
88
|
+
>
|
|
89
|
+
<div className="eth-identity-approval-policy-editor__summary-header">
|
|
90
|
+
<h3>Workflow gates</h3>
|
|
91
|
+
<Badge severity={rules.length ? "success" : "warning"}>
|
|
92
|
+
{rules.length ? "Configured" : "Needs rules"}
|
|
93
|
+
</Badge>
|
|
94
|
+
</div>
|
|
95
|
+
<dl className="eth-identity-approval-policy-editor__summary-grid">
|
|
96
|
+
<div>
|
|
97
|
+
<dt>Required approvals</dt>
|
|
98
|
+
<dd>{requiredApprovals}</dd>
|
|
99
|
+
</div>
|
|
100
|
+
<div>
|
|
101
|
+
<dt>Rules</dt>
|
|
102
|
+
<dd>{rules.length}</dd>
|
|
103
|
+
</div>
|
|
104
|
+
<div>
|
|
105
|
+
<dt>Approvers</dt>
|
|
106
|
+
<dd>{approvers.length ? approvers.length : "Unassigned"}</dd>
|
|
107
|
+
</div>
|
|
108
|
+
</dl>
|
|
109
|
+
<div className="eth-identity-approval-policy-editor__approvers">
|
|
110
|
+
<span>Reviewer pool</span>
|
|
111
|
+
{approvers.length ? (
|
|
112
|
+
<div className="eth-identity-approval-policy-editor__approver-list">
|
|
113
|
+
{approvers.map((approver) => (
|
|
114
|
+
<Tag key={approver.id}>{approver.label}</Tag>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
) : (
|
|
118
|
+
<p>No approvers assigned to this policy.</p>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
</aside>
|
|
122
|
+
</div>
|
|
123
|
+
<RuleBuilder
|
|
124
|
+
className="eth-identity-approval-policy-editor__rules"
|
|
125
|
+
rules={rules as RuleDefinition[]}
|
|
126
|
+
variables={variables}
|
|
127
|
+
onChange={(nextRules) => update({ rules: nextRules })}
|
|
128
|
+
/>
|
|
129
|
+
</Surface>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Badge, EmptyState, Surface, type EthSeverity, type SurfaceComponentProps } from "@echothink-ui/core";
|
|
2
|
+
import { DataTable, type DataColumn } from "@echothink-ui/data";
|
|
3
|
+
import type { AuditTrailEntry } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface AuditTrailProps extends Omit<SurfaceComponentProps, "children"> {
|
|
6
|
+
entries: AuditTrailEntry[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type AuditTrailRow = Record<string, unknown> & AuditTrailEntry;
|
|
10
|
+
|
|
11
|
+
export function AuditTrail({
|
|
12
|
+
entries,
|
|
13
|
+
title = "Audit trail",
|
|
14
|
+
subtitle,
|
|
15
|
+
description = "Identity and access audit history ordered by latest event.",
|
|
16
|
+
density = "compact",
|
|
17
|
+
metadata,
|
|
18
|
+
className,
|
|
19
|
+
role,
|
|
20
|
+
"aria-label": ariaLabel,
|
|
21
|
+
...props
|
|
22
|
+
}: AuditTrailProps) {
|
|
23
|
+
const rows: AuditTrailRow[] = entries.map((entry) => ({ ...entry }));
|
|
24
|
+
const successCount = entries.filter((entry) => entry.outcome === "success").length;
|
|
25
|
+
const failureCount = entries.length - successCount;
|
|
26
|
+
const resolvedSubtitle = subtitle ?? `${entries.length} ${entries.length === 1 ? "event" : "events"}`;
|
|
27
|
+
const resolvedMetadata =
|
|
28
|
+
metadata ?? [
|
|
29
|
+
{ label: "Events", value: entries.length },
|
|
30
|
+
{ label: "Successful", value: successCount },
|
|
31
|
+
{ label: "Failures", value: failureCount }
|
|
32
|
+
];
|
|
33
|
+
const columns: DataColumn<AuditTrailRow>[] = [
|
|
34
|
+
{
|
|
35
|
+
key: "timestamp",
|
|
36
|
+
header: "Time",
|
|
37
|
+
width: "8.5rem",
|
|
38
|
+
render: (row) => (
|
|
39
|
+
<time className="eth-identity-audit-trail__time">{row.timestamp}</time>
|
|
40
|
+
)
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: "actor",
|
|
44
|
+
header: "Actor",
|
|
45
|
+
width: "9rem",
|
|
46
|
+
render: (row) => <span className="eth-identity-audit-trail__actor">{row.actor}</span>
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "action",
|
|
50
|
+
header: "Action",
|
|
51
|
+
width: "11rem",
|
|
52
|
+
render: (row) => <span className="eth-identity-audit-trail__action">{row.action}</span>
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: "resource",
|
|
56
|
+
header: "Resource",
|
|
57
|
+
render: (row) => <span className="eth-identity-audit-trail__resource">{row.resource}</span>
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: "outcome",
|
|
61
|
+
header: "Outcome",
|
|
62
|
+
width: "8rem",
|
|
63
|
+
render: (row) => (
|
|
64
|
+
<span className="eth-identity-audit-trail__outcome">
|
|
65
|
+
<Badge severity={outcomeSeverity(row.outcome)}>{outcomeLabel(row.outcome)}</Badge>
|
|
66
|
+
</span>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Surface
|
|
73
|
+
{...props}
|
|
74
|
+
title={title}
|
|
75
|
+
subtitle={resolvedSubtitle}
|
|
76
|
+
description={description}
|
|
77
|
+
density={density}
|
|
78
|
+
metadata={resolvedMetadata}
|
|
79
|
+
className={["eth-identity-audit-trail", className].filter(Boolean).join(" ")}
|
|
80
|
+
data-eth-component="AuditTrail"
|
|
81
|
+
role={role ?? "region"}
|
|
82
|
+
aria-label={ariaLabel ?? (typeof title === "string" ? title : "Audit trail")}
|
|
83
|
+
>
|
|
84
|
+
<DataTable
|
|
85
|
+
rows={rows}
|
|
86
|
+
columns={columns}
|
|
87
|
+
density={density}
|
|
88
|
+
emptyState={
|
|
89
|
+
<EmptyState
|
|
90
|
+
title="No audit events"
|
|
91
|
+
description="Identity and access events will appear here when they are recorded."
|
|
92
|
+
/>
|
|
93
|
+
}
|
|
94
|
+
/>
|
|
95
|
+
</Surface>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function outcomeSeverity(outcome: AuditTrailEntry["outcome"]): EthSeverity {
|
|
100
|
+
return outcome === "success" ? "success" : "danger";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function outcomeLabel(outcome: AuditTrailEntry["outcome"]) {
|
|
104
|
+
return outcome === "success" ? "Success" : "Failure";
|
|
105
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Tag, TextInput, type SurfaceComponentProps } from "@echothink-ui/core";
|
|
3
|
+
import type { IdentityRef } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface GroupPickerProps extends Omit<
|
|
6
|
+
SurfaceComponentProps,
|
|
7
|
+
"children" | "onChange" | "value"
|
|
8
|
+
> {
|
|
9
|
+
value: string[];
|
|
10
|
+
onChange?: (value: string[]) => void;
|
|
11
|
+
onSearch?: (query: string) => void;
|
|
12
|
+
suggestions?: IdentityRef[];
|
|
13
|
+
multiple?: boolean;
|
|
14
|
+
defaultOpen?: boolean;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function GroupPicker({
|
|
19
|
+
value,
|
|
20
|
+
onChange,
|
|
21
|
+
onSearch,
|
|
22
|
+
suggestions = [],
|
|
23
|
+
multiple = true,
|
|
24
|
+
defaultOpen = false,
|
|
25
|
+
disabled = false,
|
|
26
|
+
loading = false,
|
|
27
|
+
className
|
|
28
|
+
}: GroupPickerProps) {
|
|
29
|
+
const [query, setQuery] = React.useState("");
|
|
30
|
+
const [open, setOpen] = React.useState(defaultOpen);
|
|
31
|
+
const searchRef = React.useRef<HTMLInputElement>(null);
|
|
32
|
+
const selected = value.map(
|
|
33
|
+
(id) => suggestions.find((item) => item.id === id) ?? { id, label: id }
|
|
34
|
+
);
|
|
35
|
+
const normalizedQuery = query.trim().toLocaleLowerCase();
|
|
36
|
+
const visibleSuggestions = normalizedQuery
|
|
37
|
+
? suggestions.filter((suggestion) =>
|
|
38
|
+
[suggestion.label, suggestion.email, suggestion.role, suggestion.kind]
|
|
39
|
+
.filter((field): field is string => typeof field === "string")
|
|
40
|
+
.some((field) => field.toLocaleLowerCase().includes(normalizedQuery))
|
|
41
|
+
)
|
|
42
|
+
: suggestions;
|
|
43
|
+
const selectedLabel = selected.length
|
|
44
|
+
? `${selected.length} selected`
|
|
45
|
+
: multiple
|
|
46
|
+
? "No groups selected"
|
|
47
|
+
: "No group selected";
|
|
48
|
+
const triggerLabel = multiple ? "Add groups" : selected.length ? "Change group" : "Choose group";
|
|
49
|
+
|
|
50
|
+
const toggle = (id: string) => {
|
|
51
|
+
if (disabled || loading) return;
|
|
52
|
+
|
|
53
|
+
if (!multiple) {
|
|
54
|
+
onChange?.([id]);
|
|
55
|
+
setOpen(false);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onChange?.(value.includes(id) ? value.filter((item) => item !== id) : [...value, id]);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
className={["eth-identity-group-picker", className].filter(Boolean).join(" ")}
|
|
65
|
+
data-eth-component="GroupPicker"
|
|
66
|
+
data-disabled={disabled || loading ? "true" : undefined}
|
|
67
|
+
data-open={open ? "true" : undefined}
|
|
68
|
+
>
|
|
69
|
+
<div className="eth-identity-group-picker__control">
|
|
70
|
+
<div className="eth-identity-group-picker__chips" aria-label="Selected groups">
|
|
71
|
+
{selected.length ? (
|
|
72
|
+
selected.map((item) => (
|
|
73
|
+
<Tag
|
|
74
|
+
key={item.id}
|
|
75
|
+
removable={!disabled && !loading}
|
|
76
|
+
onRemove={() => onChange?.(value.filter((id) => id !== item.id))}
|
|
77
|
+
>
|
|
78
|
+
{item.label}
|
|
79
|
+
</Tag>
|
|
80
|
+
))
|
|
81
|
+
) : (
|
|
82
|
+
<span className="eth-identity-group-picker__placeholder">
|
|
83
|
+
{multiple ? "Select groups" : "Select a group"}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
<details
|
|
88
|
+
open={open}
|
|
89
|
+
className="eth-popover eth-identity-group-picker__popover"
|
|
90
|
+
onToggle={(event) => {
|
|
91
|
+
const nextOpen = event.currentTarget.open;
|
|
92
|
+
setOpen(nextOpen);
|
|
93
|
+
|
|
94
|
+
if (nextOpen) window.setTimeout(() => searchRef.current?.focus(), 0);
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<summary
|
|
98
|
+
aria-label={`${triggerLabel}. ${selectedLabel}`}
|
|
99
|
+
aria-disabled={disabled || loading ? true : undefined}
|
|
100
|
+
onClick={(event) => {
|
|
101
|
+
if (disabled || loading) event.preventDefault();
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<span className="eth-identity-group-picker__trigger">
|
|
105
|
+
<span>{triggerLabel}</span>
|
|
106
|
+
<span className="eth-identity-group-picker__count">{selectedLabel}</span>
|
|
107
|
+
<span className="eth-identity-group-picker__chevron" aria-hidden="true" />
|
|
108
|
+
</span>
|
|
109
|
+
</summary>
|
|
110
|
+
<div className="eth-popover__content eth-identity-group-picker__content">
|
|
111
|
+
<TextInput
|
|
112
|
+
ref={searchRef}
|
|
113
|
+
type="search"
|
|
114
|
+
placeholder="Search groups"
|
|
115
|
+
value={query}
|
|
116
|
+
disabled={disabled || loading}
|
|
117
|
+
onChange={(event) => {
|
|
118
|
+
setQuery(event.currentTarget.value);
|
|
119
|
+
onSearch?.(event.currentTarget.value);
|
|
120
|
+
}}
|
|
121
|
+
/>
|
|
122
|
+
<div
|
|
123
|
+
className="eth-identity-group-picker__suggestions"
|
|
124
|
+
role="listbox"
|
|
125
|
+
aria-label="Group suggestions"
|
|
126
|
+
aria-multiselectable={multiple ? true : undefined}
|
|
127
|
+
>
|
|
128
|
+
{loading ? (
|
|
129
|
+
<div className="eth-identity-group-picker__empty" role="status">
|
|
130
|
+
Loading groups...
|
|
131
|
+
</div>
|
|
132
|
+
) : visibleSuggestions.length ? (
|
|
133
|
+
visibleSuggestions.map((suggestion) => {
|
|
134
|
+
const isSelected = value.includes(suggestion.id);
|
|
135
|
+
const meta =
|
|
136
|
+
suggestion.role ??
|
|
137
|
+
suggestion.email ??
|
|
138
|
+
(suggestion.kind === "group" ? "Group" : undefined);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<button
|
|
142
|
+
key={suggestion.id}
|
|
143
|
+
type="button"
|
|
144
|
+
className="eth-identity-group-picker__option"
|
|
145
|
+
role="option"
|
|
146
|
+
aria-selected={isSelected}
|
|
147
|
+
onClick={() => toggle(suggestion.id)}
|
|
148
|
+
>
|
|
149
|
+
<span className="eth-identity-group-picker__option-main">
|
|
150
|
+
<span className="eth-identity-group-picker__option-label">
|
|
151
|
+
{suggestion.label}
|
|
152
|
+
</span>
|
|
153
|
+
{meta ? (
|
|
154
|
+
<span className="eth-identity-group-picker__option-meta">{meta}</span>
|
|
155
|
+
) : null}
|
|
156
|
+
</span>
|
|
157
|
+
<span
|
|
158
|
+
className="eth-identity-group-picker__option-check"
|
|
159
|
+
aria-hidden="true"
|
|
160
|
+
/>
|
|
161
|
+
</button>
|
|
162
|
+
);
|
|
163
|
+
})
|
|
164
|
+
) : (
|
|
165
|
+
<div className="eth-identity-group-picker__empty" role="status">
|
|
166
|
+
{query ? "No matching groups" : "No groups available"}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</details>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActionGroup,
|
|
3
|
+
Badge,
|
|
4
|
+
StatusDot,
|
|
5
|
+
Surface,
|
|
6
|
+
type EthAction,
|
|
7
|
+
type EthOperationalStatus,
|
|
8
|
+
type SurfaceComponentProps
|
|
9
|
+
} from "@echothink-ui/core";
|
|
10
|
+
import { RoleBadge } from "./RoleBadge";
|
|
11
|
+
|
|
12
|
+
export interface IdentityCardProps extends Omit<SurfaceComponentProps, "children" | "actions"> {
|
|
13
|
+
identity: {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
email?: string;
|
|
17
|
+
avatar?: string;
|
|
18
|
+
role?: string;
|
|
19
|
+
kind: "user" | "service-account" | "group";
|
|
20
|
+
status?: EthOperationalStatus;
|
|
21
|
+
};
|
|
22
|
+
actions?: EthAction[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function IdentityCard({ identity, actions, className, ...props }: IdentityCardProps) {
|
|
26
|
+
return (
|
|
27
|
+
<Surface
|
|
28
|
+
{...props}
|
|
29
|
+
className={["eth-identity-card", `eth-identity-card--${identity.kind}`, className]
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join(" ")}
|
|
32
|
+
data-eth-component="IdentityCard"
|
|
33
|
+
>
|
|
34
|
+
<div className="eth-identity-card__body">
|
|
35
|
+
<Avatar label={identity.label} src={identity.avatar} />
|
|
36
|
+
<div className="eth-identity-card__main">
|
|
37
|
+
<h3>{identity.label}</h3>
|
|
38
|
+
{identity.email ? <p>{identity.email}</p> : null}
|
|
39
|
+
<div className="eth-identity-card__meta">
|
|
40
|
+
<Badge severity="neutral">{formatIdentityKind(identity.kind)}</Badge>
|
|
41
|
+
{identity.role ? <RoleBadge role={identity.role} /> : null}
|
|
42
|
+
{identity.status ? (
|
|
43
|
+
<StatusDot status={identity.status} label={formatIdentityStatus(identity.status)} />
|
|
44
|
+
) : null}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<ActionGroup actions={actions} />
|
|
48
|
+
</div>
|
|
49
|
+
</Surface>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function Avatar({ label, src }: { label: string; src?: string }) {
|
|
54
|
+
if (src) return <img className="eth-identity-card__avatar" src={src} alt="" aria-hidden="true" />;
|
|
55
|
+
return (
|
|
56
|
+
<span className="eth-identity-card__avatar" aria-hidden="true">
|
|
57
|
+
{initials(label)}
|
|
58
|
+
</span>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function initials(label: string) {
|
|
63
|
+
return label
|
|
64
|
+
.split(/\s+/)
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.slice(0, 2)
|
|
67
|
+
.map((part) => part[0]?.toUpperCase())
|
|
68
|
+
.join("");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatIdentityKind(kind: IdentityCardProps["identity"]["kind"]) {
|
|
72
|
+
if (kind === "service-account") return "service account";
|
|
73
|
+
return kind;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatIdentityStatus(status: NonNullable<IdentityCardProps["identity"]["status"]>) {
|
|
77
|
+
return status.replace(/-/g, " ");
|
|
78
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
FormField,
|
|
5
|
+
Select,
|
|
6
|
+
Surface,
|
|
7
|
+
TextInput,
|
|
8
|
+
type SurfaceComponentProps
|
|
9
|
+
} from "@echothink-ui/core";
|
|
10
|
+
|
|
11
|
+
export type InviteRole = string | { id: string; label: string };
|
|
12
|
+
|
|
13
|
+
export interface InviteUserPanelProps extends Omit<SurfaceComponentProps, "children"> {
|
|
14
|
+
roles: InviteRole[];
|
|
15
|
+
onInvite?: (email: string, role: string) => void;
|
|
16
|
+
defaultEmail?: string;
|
|
17
|
+
defaultRole?: string;
|
|
18
|
+
inviteLabel?: React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isValidInviteEmail(email: string) {
|
|
22
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function InviteUserPanel({
|
|
26
|
+
roles,
|
|
27
|
+
onInvite,
|
|
28
|
+
title = "Invite user",
|
|
29
|
+
description = "Send a workspace invitation and assign the recipient's initial access role.",
|
|
30
|
+
defaultEmail = "",
|
|
31
|
+
defaultRole,
|
|
32
|
+
inviteLabel = "Send invite",
|
|
33
|
+
className,
|
|
34
|
+
...props
|
|
35
|
+
}: InviteUserPanelProps) {
|
|
36
|
+
const options = React.useMemo(
|
|
37
|
+
() =>
|
|
38
|
+
roles.map((role) =>
|
|
39
|
+
typeof role === "string"
|
|
40
|
+
? { value: role, label: role }
|
|
41
|
+
: { value: role.id, label: role.label }
|
|
42
|
+
),
|
|
43
|
+
[roles]
|
|
44
|
+
);
|
|
45
|
+
const preferredRole = options.some((option) => option.value === defaultRole)
|
|
46
|
+
? defaultRole
|
|
47
|
+
: options[0]?.value;
|
|
48
|
+
const [email, setEmail] = React.useState(defaultEmail);
|
|
49
|
+
const [emailTouched, setEmailTouched] = React.useState(false);
|
|
50
|
+
const [role, setRole] = React.useState(preferredRole ?? "");
|
|
51
|
+
const [roleTouched, setRoleTouched] = React.useState(false);
|
|
52
|
+
const baseId = React.useId();
|
|
53
|
+
const emailId = `${baseId}-email`;
|
|
54
|
+
const roleId = `${baseId}-role`;
|
|
55
|
+
const trimmedEmail = email.trim();
|
|
56
|
+
const emailIsValid = isValidInviteEmail(trimmedEmail);
|
|
57
|
+
const selectedRole = options.find((option) => option.value === role);
|
|
58
|
+
const emailError = emailTouched
|
|
59
|
+
? trimmedEmail
|
|
60
|
+
? emailIsValid
|
|
61
|
+
? undefined
|
|
62
|
+
: "Enter a valid email address."
|
|
63
|
+
: "Email is required."
|
|
64
|
+
: undefined;
|
|
65
|
+
const roleError = roleTouched && !role ? "Select a role." : undefined;
|
|
66
|
+
const canInvite = Boolean(onInvite && emailIsValid && role);
|
|
67
|
+
const inviteStatus = onInvite
|
|
68
|
+
? canInvite
|
|
69
|
+
? "Ready to send"
|
|
70
|
+
: "Needs required fields"
|
|
71
|
+
: "Unavailable";
|
|
72
|
+
|
|
73
|
+
React.useEffect(() => {
|
|
74
|
+
if (!options.some((option) => option.value === role)) {
|
|
75
|
+
setRole(preferredRole ?? "");
|
|
76
|
+
}
|
|
77
|
+
}, [options, preferredRole, role]);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Surface
|
|
81
|
+
{...props}
|
|
82
|
+
title={title}
|
|
83
|
+
description={description}
|
|
84
|
+
className={["eth-identity-invite-user", className].filter(Boolean).join(" ")}
|
|
85
|
+
data-eth-component="InviteUserPanel"
|
|
86
|
+
>
|
|
87
|
+
<form
|
|
88
|
+
noValidate
|
|
89
|
+
onSubmit={(event) => {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
setEmailTouched(true);
|
|
92
|
+
setRoleTouched(true);
|
|
93
|
+
if (emailIsValid && role) onInvite?.(trimmedEmail, role);
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<div className="eth-identity-invite-user__layout">
|
|
97
|
+
<div className="eth-identity-invite-user__fields">
|
|
98
|
+
<FormField
|
|
99
|
+
id={emailId}
|
|
100
|
+
label="Email"
|
|
101
|
+
helperText="Use the recipient's managed workspace email address."
|
|
102
|
+
error={emailError}
|
|
103
|
+
required
|
|
104
|
+
>
|
|
105
|
+
<TextInput
|
|
106
|
+
id={emailId}
|
|
107
|
+
type="email"
|
|
108
|
+
value={email}
|
|
109
|
+
placeholder="name@company.com"
|
|
110
|
+
autoComplete="email"
|
|
111
|
+
onBlur={() => setEmailTouched(true)}
|
|
112
|
+
onChange={(event) => setEmail(event.currentTarget.value)}
|
|
113
|
+
/>
|
|
114
|
+
</FormField>
|
|
115
|
+
<FormField
|
|
116
|
+
id={roleId}
|
|
117
|
+
label="Role"
|
|
118
|
+
helperText="Choose the initial permission set for this invite."
|
|
119
|
+
error={roleError}
|
|
120
|
+
required
|
|
121
|
+
>
|
|
122
|
+
<Select
|
|
123
|
+
id={roleId}
|
|
124
|
+
value={role}
|
|
125
|
+
options={options}
|
|
126
|
+
disabled={!options.length}
|
|
127
|
+
onBlur={() => setRoleTouched(true)}
|
|
128
|
+
onChange={(event) => {
|
|
129
|
+
setRoleTouched(true);
|
|
130
|
+
setRole(event.currentTarget.value);
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
</FormField>
|
|
134
|
+
</div>
|
|
135
|
+
<div className="eth-identity-invite-user__summary" aria-live="polite">
|
|
136
|
+
<span className="eth-identity-invite-user__summary-label">Invitation summary</span>
|
|
137
|
+
<dl>
|
|
138
|
+
<div>
|
|
139
|
+
<dt>Recipient</dt>
|
|
140
|
+
<dd>{trimmedEmail || "Waiting for email"}</dd>
|
|
141
|
+
</div>
|
|
142
|
+
<div>
|
|
143
|
+
<dt>Initial role</dt>
|
|
144
|
+
<dd>{selectedRole?.label ?? "No role selected"}</dd>
|
|
145
|
+
</div>
|
|
146
|
+
<div>
|
|
147
|
+
<dt>Status</dt>
|
|
148
|
+
<dd>{inviteStatus}</dd>
|
|
149
|
+
</div>
|
|
150
|
+
</dl>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<div className="eth-identity-invite-user__actions">
|
|
154
|
+
<Button type="submit" disabled={!canInvite}>
|
|
155
|
+
{inviteLabel}
|
|
156
|
+
</Button>
|
|
157
|
+
<p>Access can be reviewed after the recipient accepts the invitation.</p>
|
|
158
|
+
</div>
|
|
159
|
+
</form>
|
|
160
|
+
</Surface>
|
|
161
|
+
);
|
|
162
|
+
}
|