@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +218 -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 +109 -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 +143 -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 +394 -0
  104. package/src/default/index.ts +28 -7
  105. package/src/default/parts.tsx +315 -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,165 +1,580 @@
1
1
  /**
2
- * `<WorkspaceSettings/>` — default skin for the "workspace" settings screen
3
- * (owner directive: workspace name/general settings, one of the settings
4
- * surfaces this liberary pair owns). Built entirely on this pair's EXISTING
5
- * hooks (`useWorkspace`, `useUpdateWorkspace`, `useDeleteWorkspace`) — no new
6
- * backend surface. Participants/roles live in the sibling
7
- * `<MembersManager/>` (a workspace's settings page composes both).
2
+ * `<WorkspaceSettings/>` — the default skin for a workspace's own settings:
3
+ * its name, the security policy everyone in it has to meet, and the one
4
+ * irreversible act (deletion). Participants and roles live in the sibling
5
+ * `<MembersManager/>`; invitations in `<InvitationsPane/>`.
6
+ *
7
+ * ## Two rules this screen exists to state
8
+ *
9
+ * **A control never claims what the endpoint would refuse.** The delete
10
+ * button used to be drawn from `my_role === "owner"`. That derivation is
11
+ * wrong on exactly two workspaces — the instance's default one, and a
12
+ * personal one the next sign-in re-mints — and the backend added
13
+ * `WorkspaceResponse.can_delete` + `delete_blocked_reason` (0.26.0) because
14
+ * of it: the verdict comes from the SAME `deletion_block_reason()` the DELETE
15
+ * raises from, so a screen built on it cannot promise what the endpoint
16
+ * denies. `delete_blocked_reason` is an error CODE (`error.409.workspace_is_
17
+ * personal`), which is already a key in this pair's generated bundle, so the
18
+ * refusal renders in the reader's language beside the switched-off control.
19
+ * The same rule applies to the name field, which is gated on the capability
20
+ * `workspace.update` rather than on a role name — a deployment role can hold
21
+ * the capability without being called owner.
22
+ *
23
+ * **A policy says what HOLDS, not what was asked for.** `settings.security.
24
+ * require_mfa` is a flag somebody set; `mfa_enforcement` is how far the
25
+ * sweep actually got (WORK-01, 0.25.0). An administrator who switched MFA on
26
+ * and was told "saved" while half the organization was never checked is the
27
+ * defect the status block answers, and `unverified_members` is the number
28
+ * they have to get to zero.
8
29
  */
9
- import { useEffect, useState } from "react";
30
+ import { useEffect, useMemo, useState } from "react";
10
31
  import type { ReactElement } from "react";
11
- import { Badge, Button, Card, Input, Popconfirm, Spin, Typography } from "antd";
32
+ import { Card, Checkbox, Flex, Input, Switch, Typography } from "antd";
12
33
  import {
13
34
  actionAvailable,
14
35
  actionBlocked,
15
36
  firstBlock,
16
- useActionGate,
17
- useErrorDisplay,
37
+ loadStateFromQuery,
18
38
  useT,
39
+ useTPlural,
19
40
  } from "@stapel/core";
20
- import { useWorkspace } from "../model/queries.js";
21
- import { useUpdateWorkspace, useDeleteWorkspace } from "../model/mutations.js";
41
+ import type { ActionAvailability, TranslateFn } from "@stapel/core";
42
+ import {
43
+ ErrorAlert,
44
+ GatedButton,
45
+ GatedControl,
46
+ LoadBoundary,
47
+ SkinConfirm,
48
+ SkinTheme,
49
+ } from "@stapel/tokens-antd/skin";
50
+ import { spacing } from "@stapel/tokens";
51
+ import { hasCapability } from "../model/capabilities.js";
52
+ import { useCapabilityGate, useWorkspace } from "../model/queries.js";
53
+ import {
54
+ useDeleteWorkspace,
55
+ useUpdateSecuritySettings,
56
+ useUpdateWorkspace,
57
+ } from "../model/mutations.js";
58
+ import { useWorkspaceFormat } from "../model/format.js";
59
+ import type {
60
+ ProvisionedUserPolicy,
61
+ Workspace,
62
+ WorkspaceSecuritySettings,
63
+ } from "../api/types.js";
22
64
  import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
23
- import { ErrorAlert } from "./ErrorAlert.js";
65
+ import { ActiveWorkspaceBoundary } from "./ActiveWorkspace.js";
66
+ import { Muted, StatusTag, SCREEN_STACK, FIELD_STACK } from "./parts.js";
24
67
 
25
68
  export interface WorkspaceSettingsProps {
26
- workspaceId: string;
69
+ /**
70
+ * The workspace being configured. OPTIONAL: omitted (the way the nav
71
+ * contract mounts this screen — a route, never an ambient scope), the
72
+ * active workspace comes from the runtime selection, and a screen with none
73
+ * renders the designed "choose a workspace" state rather than a blank.
74
+ */
75
+ workspaceId?: string;
27
76
  /** Called after a successful delete — the host navigates away / switches
28
77
  * to another workspace. Not called on cancel or failure. */
29
78
  onDeleted?(): void;
30
79
  }
31
80
 
81
+ /** The two first-login demands an organization may raise, in the order the
82
+ * backend documents them. Independent checkboxes, never alternatives. */
83
+ const POLICIES: readonly {
84
+ readonly value: ProvisionedUserPolicy;
85
+ readonly labelKey: string;
86
+ }[] = [
87
+ {
88
+ value: "password_change",
89
+ labelKey: WORKSPACES_I18N_KEYS.securityPolicyPasswordChange,
90
+ },
91
+ { value: "mfa_enroll", labelKey: WORKSPACES_I18N_KEYS.securityPolicyMfaEnroll },
92
+ ];
93
+
94
+ /** `settings.security`, typed. The block lives inside the free-form settings
95
+ * JSON, so the generated types say `object` and this is the read side of the
96
+ * documented correction in `api/types.ts`. */
97
+ function securityOf(workspace: Workspace): WorkspaceSecuritySettings {
98
+ const settings = (workspace.settings ?? {}) as Record<string, unknown>;
99
+ return (settings["security"] ?? {}) as WorkspaceSecuritySettings;
100
+ }
101
+
102
+ function sameStringSet(a: readonly string[], b: readonly string[]): boolean {
103
+ return a.length === b.length && a.every((value) => b.includes(value));
104
+ }
105
+
106
+ /**
107
+ * May this caller exercise `capability` in this workspace?
108
+ *
109
+ * `my_capabilities` is the mandate the server granted, and it is what the
110
+ * server re-checks — so it is what the screen reads. It is also ADDITIVE
111
+ * (stapel-workspaces ≥0.6.0): a deployment on an older backend sends the
112
+ * field not at all, and treating that silence as "no" would switch the whole
113
+ * settings screen off for its owner. So the absence — and only the absence —
114
+ * falls back to the pre-0.6 rule this pair used to apply everywhere. Where
115
+ * the field IS present, `my_role` is never consulted: a deployment role can
116
+ * hold `workspace.update` without being called owner, and the endpoint would
117
+ * accept it.
118
+ */
119
+ function mayUse(workspace: Workspace, capability: string): boolean {
120
+ const granted = workspace.my_capabilities;
121
+ if (granted === undefined) return workspace.my_role === "owner";
122
+ return hasCapability(granted, capability);
123
+ }
124
+
32
125
  export function WorkspaceSettings(props: WorkspaceSettingsProps): ReactElement {
33
- const t = useT();
34
- // Never the raw `.message` — for a response with no error envelope that
35
- // is the transport's own "Request failed with status 500" (owner report
36
- // 2026-08-09). `useErrorText` folds any thrown value into the one dialect.
37
- const errorDisplay = useErrorDisplay(WORKSPACES_I18N_KEYS.unknownError);
126
+ return (
127
+ <SkinTheme surface="base" data-testid="workspace-settings">
128
+ <ActiveWorkspaceBoundary
129
+ workspaceId={props.workspaceId}
130
+ testId="workspace-settings-workspace"
131
+ >
132
+ {(workspaceId) => (
133
+ <SettingsBody
134
+ workspaceId={workspaceId}
135
+ {...(props.onDeleted !== undefined ? { onDeleted: props.onDeleted } : {})}
136
+ />
137
+ )}
138
+ </ActiveWorkspaceBoundary>
139
+ </SkinTheme>
140
+ );
141
+ }
142
+
143
+ function SettingsBody(props: {
144
+ readonly workspaceId: string;
145
+ readonly onDeleted?: (() => void) | undefined;
146
+ }): ReactElement {
38
147
  const query = useWorkspace(props.workspaceId);
39
- const updateMutation = useUpdateWorkspace(props.workspaceId);
40
- const deleteMutation = useDeleteWorkspace();
41
-
42
- const workspace = query.data;
43
- const [name, setName] = useState("");
44
-
45
- // Two unrelated situations used to share one `disabled={!isOwner ||
46
- // !name.trim()}` bit, and a person facing either of them saw the same dead
47
- // button. `firstBlock` keeps them two, in the order you would explain them.
48
- const isOwner = workspace?.my_role === "owner";
49
- const saveGate = useActionGate(
50
- firstBlock(
51
- isOwner ? actionAvailable() : actionBlocked(WORKSPACES_I18N_KEYS.blockedNotOwner),
52
- name.trim() ? actionAvailable() : actionBlocked(WORKSPACES_I18N_KEYS.blockedNameRequired)
53
- )
148
+ return (
149
+ <LoadBoundary
150
+ state={loadStateFromQuery(query)}
151
+ testId="workspace-settings-load"
152
+ onRetry={() => {
153
+ void query.refetch();
154
+ }}
155
+ >
156
+ {(workspace) => (
157
+ <div style={SCREEN_STACK}>
158
+ <GeneralCard workspace={workspace} workspaceId={props.workspaceId} />
159
+ <SecurityCard workspace={workspace} workspaceId={props.workspaceId} />
160
+ <DangerZone
161
+ workspace={workspace}
162
+ workspaceId={props.workspaceId}
163
+ {...(props.onDeleted !== undefined ? { onDeleted: props.onDeleted } : {})}
164
+ />
165
+ </div>
166
+ )}
167
+ </LoadBoundary>
54
168
  );
169
+ }
170
+
171
+ /** Name, slug, type — the parts of a workspace a person renames. */
172
+ function GeneralCard(props: {
173
+ readonly workspace: Workspace;
174
+ readonly workspaceId: string;
175
+ }): ReactElement {
176
+ const t = useT();
177
+ const updateMutation = useUpdateWorkspace(props.workspaceId);
178
+ const [name, setName] = useState(props.workspace.name);
55
179
 
56
180
  useEffect(() => {
57
- if (workspace) setName(workspace.name);
58
- }, [workspace]);
181
+ setName(props.workspace.name);
182
+ }, [props.workspace.name]);
59
183
 
60
- function handleSave(): void {
61
- updateMutation.mutate({ name: name.trim() });
62
- }
184
+ // Ordered the way it would be explained out loud: what you may do first,
185
+ // then what you have not done yet.
186
+ const canManage: ActionAvailability = mayUse(props.workspace, "workspace.update")
187
+ ? actionAvailable()
188
+ : actionBlocked(WORKSPACES_I18N_KEYS.blockedCannotManage);
189
+ const saveGate = firstBlock(
190
+ canManage,
191
+ name.trim() === ""
192
+ ? actionBlocked(WORKSPACES_I18N_KEYS.blockedNameRequired)
193
+ : actionAvailable(),
194
+ name.trim() === props.workspace.name
195
+ ? actionBlocked(WORKSPACES_I18N_KEYS.blockedUnchanged)
196
+ : actionAvailable()
197
+ );
63
198
 
64
- function handleDelete(): void {
65
- deleteMutation.mutate(props.workspaceId, {
66
- onSuccess: () => props.onDeleted?.(),
67
- });
68
- }
199
+ const typeKey =
200
+ props.workspace.type === "personal"
201
+ ? WORKSPACES_I18N_KEYS.typePersonal
202
+ : WORKSPACES_I18N_KEYS.typeWork;
203
+
204
+ return (
205
+ <Card>
206
+ <Typography.Title level={4} style={{ marginTop: 0 }}>
207
+ {t(WORKSPACES_I18N_KEYS.settingsTitle)}
208
+ </Typography.Title>
209
+ <Typography.Text type="secondary">
210
+ {t(WORKSPACES_I18N_KEYS.settingsSubtitle)}
211
+ </Typography.Text>
212
+
213
+ <div style={{ ...FIELD_STACK, marginTop: spacing["4"] }}>
214
+ <GatedControl gate={canManage} testId="workspace-name-field">
215
+ {(bind) => (
216
+ <Flex vertical gap={spacing["1"]} style={{ width: "100%" }}>
217
+ <Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldName)}</Typography.Text>
218
+ <Input
219
+ value={name}
220
+ onChange={(event) => setName(event.target.value)}
221
+ aria-label={t(WORKSPACES_I18N_KEYS.fieldName)}
222
+ {...bind}
223
+ />
224
+ </Flex>
225
+ )}
226
+ </GatedControl>
227
+ <Flex vertical gap={spacing["1"]}>
228
+ <Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldSlug)}</Typography.Text>
229
+ <Typography.Text code>{props.workspace.slug}</Typography.Text>
230
+ </Flex>
231
+ <Flex vertical gap={spacing["1"]} align="flex-start">
232
+ <Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldType)}</Typography.Text>
233
+ <StatusTag tone="neutral" testId="workspace-type">
234
+ {t(typeKey)}
235
+ </StatusTag>
236
+ </Flex>
237
+ </div>
69
238
 
70
- if (query.isLoading && !workspace) {
71
- return <Spin data-testid="workspace-settings-loading" />;
72
- }
73
- if (!workspace) {
74
- return (
75
239
  <ErrorAlert
76
- testId="workspace-settings-error"
77
- error={
78
- errorDisplay(query.error) ?? {
79
- message: t(WORKSPACES_I18N_KEYS.unknownError),
80
- detail: undefined,
81
- }
82
- }
240
+ thrown={updateMutation.error}
241
+ style={{ marginTop: spacing["3"] }}
242
+ testId="workspace-settings-write-error"
83
243
  />
84
- );
85
- }
244
+
245
+ <div style={{ marginTop: spacing["4"] }}>
246
+ <GatedButton
247
+ gate={saveGate}
248
+ type="primary"
249
+ loading={updateMutation.isPending}
250
+ onClick={() => {
251
+ updateMutation.mutate({ name: name.trim() });
252
+ }}
253
+ testId="workspace-save"
254
+ data-analytics="none"
255
+ data-analytics-reason="business action — host app wraps with its own tracked(); pairs carry no @stapel/analytics runtime dependency by architecture"
256
+ >
257
+ {updateMutation.isPending
258
+ ? t(WORKSPACES_I18N_KEYS.saving)
259
+ : t(WORKSPACES_I18N_KEYS.save)}
260
+ </GatedButton>
261
+ </div>
262
+ </Card>
263
+ );
264
+ }
265
+
266
+ /** The `require_mfa` policy: what was asked for, and what actually holds. */
267
+ function SecurityCard(props: {
268
+ readonly workspace: Workspace;
269
+ readonly workspaceId: string;
270
+ }): ReactElement {
271
+ const t = useT();
272
+ const tPlural = useTPlural();
273
+ const format = useWorkspaceFormat();
274
+ const mutation = useUpdateSecuritySettings(props.workspaceId);
275
+ const gate = useCapabilityGate(props.workspaceId, "workspace.security.manage");
276
+ const stored = useMemo(() => securityOf(props.workspace), [props.workspace]);
277
+ const storedPolicies = useMemo<readonly ProvisionedUserPolicy[]>(
278
+ () => stored.provisioned_user_policies ?? [],
279
+ [stored]
280
+ );
281
+
282
+ /** What is SAVED, not what the person has just toggled: the enforcement
283
+ * block describes the policy the server is acting on. */
284
+ const storedRequireMfa = stored.require_mfa === true;
285
+ const [requireMfa, setRequireMfa] = useState(stored.require_mfa === true);
286
+ const [policies, setPolicies] = useState<readonly ProvisionedUserPolicy[]>(storedPolicies);
287
+
288
+ useEffect(() => {
289
+ setRequireMfa(stored.require_mfa === true);
290
+ setPolicies(storedPolicies);
291
+ }, [stored, storedPolicies]);
292
+
293
+ const allowed = mayUse(props.workspace, "workspace.security.manage");
294
+ const canManage: ActionAvailability = allowed
295
+ ? actionAvailable()
296
+ : actionBlocked(WORKSPACES_I18N_KEYS.securityBlockedCapability);
297
+ const unchanged =
298
+ requireMfa === (stored.require_mfa === true) &&
299
+ sameStringSet(policies, storedPolicies);
300
+ const saveGate = firstBlock(
301
+ canManage,
302
+ unchanged ? actionBlocked(WORKSPACES_I18N_KEYS.blockedUnchanged) : actionAvailable()
303
+ );
304
+
305
+ const enforcement = props.workspace.mfa_enforcement ?? null;
86
306
 
87
307
  return (
88
- <div data-testid="workspace-settings" style={{ display: "grid", gap: 16 }}>
89
- <Card>
90
- <Typography.Title level={4} style={{ marginTop: 0 }}>
91
- {t(WORKSPACES_I18N_KEYS.settingsTitle)}
92
- </Typography.Title>
93
- <Typography.Text type="secondary">{t(WORKSPACES_I18N_KEYS.settingsSubtitle)}</Typography.Text>
94
-
95
- <div style={{ display: "grid", gap: 12, maxWidth: 480, marginTop: 16 }}>
96
- <div>
97
- <Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldName)}</Typography.Text>
98
- <Input
99
- value={name}
100
- onChange={(e) => setName(e.target.value)}
101
- disabled={!isOwner}
102
- />
103
- {!isOwner && (
104
- <div>
105
- <Typography.Text type="secondary" data-testid="workspace-name-blocked">
106
- {t(WORKSPACES_I18N_KEYS.blockedNotOwner)}
308
+ <Card data-testid="workspace-security">
309
+ <Typography.Title level={5} style={{ marginTop: 0 }}>
310
+ {t(WORKSPACES_I18N_KEYS.securityTitle)}
311
+ </Typography.Title>
312
+ <Typography.Text type="secondary">
313
+ {t(WORKSPACES_I18N_KEYS.securitySubtitle)}
314
+ </Typography.Text>
315
+
316
+ <div style={{ ...FIELD_STACK, marginTop: spacing["4"] }}>
317
+ <GatedControl gate={canManage} testId="workspace-require-mfa-field">
318
+ {(bind) => (
319
+ <Flex gap={spacing["3"]} align="flex-start">
320
+ <Switch
321
+ checked={requireMfa}
322
+ onChange={setRequireMfa}
323
+ aria-label={t(WORKSPACES_I18N_KEYS.securityRequireMfa)}
324
+ data-testid="workspace-require-mfa"
325
+ {...bind}
326
+ />
327
+ <Flex vertical gap={spacing["0"]}>
328
+ <Typography.Text>
329
+ {t(WORKSPACES_I18N_KEYS.securityRequireMfa)}
107
330
  </Typography.Text>
108
- </div>
109
- )}
110
- </div>
111
- <div>
112
- <Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldSlug)}</Typography.Text>
113
- <div>
114
- <Typography.Text code>{workspace.slug}</Typography.Text>
115
- </div>
116
- </div>
117
- <div>
118
- <Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldType)}</Typography.Text>
119
- <div>
120
- <Badge status="default" text={workspace.type} />
121
- </div>
122
- </div>
331
+ <Muted>{t(WORKSPACES_I18N_KEYS.securityRequireMfaHint)}</Muted>
332
+ </Flex>
333
+ </Flex>
334
+ )}
335
+ </GatedControl>
336
+
337
+ <GatedControl gate={canManage} testId="workspace-policies-field">
338
+ {(bind) => (
339
+ <Flex vertical gap={spacing["2"]} align="flex-start">
340
+ <Typography.Text>
341
+ {t(WORKSPACES_I18N_KEYS.securityPoliciesLabel)}
342
+ </Typography.Text>
343
+ <Muted>{t(WORKSPACES_I18N_KEYS.securityPoliciesHint)}</Muted>
344
+ {POLICIES.map((policy) => (
345
+ <Checkbox
346
+ key={policy.value}
347
+ checked={policies.includes(policy.value)}
348
+ onChange={(event) => {
349
+ setPolicies((current) =>
350
+ event.target.checked
351
+ ? [...current, policy.value]
352
+ : current.filter((value) => value !== policy.value)
353
+ );
354
+ }}
355
+ data-testid={`workspace-policy-${policy.value}`}
356
+ {...bind}
357
+ >
358
+ {t(policy.labelKey)}
359
+ </Checkbox>
360
+ ))}
361
+ </Flex>
362
+ )}
363
+ </GatedControl>
364
+ </div>
365
+
366
+ {/* Said BEFORE the click, not discovered by a 403 after it: this
367
+ capability is declared `high`, so the backend demands a fresh
368
+ verification on top of the mandate. */}
369
+ {allowed && gate.requiresStepUp && (
370
+ <div style={{ marginTop: spacing["3"] }}>
371
+ <Muted testId="workspace-security-stepup">
372
+ {t(WORKSPACES_I18N_KEYS.securityStepUpNotice)}
373
+ </Muted>
123
374
  </div>
375
+ )}
124
376
 
125
- <ErrorAlert error={errorDisplay(updateMutation.error)} style={{ marginTop: 12 }} />
377
+ <ErrorAlert
378
+ thrown={mutation.error}
379
+ style={{ marginTop: spacing["3"] }}
380
+ testId="workspace-security-error"
381
+ />
126
382
 
127
- <Button
383
+ <div style={{ marginTop: spacing["4"] }}>
384
+ <GatedButton
385
+ gate={saveGate}
128
386
  type="primary"
129
- style={{ marginTop: 16 }}
130
- loading={updateMutation.isPending}
131
- disabled={saveGate.disabled}
132
- onClick={handleSave}
387
+ loading={mutation.isPending}
388
+ onClick={() => {
389
+ mutation.mutate({
390
+ require_mfa: requireMfa,
391
+ provisioned_user_policies: policies,
392
+ });
393
+ }}
394
+ testId="workspace-security-save"
133
395
  data-analytics="none"
134
- data-analytics-reason="business action — host app wraps with its own tracked(); pairs carry no @stapel/analytics runtime dependency by architecture"
396
+ data-analytics-reason="business action — host app wraps with its own tracked()"
135
397
  >
136
- {updateMutation.isPending ? t(WORKSPACES_I18N_KEYS.saving) : t(WORKSPACES_I18N_KEYS.save)}
137
- </Button>
138
- {/* Beside the control, as TEXT: a disabled button receives no pointer
139
- events, so a tooltip on it is a reason nobody can read. */}
140
- {saveGate.reason !== undefined && (
141
- <div style={{ marginTop: 8 }}>
142
- <Typography.Text type="secondary" data-testid="workspace-save-blocked">
143
- {saveGate.reason}
144
- </Typography.Text>
398
+ {mutation.isPending
399
+ ? t(WORKSPACES_I18N_KEYS.securitySaving)
400
+ : t(WORKSPACES_I18N_KEYS.securitySave)}
401
+ </GatedButton>
402
+ </div>
403
+
404
+ <div style={{ marginTop: spacing["5"] }}>
405
+ <Typography.Text strong>
406
+ {t(WORKSPACES_I18N_KEYS.mfaStatusTitle)}
407
+ </Typography.Text>
408
+ {enforcement === null ? (
409
+ <div style={{ marginTop: spacing["2"] }}>
410
+ {/* The note and the switch read the SAME value. Deriving "not
411
+ required in this workspace" from the absence of an enforcement
412
+ status instead put that sentence under a switch that was ON —
413
+ a screen contradicting itself two lines apart. The absence
414
+ means the sweep has not reported, which is a different fact
415
+ and is what it now says. */}
416
+ <Muted testId="workspace-mfa-off">
417
+ {t(
418
+ storedRequireMfa
419
+ ? WORKSPACES_I18N_KEYS.mfaNoStatusYet
420
+ : WORKSPACES_I18N_KEYS.mfaOffNotice
421
+ )}
422
+ </Muted>
145
423
  </div>
146
- )}
147
- </Card>
148
-
149
- {isOwner && (
150
- <Card>
151
- <Typography.Title level={5} type="danger" style={{ marginTop: 0 }}>
152
- {t(WORKSPACES_I18N_KEYS.dangerZoneTitle)}
153
- </Typography.Title>
154
- <Popconfirm
155
- title={t(WORKSPACES_I18N_KEYS.deleteWorkspaceConfirm)}
156
- onConfirm={handleDelete}
157
- okButtonProps={{ loading: deleteMutation.isPending, danger: true }}
424
+ ) : (
425
+ <Flex
426
+ vertical
427
+ gap={spacing["2"]}
428
+ align="flex-start"
429
+ style={{ marginTop: spacing["2"] }}
430
+ data-testid="workspace-mfa-status"
158
431
  >
159
- <Button danger>{t(WORKSPACES_I18N_KEYS.deleteWorkspace)}</Button>
160
- </Popconfirm>
161
- </Card>
162
- )}
163
- </div>
432
+ <Flex gap={spacing["2"]} align="center" wrap>
433
+ <Muted>{t(WORKSPACES_I18N_KEYS.mfaStateLabel)}</Muted>
434
+ <StatusTag
435
+ tone={
436
+ enforcement.state === "enforced"
437
+ ? "success"
438
+ : enforcement.state === "failed"
439
+ ? "danger"
440
+ : "warning"
441
+ }
442
+ testId="workspace-mfa-state"
443
+ >
444
+ {mfaStateLabel(t, enforcement.state)}
445
+ </StatusTag>
446
+ </Flex>
447
+ <Muted>
448
+ {tPlural(WORKSPACES_I18N_KEYS.mfaCheckedCount, {
449
+ count: enforcement.checked_members ?? 0,
450
+ })}
451
+ </Muted>
452
+ <Muted>
453
+ {tPlural(WORKSPACES_I18N_KEYS.mfaNoncompliantCount, {
454
+ count: enforcement.noncompliant_members ?? 0,
455
+ })}
456
+ </Muted>
457
+ <Typography.Text
458
+ type={(enforcement.unverified_members ?? 0) > 0 ? "warning" : "secondary"}
459
+ data-testid="workspace-mfa-unverified"
460
+ >
461
+ {tPlural(WORKSPACES_I18N_KEYS.mfaUnverifiedCount, {
462
+ count: enforcement.unverified_members ?? 0,
463
+ })}
464
+ </Typography.Text>
465
+ {(enforcement.unverified_members ?? 0) > 0 && (
466
+ <Muted>{t(WORKSPACES_I18N_KEYS.mfaUnverifiedHint)}</Muted>
467
+ )}
468
+ <Muted>
469
+ {tPlural(WORKSPACES_I18N_KEYS.mfaAttemptsCount, {
470
+ count: enforcement.attempts ?? 0,
471
+ })}
472
+ </Muted>
473
+ {format.timestamp(enforcement.last_attempt_at) !== null && (
474
+ <Muted>
475
+ {t(WORKSPACES_I18N_KEYS.mfaLastAttempt, {
476
+ date: format.timestamp(enforcement.last_attempt_at),
477
+ })}
478
+ </Muted>
479
+ )}
480
+ {format.timestamp(enforcement.completed_at) !== null && (
481
+ <Muted>
482
+ {t(WORKSPACES_I18N_KEYS.mfaCompletedAt, {
483
+ date: format.timestamp(enforcement.completed_at),
484
+ })}
485
+ </Muted>
486
+ )}
487
+ {enforcement.last_error !== undefined && enforcement.last_error !== "" && (
488
+ <Muted testId="workspace-mfa-error">
489
+ {t(WORKSPACES_I18N_KEYS.mfaLastError, { error: enforcement.last_error })}
490
+ </Muted>
491
+ )}
492
+ </Flex>
493
+ )}
494
+ </div>
495
+ </Card>
496
+ );
497
+ }
498
+
499
+ /** The four states the backend names, plus an honest line for a fifth this
500
+ * build has never heard of. */
501
+ function mfaStateLabel(t: TranslateFn, state: string): string {
502
+ switch (state) {
503
+ case "pending":
504
+ return t(WORKSPACES_I18N_KEYS.mfaStatePending);
505
+ case "enforcing":
506
+ return t(WORKSPACES_I18N_KEYS.mfaStateEnforcing);
507
+ case "enforced":
508
+ return t(WORKSPACES_I18N_KEYS.mfaStateEnforced);
509
+ case "failed":
510
+ return t(WORKSPACES_I18N_KEYS.mfaStateFailed);
511
+ default:
512
+ return t(WORKSPACES_I18N_KEYS.mfaStateOther, { state });
513
+ }
514
+ }
515
+
516
+ /** Deletion — the one act on this screen nobody can take back. */
517
+ function DangerZone(props: {
518
+ readonly workspace: Workspace;
519
+ readonly workspaceId: string;
520
+ readonly onDeleted?: () => void;
521
+ }): ReactElement {
522
+ const t = useT();
523
+ const mutation = useDeleteWorkspace();
524
+ const [confirming, setConfirming] = useState(false);
525
+
526
+ // THE SERVER'S ANSWER, not a rule re-derived from `my_role`. `can_delete`
527
+ // is additive on the wire, so a backend that predates it sends neither
528
+ // field; treating "absent" as "allowed" keeps such a deployment working
529
+ // exactly as it did, and the refusal it might answer is a translated 409
530
+ // rather than a promise this screen made.
531
+ const canDelete = props.workspace.can_delete ?? true;
532
+ const blockedReason = props.workspace.delete_blocked_reason;
533
+ const deleteGate: ActionAvailability = canDelete
534
+ ? actionAvailable()
535
+ : actionBlocked(
536
+ blockedReason !== undefined && blockedReason !== ""
537
+ ? blockedReason
538
+ : WORKSPACES_I18N_KEYS.deleteBlockedFallback
539
+ );
540
+
541
+ return (
542
+ <Card data-testid="workspace-danger-zone">
543
+ <Typography.Title level={5} type="danger" style={{ marginTop: 0 }}>
544
+ {t(WORKSPACES_I18N_KEYS.dangerZoneTitle)}
545
+ </Typography.Title>
546
+ <ErrorAlert
547
+ thrown={mutation.error}
548
+ style={{ marginBottom: spacing["3"] }}
549
+ testId="workspace-delete-error"
550
+ />
551
+ <GatedButton
552
+ gate={deleteGate}
553
+ danger
554
+ onClick={() => setConfirming(true)}
555
+ testId="workspace-delete"
556
+ data-analytics="none"
557
+ data-analytics-reason="opens the confirm; the delete itself is the tracked point"
558
+ >
559
+ {t(WORKSPACES_I18N_KEYS.deleteWorkspace)}
560
+ </GatedButton>
561
+ <SkinConfirm
562
+ open={confirming}
563
+ danger
564
+ title={t(WORKSPACES_I18N_KEYS.deleteWorkspaceConfirm)}
565
+ body={t(WORKSPACES_I18N_KEYS.deleteWorkspaceConfirmBody)}
566
+ confirmLabel={t(WORKSPACES_I18N_KEYS.deleteWorkspace)}
567
+ cancelLabel={t(WORKSPACES_I18N_KEYS.cancel)}
568
+ confirming={mutation.isPending}
569
+ onConfirm={() => {
570
+ mutation.mutate(props.workspaceId, {
571
+ onSuccess: () => props.onDeleted?.(),
572
+ onSettled: () => setConfirming(false),
573
+ });
574
+ }}
575
+ onCancel={() => setConfirming(false)}
576
+ data-testid="workspace-delete-confirm"
577
+ />
578
+ </Card>
164
579
  );
165
580
  }