@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,165 +1,580 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `<WorkspaceSettings/>` — default skin for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
32
|
+
import { Card, Checkbox, Flex, Input, Switch, Typography } from "antd";
|
|
12
33
|
import {
|
|
13
34
|
actionAvailable,
|
|
14
35
|
actionBlocked,
|
|
15
36
|
firstBlock,
|
|
16
|
-
|
|
17
|
-
useErrorDisplay,
|
|
37
|
+
loadStateFromQuery,
|
|
18
38
|
useT,
|
|
39
|
+
useTPlural,
|
|
19
40
|
} from "@stapel/core";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
}, [workspace]);
|
|
181
|
+
setName(props.workspace.name);
|
|
182
|
+
}, [props.workspace.name]);
|
|
59
183
|
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
<
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
377
|
+
<ErrorAlert
|
|
378
|
+
thrown={mutation.error}
|
|
379
|
+
style={{ marginTop: spacing["3"] }}
|
|
380
|
+
testId="workspace-security-error"
|
|
381
|
+
/>
|
|
126
382
|
|
|
127
|
-
|
|
383
|
+
<div style={{ marginTop: spacing["4"] }}>
|
|
384
|
+
<GatedButton
|
|
385
|
+
gate={saveGate}
|
|
128
386
|
type="primary"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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()
|
|
396
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
135
397
|
>
|
|
136
|
-
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
<
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
}
|