@stapel/workspaces-react 0.17.0 → 0.19.1
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/CHANGELOG.md +218 -0
- package/README.md +39 -8
- package/dist/api/generated/schema.d.ts +187 -60
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/ActiveWorkspace.d.ts +43 -0
- package/dist/default/ActiveWorkspace.d.ts.map +1 -0
- package/dist/default/ActiveWorkspace.js +33 -0
- package/dist/default/ActiveWorkspace.js.map +1 -0
- package/dist/default/AuditTrailPane.d.ts +12 -0
- package/dist/default/AuditTrailPane.d.ts.map +1 -0
- package/dist/default/AuditTrailPane.js +154 -0
- package/dist/default/AuditTrailPane.js.map +1 -0
- package/dist/default/InvitationsPane.d.ts +19 -0
- package/dist/default/InvitationsPane.d.ts.map +1 -0
- package/dist/default/InvitationsPane.js +217 -0
- package/dist/default/InvitationsPane.js.map +1 -0
- package/dist/default/InviteAcceptPage.d.ts +7 -0
- package/dist/default/InviteAcceptPage.d.ts.map +1 -1
- package/dist/default/InviteAcceptPage.js +74 -38
- package/dist/default/InviteAcceptPage.js.map +1 -1
- package/dist/default/MembersManager.d.ts +12 -4
- package/dist/default/MembersManager.d.ts.map +1 -1
- package/dist/default/MembersManager.js +320 -84
- package/dist/default/MembersManager.js.map +1 -1
- package/dist/default/RoleSelectField.d.ts +64 -0
- package/dist/default/RoleSelectField.d.ts.map +1 -0
- package/dist/default/RoleSelectField.js +78 -0
- package/dist/default/RoleSelectField.js.map +1 -0
- package/dist/default/WorkspaceSettings.d.ts +7 -1
- package/dist/default/WorkspaceSettings.d.ts.map +1 -1
- package/dist/default/WorkspaceSettings.js +203 -45
- package/dist/default/WorkspaceSettings.js.map +1 -1
- package/dist/default/WorkspacesPage.d.ts +13 -0
- package/dist/default/WorkspacesPage.d.ts.map +1 -0
- package/dist/default/WorkspacesPage.js +109 -0
- package/dist/default/WorkspacesPage.js.map +1 -0
- package/dist/default/index.d.ts +28 -7
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +24 -7
- package/dist/default/index.js.map +1 -1
- package/dist/default/parts.d.ts +104 -0
- package/dist/default/parts.d.ts.map +1 -0
- package/dist/default/parts.js +143 -0
- package/dist/default/parts.js.map +1 -0
- package/dist/headless/Members.d.ts +59 -14
- package/dist/headless/Members.d.ts.map +1 -1
- package/dist/headless/Members.js +35 -10
- package/dist/headless/Members.js.map +1 -1
- package/dist/headless/RoleSelect.d.ts +9 -3
- package/dist/headless/RoleSelect.d.ts.map +1 -1
- package/dist/headless/RoleSelect.js +3 -2
- package/dist/headless/RoleSelect.js.map +1 -1
- package/dist/i18n/es.d.ts +11 -12
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +267 -14
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.es.gen.js +5 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +30 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +15 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +5 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/i18n/keys.d.ts +209 -1
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +410 -4
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +7 -2
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +209 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model/format.d.ts +56 -0
- package/dist/model/format.d.ts.map +1 -0
- package/dist/model/format.js +125 -0
- package/dist/model/format.js.map +1 -0
- package/dist/model/selection.d.ts +15 -0
- package/dist/model/selection.d.ts.map +1 -1
- package/dist/model/selection.js +18 -1
- package/dist/model/selection.js.map +1 -1
- package/dist/nav/manifest.d.ts +45 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +77 -0
- package/dist/nav/manifest.js.map +1 -0
- package/llms.txt +11 -10
- package/manifest.json +281 -69
- package/package.json +18 -12
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +187 -60
- package/src/default/ActiveWorkspace.tsx +97 -0
- package/src/default/AuditTrailPane.tsx +309 -0
- package/src/default/InvitationsPane.tsx +501 -0
- package/src/default/InviteAcceptPage.tsx +187 -90
- package/src/default/MembersManager.tsx +832 -222
- package/src/default/RoleSelectField.tsx +187 -0
- package/src/default/WorkspaceSettings.tsx +540 -125
- package/src/default/WorkspacesPage.tsx +394 -0
- package/src/default/index.ts +28 -7
- package/src/default/parts.tsx +315 -0
- package/src/headless/Members.tsx +84 -16
- package/src/headless/RoleSelect.tsx +12 -5
- package/src/i18n/es.ts +326 -14
- package/src/i18n/generated/errors.es.gen.ts +5 -0
- package/src/i18n/generated/errors.gen.ts +15 -0
- package/src/i18n/generated/errors.json +37 -0
- package/src/i18n/generated/errors.ru.gen.ts +5 -0
- package/src/i18n/keys.ts +462 -5
- package/src/i18n/ru.ts +259 -5
- package/src/index.ts +1 -0
- package/src/model/format.ts +158 -0
- package/src/model/selection.tsx +19 -1
- package/src/nav/manifest.ts +120 -0
- package/dist/default/ErrorAlert.d.ts +0 -8
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -30
- package/dist/default/ErrorAlert.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -47
|
@@ -1,251 +1,861 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `<MembersManager/>` — default skin for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* wrapper (`useMembers`/`useInviteMembers`/`useUpdateMemberRole`/
|
|
6
|
-
* `useRemoveMember`) — no new backend surface.
|
|
2
|
+
* `<MembersManager/>` — the default skin for "who is in this workspace":
|
|
3
|
+
* the roster, an invite, a role change, a name correction, a removal, and a
|
|
4
|
+
* page walk that actually reaches the second page.
|
|
7
5
|
*
|
|
8
|
-
* Roles come from the EFFECTIVE registry via
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* backend enforces "only an owner grants owner"
|
|
14
|
-
* the invite dialog
|
|
6
|
+
* Roles come from the EFFECTIVE registry via `<RoleSelectField/>` (GET /roles,
|
|
7
|
+
* org-program §A2) — never a hardcoded builtin four: a deployment that
|
|
8
|
+
* overlays `STAPEL_WORKSPACES["ROLES"]` (e.g. a `secretary`) sees its roles
|
|
9
|
+
* here, labelled by `workspaces.role.<key>` where a bundle carries one and
|
|
10
|
+
* title-cased from the key where it does not. `owner` is offered only on an
|
|
11
|
+
* existing member's row (the backend enforces "only an owner grants owner"
|
|
12
|
+
* and last-owner protection); the invite dialog leaves it out.
|
|
13
|
+
*
|
|
14
|
+
* ## What a row's controls are allowed to claim
|
|
15
|
+
*
|
|
16
|
+
* A control that offers an action the caller's situation makes impossible is
|
|
17
|
+
* a lie the backend then has to tell. Two such rows exist here, and the
|
|
18
|
+
* members contract answers only one of them:
|
|
19
|
+
*
|
|
20
|
+
* - **The last owner.** Answerable: `MemberResponse.role` is on every row and
|
|
21
|
+
* the page envelope says whether the page IS the roster
|
|
22
|
+
* (`MembersBag.rosterComplete`). With the whole roster in hand, "exactly
|
|
23
|
+
* one row holds `owner`" is a fact, so "Remove" on that row is switched off
|
|
24
|
+
* with the reason printed beside it. On any page of a longer roster nothing
|
|
25
|
+
* is claimed — a count of a page is not a count of the roster.
|
|
26
|
+
* - **The caller's own row.** Answerable only by the SERVER. This pair has no
|
|
27
|
+
* caller identity to compare `user_id` against (`@stapel/core`'s session
|
|
28
|
+
* exposes a STATUS, never a subject; the mandate axis resolves to
|
|
29
|
+
* `anonymous`/`guest`/`member`, never to a user id), and `my_role ===
|
|
30
|
+
* "owner"` does not make the caller the user in `owner_id` once more than
|
|
31
|
+
* one membership can hold the role — guessing would grey out somebody
|
|
32
|
+
* else's row. So the row is gated on `MemberResponse.is_self`, the
|
|
33
|
+
* server-derived flag (stapel-workspaces 0.30.0), and on nothing else: a
|
|
34
|
+
* build talking to a backend that does not send it claims nothing rather
|
|
35
|
+
* than guessing. See {@link isSelf}.
|
|
36
|
+
*
|
|
37
|
+
* `is_self` gates TWO controls, not one. `MemberPasswordResetView` refuses
|
|
38
|
+
* the caller's own row with the byte-identical 404 it gives for a stranger —
|
|
39
|
+
* correct on the server (one refusal shape, nothing to learn from the
|
|
40
|
+
* difference) and invisible to a client without this flag: an ungated "Reset
|
|
41
|
+
* password" on your own row comes back with a 404 that reads as "this member
|
|
42
|
+
* has been removed". Both controls therefore ask {@link isSelf} first.
|
|
43
|
+
*
|
|
44
|
+
* ## Two-factor evidence
|
|
45
|
+
*
|
|
46
|
+
* `MemberResponse.mfa_compliant` is true / false / **null**, and null is a
|
|
47
|
+
* state of its own — nobody has asked yet (WORK-01). Under a `require_mfa`
|
|
48
|
+
* policy those members are the ones an administrator acts on, so the column
|
|
49
|
+
* prints three different things and never folds the unknown into a "no".
|
|
15
50
|
*/
|
|
16
51
|
import { useState } from "react";
|
|
17
|
-
import type { ReactElement } from "react";
|
|
18
|
-
import { Button, Card,
|
|
19
|
-
import
|
|
20
|
-
|
|
52
|
+
import type { ReactElement, ReactNode } from "react";
|
|
53
|
+
import { Button, Card, Flex, Input, Typography, theme as antdTheme } from "antd";
|
|
54
|
+
import {
|
|
55
|
+
actionAvailable,
|
|
56
|
+
actionBlocked,
|
|
57
|
+
firstBlock,
|
|
58
|
+
useT,
|
|
59
|
+
useTPlural,
|
|
60
|
+
} from "@stapel/core";
|
|
61
|
+
import type { ActionAvailability } from "@stapel/core";
|
|
62
|
+
import {
|
|
63
|
+
EmptyState,
|
|
64
|
+
ErrorAlert,
|
|
65
|
+
GatedButton,
|
|
66
|
+
LoadList,
|
|
67
|
+
SkinConfirm,
|
|
68
|
+
SkinDialog,
|
|
69
|
+
SkinTheme,
|
|
70
|
+
} from "@stapel/tokens-antd/skin";
|
|
71
|
+
import { spacing } from "@stapel/tokens";
|
|
21
72
|
import { Members } from "../headless/Members.js";
|
|
22
|
-
import {
|
|
23
|
-
import type {
|
|
24
|
-
import
|
|
73
|
+
import type { MembersBag } from "../headless/Members.js";
|
|
74
|
+
import type { Member, MembersParams } from "../api/types.js";
|
|
75
|
+
import { useResetMemberPassword } from "../model/mutations.js";
|
|
76
|
+
import { ActiveWorkspaceBoundary } from "./ActiveWorkspace.js";
|
|
77
|
+
import { useWorkspaceFormat } from "../model/format.js";
|
|
25
78
|
import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
|
|
26
|
-
import {
|
|
79
|
+
import { AnchorPager, Muted, PersonLine, RowActions, StatusTag } from "./parts.js";
|
|
80
|
+
import { RoleSelectField } from "./RoleSelectField.js";
|
|
27
81
|
|
|
28
82
|
export interface MembersManagerProps {
|
|
29
|
-
workspaceId: string;
|
|
30
83
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
84
|
+
* The workspace whose roster this is. OPTIONAL: omitted (the way the nav
|
|
85
|
+
* contract mounts this screen — it routes, it does not hand over an ambient
|
|
86
|
+
* scope), the active workspace is read from the runtime selection
|
|
87
|
+
* (`WorkspaceSelection`), and a screen with none renders the designed
|
|
88
|
+
* "choose a workspace" state rather than a blank.
|
|
89
|
+
*/
|
|
90
|
+
workspaceId?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Whether the caller may invite, change roles, rename and remove members.
|
|
93
|
+
* The host already knows the caller's own verdict in this workspace (e.g.
|
|
33
94
|
* `useCapabilities(workspaceId).can("members.invite")`, or the coarser
|
|
34
95
|
* `my_role`); this component doesn't re-derive it — pass `false` for a
|
|
35
|
-
* read-only roster
|
|
96
|
+
* read-only roster, which says so rather than silently dropping controls.
|
|
97
|
+
* Default `true`.
|
|
36
98
|
*/
|
|
37
99
|
canManage?: boolean;
|
|
38
100
|
}
|
|
39
101
|
|
|
40
102
|
const DEFAULT_INVITE_ROLE = "member";
|
|
41
103
|
|
|
104
|
+
/** The system-protected role the backend's last-owner rule is about. */
|
|
105
|
+
const OWNER_ROLE = "owner";
|
|
106
|
+
|
|
107
|
+
/** The suspension the `require_mfa` sweep writes. Any other reason is shown
|
|
108
|
+
* as the plain "suspended" tag — this pair does not invent copy for a value
|
|
109
|
+
* the backend has not documented. */
|
|
110
|
+
const SUSPENDED_NO_MFA = "no_mfa";
|
|
111
|
+
|
|
112
|
+
/** A page of the walk. Anchor + direction is the whole cursor; `index` is how
|
|
113
|
+
* many steps this screen has taken, which is the only page NUMBER an anchor
|
|
114
|
+
* API can honestly show. */
|
|
115
|
+
interface Walk {
|
|
116
|
+
readonly anchor: string | undefined;
|
|
117
|
+
readonly direction: "next" | "prev" | undefined;
|
|
118
|
+
readonly index: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const FIRST_PAGE: Walk = { anchor: undefined, direction: undefined, index: 1 };
|
|
122
|
+
|
|
123
|
+
/** A shape good enough to send: the backend answers `error.400.invalid_email`
|
|
124
|
+
* per address after a round trip, and finding out one at a time is the defect
|
|
125
|
+
* this closes. Deliberately loose — address validation belongs to the server;
|
|
126
|
+
* this only catches what is obviously not an address. */
|
|
127
|
+
function looksLikeEmail(value: string): boolean {
|
|
128
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function splitEmails(text: string): readonly string[] {
|
|
132
|
+
return text
|
|
133
|
+
.split(/[,\s]+/)
|
|
134
|
+
.map((value) => value.trim())
|
|
135
|
+
.filter((value) => value.length > 0);
|
|
136
|
+
}
|
|
137
|
+
|
|
42
138
|
export function MembersManager(props: MembersManagerProps): ReactElement {
|
|
139
|
+
return (
|
|
140
|
+
<SkinTheme data-testid="members-manager">
|
|
141
|
+
<ActiveWorkspaceBoundary
|
|
142
|
+
workspaceId={props.workspaceId}
|
|
143
|
+
testId="members-manager-workspace"
|
|
144
|
+
>
|
|
145
|
+
{(workspaceId) => (
|
|
146
|
+
<Roster workspaceId={workspaceId} canManage={props.canManage ?? true} />
|
|
147
|
+
)}
|
|
148
|
+
</ActiveWorkspaceBoundary>
|
|
149
|
+
</SkinTheme>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The roster once the workspace is known — the walk and the filter live here
|
|
154
|
+
* because the cursor belongs to this screen's state, not to the bag's. */
|
|
155
|
+
function Roster(props: {
|
|
156
|
+
readonly workspaceId: string;
|
|
157
|
+
readonly canManage: boolean;
|
|
158
|
+
}): ReactElement {
|
|
159
|
+
const [walk, setWalk] = useState<Walk>(FIRST_PAGE);
|
|
160
|
+
const [search, setSearch] = useState("");
|
|
161
|
+
|
|
162
|
+
const params: MembersParams = {
|
|
163
|
+
...(walk.anchor !== undefined ? { anchor: walk.anchor } : {}),
|
|
164
|
+
...(walk.direction !== undefined ? { direction: walk.direction } : {}),
|
|
165
|
+
...(search.trim() !== "" ? { search: search.trim() } : {}),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<Members workspaceId={props.workspaceId} params={params}>
|
|
170
|
+
{(bag) => (
|
|
171
|
+
<RosterCard
|
|
172
|
+
bag={bag}
|
|
173
|
+
workspaceId={props.workspaceId}
|
|
174
|
+
canManage={props.canManage}
|
|
175
|
+
walk={walk}
|
|
176
|
+
search={search}
|
|
177
|
+
onSearch={(value) => {
|
|
178
|
+
// A new filter is a new walk: an anchor from the old one points
|
|
179
|
+
// into a list that no longer exists.
|
|
180
|
+
setSearch(value);
|
|
181
|
+
setWalk(FIRST_PAGE);
|
|
182
|
+
}}
|
|
183
|
+
onWalk={setWalk}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
</Members>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function RosterCard(props: {
|
|
191
|
+
readonly bag: MembersBag;
|
|
192
|
+
readonly workspaceId: string;
|
|
193
|
+
readonly canManage: boolean;
|
|
194
|
+
readonly walk: Walk;
|
|
195
|
+
readonly search: string;
|
|
196
|
+
readonly onSearch: (value: string) => void;
|
|
197
|
+
readonly onWalk: (walk: Walk) => void;
|
|
198
|
+
}): ReactElement {
|
|
43
199
|
const t = useT();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// 2026-08-09). `useErrorText` folds any thrown value into the one dialect.
|
|
47
|
-
const errorDisplay = useErrorDisplay(WORKSPACES_I18N_KEYS.unknownError);
|
|
48
|
-
const canManage = props.canManage ?? true;
|
|
200
|
+
const tPlural = useTPlural();
|
|
201
|
+
const { bag, canManage } = props;
|
|
49
202
|
const [inviteOpen, setInviteOpen] = useState(false);
|
|
50
|
-
const [
|
|
51
|
-
const [
|
|
203
|
+
const [removing, setRemoving] = useState<Member | null>(null);
|
|
204
|
+
const [renaming, setRenaming] = useState<Member | null>(null);
|
|
205
|
+
const [resetting, setResetting] = useState<Member | null>(null);
|
|
206
|
+
|
|
207
|
+
const page = bag.page;
|
|
208
|
+
const rosterEmpty = bag.state.status === "ready" && bag.state.data.length === 0;
|
|
209
|
+
const showSearch = !rosterEmpty || props.search.trim() !== "";
|
|
52
210
|
|
|
53
211
|
return (
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
{
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
</Popconfirm>
|
|
130
|
-
),
|
|
131
|
-
},
|
|
132
|
-
]
|
|
133
|
-
: []),
|
|
134
|
-
];
|
|
135
|
-
|
|
136
|
-
return (
|
|
137
|
-
<Card data-testid="members-manager">
|
|
138
|
-
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16 }}>
|
|
139
|
-
<div>
|
|
140
|
-
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
|
141
|
-
{t(WORKSPACES_I18N_KEYS.membersTitle)}
|
|
142
|
-
</Typography.Title>
|
|
143
|
-
<Typography.Text type="secondary">{t(WORKSPACES_I18N_KEYS.membersSubtitle)}</Typography.Text>
|
|
144
|
-
</div>
|
|
145
|
-
{canManage && (
|
|
146
|
-
<Button
|
|
147
|
-
type="primary"
|
|
148
|
-
onClick={openInvite}
|
|
149
|
-
data-analytics="none"
|
|
150
|
-
data-analytics-reason="local-ui-open-invite-dialog"
|
|
151
|
-
>
|
|
152
|
-
{t(WORKSPACES_I18N_KEYS.membersInvite)}
|
|
153
|
-
</Button>
|
|
154
|
-
)}
|
|
155
|
-
</div>
|
|
156
|
-
|
|
157
|
-
{/* A write that failed — the roster read has its own arm below. */}
|
|
158
|
-
{writeError !== null && (
|
|
159
|
-
<ErrorAlert error={errorDisplay(writeError)} style={{ marginTop: 12 }} />
|
|
160
|
-
)}
|
|
161
|
-
{rolesFailed && (
|
|
162
|
-
<ErrorAlert
|
|
163
|
-
testId="members-roles-error"
|
|
164
|
-
error={{ message: t(WORKSPACES_I18N_KEYS.rolesLoadFailed), detail: undefined }}
|
|
165
|
-
style={{ marginTop: 12 }}
|
|
166
|
-
/>
|
|
167
|
-
)}
|
|
168
|
-
|
|
169
|
-
{matchList(state, {
|
|
170
|
-
loading: () => (
|
|
171
|
-
<Table<Member>
|
|
172
|
-
style={{ marginTop: 16 }}
|
|
173
|
-
size="small"
|
|
174
|
-
loading
|
|
175
|
-
rowKey={(member) => member.id}
|
|
176
|
-
dataSource={[]}
|
|
177
|
-
columns={columns}
|
|
178
|
-
pagination={false}
|
|
179
|
-
/>
|
|
180
|
-
),
|
|
181
|
-
// An antd Table with no rows renders "No data". On a failed
|
|
182
|
-
// read that is the empty-state lie with a built-in
|
|
183
|
-
// illustration, so the table does not get rendered at all.
|
|
184
|
-
failed: (error) => (
|
|
185
|
-
<div style={{ marginTop: 16 }} data-testid="members-list-error">
|
|
186
|
-
<ErrorAlert error={errorDisplay(error)} />
|
|
212
|
+
<Card data-testid="members-manager-card">
|
|
213
|
+
<Flex justify="space-between" align="flex-start" gap={spacing["4"]} wrap>
|
|
214
|
+
<Flex vertical gap={spacing["1"]}>
|
|
215
|
+
<Typography.Title level={4} style={{ marginTop: 0, marginBottom: 0 }}>
|
|
216
|
+
{t(WORKSPACES_I18N_KEYS.membersTitle)}
|
|
217
|
+
</Typography.Title>
|
|
218
|
+
<Typography.Text type="secondary">
|
|
219
|
+
{t(WORKSPACES_I18N_KEYS.membersSubtitle)}
|
|
220
|
+
</Typography.Text>
|
|
221
|
+
{page !== null && (
|
|
222
|
+
<Muted testId="members-count">
|
|
223
|
+
{tPlural(WORKSPACES_I18N_KEYS.membersCount, { count: page.count })}
|
|
224
|
+
</Muted>
|
|
225
|
+
)}
|
|
226
|
+
</Flex>
|
|
227
|
+
{/* The one primary on this screen. Everything else on a row is a
|
|
228
|
+
quiet link or a danger link — and on an EMPTY roster the empty
|
|
229
|
+
state owns the invite door instead, because two solid "Invite"
|
|
230
|
+
buttons on one screen is not emphasis, it is a coin toss. */}
|
|
231
|
+
{canManage && !rosterEmpty && (
|
|
232
|
+
<Button
|
|
233
|
+
type="primary"
|
|
234
|
+
onClick={() => setInviteOpen(true)}
|
|
235
|
+
data-analytics="none"
|
|
236
|
+
data-analytics-reason="local-ui-open-invite-dialog"
|
|
237
|
+
data-testid="members-invite-open"
|
|
238
|
+
>
|
|
239
|
+
{t(WORKSPACES_I18N_KEYS.membersInvite)}
|
|
240
|
+
</Button>
|
|
241
|
+
)}
|
|
242
|
+
</Flex>
|
|
243
|
+
|
|
244
|
+
{!canManage && (
|
|
245
|
+
<div style={{ marginTop: spacing["2"] }}>
|
|
246
|
+
<Muted testId="members-read-only">
|
|
247
|
+
{t(WORKSPACES_I18N_KEYS.membersBlockedReadOnly)}
|
|
248
|
+
</Muted>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
{/* A filter over nothing is furniture. It stays while a SEARCH is
|
|
253
|
+
running (that is how a person clears one that matched nobody) and
|
|
254
|
+
goes when the roster itself is empty. */}
|
|
255
|
+
{showSearch && (
|
|
256
|
+
<div style={{ marginTop: spacing["4"] }}>
|
|
257
|
+
<Input
|
|
258
|
+
value={props.search}
|
|
259
|
+
onChange={(event) => props.onSearch(event.target.value)}
|
|
260
|
+
placeholder={t(WORKSPACES_I18N_KEYS.membersSearchPlaceholder)}
|
|
261
|
+
aria-label={t(WORKSPACES_I18N_KEYS.membersSearchPlaceholder)}
|
|
262
|
+
allowClear
|
|
263
|
+
data-testid="members-search"
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{/* A write that failed — the roster read has its own arm below. */}
|
|
269
|
+
<ErrorAlert
|
|
270
|
+
thrown={bag.writeError}
|
|
271
|
+
style={{ marginTop: spacing["3"] }}
|
|
272
|
+
testId="members-write-error"
|
|
273
|
+
/>
|
|
274
|
+
|
|
275
|
+
<div style={{ marginTop: spacing["4"] }}>
|
|
276
|
+
<LoadList
|
|
277
|
+
state={bag.state}
|
|
278
|
+
testId="members-list"
|
|
279
|
+
onRetry={bag.refetch}
|
|
280
|
+
empty={
|
|
281
|
+
<EmptyState
|
|
282
|
+
title={t(WORKSPACES_I18N_KEYS.membersEmpty)}
|
|
283
|
+
testId="members-list-empty"
|
|
284
|
+
{...(canManage
|
|
285
|
+
? {
|
|
286
|
+
action: (
|
|
187
287
|
<Button
|
|
188
|
-
|
|
189
|
-
onClick={
|
|
288
|
+
type="primary"
|
|
289
|
+
onClick={() => setInviteOpen(true)}
|
|
190
290
|
data-analytics="none"
|
|
191
|
-
data-analytics-reason="local-ui-
|
|
291
|
+
data-analytics-reason="local-ui-open-invite-dialog"
|
|
292
|
+
data-testid="members-invite-open"
|
|
192
293
|
>
|
|
193
|
-
{t(WORKSPACES_I18N_KEYS.
|
|
294
|
+
{t(WORKSPACES_I18N_KEYS.membersInvite)}
|
|
194
295
|
</Button>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
296
|
+
),
|
|
297
|
+
}
|
|
298
|
+
: {})}
|
|
299
|
+
/>
|
|
300
|
+
}
|
|
301
|
+
>
|
|
302
|
+
{(members) => (
|
|
303
|
+
<div role="list" data-testid="members-rows">
|
|
304
|
+
{members.map((member) => (
|
|
305
|
+
<MemberRow
|
|
306
|
+
key={member.id}
|
|
307
|
+
member={member}
|
|
308
|
+
rows={members}
|
|
309
|
+
bag={bag}
|
|
310
|
+
canManage={canManage}
|
|
311
|
+
onRename={() => setRenaming(member)}
|
|
312
|
+
onRemove={() => setRemoving(member)}
|
|
313
|
+
onResetPassword={() => setResetting(member)}
|
|
314
|
+
/>
|
|
315
|
+
))}
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
</LoadList>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
{page !== null && (
|
|
322
|
+
<AnchorPager
|
|
323
|
+
hasPrev={page.hasPrev}
|
|
324
|
+
hasNext={page.hasNext}
|
|
325
|
+
prevLabel={t(WORKSPACES_I18N_KEYS.pagerPrev)}
|
|
326
|
+
nextLabel={t(WORKSPACES_I18N_KEYS.pagerNext)}
|
|
327
|
+
position={t(WORKSPACES_I18N_KEYS.pagerPosition, { page: props.walk.index })}
|
|
328
|
+
testId="members-pager"
|
|
329
|
+
onPrev={() =>
|
|
330
|
+
props.onWalk({
|
|
331
|
+
anchor: page.prevAnchor ?? undefined,
|
|
332
|
+
direction: "prev",
|
|
333
|
+
index: Math.max(1, props.walk.index - 1),
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
onNext={() =>
|
|
337
|
+
props.onWalk({
|
|
338
|
+
anchor: page.nextAnchor ?? undefined,
|
|
339
|
+
direction: "next",
|
|
340
|
+
index: props.walk.index + 1,
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
/>
|
|
344
|
+
)}
|
|
345
|
+
|
|
346
|
+
<InviteDialog
|
|
347
|
+
open={inviteOpen}
|
|
348
|
+
onClose={() => setInviteOpen(false)}
|
|
349
|
+
isInviting={bag.isInviting}
|
|
350
|
+
onInvite={(body) => {
|
|
351
|
+
bag.invite(body);
|
|
352
|
+
setInviteOpen(false);
|
|
353
|
+
}}
|
|
354
|
+
/>
|
|
355
|
+
|
|
356
|
+
<RenameDialog
|
|
357
|
+
member={renaming}
|
|
358
|
+
onClose={() => setRenaming(null)}
|
|
359
|
+
isRenaming={bag.isRenaming}
|
|
360
|
+
onRename={(displayName) => {
|
|
361
|
+
if (renaming === null) return;
|
|
362
|
+
bag.rename({ userId: renaming.user_id, displayName });
|
|
363
|
+
setRenaming(null);
|
|
364
|
+
}}
|
|
365
|
+
/>
|
|
366
|
+
|
|
367
|
+
<PasswordResetDialog
|
|
368
|
+
workspaceId={props.workspaceId}
|
|
369
|
+
member={resetting}
|
|
370
|
+
onClose={() => setResetting(null)}
|
|
371
|
+
/>
|
|
372
|
+
|
|
373
|
+
{/* ONE confirm for the list, keyed by the pending row — not one per
|
|
374
|
+
row, which is N dialogs mounted to answer a question about one. */}
|
|
375
|
+
<SkinConfirm
|
|
376
|
+
open={removing !== null}
|
|
377
|
+
danger
|
|
378
|
+
title={t(WORKSPACES_I18N_KEYS.membersRemoveConfirm)}
|
|
379
|
+
body={t(WORKSPACES_I18N_KEYS.membersRemoveConfirmBody, {
|
|
380
|
+
member: removing?.display_name ?? removing?.email ?? "",
|
|
381
|
+
})}
|
|
382
|
+
confirmLabel={t(WORKSPACES_I18N_KEYS.membersRemove)}
|
|
383
|
+
cancelLabel={t(WORKSPACES_I18N_KEYS.cancel)}
|
|
384
|
+
confirming={bag.isRemoving}
|
|
385
|
+
onConfirm={() => {
|
|
386
|
+
if (removing !== null) bag.remove(removing.user_id);
|
|
387
|
+
setRemoving(null);
|
|
388
|
+
}}
|
|
389
|
+
onCancel={() => setRemoving(null)}
|
|
390
|
+
data-testid="members-remove-confirm"
|
|
391
|
+
/>
|
|
392
|
+
</Card>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Is this row the READER? The server's answer (`MemberResponse.is_self`,
|
|
398
|
+
* stapel-workspaces 0.30.0) and only the server's.
|
|
399
|
+
*
|
|
400
|
+
* The field is OPTIONAL in the contract, and the `=== true` is the whole
|
|
401
|
+
* point of reading it that way: a deployment on an older backend sends
|
|
402
|
+
* nothing, and the absence must read as "the server did not say" — never as
|
|
403
|
+
* "no". A comparison against a session id would be the client re-deriving an
|
|
404
|
+
* identity it does not hold, which is the shape of the `my_role` defect
|
|
405
|
+
* `can_delete` was added to close.
|
|
406
|
+
*/
|
|
407
|
+
function isSelf(member: Member): boolean {
|
|
408
|
+
return member.is_self === true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Whether removing THIS member is offerable. Two rules, in the order a person
|
|
413
|
+
* would be told them; everything else is the backend's to refuse.
|
|
414
|
+
*/
|
|
415
|
+
function removeAvailability(
|
|
416
|
+
member: Member,
|
|
417
|
+
rows: readonly Member[],
|
|
418
|
+
rosterComplete: boolean
|
|
419
|
+
): ActionAvailability {
|
|
420
|
+
if (isSelf(member)) {
|
|
421
|
+
return actionBlocked(WORKSPACES_I18N_KEYS.membersRemoveBlockedSelf);
|
|
422
|
+
}
|
|
423
|
+
if (!rosterComplete || member.role !== OWNER_ROLE) return actionAvailable();
|
|
424
|
+
const owners = rows.filter((row) => row.role === OWNER_ROLE).length;
|
|
425
|
+
return owners <= 1
|
|
426
|
+
? actionBlocked(WORKSPACES_I18N_KEYS.membersRemoveBlockedLastOwner)
|
|
427
|
+
: actionAvailable();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Whether "Reset password" is offerable on THIS row. One rule, and it is the
|
|
432
|
+
* server's: `MemberPasswordResetView` refuses the caller's own row with the
|
|
433
|
+
* byte-identical 404 it gives for a stranger ("Yourself is not in the set
|
|
434
|
+
* this endpoint acts on"), so an ungated button here would read the backend's
|
|
435
|
+
* correct refusal as "this member has been removed" and say so to an admin
|
|
436
|
+
* looking at their own name. Everything else — the owner-target rule, the
|
|
437
|
+
* privileged-account refusal, the step-up — the backend answers, and this
|
|
438
|
+
* screen states rather than predicts.
|
|
439
|
+
*/
|
|
440
|
+
function resetPasswordAvailability(member: Member): ActionAvailability {
|
|
441
|
+
return isSelf(member)
|
|
442
|
+
? actionBlocked(WORKSPACES_I18N_KEYS.membersResetBlockedSelf)
|
|
443
|
+
: actionAvailable();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function MemberRow(props: {
|
|
447
|
+
readonly member: Member;
|
|
448
|
+
readonly rows: readonly Member[];
|
|
449
|
+
readonly bag: MembersBag;
|
|
450
|
+
readonly canManage: boolean;
|
|
451
|
+
readonly onRename: () => void;
|
|
452
|
+
readonly onRemove: () => void;
|
|
453
|
+
readonly onResetPassword: () => void;
|
|
454
|
+
}): ReactElement {
|
|
455
|
+
const t = useT();
|
|
456
|
+
const format = useWorkspaceFormat();
|
|
457
|
+
const { token } = antdTheme.useToken();
|
|
458
|
+
const { member, bag, canManage } = props;
|
|
459
|
+
|
|
460
|
+
const lastSeen = format.relative(member.last_accessed_at);
|
|
461
|
+
const joined = format.date(member.accepted_at ?? member.invited_at);
|
|
462
|
+
|
|
463
|
+
const tags: ReactNode = (
|
|
464
|
+
<>
|
|
465
|
+
{member.suspended_at !== null && member.suspended_at !== undefined && (
|
|
466
|
+
<StatusTag tone="danger" testId="member-suspended">
|
|
467
|
+
{t(WORKSPACES_I18N_KEYS.membersSuspended)}
|
|
468
|
+
</StatusTag>
|
|
469
|
+
)}
|
|
470
|
+
{member.provisioned === true && (
|
|
471
|
+
<StatusTag tone="neutral">{t(WORKSPACES_I18N_KEYS.membersProvisioned)}</StatusTag>
|
|
248
472
|
)}
|
|
249
|
-
|
|
473
|
+
<MfaTag compliant={member.mfa_compliant ?? null} />
|
|
474
|
+
</>
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
return (
|
|
478
|
+
<div
|
|
479
|
+
role="listitem"
|
|
480
|
+
data-testid={`member-row-${member.user_id}`}
|
|
481
|
+
style={{
|
|
482
|
+
display: "flex",
|
|
483
|
+
flexWrap: "wrap",
|
|
484
|
+
alignItems: "center",
|
|
485
|
+
justifyContent: "space-between",
|
|
486
|
+
gap: spacing["3"],
|
|
487
|
+
paddingTop: spacing["3"],
|
|
488
|
+
paddingBottom: spacing["3"],
|
|
489
|
+
borderBottom: `1px solid ${token.colorSplit}`,
|
|
490
|
+
}}
|
|
491
|
+
>
|
|
492
|
+
<PersonLine
|
|
493
|
+
name={member.display_name ?? null}
|
|
494
|
+
email={member.email}
|
|
495
|
+
tags={tags}
|
|
496
|
+
caption={
|
|
497
|
+
<>
|
|
498
|
+
{joined !== null && t(WORKSPACES_I18N_KEYS.membersJoined, { date: joined })}
|
|
499
|
+
{joined !== null && " · "}
|
|
500
|
+
{lastSeen === null
|
|
501
|
+
? t(WORKSPACES_I18N_KEYS.membersLastSeenNever)
|
|
502
|
+
: t(WORKSPACES_I18N_KEYS.membersLastSeen, { date: lastSeen })}
|
|
503
|
+
{member.suspension_reason === SUSPENDED_NO_MFA && (
|
|
504
|
+
<> · {t(WORKSPACES_I18N_KEYS.membersSuspendedNoMfa)}</>
|
|
505
|
+
)}
|
|
506
|
+
</>
|
|
507
|
+
}
|
|
508
|
+
/>
|
|
509
|
+
<Flex gap={spacing["3"]} align="center" wrap>
|
|
510
|
+
<div style={{ minWidth: "10rem" }}>
|
|
511
|
+
<RoleSelectField
|
|
512
|
+
value={member.role}
|
|
513
|
+
size="small"
|
|
514
|
+
label={t(WORKSPACES_I18N_KEYS.membersRolePickerLabel, {
|
|
515
|
+
member: member.display_name ?? member.email ?? member.role,
|
|
516
|
+
})}
|
|
517
|
+
disabled={!canManage}
|
|
518
|
+
onChange={(role) => bag.updateRole({ userId: member.user_id, role })}
|
|
519
|
+
testId={`member-role-${member.user_id}`}
|
|
520
|
+
/>
|
|
521
|
+
</div>
|
|
522
|
+
</Flex>
|
|
523
|
+
{/* The controls, and — as a footnote spanning the row, not as
|
|
524
|
+
paragraphs inside the action column — the refusals behind them. The
|
|
525
|
+
viewer's OWN row refuses two of the three, and while those reasons
|
|
526
|
+
lived beside the buttons they widened the column until that one row
|
|
527
|
+
wrapped to the stacked phone layout in the middle of a desktop
|
|
528
|
+
table, two geometries deep. */}
|
|
529
|
+
{canManage && (
|
|
530
|
+
<RowActions
|
|
531
|
+
testId={`member-blocked-${member.user_id}`}
|
|
532
|
+
actions={[
|
|
533
|
+
{
|
|
534
|
+
key: "rename",
|
|
535
|
+
gate: actionAvailable(),
|
|
536
|
+
label: t(WORKSPACES_I18N_KEYS.membersRename),
|
|
537
|
+
onClick: props.onRename,
|
|
538
|
+
testId: `member-rename-${member.user_id}`,
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
key: "reset",
|
|
542
|
+
gate: resetPasswordAvailability(member),
|
|
543
|
+
label: t(WORKSPACES_I18N_KEYS.membersResetPassword),
|
|
544
|
+
onClick: props.onResetPassword,
|
|
545
|
+
testId: `member-reset-password-${member.user_id}`,
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
key: "remove",
|
|
549
|
+
gate: removeAvailability(member, props.rows, bag.rosterComplete),
|
|
550
|
+
label: t(WORKSPACES_I18N_KEYS.membersRemove),
|
|
551
|
+
onClick: props.onRemove,
|
|
552
|
+
danger: true,
|
|
553
|
+
testId: `member-remove-${member.user_id}`,
|
|
554
|
+
},
|
|
555
|
+
]}
|
|
556
|
+
/>
|
|
557
|
+
)}
|
|
558
|
+
</div>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Three states, never two: confirmed, missing, and nobody has asked. */
|
|
563
|
+
function MfaTag(props: { readonly compliant: boolean | null }): ReactElement {
|
|
564
|
+
const t = useT();
|
|
565
|
+
if (props.compliant === true) {
|
|
566
|
+
return (
|
|
567
|
+
<StatusTag tone="success" testId="member-mfa">
|
|
568
|
+
{`${t(WORKSPACES_I18N_KEYS.membersMfaLabel)}: ${t(WORKSPACES_I18N_KEYS.membersMfaCompliant)}`}
|
|
569
|
+
</StatusTag>
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
if (props.compliant === false) {
|
|
573
|
+
return (
|
|
574
|
+
<StatusTag tone="warning" testId="member-mfa">
|
|
575
|
+
{`${t(WORKSPACES_I18N_KEYS.membersMfaLabel)}: ${t(WORKSPACES_I18N_KEYS.membersMfaNoncompliant)}`}
|
|
576
|
+
</StatusTag>
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
return (
|
|
580
|
+
<StatusTag tone="neutral" testId="member-mfa">
|
|
581
|
+
{`${t(WORKSPACES_I18N_KEYS.membersMfaLabel)}: ${t(WORKSPACES_I18N_KEYS.membersMfaUnknown)}`}
|
|
582
|
+
</StatusTag>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The invite dialog — a bottom sheet on a phone, a centred modal on
|
|
588
|
+
* tablet/desktop, because that is what `SkinDialog` is (owner ruling
|
|
589
|
+
* 2026-08-24, stated once in `@stapel/tokens-antd/skin`).
|
|
590
|
+
*/
|
|
591
|
+
function InviteDialog(props: {
|
|
592
|
+
readonly open: boolean;
|
|
593
|
+
readonly onClose: () => void;
|
|
594
|
+
readonly isInviting: boolean;
|
|
595
|
+
readonly onInvite: (body: {
|
|
596
|
+
emails: string[];
|
|
597
|
+
role: string;
|
|
598
|
+
display_name?: string;
|
|
599
|
+
}) => void;
|
|
600
|
+
}): ReactElement {
|
|
601
|
+
const t = useT();
|
|
602
|
+
const [emailsText, setEmailsText] = useState("");
|
|
603
|
+
const [role, setRole] = useState(DEFAULT_INVITE_ROLE);
|
|
604
|
+
const [displayName, setDisplayName] = useState("");
|
|
605
|
+
|
|
606
|
+
const emails = splitEmails(emailsText);
|
|
607
|
+
const firstBad = emails.find((email) => !looksLikeEmail(email));
|
|
608
|
+
// Ordered the way it would be explained out loud: the thing that is empty
|
|
609
|
+
// first, the thing that is wrong second.
|
|
610
|
+
const gate = firstBlock(
|
|
611
|
+
emails.length === 0
|
|
612
|
+
? actionBlocked(WORKSPACES_I18N_KEYS.membersInviteBlockedNoEmails)
|
|
613
|
+
: actionAvailable(),
|
|
614
|
+
firstBad !== undefined
|
|
615
|
+
? actionBlocked(WORKSPACES_I18N_KEYS.membersInviteBlockedBadEmail, {
|
|
616
|
+
email: firstBad,
|
|
617
|
+
})
|
|
618
|
+
: actionAvailable()
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<SkinDialog
|
|
623
|
+
open={props.open}
|
|
624
|
+
onClose={props.onClose}
|
|
625
|
+
title={t(WORKSPACES_I18N_KEYS.membersInviteDialogTitle)}
|
|
626
|
+
dismissLabel={t(WORKSPACES_I18N_KEYS.dialogClose)}
|
|
627
|
+
data-testid="members-invite-dialog"
|
|
628
|
+
footer={
|
|
629
|
+
<GatedButton
|
|
630
|
+
gate={gate}
|
|
631
|
+
type="primary"
|
|
632
|
+
loading={props.isInviting}
|
|
633
|
+
onClick={() => {
|
|
634
|
+
props.onInvite({
|
|
635
|
+
emails: [...emails],
|
|
636
|
+
role,
|
|
637
|
+
...(displayName.trim() !== "" ? { display_name: displayName.trim() } : {}),
|
|
638
|
+
});
|
|
639
|
+
setEmailsText("");
|
|
640
|
+
setDisplayName("");
|
|
641
|
+
setRole(DEFAULT_INVITE_ROLE);
|
|
642
|
+
}}
|
|
643
|
+
testId="members-invite-submit"
|
|
644
|
+
data-analytics="none"
|
|
645
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
646
|
+
>
|
|
647
|
+
{t(WORKSPACES_I18N_KEYS.membersInviteSubmit)}
|
|
648
|
+
</GatedButton>
|
|
649
|
+
}
|
|
650
|
+
>
|
|
651
|
+
<Flex vertical gap={spacing["3"]}>
|
|
652
|
+
<Flex vertical gap={spacing["1"]}>
|
|
653
|
+
<Typography.Text>
|
|
654
|
+
{t(WORKSPACES_I18N_KEYS.membersInviteEmailsLabel)}
|
|
655
|
+
</Typography.Text>
|
|
656
|
+
<Input
|
|
657
|
+
value={emailsText}
|
|
658
|
+
onChange={(event) => setEmailsText(event.target.value)}
|
|
659
|
+
placeholder={t(WORKSPACES_I18N_KEYS.membersInviteEmailsPlaceholder)}
|
|
660
|
+
aria-label={t(WORKSPACES_I18N_KEYS.membersInviteEmailsLabel)}
|
|
661
|
+
/>
|
|
662
|
+
</Flex>
|
|
663
|
+
<Flex vertical gap={spacing["1"]}>
|
|
664
|
+
<Typography.Text>{t(WORKSPACES_I18N_KEYS.membersInviteNameLabel)}</Typography.Text>
|
|
665
|
+
<Input
|
|
666
|
+
value={displayName}
|
|
667
|
+
onChange={(event) => setDisplayName(event.target.value)}
|
|
668
|
+
placeholder={t(WORKSPACES_I18N_KEYS.membersInviteNamePlaceholder)}
|
|
669
|
+
aria-label={t(WORKSPACES_I18N_KEYS.membersInviteNameLabel)}
|
|
670
|
+
/>
|
|
671
|
+
</Flex>
|
|
672
|
+
<RoleSelectField
|
|
673
|
+
value={role}
|
|
674
|
+
onChange={setRole}
|
|
675
|
+
excludeOwner
|
|
676
|
+
showLabel
|
|
677
|
+
label={t(WORKSPACES_I18N_KEYS.membersInviteRoleLabel)}
|
|
678
|
+
testId="members-invite-role"
|
|
679
|
+
/>
|
|
680
|
+
</Flex>
|
|
681
|
+
</SkinDialog>
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The name correction an owner/admin applies without waiting for the person
|
|
687
|
+
* themselves. It writes the CANONICAL name (stapel-profiles), which the hint
|
|
688
|
+
* under the field says out loud — a workspace-local note is what people
|
|
689
|
+
* expect from a field in a workspace screen, and this is not one.
|
|
690
|
+
*/
|
|
691
|
+
function RenameDialog(props: {
|
|
692
|
+
readonly member: Member | null;
|
|
693
|
+
readonly onClose: () => void;
|
|
694
|
+
readonly isRenaming: boolean;
|
|
695
|
+
readonly onRename: (displayName: string | null) => void;
|
|
696
|
+
}): ReactElement {
|
|
697
|
+
const t = useT();
|
|
698
|
+
const current = props.member?.display_name ?? "";
|
|
699
|
+
const [value, setValue] = useState(current);
|
|
700
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
701
|
+
|
|
702
|
+
// Seed the field from the row the dialog was opened for, without an effect:
|
|
703
|
+
// a different member in the same mounted dialog is a different edit.
|
|
704
|
+
if (props.member !== null && editingId !== props.member.id) {
|
|
705
|
+
setEditingId(props.member.id);
|
|
706
|
+
setValue(props.member.display_name ?? "");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const gate =
|
|
710
|
+
value.trim() === current.trim()
|
|
711
|
+
? actionBlocked(WORKSPACES_I18N_KEYS.membersRenameBlockedUnchanged)
|
|
712
|
+
: actionAvailable();
|
|
713
|
+
|
|
714
|
+
return (
|
|
715
|
+
<SkinDialog
|
|
716
|
+
open={props.member !== null}
|
|
717
|
+
onClose={props.onClose}
|
|
718
|
+
title={t(WORKSPACES_I18N_KEYS.membersRenameDialogTitle)}
|
|
719
|
+
dismissLabel={t(WORKSPACES_I18N_KEYS.dialogClose)}
|
|
720
|
+
data-testid="members-rename-dialog"
|
|
721
|
+
footer={
|
|
722
|
+
<GatedButton
|
|
723
|
+
gate={gate}
|
|
724
|
+
type="primary"
|
|
725
|
+
loading={props.isRenaming}
|
|
726
|
+
onClick={() => props.onRename(value.trim() === "" ? null : value.trim())}
|
|
727
|
+
testId="members-rename-submit"
|
|
728
|
+
data-analytics="none"
|
|
729
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
730
|
+
>
|
|
731
|
+
{t(WORKSPACES_I18N_KEYS.membersRenameSubmit)}
|
|
732
|
+
</GatedButton>
|
|
733
|
+
}
|
|
734
|
+
>
|
|
735
|
+
<Flex vertical gap={spacing["2"]}>
|
|
736
|
+
<Typography.Text>{t(WORKSPACES_I18N_KEYS.membersRenameLabel)}</Typography.Text>
|
|
737
|
+
<Input
|
|
738
|
+
value={value}
|
|
739
|
+
onChange={(event) => setValue(event.target.value)}
|
|
740
|
+
placeholder={t(WORKSPACES_I18N_KEYS.membersRenamePlaceholder)}
|
|
741
|
+
aria-label={t(WORKSPACES_I18N_KEYS.membersRenameLabel)}
|
|
742
|
+
data-testid="members-rename-input"
|
|
743
|
+
/>
|
|
744
|
+
<Muted>{t(WORKSPACES_I18N_KEYS.membersRenameHint)}</Muted>
|
|
745
|
+
</Flex>
|
|
746
|
+
</SkinDialog>
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Reset a member's password on the organization's order — an account takeover
|
|
752
|
+
* performed on purpose, so the dialog states every part of it before the
|
|
753
|
+
* click and once more after.
|
|
754
|
+
*
|
|
755
|
+
* Three things this surface has to get right, all of them the backend's own
|
|
756
|
+
* rules stated on the glass rather than re-derived:
|
|
757
|
+
*
|
|
758
|
+
* - **The step-up is announced, not discovered.** The capability is declared
|
|
759
|
+
* `high`, so `@requires_verification(scope="sensitive")` will demand a
|
|
760
|
+
* fresh confirmation; core's client drives the challenge and replays the
|
|
761
|
+
* call. A person who reads "you will be asked to confirm" before pressing
|
|
762
|
+
* the button experiences a step, not a refusal.
|
|
763
|
+
* - **The generated password is shown ONCE.** It comes back only when the
|
|
764
|
+
* request omitted one, is never re-fetchable, and the hook deliberately
|
|
765
|
+
* keeps it out of the query cache (`gcTime: 0`). Closing the dialog resets
|
|
766
|
+
* the mutation, so the credential leaves the screen with it.
|
|
767
|
+
* - **`notified: false` is said out loud.** It means the account had no
|
|
768
|
+
* channel to be told on, which makes the admin the only person who can
|
|
769
|
+
* tell them — silence there is how a reset becomes indistinguishable from
|
|
770
|
+
* a takeover.
|
|
771
|
+
*/
|
|
772
|
+
function PasswordResetDialog(props: {
|
|
773
|
+
readonly workspaceId: string;
|
|
774
|
+
readonly member: Member | null;
|
|
775
|
+
readonly onClose: () => void;
|
|
776
|
+
}): ReactElement {
|
|
777
|
+
const t = useT();
|
|
778
|
+
const reset = useResetMemberPassword(props.workspaceId);
|
|
779
|
+
const { member } = props;
|
|
780
|
+
const who = member?.display_name ?? member?.email ?? "";
|
|
781
|
+
const result = reset.data;
|
|
782
|
+
|
|
783
|
+
const close = (): void => {
|
|
784
|
+
// The result carries a live credential: it goes when the dialog goes.
|
|
785
|
+
reset.reset();
|
|
786
|
+
props.onClose();
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
return (
|
|
790
|
+
<SkinDialog
|
|
791
|
+
open={member !== null}
|
|
792
|
+
onClose={close}
|
|
793
|
+
title={t(WORKSPACES_I18N_KEYS.membersResetDialogTitle, { member: who })}
|
|
794
|
+
dismissLabel={t(WORKSPACES_I18N_KEYS.dialogClose)}
|
|
795
|
+
data-testid="members-reset-dialog"
|
|
796
|
+
footer={
|
|
797
|
+
result === undefined ? (
|
|
798
|
+
<Button
|
|
799
|
+
type="primary"
|
|
800
|
+
danger
|
|
801
|
+
loading={reset.isPending}
|
|
802
|
+
onClick={() => {
|
|
803
|
+
if (member !== null) reset.mutate({ userId: member.user_id });
|
|
804
|
+
}}
|
|
805
|
+
data-testid="members-reset-submit"
|
|
806
|
+
data-analytics="none"
|
|
807
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
808
|
+
>
|
|
809
|
+
{t(WORKSPACES_I18N_KEYS.membersResetSubmit)}
|
|
810
|
+
</Button>
|
|
811
|
+
) : (
|
|
812
|
+
<Button
|
|
813
|
+
type="primary"
|
|
814
|
+
onClick={close}
|
|
815
|
+
data-testid="members-reset-done"
|
|
816
|
+
data-analytics="none"
|
|
817
|
+
data-analytics-reason="local-ui-close-dialog"
|
|
818
|
+
>
|
|
819
|
+
{t(WORKSPACES_I18N_KEYS.dialogClose)}
|
|
820
|
+
</Button>
|
|
821
|
+
)
|
|
822
|
+
}
|
|
823
|
+
>
|
|
824
|
+
<Flex vertical gap={spacing["3"]}>
|
|
825
|
+
{result === undefined ? (
|
|
826
|
+
<>
|
|
827
|
+
<Typography.Text>
|
|
828
|
+
{t(WORKSPACES_I18N_KEYS.membersResetDialogBody)}
|
|
829
|
+
</Typography.Text>
|
|
830
|
+
<Muted testId="members-reset-stepup">
|
|
831
|
+
{t(WORKSPACES_I18N_KEYS.membersResetStepUp)}
|
|
832
|
+
</Muted>
|
|
833
|
+
<ErrorAlert thrown={reset.error} testId="members-reset-error" />
|
|
834
|
+
</>
|
|
835
|
+
) : (
|
|
836
|
+
<Flex vertical gap={spacing["2"]} data-testid="members-reset-result">
|
|
837
|
+
<Typography.Text>
|
|
838
|
+
{t(WORKSPACES_I18N_KEYS.membersResetDone, { member: who })}
|
|
839
|
+
</Typography.Text>
|
|
840
|
+
{typeof result.generated_password === "string" && (
|
|
841
|
+
<>
|
|
842
|
+
<Typography.Text>
|
|
843
|
+
{t(WORKSPACES_I18N_KEYS.membersResetGenerated)}
|
|
844
|
+
</Typography.Text>
|
|
845
|
+
<Typography.Text code data-testid="members-reset-password">
|
|
846
|
+
{result.generated_password}
|
|
847
|
+
</Typography.Text>
|
|
848
|
+
<Muted>{t(WORKSPACES_I18N_KEYS.membersResetGeneratedHint)}</Muted>
|
|
849
|
+
</>
|
|
850
|
+
)}
|
|
851
|
+
{result.notified === false && (
|
|
852
|
+
<Muted testId="members-reset-not-notified">
|
|
853
|
+
{t(WORKSPACES_I18N_KEYS.membersResetNotNotified)}
|
|
854
|
+
</Muted>
|
|
855
|
+
)}
|
|
856
|
+
</Flex>
|
|
857
|
+
)}
|
|
858
|
+
</Flex>
|
|
859
|
+
</SkinDialog>
|
|
250
860
|
);
|
|
251
861
|
}
|