@stapel/workspaces-react 0.9.0 → 0.11.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 (46) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/api/generated/schema.d.ts +283 -17
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +23 -0
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/workspacesApi.d.ts +37 -1
  7. package/dist/api/workspacesApi.d.ts.map +1 -1
  8. package/dist/api/workspacesApi.js +4 -0
  9. package/dist/api/workspacesApi.js.map +1 -1
  10. package/dist/i18n/generated/errors.gen.d.ts +30 -0
  11. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  12. package/dist/i18n/generated/errors.gen.js +15 -0
  13. package/dist/i18n/generated/errors.gen.js.map +1 -1
  14. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  15. package/dist/i18n/generated/errors.ru.gen.js +5 -0
  16. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  17. package/dist/index.d.ts +5 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/model/mutations.d.ts +98 -1
  22. package/dist/model/mutations.d.ts.map +1 -1
  23. package/dist/model/mutations.js +132 -0
  24. package/dist/model/mutations.js.map +1 -1
  25. package/dist/model/queryKeys.d.ts +1 -0
  26. package/dist/model/queryKeys.d.ts.map +1 -1
  27. package/dist/model/queryKeys.js +7 -0
  28. package/dist/model/queryKeys.js.map +1 -1
  29. package/dist/model/selection.d.ts +146 -0
  30. package/dist/model/selection.d.ts.map +1 -0
  31. package/dist/model/selection.js +217 -0
  32. package/dist/model/selection.js.map +1 -0
  33. package/llms.txt +11 -3
  34. package/manifest.json +117 -2
  35. package/package.json +2 -2
  36. package/src/analytics/generated/events.json +1 -1
  37. package/src/api/generated/schema.ts +283 -17
  38. package/src/api/types.ts +23 -0
  39. package/src/api/workspacesApi.ts +74 -0
  40. package/src/i18n/generated/errors.gen.ts +15 -0
  41. package/src/i18n/generated/errors.json +35 -0
  42. package/src/i18n/generated/errors.ru.gen.ts +5 -0
  43. package/src/index.ts +22 -0
  44. package/src/model/mutations.ts +186 -0
  45. package/src/model/queryKeys.ts +8 -0
  46. package/src/model/selection.tsx +382 -0
@@ -126,6 +126,46 @@ export interface paths {
126
126
  patch?: never;
127
127
  trace?: never;
128
128
  };
129
+ "/workspaces/api/v1/{workspace_id}/invitations/{invitation_id}/name": {
130
+ parameters: {
131
+ query?: never;
132
+ header?: never;
133
+ path?: never;
134
+ cookie?: never;
135
+ };
136
+ get?: never;
137
+ put?: never;
138
+ post?: never;
139
+ delete?: never;
140
+ options?: never;
141
+ head?: never;
142
+ /**
143
+ * @description ``PATCH <ws>/invitations/<id>/name`` — fix a pending invite's name hint.
144
+ *
145
+ * The same correction as :class:`MemberNameView`, one step earlier: the
146
+ * invitee has not accepted, so there is no profile of theirs to write and
147
+ * the name lives on the invitation as ``display_name_hint`` — the invite
148
+ * modal's "Name" field, which ``accept_invitation`` copies onto the
149
+ * membership at acceptance. Editing it after the fact is why this endpoint
150
+ * exists: before #109's invitation surface the only fix for a typo in an
151
+ * invitee's name was to revoke and re-invite, which re-mails the person.
152
+ *
153
+ * Only a **pending** invitation is editable, with the same keyed refusals
154
+ * revoke gives for each terminal state (``invitation_revoked`` /
155
+ * ``already_used`` / ``declined`` / ``expired``): an accepted invitation's
156
+ * name is the member's name now — use the member endpoint — and a dead
157
+ * invitation is not a thing to relabel. Unknown and cross-workspace ids
158
+ * collapse into one identical 404 (inherited resolution).
159
+ *
160
+ * The value is still held to stapel-profiles' name canon even though the
161
+ * column being written is local: this hint becomes a displayed name, and
162
+ * the two endpoints must not disagree about what a name may contain.
163
+ *
164
+ * **Permissions:** `IsAuthenticated`
165
+ */
166
+ patch: operations["workspaces_api_v1_invitations_name_partial_update"];
167
+ trace?: never;
168
+ };
129
169
  "/workspaces/api/v1/{workspace_id}/invitations/{invitation_id}/resend": {
130
170
  parameters: {
131
171
  query?: never;
@@ -261,6 +301,50 @@ export interface paths {
261
301
  patch: operations["workspaces_api_v1_members_partial_update"];
262
302
  trace?: never;
263
303
  };
304
+ "/workspaces/api/v1/{workspace_id}/members/{user_id}/name": {
305
+ parameters: {
306
+ query?: never;
307
+ header?: never;
308
+ path?: never;
309
+ cookie?: never;
310
+ };
311
+ get?: never;
312
+ put?: never;
313
+ post?: never;
314
+ delete?: never;
315
+ options?: never;
316
+ head?: never;
317
+ /**
318
+ * @description ``PATCH <ws>/members/<user_id>/name`` — correct a member's display name.
319
+ *
320
+ * Writes the CANONICAL name: stapel-profiles' ``Profile.display_name``,
321
+ * reached through this module's existing in-process profiles seam
322
+ * (``services.set_profile_display_name``), which also publishes
323
+ * ``profile.changed`` so every consumer of that name follows.
324
+ *
325
+ * NOT ``WorkspaceMember.display_name_hint``: the hint is a pre-profile
326
+ * placeholder, copied once at creation and dark from the moment a real
327
+ * profile exists (see its docstring in ``models.py``). Writing it would
328
+ * produce a correction the roster shows and nothing else in the product
329
+ * ever does — including, eventually, the roster.
330
+ *
331
+ * Where stapel-profiles does not run in this process there is nothing to
332
+ * write and no remote operation to call for it (that module publishes no
333
+ * write-somebody-else's-name endpoint and no comm Function), so the answer
334
+ * is an honest ``error.503.profiles_unavailable`` — never a 200 over a
335
+ * write that did not happen.
336
+ *
337
+ * Only an owner may rename an owner — the same hardcoded owner protection
338
+ * that role changes, removals and password resets carry. Renaming is not
339
+ * escalation, but an admin relabelling the owner of the organization on
340
+ * every screen in the product is close enough to the same act to answer
341
+ * the same way.
342
+ *
343
+ * **Permissions:** `IsAuthenticated`
344
+ */
345
+ patch: operations["workspaces_api_v1_members_name_partial_update"];
346
+ trace?: never;
347
+ };
264
348
  "/workspaces/api/v1/{workspace_id}/members/{user_id}/password/reset": {
265
349
  parameters: {
266
350
  query?: never;
@@ -577,6 +661,50 @@ export interface paths {
577
661
  patch?: never;
578
662
  trace?: never;
579
663
  };
664
+ "/workspaces/api/v1/me/preferred-workspace": {
665
+ parameters: {
666
+ query?: never;
667
+ header?: never;
668
+ path?: never;
669
+ cookie?: never;
670
+ };
671
+ get?: never;
672
+ /**
673
+ * @description ``PUT/DELETE me/preferred-workspace`` — the person states where home is.
674
+ *
675
+ * ``STAPEL_WORKSPACES["DEFAULT_WORKSPACE_ID"]`` already describes itself as
676
+ * "a DEFAULT, not a cage: a person still switches spaces, and their
677
+ * explicit choice wins over it" — and until this endpoint there was nowhere
678
+ * for that choice to be written down. Clients filled the hole by guessing,
679
+ * and the guess (``workspaces[0]`` off a recency-ordered list) is #239.
680
+ *
681
+ * The choice is stated, never inferred. ``last_accessed_at`` remains what
682
+ * it has always been: telemetry written as a side effect of a GET, used to
683
+ * sort the list and for nothing else.
684
+ *
685
+ * Deliberately user-scoped rather than workspace-scoped
686
+ * (``.../<workspace_id>/prefer``): there is exactly one answer per person,
687
+ * and a route shaped per workspace would invite a second one.
688
+ *
689
+ * **Permissions:** `IsAuthenticated`
690
+ */
691
+ put: operations["workspaces_api_v1_me_preferred_workspace_update"];
692
+ post?: never;
693
+ /**
694
+ * @description Clear the choice — back to the instance default / the client's chain.
695
+ *
696
+ * Answers 200 with an empty id rather than 204: the client's whole job
697
+ * here is to re-resolve, and handing it the new state saves it from
698
+ * guessing what "no content" left behind. Idempotent.
699
+ *
700
+ * **Permissions:** `IsAuthenticated`
701
+ */
702
+ delete: operations["workspaces_api_v1_me_preferred_workspace_destroy"];
703
+ options?: never;
704
+ head?: never;
705
+ patch?: never;
706
+ trace?: never;
707
+ };
580
708
  "/workspaces/api/v1/roles": {
581
709
  parameters: {
582
710
  query?: never;
@@ -606,34 +734,42 @@ export interface paths {
606
734
  export type webhooks = Record<string, never>;
607
735
  export interface components {
608
736
  schemas: {
737
+ /** @description What the name is after the write — the stored value, not the request. */
738
+ DisplayNameResponse: {
739
+ /**
740
+ * @description The stored name, trimmed and canon-checked. Empty string when the name was cleared
741
+ * @example Ada Lovelace
742
+ */
743
+ display_name: string;
744
+ };
609
745
  /**
610
- * @description Как развёрнут ЭТОТ инстанс — то, что клиенту нужно знать ДО того, как
611
- * он решит, что показать человеку без пространства.
746
+ * @description How THIS instance is deployed — what the client needs to know before
747
+ * deciding what to show a person with no workspace.
612
748
  *
613
- * Ось ``landing`` (``STREET_LANDING_MODE``) существовала с 03.08.2026, но
614
- * жила только в окружении бэкенда: наружу не отдавалась ничем, и отличить
615
- * закрытый контур от публичного облака на клиенте было НЕЧЕМ.
749
+ * The ``landing`` axis (``STREET_LANDING_MODE``) has existed since
750
+ * 2026-08-03 but lived only in backend config, with nothing exposing it —
751
+ * a client had no way to tell a closed deployment from a public cloud one.
616
752
  *
617
- * Почему это не косметика. Экран, который видит человек, выброшенный из
618
- * Спейса (или вышедший сам), в этих двух мирах разный по существу:
753
+ * This matters because the screen shown to someone who is no longer (or
754
+ * never was) a workspace member differs by deployment:
619
755
  *
620
- * * ``none`` — закрытый контур: своего пространства у человека нет и
621
- * взяться неоткуда. Честный экран говорит «вы не участник, попросите
622
- * приглашение» и никуда не зовёт;
623
- * * ``personal`` — публичное облако: у него есть собственное
624
- * пространство, и туда можно вести.
756
+ * * ``none`` — closed deployment: the person has no workspace and none is
757
+ * obtainable. An honest screen says "you're not a member, ask for an
758
+ * invite" and offers no path forward;
759
+ * * ``personal`` — public cloud: they have their own workspace, and the
760
+ * client can route them there.
625
761
  *
626
- * Показать «создайте встречу» тому, кому инстанс её создать не даст, —
627
- * это тупик, нарисованный кнопкой.
762
+ * Offering "create a workspace" to someone the instance will never let
763
+ * create one is a dead end drawn as a button.
628
764
  */
629
765
  InstanceShapeResponse: {
630
766
  /**
631
- * @description С чем приземляется человек «с улицы»
767
+ * @description What a "street" (no-invite) arrival lands on
632
768
  * @example personal
633
769
  */
634
770
  landing: string;
635
771
  /**
636
- * @description Можно ли завести учётку самому
772
+ * @description Whether self-registration is available
637
773
  * @example True
638
774
  */
639
775
  registration_open: boolean;
@@ -754,7 +890,7 @@ export interface components {
754
890
  /** @description Invite payload. */
755
891
  MemberInviteRequest: {
756
892
  /**
757
- * @description Name hint for the invitee (this invite's "Имя" field), applied to every email in this request. Optional — an invite without one behaves exactly as before. NOT written into stapel-profiles directly (that store is the invitee's own): held on the invitation, copied onto the member at accept time, and shown only until stapel-profiles has a real name for the person
893
+ * @description Name hint for the invitee (this invite's "Name" field), applied to every email in this request. Optional — an invite without one behaves exactly as before. NOT written into stapel-profiles directly (that store is the invitee's own): held on the invitation, copied onto the member at accept time, and shown only until stapel-profiles has a real name for the person
758
894
  * @example Ada Lovelace
759
895
  */
760
896
  display_name?: string | null;
@@ -888,6 +1024,14 @@ export interface components {
888
1024
  /** @description Number of items in current page */
889
1025
  count: number;
890
1026
  };
1027
+ /** @description Roster-side name correction (one field, both name-edit endpoints). */
1028
+ PatchedDisplayNameUpdateRequest: {
1029
+ /**
1030
+ * @description The name to show for this person. Held to stapel-profiles' display-name canon (validate_display_name) — this module never adds a second, differently-strict rule. Blank, whitespace-only, null and a missing key all mean the same thing: clear the name
1031
+ * @example Ada Lovelace
1032
+ */
1033
+ display_name?: string | null;
1034
+ };
891
1035
  /** @description MemberUpdateRequest(role: str) */
892
1036
  PatchedMemberUpdateRequest: {
893
1037
  role?: string;
@@ -900,6 +1044,23 @@ export interface components {
900
1044
  [key: string]: unknown;
901
1045
  } | null;
902
1046
  };
1047
+ /** @description Which workspace the person is choosing as home. */
1048
+ PreferredWorkspaceRequest: {
1049
+ /**
1050
+ * Format: uuid
1051
+ * @description UUID of a workspace the caller actively belongs to
1052
+ * @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
1053
+ */
1054
+ workspace_id: string;
1055
+ };
1056
+ /** @description The caller's stated home workspace after the write. */
1057
+ PreferredWorkspaceResponse: {
1058
+ /**
1059
+ * @description The workspace now recorded as the caller's choice, or "" after a DELETE cleared it
1060
+ * @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
1061
+ */
1062
+ preferred_workspace_id?: string;
1063
+ };
903
1064
  /** @description Provision an org-created (synthetic) member (org-program spec §C1). */
904
1065
  ProvisionMemberRequest: {
905
1066
  /**
@@ -1032,6 +1193,11 @@ export interface components {
1032
1193
  * @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
1033
1194
  */
1034
1195
  default_workspace_id?: string;
1196
+ /**
1197
+ * @description The person's OWN stated choice of home workspace (PUT me/preferred-workspace), echoed back ONLY while the membership carrying it is active, else "". This is the "explicit choice" default_workspace_id documents itself as yielding to, so a client resolves preferred first and the instance default only after it. Never inferred from behaviour — where somebody last clicked is last_accessed_at, which is telemetry, and reading it as a choice is what produced #239
1198
+ * @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
1199
+ */
1200
+ preferred_workspace_id?: string;
1035
1201
  };
1036
1202
  /** @description Workspace details. */
1037
1203
  WorkspaceResponse: {
@@ -1249,6 +1415,34 @@ export interface operations {
1249
1415
  };
1250
1416
  };
1251
1417
  };
1418
+ workspaces_api_v1_invitations_name_partial_update: {
1419
+ parameters: {
1420
+ query?: never;
1421
+ header?: never;
1422
+ path: {
1423
+ invitation_id: string;
1424
+ workspace_id: string;
1425
+ };
1426
+ cookie?: never;
1427
+ };
1428
+ requestBody?: {
1429
+ content: {
1430
+ "application/json": components["schemas"]["PatchedDisplayNameUpdateRequest"];
1431
+ "application/x-www-form-urlencoded": components["schemas"]["PatchedDisplayNameUpdateRequest"];
1432
+ "multipart/form-data": components["schemas"]["PatchedDisplayNameUpdateRequest"];
1433
+ };
1434
+ };
1435
+ responses: {
1436
+ 200: {
1437
+ headers: {
1438
+ [name: string]: unknown;
1439
+ };
1440
+ content: {
1441
+ "application/json": components["schemas"]["DisplayNameResponse"];
1442
+ };
1443
+ };
1444
+ };
1445
+ };
1252
1446
  workspaces_api_v1_invitations_resend_create: {
1253
1447
  parameters: {
1254
1448
  query?: never;
@@ -1372,6 +1566,34 @@ export interface operations {
1372
1566
  };
1373
1567
  };
1374
1568
  };
1569
+ workspaces_api_v1_members_name_partial_update: {
1570
+ parameters: {
1571
+ query?: never;
1572
+ header?: never;
1573
+ path: {
1574
+ user_id: string;
1575
+ workspace_id: string;
1576
+ };
1577
+ cookie?: never;
1578
+ };
1579
+ requestBody?: {
1580
+ content: {
1581
+ "application/json": components["schemas"]["PatchedDisplayNameUpdateRequest"];
1582
+ "application/x-www-form-urlencoded": components["schemas"]["PatchedDisplayNameUpdateRequest"];
1583
+ "multipart/form-data": components["schemas"]["PatchedDisplayNameUpdateRequest"];
1584
+ };
1585
+ };
1586
+ responses: {
1587
+ 200: {
1588
+ headers: {
1589
+ [name: string]: unknown;
1590
+ };
1591
+ content: {
1592
+ "application/json": components["schemas"]["DisplayNameResponse"];
1593
+ };
1594
+ };
1595
+ };
1596
+ };
1375
1597
  workspaces_api_v1_members_password_reset_create: {
1376
1598
  parameters: {
1377
1599
  query?: never;
@@ -1611,6 +1833,50 @@ export interface operations {
1611
1833
  };
1612
1834
  };
1613
1835
  };
1836
+ workspaces_api_v1_me_preferred_workspace_update: {
1837
+ parameters: {
1838
+ query?: never;
1839
+ header?: never;
1840
+ path?: never;
1841
+ cookie?: never;
1842
+ };
1843
+ requestBody: {
1844
+ content: {
1845
+ "application/json": components["schemas"]["PreferredWorkspaceRequest"];
1846
+ "application/x-www-form-urlencoded": components["schemas"]["PreferredWorkspaceRequest"];
1847
+ "multipart/form-data": components["schemas"]["PreferredWorkspaceRequest"];
1848
+ };
1849
+ };
1850
+ responses: {
1851
+ 200: {
1852
+ headers: {
1853
+ [name: string]: unknown;
1854
+ };
1855
+ content: {
1856
+ "application/json": components["schemas"]["PreferredWorkspaceResponse"];
1857
+ };
1858
+ };
1859
+ };
1860
+ };
1861
+ workspaces_api_v1_me_preferred_workspace_destroy: {
1862
+ parameters: {
1863
+ query?: never;
1864
+ header?: never;
1865
+ path?: never;
1866
+ cookie?: never;
1867
+ };
1868
+ requestBody?: never;
1869
+ responses: {
1870
+ 200: {
1871
+ headers: {
1872
+ [name: string]: unknown;
1873
+ };
1874
+ content: {
1875
+ "application/json": components["schemas"]["PreferredWorkspaceResponse"];
1876
+ };
1877
+ };
1878
+ };
1879
+ };
1614
1880
  workspaces_api_v1_roles_retrieve: {
1615
1881
  parameters: {
1616
1882
  query?: never;
package/src/api/types.ts CHANGED
@@ -24,6 +24,15 @@ export type Operations = operations;
24
24
  export type Workspace = Schemas["WorkspaceResponse"];
25
25
  /** GET / 200 body — the caller's workspaces (memberships). */
26
26
  export type WorkspaceList = Schemas["WorkspaceListResponse"];
27
+ /** PUT /me/preferred-workspace request body — the workspace the person is
28
+ * choosing as home. Must be one they actively belong to; anything else
29
+ * (unknown, not a member, invitation still pending, membership suspended)
30
+ * answers ONE identical `error.404.workspace_not_found`, so the endpoint
31
+ * cannot be used to probe which workspace ids are real. */
32
+ export type PreferredWorkspace = Schemas["PreferredWorkspaceRequest"];
33
+ /** PUT/DELETE /me/preferred-workspace 200 body — the choice after the write
34
+ * (`""` after a DELETE cleared it). */
35
+ export type PreferredWorkspaceResult = Schemas["PreferredWorkspaceResponse"];
27
36
  /** POST / request body — create a workspace (slug auto-generated when omitted). */
28
37
  export type WorkspaceCreate = Schemas["WorkspaceCreateRequest"];
29
38
  /** PATCH /{id} request body — a partial name / slug / settings update. */
@@ -53,6 +62,20 @@ export type MemberPasswordReset = Schemas["MemberPasswordResetRequest"];
53
62
  * — see {@link "../model/mutations.js".useResetMemberPassword}, which keeps it
54
63
  * out of the query cache. */
55
64
  export type MemberPasswordResetResult = Schemas["MemberPasswordResetResponse"];
65
+ /**
66
+ * PATCH /{id}/members/{userId}/name and PATCH
67
+ * /{id}/invitations/{invitationId}/name request body — the ONE field both
68
+ * name-edit endpoints take (stapel-workspaces ≥0.19.0). Blank,
69
+ * whitespace-only, `null` and a missing key all mean the same thing to the
70
+ * backend: clear the name. The value is held to stapel-profiles'
71
+ * `validate_display_name` canon (35-char ceiling as the serializer's
72
+ * `max_length`) — this module declares no second, differently-strict rule.
73
+ */
74
+ export type DisplayNameUpdate = Schemas["PatchedDisplayNameUpdateRequest"];
75
+ /** PATCH …/name 200 body — what the name IS after the write (the stored,
76
+ * trimmed, canon-checked value; empty string when the name was cleared), not
77
+ * an echo of the request. */
78
+ export type DisplayNameResult = Schemas["DisplayNameResponse"];
56
79
  /** POST /invitations/accept request body — the token from the email link. */
57
80
  export type InvitationAccept = Schemas["InvitationAcceptRequest"];
58
81
  /** GET /invitations/{token} 200 body — the public (AllowAny) preview the
@@ -1,5 +1,7 @@
1
1
  import type { StapelClient, StapelRequestOptions } from "@stapel/core";
2
2
  import type {
3
+ DisplayNameResult,
4
+ DisplayNameUpdate,
3
5
  Invitation,
4
6
  InvitationPage,
5
7
  InvitationsParams,
@@ -11,6 +13,8 @@ import type {
11
13
  MemberPasswordResetResult,
12
14
  MemberRoleUpdate,
13
15
  MembersParams,
16
+ PreferredWorkspace,
17
+ PreferredWorkspaceResult,
14
18
  InvitationAccept,
15
19
  InvitationClaim,
16
20
  InvitationPreview,
@@ -74,6 +78,15 @@ export interface WorkspacesApi {
74
78
  /** Soft-delete a workspace (owner only). */
75
79
  deleteWorkspace(workspaceId: string): Promise<void>;
76
80
 
81
+ /**
82
+ * Record the caller's home workspace — their EXPLICIT choice, which the
83
+ * instance's `default_workspace_id` documents itself as yielding to.
84
+ * Answers 404 for anything they cannot actively open.
85
+ */
86
+ setPreferredWorkspace(body: PreferredWorkspace): Promise<PreferredWorkspaceResult>;
87
+ /** Clear the caller's home workspace — back to the resolution chain. Idempotent. */
88
+ clearPreferredWorkspace(): Promise<PreferredWorkspaceResult>;
89
+
77
90
  /** A workspace's members (viewer+), an anchor-paginated page. */
78
91
  listMembers(
79
92
  workspaceId: string,
@@ -93,6 +106,24 @@ export interface WorkspacesApi {
93
106
  /** Remove a member from a workspace (admin+; the last owner is protected). */
94
107
  removeMember(workspaceId: string, userId: string): Promise<void>;
95
108
 
109
+ /**
110
+ * Correct a member's display name (stapel-workspaces ≥0.19.0, capability
111
+ * `members.role.change`). Writes the CANONICAL name — stapel-profiles'
112
+ * `Profile.display_name` through this module's in-process profiles seam,
113
+ * which also publishes `profile.changed` — NOT the membership's
114
+ * `display_name_hint`, which is a pre-profile placeholder that goes dark the
115
+ * moment a real profile exists.
116
+ *
117
+ * Only an owner may rename an owner. Where stapel-profiles does not run in
118
+ * the deployment's process the answer is `error.503.profiles_unavailable`,
119
+ * never a 200 over a write that did not happen.
120
+ */
121
+ renameMember(
122
+ workspaceId: string,
123
+ userId: string,
124
+ body: DisplayNameUpdate
125
+ ): Promise<DisplayNameResult>;
126
+
96
127
  /**
97
128
  * Reset a member's password on the organization's order (stapel-workspaces
98
129
  * ≥0.14.0, #110). Capability `members.password.reset`, declared **high** —
@@ -141,6 +172,25 @@ export interface WorkspacesApi {
141
172
  workspaceId: string,
142
173
  invitationId: string
143
174
  ): Promise<Invitation>;
175
+ /**
176
+ * Fix a still-PENDING invitation's name hint (stapel-workspaces ≥0.19.0,
177
+ * capability `members.role.change` — the same one as {@link renameMember},
178
+ * because this hint IS the member's name after acceptance and splitting the
179
+ * two would let a role fix a name that reverts).
180
+ *
181
+ * The same correction as {@link renameMember}, one step earlier: the invitee
182
+ * has not accepted, so there is no profile of theirs to write and the name
183
+ * lives on the invitation. Before it, the only fix for a typo in an
184
+ * invitee's name was revoke-and-re-invite, which re-mails the person. A
185
+ * terminal invitation refuses with the keyed answers revoke gives
186
+ * (`error.400.invitation_already_used` / `_revoked` / `_declined` /
187
+ * `_expired`).
188
+ */
189
+ renameInvitation(
190
+ workspaceId: string,
191
+ invitationId: string,
192
+ body: DisplayNameUpdate
193
+ ): Promise<DisplayNameResult>;
144
194
 
145
195
  /** Accept an invitation by its token — returns the caller's new membership. */
146
196
  acceptInvitation(body: InvitationAccept): Promise<Member>;
@@ -199,6 +249,16 @@ export function createWorkspacesApi(client: StapelClient): WorkspacesApi {
199
249
  deleteWorkspace: (workspaceId) =>
200
250
  client.delete(`/${encodeURIComponent(workspaceId)}`, mutating()),
201
251
 
252
+ setPreferredWorkspace: (body) =>
253
+ client.put(
254
+ "/me/preferred-workspace",
255
+ body satisfies PreferredWorkspace,
256
+ mutating()
257
+ ),
258
+
259
+ clearPreferredWorkspace: () =>
260
+ client.delete("/me/preferred-workspace", mutating()),
261
+
202
262
  listMembers: (workspaceId, params) => {
203
263
  const query: Record<string, string | number> = {};
204
264
  if (params?.anchor !== undefined) query.anchor = params.anchor;
@@ -230,6 +290,20 @@ export function createWorkspacesApi(client: StapelClient): WorkspacesApi {
230
290
  mutating()
231
291
  ),
232
292
 
293
+ renameMember: (workspaceId, userId, body) =>
294
+ client.patch(
295
+ `/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(userId)}/name`,
296
+ body satisfies DisplayNameUpdate,
297
+ mutating()
298
+ ),
299
+
300
+ renameInvitation: (workspaceId, invitationId, body) =>
301
+ client.patch(
302
+ `/${encodeURIComponent(workspaceId)}/invitations/${encodeURIComponent(invitationId)}/name`,
303
+ body satisfies DisplayNameUpdate,
304
+ mutating()
305
+ ),
306
+
233
307
  resetMemberPassword: (workspaceId, userId, body) =>
234
308
  client.post(
235
309
  `/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(userId)}/password/reset`,
@@ -39,6 +39,10 @@ export const WORKSPACES_ERRORS = {
39
39
  "error.400.bad_request": { status: 400, params: [], remediation: "fix_input", en: "Bad request" },
40
40
  "error.400.captcha_invalid": { status: 400, params: [], remediation: "retry", en: "Captcha verification failed. Please try again." },
41
41
  "error.400.captcha_required": { status: 400, params: [], remediation: "retry", en: "Captcha token is required." },
42
+ "error.400.display_name_emoji": { status: 400, params: [], remediation: "fix_input", en: "Display name cannot contain emoji" },
43
+ "error.400.display_name_forbidden_chars": { status: 400, params: [], remediation: "fix_input", en: "Display name contains forbidden characters" },
44
+ "error.400.display_name_invisible_chars": { status: 400, params: [], remediation: "fix_input", en: "Display name contains invisible characters" },
45
+ "error.400.display_name_too_short": { status: 400, params: [], remediation: "fix_input", en: "Display name must be at least 2 characters" },
42
46
  "error.400.expected_list": { status: 400, params: [], remediation: "fix_input", en: "Expected a list of items" },
43
47
  "error.400.field.blank": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} may not be blank" },
44
48
  "error.400.field.does_not_exist": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} does not exist" },
@@ -96,6 +100,7 @@ export const WORKSPACES_ERRORS = {
96
100
  "error.429.too_many_requests": { status: 429, params: [], remediation: "wait_and_retry", en: "Too many requests. Please try again later." },
97
101
  "error.500.internal": { status: 500, params: [], remediation: "contact_support", en: "Something went wrong" },
98
102
  "error.503.auth_unavailable": { status: 503, params: [], remediation: "wait_and_retry", en: "The authentication service is unavailable; try again later" },
103
+ "error.503.profiles_unavailable": { status: 503, params: [], remediation: "wait_and_retry", en: "The profiles service is unavailable; try again later" },
99
104
  } as const;
100
105
 
101
106
  export type WorkspacesErrorCode = keyof typeof WORKSPACES_ERRORS;
@@ -106,6 +111,10 @@ export const WORKSPACES_ERROR_CODES: readonly WorkspacesErrorCode[] = [
106
111
  "error.400.bad_request",
107
112
  "error.400.captcha_invalid",
108
113
  "error.400.captcha_required",
114
+ "error.400.display_name_emoji",
115
+ "error.400.display_name_forbidden_chars",
116
+ "error.400.display_name_invisible_chars",
117
+ "error.400.display_name_too_short",
109
118
  "error.400.expected_list",
110
119
  "error.400.field.blank",
111
120
  "error.400.field.does_not_exist",
@@ -163,6 +172,7 @@ export const WORKSPACES_ERROR_CODES: readonly WorkspacesErrorCode[] = [
163
172
  "error.429.too_many_requests",
164
173
  "error.500.internal",
165
174
  "error.503.auth_unavailable",
175
+ "error.503.profiles_unavailable",
166
176
  ];
167
177
 
168
178
  /**
@@ -175,6 +185,10 @@ export const workspacesErrorBundleEn: Record<WorkspacesErrorCode, string> = {
175
185
  "error.400.bad_request": "Bad request",
176
186
  "error.400.captcha_invalid": "Captcha verification failed. Please try again.",
177
187
  "error.400.captcha_required": "Captcha token is required.",
188
+ "error.400.display_name_emoji": "Display name cannot contain emoji",
189
+ "error.400.display_name_forbidden_chars": "Display name contains forbidden characters",
190
+ "error.400.display_name_invisible_chars": "Display name contains invisible characters",
191
+ "error.400.display_name_too_short": "Display name must be at least 2 characters",
178
192
  "error.400.expected_list": "Expected a list of items",
179
193
  "error.400.field.blank": "{field} may not be blank",
180
194
  "error.400.field.does_not_exist": "{field} does not exist",
@@ -232,4 +246,5 @@ export const workspacesErrorBundleEn: Record<WorkspacesErrorCode, string> = {
232
246
  "error.429.too_many_requests": "Too many requests. Please try again later.",
233
247
  "error.500.internal": "Something went wrong",
234
248
  "error.503.auth_unavailable": "The authentication service is unavailable; try again later",
249
+ "error.503.profiles_unavailable": "The profiles service is unavailable; try again later",
235
250
  };
@@ -27,6 +27,34 @@
27
27
  "remediation": "retry",
28
28
  "en": "Captcha token is required."
29
29
  },
30
+ {
31
+ "code": "error.400.display_name_emoji",
32
+ "status": 400,
33
+ "params": [],
34
+ "remediation": "fix_input",
35
+ "en": "Display name cannot contain emoji"
36
+ },
37
+ {
38
+ "code": "error.400.display_name_forbidden_chars",
39
+ "status": 400,
40
+ "params": [],
41
+ "remediation": "fix_input",
42
+ "en": "Display name contains forbidden characters"
43
+ },
44
+ {
45
+ "code": "error.400.display_name_invisible_chars",
46
+ "status": 400,
47
+ "params": [],
48
+ "remediation": "fix_input",
49
+ "en": "Display name contains invisible characters"
50
+ },
51
+ {
52
+ "code": "error.400.display_name_too_short",
53
+ "status": 400,
54
+ "params": [],
55
+ "remediation": "fix_input",
56
+ "en": "Display name must be at least 2 characters"
57
+ },
30
58
  {
31
59
  "code": "error.400.expected_list",
32
60
  "status": 400,
@@ -461,5 +489,12 @@
461
489
  "params": [],
462
490
  "remediation": "wait_and_retry",
463
491
  "en": "The authentication service is unavailable; try again later"
492
+ },
493
+ {
494
+ "code": "error.503.profiles_unavailable",
495
+ "status": 503,
496
+ "params": [],
497
+ "remediation": "wait_and_retry",
498
+ "en": "The profiles service is unavailable; try again later"
464
499
  }
465
500
  ]
@@ -16,6 +16,10 @@ export const workspacesErrorBundleRu: Record<WorkspacesErrorCode, string> = {
16
16
  "error.400.bad_request": "Некорректный запрос",
17
17
  "error.400.captcha_invalid": "Проверка капчи не пройдена. Пожалуйста, попробуйте ещё раз.",
18
18
  "error.400.captcha_required": "Требуется токен капчи.",
19
+ "error.400.display_name_emoji": "Отображаемое имя не может содержать эмодзи",
20
+ "error.400.display_name_forbidden_chars": "Отображаемое имя содержит запрещённые символы",
21
+ "error.400.display_name_invisible_chars": "Отображаемое имя содержит невидимые символы",
22
+ "error.400.display_name_too_short": "Отображаемое имя должно содержать не менее 2 символов",
19
23
  "error.400.expected_list": "Ожидался список элементов",
20
24
  "error.400.field.blank": "Поле «{field}» не может быть пустым",
21
25
  "error.400.field.does_not_exist": "«{field}» не существует",
@@ -73,4 +77,5 @@ export const workspacesErrorBundleRu: Record<WorkspacesErrorCode, string> = {
73
77
  "error.429.too_many_requests": "Слишком много запросов. Пожалуйста, повторите попытку позже.",
74
78
  "error.500.internal": "Что-то пошло не так",
75
79
  "error.503.auth_unavailable": "Сервис аутентификации недоступен; повторите попытку позже",
80
+ "error.503.profiles_unavailable": "Сервис профилей недоступен; повторите попытку позже",
76
81
  };