@stapel/workspaces-react 0.17.0 → 0.19.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 (123) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +39 -8
  3. package/dist/api/generated/schema.d.ts +187 -60
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/ActiveWorkspace.d.ts +43 -0
  6. package/dist/default/ActiveWorkspace.d.ts.map +1 -0
  7. package/dist/default/ActiveWorkspace.js +33 -0
  8. package/dist/default/ActiveWorkspace.js.map +1 -0
  9. package/dist/default/AuditTrailPane.d.ts +12 -0
  10. package/dist/default/AuditTrailPane.d.ts.map +1 -0
  11. package/dist/default/AuditTrailPane.js +154 -0
  12. package/dist/default/AuditTrailPane.js.map +1 -0
  13. package/dist/default/InvitationsPane.d.ts +19 -0
  14. package/dist/default/InvitationsPane.d.ts.map +1 -0
  15. package/dist/default/InvitationsPane.js +217 -0
  16. package/dist/default/InvitationsPane.js.map +1 -0
  17. package/dist/default/InviteAcceptPage.d.ts +7 -0
  18. package/dist/default/InviteAcceptPage.d.ts.map +1 -1
  19. package/dist/default/InviteAcceptPage.js +74 -38
  20. package/dist/default/InviteAcceptPage.js.map +1 -1
  21. package/dist/default/MembersManager.d.ts +12 -4
  22. package/dist/default/MembersManager.d.ts.map +1 -1
  23. package/dist/default/MembersManager.js +320 -84
  24. package/dist/default/MembersManager.js.map +1 -1
  25. package/dist/default/RoleSelectField.d.ts +64 -0
  26. package/dist/default/RoleSelectField.d.ts.map +1 -0
  27. package/dist/default/RoleSelectField.js +78 -0
  28. package/dist/default/RoleSelectField.js.map +1 -0
  29. package/dist/default/WorkspaceSettings.d.ts +7 -1
  30. package/dist/default/WorkspaceSettings.d.ts.map +1 -1
  31. package/dist/default/WorkspaceSettings.js +203 -45
  32. package/dist/default/WorkspaceSettings.js.map +1 -1
  33. package/dist/default/WorkspacesPage.d.ts +13 -0
  34. package/dist/default/WorkspacesPage.d.ts.map +1 -0
  35. package/dist/default/WorkspacesPage.js +108 -0
  36. package/dist/default/WorkspacesPage.js.map +1 -0
  37. package/dist/default/index.d.ts +28 -7
  38. package/dist/default/index.d.ts.map +1 -1
  39. package/dist/default/index.js +24 -7
  40. package/dist/default/index.js.map +1 -1
  41. package/dist/default/parts.d.ts +104 -0
  42. package/dist/default/parts.d.ts.map +1 -0
  43. package/dist/default/parts.js +140 -0
  44. package/dist/default/parts.js.map +1 -0
  45. package/dist/headless/Members.d.ts +59 -14
  46. package/dist/headless/Members.d.ts.map +1 -1
  47. package/dist/headless/Members.js +35 -10
  48. package/dist/headless/Members.js.map +1 -1
  49. package/dist/headless/RoleSelect.d.ts +9 -3
  50. package/dist/headless/RoleSelect.d.ts.map +1 -1
  51. package/dist/headless/RoleSelect.js +3 -2
  52. package/dist/headless/RoleSelect.js.map +1 -1
  53. package/dist/i18n/es.d.ts +11 -12
  54. package/dist/i18n/es.d.ts.map +1 -1
  55. package/dist/i18n/es.js +267 -14
  56. package/dist/i18n/es.js.map +1 -1
  57. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  58. package/dist/i18n/generated/errors.es.gen.js +5 -0
  59. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  60. package/dist/i18n/generated/errors.gen.d.ts +30 -0
  61. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  62. package/dist/i18n/generated/errors.gen.js +15 -0
  63. package/dist/i18n/generated/errors.gen.js.map +1 -1
  64. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  65. package/dist/i18n/generated/errors.ru.gen.js +5 -0
  66. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  67. package/dist/i18n/keys.d.ts +209 -1
  68. package/dist/i18n/keys.d.ts.map +1 -1
  69. package/dist/i18n/keys.js +410 -4
  70. package/dist/i18n/keys.js.map +1 -1
  71. package/dist/i18n/ru.d.ts +7 -2
  72. package/dist/i18n/ru.d.ts.map +1 -1
  73. package/dist/i18n/ru.js +209 -4
  74. package/dist/i18n/ru.js.map +1 -1
  75. package/dist/index.d.ts +1 -1
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/model/format.d.ts +56 -0
  80. package/dist/model/format.d.ts.map +1 -0
  81. package/dist/model/format.js +125 -0
  82. package/dist/model/format.js.map +1 -0
  83. package/dist/model/selection.d.ts +15 -0
  84. package/dist/model/selection.d.ts.map +1 -1
  85. package/dist/model/selection.js +18 -1
  86. package/dist/model/selection.js.map +1 -1
  87. package/dist/nav/manifest.d.ts +45 -0
  88. package/dist/nav/manifest.d.ts.map +1 -0
  89. package/dist/nav/manifest.js +77 -0
  90. package/dist/nav/manifest.js.map +1 -0
  91. package/llms.txt +11 -10
  92. package/manifest.json +281 -69
  93. package/package.json +18 -12
  94. package/src/analytics/generated/events.json +1 -1
  95. package/src/api/generated/schema.ts +187 -60
  96. package/src/default/ActiveWorkspace.tsx +97 -0
  97. package/src/default/AuditTrailPane.tsx +309 -0
  98. package/src/default/InvitationsPane.tsx +501 -0
  99. package/src/default/InviteAcceptPage.tsx +187 -90
  100. package/src/default/MembersManager.tsx +832 -222
  101. package/src/default/RoleSelectField.tsx +187 -0
  102. package/src/default/WorkspaceSettings.tsx +540 -125
  103. package/src/default/WorkspacesPage.tsx +387 -0
  104. package/src/default/index.ts +28 -7
  105. package/src/default/parts.tsx +308 -0
  106. package/src/headless/Members.tsx +84 -16
  107. package/src/headless/RoleSelect.tsx +12 -5
  108. package/src/i18n/es.ts +326 -14
  109. package/src/i18n/generated/errors.es.gen.ts +5 -0
  110. package/src/i18n/generated/errors.gen.ts +15 -0
  111. package/src/i18n/generated/errors.json +37 -0
  112. package/src/i18n/generated/errors.ru.gen.ts +5 -0
  113. package/src/i18n/keys.ts +462 -5
  114. package/src/i18n/ru.ts +259 -5
  115. package/src/index.ts +1 -0
  116. package/src/model/format.ts +158 -0
  117. package/src/model/selection.tsx +19 -1
  118. package/src/nav/manifest.ts +120 -0
  119. package/dist/default/ErrorAlert.d.ts +0 -8
  120. package/dist/default/ErrorAlert.d.ts.map +0 -1
  121. package/dist/default/ErrorAlert.js +0 -30
  122. package/dist/default/ErrorAlert.js.map +0 -1
  123. package/src/default/ErrorAlert.tsx +0 -47
@@ -1,251 +1,861 @@
1
1
  /**
2
- * `<MembersManager/>` — default skin for the "members & roles" settings
3
- * screen (owner directive: workspace settings — name/members/roles/
4
- * invites). Built entirely on this pair's EXISTING `Members` headless
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 the `RoleSelect` headless
9
- * (GET /roles, org-program §A2) — not a hardcoded builtin four: a deployment
10
- * that overlays `STAPEL_WORKSPACES["ROLES"]` (e.g. a `secretary`) sees its
11
- * roles here with `workspaces.role.<key>` labels (client-bundle merge,
12
- * raw-name fallback). `owner` is offered only in the member-row select (the
13
- * backend enforces "only an owner grants owner" and last-owner protection);
14
- * the invite dialog filters it out, as before.
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, Empty, Input, Modal, Popconfirm, Select, Table, Typography } from "antd";
19
- import type { TableProps } from "antd";
20
- import { loadedRowsOrEmpty, matchList, useErrorDisplay, useT } from "@stapel/core";
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 { RoleSelect } from "../headless/RoleSelect.js";
23
- import type { RoleSelectBag } from "../headless/RoleSelect.js";
24
- import type { Member } from "../api/types.js";
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 { ErrorAlert } from "./ErrorAlert.js";
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
- * Whether the caller may invite, change roles, and remove members. The
32
- * host already knows the caller's own verdict in this workspace (e.g.
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. Default `true`.
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
- // Never the raw `.message` — for a response with no error envelope that
45
- // is the transport's own "Request failed with status 500" (owner report
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 [emailsText, setEmailsText] = useState("");
51
- const [inviteRole, setInviteRole] = useState<string>(DEFAULT_INVITE_ROLE);
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
- <RoleSelect>
55
- {(rolesBag: RoleSelectBag) => (
56
- <Members workspaceId={props.workspaceId}>
57
- {({ state, writeError, invite, isInviting, updateRole, remove, refetch }) => {
58
- const { labelFor } = rolesBag;
59
- // The registry read is a SEPARATE load from the roster's, and it
60
- // gets its own sentence below rather than silently producing an
61
- // empty picker — a role menu with nothing in it and a role menu
62
- // that could not be fetched look identical to a person.
63
- const rolesFailed = rolesBag.state.status === "failed";
64
- const rolesLoading = rolesBag.state.status === "loading";
65
- const roleOptions = loadedRowsOrEmpty(rolesBag.state).map((r) => ({
66
- value: r.role,
67
- label: labelFor(r.role),
68
- }));
69
- const inviteRoleOptions = roleOptions.filter(
70
- (o) => o.value !== "owner"
71
- );
72
-
73
- function openInvite(): void {
74
- setEmailsText("");
75
- setInviteRole(DEFAULT_INVITE_ROLE);
76
- setInviteOpen(true);
77
- }
78
-
79
- function submitInvite(): void {
80
- const emails = emailsText
81
- .split(/[,\s]+/)
82
- .map((e) => e.trim())
83
- .filter((e) => e.length > 0);
84
- if (emails.length === 0) return;
85
- invite({ emails, role: inviteRole });
86
- setInviteOpen(false);
87
- }
88
-
89
- const columns: TableProps<Member>["columns"] = [
90
- {
91
- title: "Email",
92
- dataIndex: "email",
93
- key: "email",
94
- render: (value: string | null) => value ?? "—",
95
- },
96
- {
97
- title: t(WORKSPACES_I18N_KEYS.membersInviteRoleLabel),
98
- key: "role",
99
- render: (_: unknown, member: Member) =>
100
- canManage ? (
101
- <Select<string>
102
- value={member.role}
103
- style={{ width: 160 }}
104
- loading={rolesLoading}
105
- onChange={(next) => updateRole({ userId: member.user_id, role: next })}
106
- // A member's CURRENT role may be missing from the
107
- // registry options (a deployment removed an overlay
108
- // role) — antd shows the raw value then, which matches
109
- // labelFor's raw-name fallback contract.
110
- options={roleOptions}
111
- />
112
- ) : (
113
- <span>{labelFor(member.role)}</span>
114
- ),
115
- },
116
- ...(canManage
117
- ? [
118
- {
119
- title: "",
120
- key: "actions",
121
- render: (_: unknown, member: Member) => (
122
- <Popconfirm
123
- title={t(WORKSPACES_I18N_KEYS.membersRemoveConfirm)}
124
- onConfirm={() => remove(member.user_id)}
125
- >
126
- <Button danger type="link">
127
- {t(WORKSPACES_I18N_KEYS.membersRemove)}
128
- </Button>
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
- style={{ marginTop: 12 }}
189
- onClick={refetch}
288
+ type="primary"
289
+ onClick={() => setInviteOpen(true)}
190
290
  data-analytics="none"
191
- data-analytics-reason="local-ui-refetch-after-a-stated-read-failure"
291
+ data-analytics-reason="local-ui-open-invite-dialog"
292
+ data-testid="members-invite-open"
192
293
  >
193
- {t(WORKSPACES_I18N_KEYS.retry)}
294
+ {t(WORKSPACES_I18N_KEYS.membersInvite)}
194
295
  </Button>
195
- </div>
196
- ),
197
- empty: () => (
198
- <Empty
199
- style={{ marginTop: 16 }}
200
- data-testid="members-list-empty"
201
- description={t(WORKSPACES_I18N_KEYS.membersEmpty)}
202
- />
203
- ),
204
- ready: (members) => (
205
- <Table<Member>
206
- style={{ marginTop: 16 }}
207
- size="small"
208
- rowKey={(member) => member.id}
209
- dataSource={[...members]}
210
- columns={columns}
211
- pagination={false}
212
- />
213
- ),
214
- })}
215
-
216
- <Modal
217
- title={t(WORKSPACES_I18N_KEYS.membersInviteDialogTitle)}
218
- open={inviteOpen}
219
- onCancel={() => setInviteOpen(false)}
220
- onOk={submitInvite}
221
- okButtonProps={{ loading: isInviting, disabled: emailsText.trim().length === 0 }}
222
- >
223
- <div style={{ display: "grid", gap: 12 }}>
224
- <div>
225
- <Typography.Text>{t(WORKSPACES_I18N_KEYS.membersInviteEmailsLabel)}</Typography.Text>
226
- <Input
227
- value={emailsText}
228
- onChange={(e) => setEmailsText(e.target.value)}
229
- placeholder={t(WORKSPACES_I18N_KEYS.membersInviteEmailsPlaceholder)}
230
- />
231
- </div>
232
- <div>
233
- <Typography.Text>{t(WORKSPACES_I18N_KEYS.membersInviteRoleLabel)}</Typography.Text>
234
- <Select<string>
235
- value={inviteRole}
236
- onChange={setInviteRole}
237
- style={{ width: "100%" }}
238
- loading={rolesLoading}
239
- options={inviteRoleOptions}
240
- />
241
- </div>
242
- </div>
243
- </Modal>
244
- </Card>
245
- );
246
- }}
247
- </Members>
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
- </RoleSelect>
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
  }