@stapel/workspaces-react 0.13.0 → 0.15.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.
- package/CHANGELOG.md +65 -0
- package/dist/api/generated/schema.d.ts +165 -3
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +22 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/workspacesApi.d.ts +5 -1
- package/dist/api/workspacesApi.d.ts.map +1 -1
- package/dist/api/workspacesApi.js +16 -0
- package/dist/api/workspacesApi.js.map +1 -1
- package/dist/default/MembersManager.d.ts.map +1 -1
- package/dist/default/MembersManager.js +20 -6
- package/dist/default/MembersManager.js.map +1 -1
- package/dist/default/WorkspaceSettings.d.ts.map +1 -1
- package/dist/default/WorkspaceSettings.js +7 -3
- package/dist/default/WorkspaceSettings.js.map +1 -1
- package/dist/headless/Can.d.ts +8 -0
- package/dist/headless/Can.d.ts.map +1 -1
- package/dist/headless/Can.js +5 -1
- package/dist/headless/Can.js.map +1 -1
- package/dist/headless/Members.d.ts +14 -13
- package/dist/headless/Members.d.ts.map +1 -1
- package/dist/headless/Members.js +4 -12
- package/dist/headless/Members.js.map +1 -1
- package/dist/headless/RoleSelect.d.ts +15 -9
- package/dist/headless/RoleSelect.d.ts.map +1 -1
- package/dist/headless/RoleSelect.js +12 -8
- package/dist/headless/RoleSelect.js.map +1 -1
- package/dist/headless/WorkspaceList.d.ts +25 -10
- package/dist/headless/WorkspaceList.d.ts.map +1 -1
- package/dist/headless/WorkspaceList.js +14 -5
- package/dist/headless/WorkspaceList.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.es.gen.js +2 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +12 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +6 -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 +2 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/i18n/keys.d.ts +13 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +23 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +7 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts +46 -8
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +53 -9
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +3 -1
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +6 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/model/selection.d.ts +20 -7
- package/dist/model/selection.d.ts.map +1 -1
- package/dist/model/selection.js +32 -12
- package/dist/model/selection.js.map +1 -1
- package/llms.txt +6 -3
- package/manifest.json +46 -3
- package/package.json +2 -2
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +165 -3
- package/src/api/types.ts +23 -0
- package/src/api/workspacesApi.ts +22 -0
- package/src/default/MembersManager.tsx +69 -16
- package/src/default/WorkspaceSettings.tsx +41 -5
- package/src/headless/Can.tsx +13 -1
- package/src/headless/Members.tsx +18 -26
- package/src/headless/RoleSelect.tsx +17 -14
- package/src/headless/WorkspaceList.tsx +31 -14
- package/src/i18n/generated/errors.es.gen.ts +2 -0
- package/src/i18n/generated/errors.gen.ts +6 -0
- package/src/i18n/generated/errors.json +16 -0
- package/src/i18n/generated/errors.ru.gen.ts +2 -0
- package/src/i18n/keys.ts +26 -0
- package/src/i18n/ru.ts +10 -0
- package/src/index.ts +5 -0
- package/src/model/queries.ts +77 -16
- package/src/model/queryKeys.ts +16 -1
- package/src/model/selection.tsx +73 -21
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import { useI18n } from "@stapel/core";
|
|
3
|
-
import type {
|
|
2
|
+
import { loadStateFromQuery, useI18n } from "@stapel/core";
|
|
3
|
+
import type { LoadState } from "@stapel/core";
|
|
4
4
|
import type { RoleInfo } from "../api/types.js";
|
|
5
5
|
import { useRoles } from "../model/queries.js";
|
|
6
6
|
|
|
7
7
|
/** Render-prop bag for {@link RoleSelect}. */
|
|
8
8
|
export interface RoleSelectBag {
|
|
9
9
|
/** The effective registry (GET /roles): builtin four + the deployment's
|
|
10
|
-
* overlay, rank-descending.
|
|
11
|
-
|
|
10
|
+
* overlay, rank-descending. A state rather than an array: a role picker
|
|
11
|
+
* built from `[]` because the registry read FAILED offers no roles at all
|
|
12
|
+
* and looks exactly like a deployment that defined none. */
|
|
13
|
+
readonly state: LoadState<readonly RoleInfo[]>;
|
|
12
14
|
/**
|
|
13
15
|
* Display label for a role key: the i18n key `workspaces.role.<key>` when
|
|
14
16
|
* the current locale's merged bundle carries it (the pair ships the builtin
|
|
@@ -18,9 +20,6 @@ export interface RoleSelectBag {
|
|
|
18
20
|
* never render as a dotted i18n key.
|
|
19
21
|
*/
|
|
20
22
|
labelFor(role: string): string;
|
|
21
|
-
readonly isLoading: boolean;
|
|
22
|
-
readonly isError: boolean;
|
|
23
|
-
readonly error: StapelApiError | null;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
/**
|
|
@@ -30,9 +29,16 @@ export interface RoleSelectBag {
|
|
|
30
29
|
*
|
|
31
30
|
* ```tsx
|
|
32
31
|
* <RoleSelect>
|
|
33
|
-
* {({
|
|
34
|
-
*
|
|
35
|
-
*
|
|
32
|
+
* {({ state, labelFor }) =>
|
|
33
|
+
* matchList(state, {
|
|
34
|
+
* loading: () => <Select loading options={[]} />,
|
|
35
|
+
* failed: (error) => <RoleLoadFailed error={error} />,
|
|
36
|
+
* empty: () => <Select disabled options={[]} />,
|
|
37
|
+
* ready: (roles) => (
|
|
38
|
+
* <Select options={roles.map((r) => ({ value: r.role, label: labelFor(r.role) }))} />
|
|
39
|
+
* ),
|
|
40
|
+
* })
|
|
41
|
+
* }
|
|
36
42
|
* </RoleSelect>
|
|
37
43
|
* ```
|
|
38
44
|
*
|
|
@@ -49,10 +55,7 @@ export function RoleSelect(props: {
|
|
|
49
55
|
// must show its raw name, not `workspaces.role.secretary`).
|
|
50
56
|
const bundle = i18n.getBundle();
|
|
51
57
|
return props.children({
|
|
52
|
-
|
|
58
|
+
state: loadStateFromQuery(query),
|
|
53
59
|
labelFor: (role) => bundle[`workspaces.role.${role}`] ?? role,
|
|
54
|
-
isLoading: query.isLoading,
|
|
55
|
-
isError: query.isError,
|
|
56
|
-
error: query.error ?? null,
|
|
57
60
|
});
|
|
58
61
|
}
|
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import
|
|
2
|
+
import { loadStateFromQuery, mapLoad } from "@stapel/core";
|
|
3
|
+
import type { LoadState, StapelApiError } from "@stapel/core";
|
|
3
4
|
import type { Workspace, WorkspaceCreate } from "../api/types.js";
|
|
4
5
|
import { useWorkspaces } from "../model/queries.js";
|
|
5
6
|
import { useCreateWorkspace } from "../model/mutations.js";
|
|
6
7
|
|
|
7
8
|
/** Render-prop bag for {@link WorkspaceList}. */
|
|
8
9
|
export interface WorkspaceListBag {
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The read, as a state a skin cannot flatten: `loading` / `ready` with the
|
|
12
|
+
* rows / `failed` with the error. Render it with core's `matchList`, whose
|
|
13
|
+
* four arms are all required, so "you have no workspaces" can only be said
|
|
14
|
+
* about a load that actually succeeded.
|
|
15
|
+
*
|
|
16
|
+
* This replaced a `workspaces: readonly Workspace[]` field that was `[]` in
|
|
17
|
+
* all three cases. On 2026-08-09 the list endpoint answered 404 for hours
|
|
18
|
+
* and every screen built on that field said the person had no workspaces.
|
|
19
|
+
* The bag carried `isError` beside it the whole time; nothing forced a skin
|
|
20
|
+
* to look, so nothing did.
|
|
21
|
+
*/
|
|
22
|
+
readonly state: LoadState<readonly Workspace[]>;
|
|
17
23
|
/** Create a workspace (owner-seeded). */
|
|
18
24
|
create(body: WorkspaceCreate): void;
|
|
19
25
|
/** A create call is in flight. */
|
|
20
26
|
readonly isCreating: boolean;
|
|
21
27
|
/** The workspace created by the last `create`, else null. */
|
|
22
28
|
readonly created: Workspace | null;
|
|
29
|
+
/** The CREATE's failure — a write, and therefore a different question from
|
|
30
|
+
* {@link state}, which is about the read. */
|
|
31
|
+
readonly createError: StapelApiError | null;
|
|
23
32
|
/** Refetch the list from the server. */
|
|
24
33
|
refetch(): void;
|
|
25
34
|
}
|
|
@@ -32,7 +41,14 @@ export interface WorkspaceListBag {
|
|
|
32
41
|
*
|
|
33
42
|
* ```tsx
|
|
34
43
|
* <WorkspaceList>
|
|
35
|
-
* {({
|
|
44
|
+
* {({ state, create }) =>
|
|
45
|
+
* matchList(state, {
|
|
46
|
+
* loading: () => <Spinner />,
|
|
47
|
+
* failed: (error) => <ErrorPanel error={error} onRetry={refetch} />,
|
|
48
|
+
* empty: () => <FirstWorkspacePrompt onCreate={create} />,
|
|
49
|
+
* ready: (workspaces) => <List items={workspaces} />,
|
|
50
|
+
* })
|
|
51
|
+
* }
|
|
36
52
|
* </WorkspaceList>
|
|
37
53
|
* ```
|
|
38
54
|
*/
|
|
@@ -42,15 +58,16 @@ export function WorkspaceList(props: {
|
|
|
42
58
|
const query = useWorkspaces();
|
|
43
59
|
const mutation = useCreateWorkspace();
|
|
44
60
|
return props.children({
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
61
|
+
// `workspaces` is optional in the wire schema, so a SUCCESSFUL response
|
|
62
|
+
// may legitimately carry none. That is an empty list, not a failed load —
|
|
63
|
+
// the distinction this whole field exists for is already made above it.
|
|
64
|
+
state: mapLoad(loadStateFromQuery(query), (list) => list.workspaces ?? []),
|
|
49
65
|
create: (body) => {
|
|
50
66
|
mutation.mutate(body);
|
|
51
67
|
},
|
|
52
68
|
isCreating: mutation.isPending,
|
|
53
69
|
created: mutation.data ?? null,
|
|
70
|
+
createError: mutation.error ?? null,
|
|
54
71
|
refetch: () => {
|
|
55
72
|
void query.refetch();
|
|
56
73
|
},
|
|
@@ -56,6 +56,7 @@ export const workspacesErrorBundleEs: Record<WorkspacesErrorCode, string> = {
|
|
|
56
56
|
"error.403.role_exceeds_inviter_rank": "No puedes conceder un rol superior al tuyo ({role})",
|
|
57
57
|
"error.403.verification_enrollment_required": "Es necesario registrar un factor de verificación.",
|
|
58
58
|
"error.403.verification_required": "Se requiere verificación adicional",
|
|
59
|
+
"error.403.workspace_creation_closed": "Esta instancia no te permite crear espacios de trabajo",
|
|
59
60
|
"error.404.ad_not_found": "Anuncio no encontrado",
|
|
60
61
|
"error.404.invitation_not_found": "Invitación no encontrada",
|
|
61
62
|
"error.404.member_not_found": "Miembro no encontrado en este espacio de trabajo",
|
|
@@ -73,6 +74,7 @@ export const workspacesErrorBundleEs: Record<WorkspacesErrorCode, string> = {
|
|
|
73
74
|
"error.422.unprocessable_entity": "Entidad no procesable",
|
|
74
75
|
"error.423.locked": "El recurso está bloqueado",
|
|
75
76
|
"error.423.verification_locked": "Demasiados intentos fallidos — verificación bloqueada",
|
|
77
|
+
"error.429.invitation_resend_cooldown": "Esta invitación se envió por correo hace poco; puedes volver a enviarla en {retry_after} segundos",
|
|
76
78
|
"error.429.rate_limit": "Demasiados intentos. Inténtalo de nuevo en {retry_after_minutes} minutos.",
|
|
77
79
|
"error.429.too_many_requests": "Demasiadas solicitudes. Inténtalo de nuevo más tarde.",
|
|
78
80
|
"error.500.internal": "Algo salió mal",
|
|
@@ -79,6 +79,7 @@ export const WORKSPACES_ERRORS = {
|
|
|
79
79
|
"error.403.role_exceeds_inviter_rank": { status: 403, params: ["role"], remediation: "fix_input", en: "You cannot grant a role that outranks your own ({role})" },
|
|
80
80
|
"error.403.verification_enrollment_required": { status: 403, params: [], remediation: "verify", en: "Verification factor enrollment required" },
|
|
81
81
|
"error.403.verification_required": { status: 403, params: [], remediation: "verify", en: "Additional verification required" },
|
|
82
|
+
"error.403.workspace_creation_closed": { status: 403, params: [], remediation: "contact_support", en: "This instance does not allow you to create workspaces" },
|
|
82
83
|
"error.404.ad_not_found": { status: 404, params: [], remediation: "retry", en: "Listing not found" },
|
|
83
84
|
"error.404.invitation_not_found": { status: 404, params: [], remediation: "fix_input", en: "Invitation not found" },
|
|
84
85
|
"error.404.member_not_found": { status: 404, params: [], remediation: "fix_input", en: "Member not found in this workspace" },
|
|
@@ -96,6 +97,7 @@ export const WORKSPACES_ERRORS = {
|
|
|
96
97
|
"error.422.unprocessable_entity": { status: 422, params: [], remediation: "wait_and_retry", en: "Unprocessable entity" },
|
|
97
98
|
"error.423.locked": { status: 423, params: [], remediation: "wait_and_retry", en: "Resource is locked" },
|
|
98
99
|
"error.423.verification_locked": { status: 423, params: [], remediation: "wait_and_retry", en: "Too many failed attempts — verification locked" },
|
|
100
|
+
"error.429.invitation_resend_cooldown": { status: 429, params: ["retry_after"], remediation: "wait_and_retry", en: "This invitation was emailed recently; you can send it again in {retry_after} seconds" },
|
|
99
101
|
"error.429.rate_limit": { status: 429, params: ["retry_after_minutes"], remediation: "wait_and_retry", en: "Too many attempts. Try again in {retry_after_minutes} minutes." },
|
|
100
102
|
"error.429.too_many_requests": { status: 429, params: [], remediation: "wait_and_retry", en: "Too many requests. Please try again later." },
|
|
101
103
|
"error.500.internal": { status: 500, params: [], remediation: "contact_support", en: "Something went wrong" },
|
|
@@ -152,6 +154,7 @@ export const WORKSPACES_ERROR_CODES: readonly WorkspacesErrorCode[] = [
|
|
|
152
154
|
"error.403.role_exceeds_inviter_rank",
|
|
153
155
|
"error.403.verification_enrollment_required",
|
|
154
156
|
"error.403.verification_required",
|
|
157
|
+
"error.403.workspace_creation_closed",
|
|
155
158
|
"error.404.ad_not_found",
|
|
156
159
|
"error.404.invitation_not_found",
|
|
157
160
|
"error.404.member_not_found",
|
|
@@ -169,6 +172,7 @@ export const WORKSPACES_ERROR_CODES: readonly WorkspacesErrorCode[] = [
|
|
|
169
172
|
"error.422.unprocessable_entity",
|
|
170
173
|
"error.423.locked",
|
|
171
174
|
"error.423.verification_locked",
|
|
175
|
+
"error.429.invitation_resend_cooldown",
|
|
172
176
|
"error.429.rate_limit",
|
|
173
177
|
"error.429.too_many_requests",
|
|
174
178
|
"error.500.internal",
|
|
@@ -227,6 +231,7 @@ export const workspacesErrorBundleEn: Record<WorkspacesErrorCode, string> = {
|
|
|
227
231
|
"error.403.role_exceeds_inviter_rank": "You cannot grant a role that outranks your own ({role})",
|
|
228
232
|
"error.403.verification_enrollment_required": "Verification factor enrollment required",
|
|
229
233
|
"error.403.verification_required": "Additional verification required",
|
|
234
|
+
"error.403.workspace_creation_closed": "This instance does not allow you to create workspaces",
|
|
230
235
|
"error.404.ad_not_found": "Listing not found",
|
|
231
236
|
"error.404.invitation_not_found": "Invitation not found",
|
|
232
237
|
"error.404.member_not_found": "Member not found in this workspace",
|
|
@@ -244,6 +249,7 @@ export const workspacesErrorBundleEn: Record<WorkspacesErrorCode, string> = {
|
|
|
244
249
|
"error.422.unprocessable_entity": "Unprocessable entity",
|
|
245
250
|
"error.423.locked": "Resource is locked",
|
|
246
251
|
"error.423.verification_locked": "Too many failed attempts — verification locked",
|
|
252
|
+
"error.429.invitation_resend_cooldown": "This invitation was emailed recently; you can send it again in {retry_after} seconds",
|
|
247
253
|
"error.429.rate_limit": "Too many attempts. Try again in {retry_after_minutes} minutes.",
|
|
248
254
|
"error.429.too_many_requests": "Too many requests. Please try again later.",
|
|
249
255
|
"error.500.internal": "Something went wrong",
|
|
@@ -341,6 +341,13 @@
|
|
|
341
341
|
"remediation": "verify",
|
|
342
342
|
"en": "Additional verification required"
|
|
343
343
|
},
|
|
344
|
+
{
|
|
345
|
+
"code": "error.403.workspace_creation_closed",
|
|
346
|
+
"status": 403,
|
|
347
|
+
"params": [],
|
|
348
|
+
"remediation": "contact_support",
|
|
349
|
+
"en": "This instance does not allow you to create workspaces"
|
|
350
|
+
},
|
|
344
351
|
{
|
|
345
352
|
"code": "error.404.ad_not_found",
|
|
346
353
|
"status": 404,
|
|
@@ -460,6 +467,15 @@
|
|
|
460
467
|
"remediation": "wait_and_retry",
|
|
461
468
|
"en": "Too many failed attempts — verification locked"
|
|
462
469
|
},
|
|
470
|
+
{
|
|
471
|
+
"code": "error.429.invitation_resend_cooldown",
|
|
472
|
+
"status": 429,
|
|
473
|
+
"params": [
|
|
474
|
+
"retry_after"
|
|
475
|
+
],
|
|
476
|
+
"remediation": "wait_and_retry",
|
|
477
|
+
"en": "This invitation was emailed recently; you can send it again in {retry_after} seconds"
|
|
478
|
+
},
|
|
463
479
|
{
|
|
464
480
|
"code": "error.429.rate_limit",
|
|
465
481
|
"status": 429,
|
|
@@ -56,6 +56,7 @@ export const workspacesErrorBundleRu: Record<WorkspacesErrorCode, string> = {
|
|
|
56
56
|
"error.403.role_exceeds_inviter_rank": "Вы не можете выдать роль, которая превышает вашу собственную ({role})",
|
|
57
57
|
"error.403.verification_enrollment_required": "Требуется регистрация фактора подтверждения.",
|
|
58
58
|
"error.403.verification_required": "Требуется дополнительная проверка",
|
|
59
|
+
"error.403.workspace_creation_closed": "На этом сервере вам недоступно создание рабочих пространств",
|
|
59
60
|
"error.404.ad_not_found": "Объявление не найдено",
|
|
60
61
|
"error.404.invitation_not_found": "Приглашение не найдено",
|
|
61
62
|
"error.404.member_not_found": "Участник не найден в этом рабочем пространстве",
|
|
@@ -73,6 +74,7 @@ export const workspacesErrorBundleRu: Record<WorkspacesErrorCode, string> = {
|
|
|
73
74
|
"error.422.unprocessable_entity": "Невозможно обработать данные запроса",
|
|
74
75
|
"error.423.locked": "Ресурс заблокирован",
|
|
75
76
|
"error.423.verification_locked": "Слишком много неудачных попыток — подтверждение заблокировано",
|
|
77
|
+
"error.429.invitation_resend_cooldown": "Это приглашение недавно отправлялось; отправить снова можно через {retry_after} с",
|
|
76
78
|
"error.429.rate_limit": "Слишком много попыток. Повторите попытку через {retry_after_minutes} мин.",
|
|
77
79
|
"error.429.too_many_requests": "Слишком много запросов. Пожалуйста, повторите попытку позже.",
|
|
78
80
|
"error.500.internal": "Что-то пошло не так",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -12,13 +12,26 @@ import { workspacesErrorBundleEn } from "./generated/errors.gen.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export const WORKSPACES_I18N_KEYS = {
|
|
14
14
|
unknownError: "workspaces.error.unknown",
|
|
15
|
+
// Retry affordance beside a stated failure — an error a person cannot act
|
|
16
|
+
// on is only half the message.
|
|
17
|
+
retry: "workspaces.retry",
|
|
15
18
|
// Workspace list (WorkspaceList headless)
|
|
16
19
|
listLoading: "workspaces.list.loading",
|
|
17
20
|
listEmpty: "workspaces.list.empty",
|
|
21
|
+
/**
|
|
22
|
+
* "We could not load your workspaces" — the sentence whose ABSENCE was the
|
|
23
|
+
* 2026-08-09 incident. Deliberately about US failing to load, never about
|
|
24
|
+
* the person having none, and never a 404's "no longer available" (which
|
|
25
|
+
* asserts the thing does not exist — tracker #211).
|
|
26
|
+
*/
|
|
27
|
+
listLoadFailed: "workspaces.list.load_failed",
|
|
18
28
|
listCreate: "workspaces.list.create",
|
|
19
29
|
listCreating: "workspaces.list.creating",
|
|
20
30
|
// Members (Members headless)
|
|
21
31
|
membersLoading: "workspaces.members.loading",
|
|
32
|
+
membersEmpty: "workspaces.members.empty",
|
|
33
|
+
membersLoadFailed: "workspaces.members.load_failed",
|
|
34
|
+
rolesLoadFailed: "workspaces.roles.load_failed",
|
|
22
35
|
membersInvite: "workspaces.members.invite",
|
|
23
36
|
membersInviting: "workspaces.members.inviting",
|
|
24
37
|
membersUpdateRole: "workspaces.members.update_role",
|
|
@@ -34,6 +47,9 @@ export const WORKSPACES_I18N_KEYS = {
|
|
|
34
47
|
fieldSlug: "workspaces.settings.field.slug",
|
|
35
48
|
fieldType: "workspaces.settings.field.type",
|
|
36
49
|
save: "workspaces.settings.save",
|
|
50
|
+
// Why a settings control is switched off (core's `useActionGate`).
|
|
51
|
+
blockedNotOwner: "workspaces.settings.blocked.not_owner",
|
|
52
|
+
blockedNameRequired: "workspaces.settings.blocked.name_required",
|
|
37
53
|
saving: "workspaces.settings.saving",
|
|
38
54
|
dangerZoneTitle: "workspaces.settings.danger_zone.title",
|
|
39
55
|
deleteWorkspace: "workspaces.settings.danger_zone.delete",
|
|
@@ -102,11 +118,18 @@ export const workspacesI18nBundleEn: I18nDictionary = {
|
|
|
102
118
|
|
|
103
119
|
// workspaces-react UI
|
|
104
120
|
"workspaces.error.unknown": "Something went wrong. Please try again.",
|
|
121
|
+
"workspaces.retry": "Try again",
|
|
105
122
|
"workspaces.list.loading": "Loading workspaces…",
|
|
106
123
|
"workspaces.list.empty": "No workspaces yet.",
|
|
124
|
+
"workspaces.list.load_failed":
|
|
125
|
+
"We could not load your workspaces. This is a problem on our side, not a sign that you have none.",
|
|
107
126
|
"workspaces.list.create": "Create workspace",
|
|
108
127
|
"workspaces.list.creating": "Creating…",
|
|
109
128
|
"workspaces.members.loading": "Loading members…",
|
|
129
|
+
"workspaces.members.empty": "No members yet.",
|
|
130
|
+
"workspaces.members.load_failed": "We could not load the member list.",
|
|
131
|
+
"workspaces.roles.load_failed":
|
|
132
|
+
"We could not load the role list, so the role picker is empty. It is not a workspace without roles.",
|
|
110
133
|
"workspaces.members.invite": "Invite",
|
|
111
134
|
"workspaces.members.inviting": "Inviting…",
|
|
112
135
|
"workspaces.members.update_role": "Change role",
|
|
@@ -120,6 +143,9 @@ export const workspacesI18nBundleEn: I18nDictionary = {
|
|
|
120
143
|
"workspaces.settings.field.slug": "URL slug",
|
|
121
144
|
"workspaces.settings.field.type": "Type",
|
|
122
145
|
"workspaces.settings.save": "Save changes",
|
|
146
|
+
"workspaces.settings.blocked.not_owner":
|
|
147
|
+
"Only the workspace owner can change these settings.",
|
|
148
|
+
"workspaces.settings.blocked.name_required": "Enter a workspace name.",
|
|
123
149
|
"workspaces.settings.saving": "Saving…",
|
|
124
150
|
"workspaces.settings.danger_zone.title": "Danger zone",
|
|
125
151
|
"workspaces.settings.danger_zone.delete": "Delete workspace",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -25,11 +25,18 @@ export const workspacesI18nBundleRu: I18nDictionary = {
|
|
|
25
25
|
|
|
26
26
|
// workspaces-react UI (hand-written ru mirror of the en copy in keys.ts)
|
|
27
27
|
"workspaces.error.unknown": "Что-то пошло не так. Попробуйте ещё раз.",
|
|
28
|
+
"workspaces.retry": "Попробовать снова",
|
|
28
29
|
"workspaces.list.loading": "Загрузка рабочих пространств…",
|
|
29
30
|
"workspaces.list.empty": "Пока нет рабочих пространств.",
|
|
31
|
+
"workspaces.list.load_failed":
|
|
32
|
+
"Не удалось загрузить ваши рабочие пространства. Это сбой на нашей стороне, а не признак того, что их у вас нет.",
|
|
30
33
|
"workspaces.list.create": "Создать рабочее пространство",
|
|
31
34
|
"workspaces.list.creating": "Создание…",
|
|
32
35
|
"workspaces.members.loading": "Загрузка участников…",
|
|
36
|
+
"workspaces.members.empty": "Пока нет участников.",
|
|
37
|
+
"workspaces.members.load_failed": "Не удалось загрузить список участников.",
|
|
38
|
+
"workspaces.roles.load_failed":
|
|
39
|
+
"Не удалось загрузить список ролей, поэтому выбор роли пуст. Это не рабочее пространство без ролей.",
|
|
33
40
|
"workspaces.members.invite": "Пригласить",
|
|
34
41
|
"workspaces.members.inviting": "Приглашение…",
|
|
35
42
|
"workspaces.members.update_role": "Изменить роль",
|
|
@@ -43,6 +50,9 @@ export const workspacesI18nBundleRu: I18nDictionary = {
|
|
|
43
50
|
"workspaces.settings.field.slug": "URL-идентификатор",
|
|
44
51
|
"workspaces.settings.field.type": "Тип",
|
|
45
52
|
"workspaces.settings.save": "Сохранить изменения",
|
|
53
|
+
"workspaces.settings.blocked.not_owner":
|
|
54
|
+
"Менять эти настройки может только владелец рабочего пространства.",
|
|
55
|
+
"workspaces.settings.blocked.name_required": "Введите название рабочего пространства.",
|
|
46
56
|
"workspaces.settings.saving": "Сохранение…",
|
|
47
57
|
"workspaces.settings.danger_zone.title": "Опасная зона",
|
|
48
58
|
"workspaces.settings.danger_zone.delete": "Удалить рабочее пространство",
|
package/src/index.ts
CHANGED
|
@@ -69,6 +69,8 @@ export {
|
|
|
69
69
|
useWorkspaces,
|
|
70
70
|
useWorkspace,
|
|
71
71
|
useMembers,
|
|
72
|
+
useAudit,
|
|
73
|
+
useCanCreateWorkspace,
|
|
72
74
|
useInvitations,
|
|
73
75
|
useInfiniteInvitations,
|
|
74
76
|
useRoles,
|
|
@@ -126,6 +128,9 @@ export type {
|
|
|
126
128
|
WorkspaceList as WorkspaceListData,
|
|
127
129
|
WorkspaceCreate,
|
|
128
130
|
WorkspaceUpdate,
|
|
131
|
+
AuditEvent,
|
|
132
|
+
AuditPage,
|
|
133
|
+
AuditParams,
|
|
129
134
|
Member as MemberData,
|
|
130
135
|
MemberPage as MemberListData,
|
|
131
136
|
MembersParams,
|
package/src/model/queries.ts
CHANGED
|
@@ -4,14 +4,16 @@ import type {
|
|
|
4
4
|
UseInfiniteQueryResult,
|
|
5
5
|
UseQueryResult,
|
|
6
6
|
} from "@tanstack/react-query";
|
|
7
|
-
import { useActiveSessionReady } from "@stapel/core";
|
|
8
|
-
import type { StapelApiError } from "@stapel/core";
|
|
7
|
+
import { loadStateFromQuery, mapLoad, useActiveSessionReady } from "@stapel/core";
|
|
8
|
+
import type { LoadState, StapelApiError } from "@stapel/core";
|
|
9
9
|
import type {
|
|
10
10
|
InvitationPage,
|
|
11
11
|
InvitationPreview,
|
|
12
12
|
InstanceShape,
|
|
13
13
|
InvitationsParams,
|
|
14
14
|
MemberPage,
|
|
15
|
+
AuditPage,
|
|
16
|
+
AuditParams,
|
|
15
17
|
MembersParams,
|
|
16
18
|
RoleInfo,
|
|
17
19
|
Workspace,
|
|
@@ -90,6 +92,55 @@ export function useMembers(
|
|
|
90
92
|
});
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* One page of a workspace's membership history (GET /{id}/audit,
|
|
97
|
+
* stapel-workspaces 0.24) — who let this person in, who took them out, when.
|
|
98
|
+
*
|
|
99
|
+
* WHY THE PAIR CARRIES THIS AT ALL. Nothing kept a record before: the comm
|
|
100
|
+
* events this module emits are notifications to other services that nobody
|
|
101
|
+
* stores, and half the transitions emit nothing. Every product that grew an
|
|
102
|
+
* admin screen therefore had the same unanswerable question, and the first one
|
|
103
|
+
* to need it wrote a bespoke fetch through the runtime client — the copy this
|
|
104
|
+
* hook exists to make unnecessary.
|
|
105
|
+
*
|
|
106
|
+
* Gated on `members.view` server-side: every role that may see who is in the
|
|
107
|
+
* room may see how they got there. Disabled until a `workspaceId` is given
|
|
108
|
+
* (and the session is ready — see {@link useWorkspace}).
|
|
109
|
+
*
|
|
110
|
+
* ANCHOR pagination like {@link useMembers}. `action` narrows to one
|
|
111
|
+
* transition, `user_id` to one person's history AS THE SUBJECT — a value the
|
|
112
|
+
* server cannot parse matches nothing rather than being ignored.
|
|
113
|
+
*/
|
|
114
|
+
export function useAudit(
|
|
115
|
+
workspaceId: string | null,
|
|
116
|
+
params?: AuditParams
|
|
117
|
+
): UseQueryResult<AuditPage, StapelApiError> {
|
|
118
|
+
const api = useWorkspacesApi();
|
|
119
|
+
const sessionReady = useActiveSessionReady();
|
|
120
|
+
const p = params ?? {};
|
|
121
|
+
return useQuery({
|
|
122
|
+
queryKey: workspacesQueryKeys.auditPage(workspaceId ?? "", p),
|
|
123
|
+
queryFn: () => api.listAudit(workspaceId as string, p),
|
|
124
|
+
enabled: sessionReady && workspaceId !== null && workspaceId !== "",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* MAY THIS CALLER FOUND A WORKSPACE on this instance — the server's answer
|
|
130
|
+
* (`can_create_workspace`, stapel-workspaces 0.24's `WORKSPACE_CREATE_POLICY`),
|
|
131
|
+
* not a rule re-derived from the deploy shape.
|
|
132
|
+
*
|
|
133
|
+
* Ask this before drawing a "+ New space" control. The same helper answers the
|
|
134
|
+
* gate on `POST /workspaces`, so a drawn button always opens — a client that
|
|
135
|
+
* decided for itself would either draw one that 403s or hide one that should
|
|
136
|
+
* be there. FAILS CLOSED while the list is loading or failed: a hidden action
|
|
137
|
+
* for a second beats an action that refuses.
|
|
138
|
+
*/
|
|
139
|
+
export function useCanCreateWorkspace(): boolean {
|
|
140
|
+
const { data } = useWorkspaces();
|
|
141
|
+
return data?.can_create_workspace ?? false;
|
|
142
|
+
}
|
|
143
|
+
|
|
93
144
|
/**
|
|
94
145
|
* One page of a workspace's invitations (GET /{id}/invitations, #109) — the
|
|
95
146
|
* admin's "who has not accepted yet" table. Disabled until a `workspaceId` is
|
|
@@ -223,15 +274,21 @@ export function useInvitationPreview(
|
|
|
223
274
|
/** What {@link useCapabilities} returns: the caller's granted capability
|
|
224
275
|
* strings in one workspace plus the wildcard-aware `can()` check. */
|
|
225
276
|
export interface CapabilitiesResult {
|
|
226
|
-
/**
|
|
227
|
-
*
|
|
228
|
-
|
|
277
|
+
/**
|
|
278
|
+
* The capability read, as a state. `ready` with the verbatim registry
|
|
279
|
+
* strings of the caller's role (wildcards included; `[]` for a non-member,
|
|
280
|
+
* and for a backend older than 0.6.0 which does not send the field at all).
|
|
281
|
+
*
|
|
282
|
+
* A state and not an array, because "you may not do this" and "we could not
|
|
283
|
+
* find out whether you may" both end in a hidden button, and only one of
|
|
284
|
+
* them should end in silence. `can()` stays deny-by-default in both — that
|
|
285
|
+
* part is a security property and does not change — but a screen that wants
|
|
286
|
+
* to SAY why can now tell them apart.
|
|
287
|
+
*/
|
|
288
|
+
readonly state: LoadState<readonly string[]>;
|
|
229
289
|
/** Wildcard-aware check (`*` / `prefix.*` — the backend matcher, ported).
|
|
230
290
|
* UI convenience only: the backend re-checks on every operation. */
|
|
231
291
|
can(capability: string): boolean;
|
|
232
|
-
readonly isLoading: boolean;
|
|
233
|
-
readonly isError: boolean;
|
|
234
|
-
readonly error: StapelApiError | null;
|
|
235
292
|
}
|
|
236
293
|
|
|
237
294
|
/**
|
|
@@ -243,13 +300,17 @@ export interface CapabilitiesResult {
|
|
|
243
300
|
*/
|
|
244
301
|
export function useCapabilities(workspaceId: string | null): CapabilitiesResult {
|
|
245
302
|
const query = useWorkspace(workspaceId);
|
|
246
|
-
|
|
303
|
+
// `my_capabilities` is additive since stapel-workspaces 0.6.0, so a
|
|
304
|
+
// SUCCESSFUL read against an older backend legitimately carries none. That
|
|
305
|
+
// is an empty grant, not a failed load, and the two must not merge.
|
|
306
|
+
const state = mapLoad(
|
|
307
|
+
loadStateFromQuery(query),
|
|
308
|
+
(workspace) => workspace.my_capabilities ?? []
|
|
309
|
+
);
|
|
247
310
|
return {
|
|
248
|
-
|
|
249
|
-
can: (capability) =>
|
|
250
|
-
|
|
251
|
-
isError: query.isError,
|
|
252
|
-
error: query.error ?? null,
|
|
311
|
+
state,
|
|
312
|
+
can: (capability) =>
|
|
313
|
+
state.status === "ready" && hasCapability(state.data, capability),
|
|
253
314
|
};
|
|
254
315
|
}
|
|
255
316
|
|
|
@@ -299,7 +360,7 @@ export function useCapabilityGate(
|
|
|
299
360
|
workspaceId: string | null,
|
|
300
361
|
capability: string
|
|
301
362
|
): CapabilityGate {
|
|
302
|
-
const { can,
|
|
363
|
+
const { can, state } = useCapabilities(workspaceId);
|
|
303
364
|
const level = capabilityLevel(capability);
|
|
304
365
|
return {
|
|
305
366
|
capability,
|
|
@@ -307,6 +368,6 @@ export function useCapabilityGate(
|
|
|
307
368
|
level,
|
|
308
369
|
requiresStepUp: level === "high",
|
|
309
370
|
stepUpScope: level === "high" ? SENSITIVE_SCOPE : null,
|
|
310
|
-
isLoading,
|
|
371
|
+
isLoading: state.status === "loading",
|
|
311
372
|
};
|
|
312
373
|
}
|
package/src/model/queryKeys.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AuditParams,
|
|
3
|
+
InvitationsParams,
|
|
4
|
+
MembersParams,
|
|
5
|
+
} from "../api/types.js";
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* Namespaced TanStack Query keys (frontend-standard §2 — namespaced keys).
|
|
@@ -19,6 +23,11 @@ export const workspacesQueryKeys: {
|
|
|
19
23
|
workspaceId: string,
|
|
20
24
|
params: MembersParams
|
|
21
25
|
): readonly ["workspaces", "members", string, MembersParams];
|
|
26
|
+
audit(workspaceId: string): readonly ["workspaces", "audit", string];
|
|
27
|
+
auditPage(
|
|
28
|
+
workspaceId: string,
|
|
29
|
+
params: AuditParams
|
|
30
|
+
): readonly ["workspaces", "audit", string, AuditParams];
|
|
22
31
|
invitations(workspaceId: string): readonly ["workspaces", "invitations", string];
|
|
23
32
|
invitationsPage(
|
|
24
33
|
workspaceId: string,
|
|
@@ -46,6 +55,12 @@ export const workspacesQueryKeys: {
|
|
|
46
55
|
// The bare 3-tuple is a valid prefix of membersPage's 4-tuple, so
|
|
47
56
|
// invalidating `members(workspaceId)` (mutations.ts) drops every page.
|
|
48
57
|
members: (workspaceId) => [ROOT, "members", workspaceId],
|
|
58
|
+
/** The workspace's membership history (stapel-workspaces 0.24). Its own
|
|
59
|
+
* root, not a slice of `members`: history is append-only and a member
|
|
60
|
+
* mutation invalidates the ROSTER — the history it also appended to is
|
|
61
|
+
* invalidated deliberately, by naming this key, not as a side effect. */
|
|
62
|
+
audit: (workspaceId) => [ROOT, "audit", workspaceId],
|
|
63
|
+
auditPage: (workspaceId, params) => [ROOT, "audit", workspaceId, params],
|
|
49
64
|
// EVERY workspace's roster, not one. Not a read key — nothing fetches it —
|
|
50
65
|
// but the invalidation scope of a write that moves a name the whole fleet
|
|
51
66
|
// shares: `MemberResponse.display_name` is a live lookup in
|