@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/api/generated/schema.d.ts +165 -3
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +22 -0
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/workspacesApi.d.ts +5 -1
  7. package/dist/api/workspacesApi.d.ts.map +1 -1
  8. package/dist/api/workspacesApi.js +16 -0
  9. package/dist/api/workspacesApi.js.map +1 -1
  10. package/dist/default/MembersManager.d.ts.map +1 -1
  11. package/dist/default/MembersManager.js +20 -6
  12. package/dist/default/MembersManager.js.map +1 -1
  13. package/dist/default/WorkspaceSettings.d.ts.map +1 -1
  14. package/dist/default/WorkspaceSettings.js +7 -3
  15. package/dist/default/WorkspaceSettings.js.map +1 -1
  16. package/dist/headless/Can.d.ts +8 -0
  17. package/dist/headless/Can.d.ts.map +1 -1
  18. package/dist/headless/Can.js +5 -1
  19. package/dist/headless/Can.js.map +1 -1
  20. package/dist/headless/Members.d.ts +14 -13
  21. package/dist/headless/Members.d.ts.map +1 -1
  22. package/dist/headless/Members.js +4 -12
  23. package/dist/headless/Members.js.map +1 -1
  24. package/dist/headless/RoleSelect.d.ts +15 -9
  25. package/dist/headless/RoleSelect.d.ts.map +1 -1
  26. package/dist/headless/RoleSelect.js +12 -8
  27. package/dist/headless/RoleSelect.js.map +1 -1
  28. package/dist/headless/WorkspaceList.d.ts +25 -10
  29. package/dist/headless/WorkspaceList.d.ts.map +1 -1
  30. package/dist/headless/WorkspaceList.js +14 -5
  31. package/dist/headless/WorkspaceList.js.map +1 -1
  32. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  33. package/dist/i18n/generated/errors.es.gen.js +2 -0
  34. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  35. package/dist/i18n/generated/errors.gen.d.ts +12 -0
  36. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  37. package/dist/i18n/generated/errors.gen.js +6 -0
  38. package/dist/i18n/generated/errors.gen.js.map +1 -1
  39. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  40. package/dist/i18n/generated/errors.ru.gen.js +2 -0
  41. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  42. package/dist/i18n/keys.d.ts +13 -0
  43. package/dist/i18n/keys.d.ts.map +1 -1
  44. package/dist/i18n/keys.js +23 -0
  45. package/dist/i18n/keys.js.map +1 -1
  46. package/dist/i18n/ru.d.ts.map +1 -1
  47. package/dist/i18n/ru.js +7 -0
  48. package/dist/i18n/ru.js.map +1 -1
  49. package/dist/index.d.ts +2 -2
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/model/queries.d.ts +46 -8
  54. package/dist/model/queries.d.ts.map +1 -1
  55. package/dist/model/queries.js +53 -9
  56. package/dist/model/queries.js.map +1 -1
  57. package/dist/model/queryKeys.d.ts +3 -1
  58. package/dist/model/queryKeys.d.ts.map +1 -1
  59. package/dist/model/queryKeys.js +6 -0
  60. package/dist/model/queryKeys.js.map +1 -1
  61. package/dist/model/selection.d.ts +20 -7
  62. package/dist/model/selection.d.ts.map +1 -1
  63. package/dist/model/selection.js +32 -12
  64. package/dist/model/selection.js.map +1 -1
  65. package/llms.txt +6 -3
  66. package/manifest.json +46 -3
  67. package/package.json +2 -2
  68. package/src/analytics/generated/events.json +1 -1
  69. package/src/api/generated/schema.ts +165 -3
  70. package/src/api/types.ts +23 -0
  71. package/src/api/workspacesApi.ts +22 -0
  72. package/src/default/MembersManager.tsx +69 -16
  73. package/src/default/WorkspaceSettings.tsx +41 -5
  74. package/src/headless/Can.tsx +13 -1
  75. package/src/headless/Members.tsx +18 -26
  76. package/src/headless/RoleSelect.tsx +17 -14
  77. package/src/headless/WorkspaceList.tsx +31 -14
  78. package/src/i18n/generated/errors.es.gen.ts +2 -0
  79. package/src/i18n/generated/errors.gen.ts +6 -0
  80. package/src/i18n/generated/errors.json +16 -0
  81. package/src/i18n/generated/errors.ru.gen.ts +2 -0
  82. package/src/i18n/keys.ts +26 -0
  83. package/src/i18n/ru.ts +10 -0
  84. package/src/index.ts +5 -0
  85. package/src/model/queries.ts +77 -16
  86. package/src/model/queryKeys.ts +16 -1
  87. 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 { StapelApiError } from "@stapel/core";
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. Empty while loading. */
11
- readonly roles: readonly RoleInfo[];
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
- * {({ roles, labelFor }) => (
34
- * <Select options={roles.map((r) => ({ value: r.role, label: labelFor(r.role) }))} />
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
- roles: query.data ?? [],
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 type { StapelApiError } from "@stapel/core";
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
- /** The caller's workspaces once loaded (empty before load). */
10
- readonly workspaces: readonly Workspace[];
11
- /** The initial list load is in flight (no data yet). */
12
- readonly isLoading: boolean;
13
- /** Either the read or the create failed. */
14
- readonly isError: boolean;
15
- /** The error, when `isError` (a localizable `StapelApiError`), else null. */
16
- readonly error: StapelApiError | null;
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
- * {({ workspaces, create }) => ( ... )}
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
- workspaces: query.data?.workspaces ?? [],
46
- isLoading: query.isLoading,
47
- isError: query.isError || mutation.isError,
48
- error: query.error ?? mutation.error ?? null,
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,
@@ -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
- /** Verbatim registry strings of the caller's role (wildcards included);
227
- * empty while loading or when the caller is not a member. */
228
- readonly capabilities: readonly string[];
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
- const capabilities = query.data?.my_capabilities ?? [];
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
- capabilities,
249
- can: (capability) => hasCapability(capabilities, capability),
250
- isLoading: query.isLoading,
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, isLoading } = useCapabilities(workspaceId);
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
  }
@@ -1,4 +1,8 @@
1
- import type { InvitationsParams, MembersParams } from "../api/types.js";
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