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