@stapel/workspaces-react 0.17.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/README.md +39 -8
  3. package/dist/api/generated/schema.d.ts +187 -60
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/ActiveWorkspace.d.ts +43 -0
  6. package/dist/default/ActiveWorkspace.d.ts.map +1 -0
  7. package/dist/default/ActiveWorkspace.js +33 -0
  8. package/dist/default/ActiveWorkspace.js.map +1 -0
  9. package/dist/default/AuditTrailPane.d.ts +12 -0
  10. package/dist/default/AuditTrailPane.d.ts.map +1 -0
  11. package/dist/default/AuditTrailPane.js +154 -0
  12. package/dist/default/AuditTrailPane.js.map +1 -0
  13. package/dist/default/InvitationsPane.d.ts +19 -0
  14. package/dist/default/InvitationsPane.d.ts.map +1 -0
  15. package/dist/default/InvitationsPane.js +217 -0
  16. package/dist/default/InvitationsPane.js.map +1 -0
  17. package/dist/default/InviteAcceptPage.d.ts +7 -0
  18. package/dist/default/InviteAcceptPage.d.ts.map +1 -1
  19. package/dist/default/InviteAcceptPage.js +74 -38
  20. package/dist/default/InviteAcceptPage.js.map +1 -1
  21. package/dist/default/MembersManager.d.ts +12 -4
  22. package/dist/default/MembersManager.d.ts.map +1 -1
  23. package/dist/default/MembersManager.js +320 -84
  24. package/dist/default/MembersManager.js.map +1 -1
  25. package/dist/default/RoleSelectField.d.ts +64 -0
  26. package/dist/default/RoleSelectField.d.ts.map +1 -0
  27. package/dist/default/RoleSelectField.js +78 -0
  28. package/dist/default/RoleSelectField.js.map +1 -0
  29. package/dist/default/WorkspaceSettings.d.ts +7 -1
  30. package/dist/default/WorkspaceSettings.d.ts.map +1 -1
  31. package/dist/default/WorkspaceSettings.js +203 -45
  32. package/dist/default/WorkspaceSettings.js.map +1 -1
  33. package/dist/default/WorkspacesPage.d.ts +13 -0
  34. package/dist/default/WorkspacesPage.d.ts.map +1 -0
  35. package/dist/default/WorkspacesPage.js +109 -0
  36. package/dist/default/WorkspacesPage.js.map +1 -0
  37. package/dist/default/index.d.ts +28 -7
  38. package/dist/default/index.d.ts.map +1 -1
  39. package/dist/default/index.js +24 -7
  40. package/dist/default/index.js.map +1 -1
  41. package/dist/default/parts.d.ts +104 -0
  42. package/dist/default/parts.d.ts.map +1 -0
  43. package/dist/default/parts.js +143 -0
  44. package/dist/default/parts.js.map +1 -0
  45. package/dist/headless/Members.d.ts +59 -14
  46. package/dist/headless/Members.d.ts.map +1 -1
  47. package/dist/headless/Members.js +35 -10
  48. package/dist/headless/Members.js.map +1 -1
  49. package/dist/headless/RoleSelect.d.ts +9 -3
  50. package/dist/headless/RoleSelect.d.ts.map +1 -1
  51. package/dist/headless/RoleSelect.js +3 -2
  52. package/dist/headless/RoleSelect.js.map +1 -1
  53. package/dist/i18n/es.d.ts +11 -12
  54. package/dist/i18n/es.d.ts.map +1 -1
  55. package/dist/i18n/es.js +267 -14
  56. package/dist/i18n/es.js.map +1 -1
  57. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  58. package/dist/i18n/generated/errors.es.gen.js +5 -0
  59. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  60. package/dist/i18n/generated/errors.gen.d.ts +30 -0
  61. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  62. package/dist/i18n/generated/errors.gen.js +15 -0
  63. package/dist/i18n/generated/errors.gen.js.map +1 -1
  64. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  65. package/dist/i18n/generated/errors.ru.gen.js +5 -0
  66. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  67. package/dist/i18n/keys.d.ts +209 -1
  68. package/dist/i18n/keys.d.ts.map +1 -1
  69. package/dist/i18n/keys.js +410 -4
  70. package/dist/i18n/keys.js.map +1 -1
  71. package/dist/i18n/ru.d.ts +7 -2
  72. package/dist/i18n/ru.d.ts.map +1 -1
  73. package/dist/i18n/ru.js +209 -4
  74. package/dist/i18n/ru.js.map +1 -1
  75. package/dist/index.d.ts +1 -1
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/model/format.d.ts +56 -0
  80. package/dist/model/format.d.ts.map +1 -0
  81. package/dist/model/format.js +125 -0
  82. package/dist/model/format.js.map +1 -0
  83. package/dist/model/selection.d.ts +15 -0
  84. package/dist/model/selection.d.ts.map +1 -1
  85. package/dist/model/selection.js +18 -1
  86. package/dist/model/selection.js.map +1 -1
  87. package/dist/nav/manifest.d.ts +45 -0
  88. package/dist/nav/manifest.d.ts.map +1 -0
  89. package/dist/nav/manifest.js +77 -0
  90. package/dist/nav/manifest.js.map +1 -0
  91. package/llms.txt +11 -10
  92. package/manifest.json +281 -69
  93. package/package.json +18 -12
  94. package/src/analytics/generated/events.json +1 -1
  95. package/src/api/generated/schema.ts +187 -60
  96. package/src/default/ActiveWorkspace.tsx +97 -0
  97. package/src/default/AuditTrailPane.tsx +309 -0
  98. package/src/default/InvitationsPane.tsx +501 -0
  99. package/src/default/InviteAcceptPage.tsx +187 -90
  100. package/src/default/MembersManager.tsx +832 -222
  101. package/src/default/RoleSelectField.tsx +187 -0
  102. package/src/default/WorkspaceSettings.tsx +540 -125
  103. package/src/default/WorkspacesPage.tsx +394 -0
  104. package/src/default/index.ts +28 -7
  105. package/src/default/parts.tsx +315 -0
  106. package/src/headless/Members.tsx +84 -16
  107. package/src/headless/RoleSelect.tsx +12 -5
  108. package/src/i18n/es.ts +326 -14
  109. package/src/i18n/generated/errors.es.gen.ts +5 -0
  110. package/src/i18n/generated/errors.gen.ts +15 -0
  111. package/src/i18n/generated/errors.json +37 -0
  112. package/src/i18n/generated/errors.ru.gen.ts +5 -0
  113. package/src/i18n/keys.ts +462 -5
  114. package/src/i18n/ru.ts +259 -5
  115. package/src/index.ts +1 -0
  116. package/src/model/format.ts +158 -0
  117. package/src/model/selection.tsx +19 -1
  118. package/src/nav/manifest.ts +120 -0
  119. package/dist/default/ErrorAlert.d.ts +0 -8
  120. package/dist/default/ErrorAlert.d.ts.map +0 -1
  121. package/dist/default/ErrorAlert.js +0 -30
  122. package/dist/default/ErrorAlert.js.map +0 -1
  123. package/src/default/ErrorAlert.tsx +0 -47
@@ -10,26 +10,26 @@ export interface paths {
10
10
  cookie?: never;
11
11
  };
12
12
  /**
13
- * @description Overridable serializer seams for API views.
13
+ * @description Turns an unaskable plan ceiling into 503 for the whole view.
14
14
  *
15
- * Subclasses (or downstream projects) can swap the request/response
16
- * serializers without copying method bodies:
17
- *
18
- * class MyWorkspaceDetailView(WorkspaceDetailView):
19
- * response_serializer_class = MyWorkspaceResponseSerializer
15
+ * Mixed in rather than written as an ``except`` at each call site because
16
+ * the seam is consulted from views AND from inside ``services`` under the
17
+ * workspace lock, and the set of call sites grows: a view that gains a
18
+ * method later inherits the mapping instead of re-deriving it. The 402
19
+ * denials stay per-call-site — those carry a limit the screen renders.
20
20
  *
21
21
  * **Permissions:** `IsAuthenticated`
22
22
  */
23
23
  get: operations["workspaces_api_v1_retrieve"];
24
24
  put?: never;
25
25
  /**
26
- * @description Overridable serializer seams for API views.
27
- *
28
- * Subclasses (or downstream projects) can swap the request/response
29
- * serializers without copying method bodies:
26
+ * @description Turns an unaskable plan ceiling into 503 for the whole view.
30
27
  *
31
- * class MyWorkspaceDetailView(WorkspaceDetailView):
32
- * response_serializer_class = MyWorkspaceResponseSerializer
28
+ * Mixed in rather than written as an ``except`` at each call site because
29
+ * the seam is consulted from views AND from inside ``services`` under the
30
+ * workspace lock, and the set of call sites grows: a view that gains a
31
+ * method later inherits the mapping instead of re-deriving it. The 402
32
+ * denials stay per-call-site — those carry a limit the screen renders.
33
33
  *
34
34
  * **Permissions:** `IsAuthenticated`
35
35
  */
@@ -48,13 +48,21 @@ export interface paths {
48
48
  cookie?: never;
49
49
  };
50
50
  /**
51
- * @description Overridable serializer seams for API views.
51
+ * @description Overridable request/response serializer seam for an API view.
52
+ *
53
+ * Declare the classes as attributes, read them through the getters::
52
54
  *
53
- * Subclasses (or downstream projects) can swap the request/response
54
- * serializers without copying method bodies:
55
+ * class WalletView(SerializerSeamMixin, APIView):
56
+ * response_serializer_class = WalletResponseSerializer
55
57
  *
56
- * class MyWorkspaceDetailView(WorkspaceDetailView):
57
- * response_serializer_class = MyWorkspaceResponseSerializer
58
+ * def get(self, request):
59
+ * response_cls = self.get_response_serializer_class()
60
+ * return StapelResponse(response_cls(wallet_to_dto(...)))
61
+ *
62
+ * A host project swaps either direction by subclassing and setting the
63
+ * attribute, or overrides the getter for a per-request decision — no HTTP
64
+ * method body is ever copied. ``None`` means the direction carries no
65
+ * serializer (raw ``request.FILES``, an empty 204 body).
58
66
  *
59
67
  * **Permissions:** `IsAuthenticated`
60
68
  */
@@ -62,13 +70,13 @@ export interface paths {
62
70
  put?: never;
63
71
  post?: never;
64
72
  /**
65
- * @description Overridable serializer seams for API views.
66
- *
67
- * Subclasses (or downstream projects) can swap the request/response
68
- * serializers without copying method bodies:
73
+ * @description End a workspace — the transition itself lives in the service.
69
74
  *
70
- * class MyWorkspaceDetailView(WorkspaceDetailView):
71
- * response_serializer_class = MyWorkspaceResponseSerializer
75
+ * The view's whole job here is authority (owner, and only owner) and
76
+ * turning the service's typed refusal back into the keyed 409 the
77
+ * frontend routes on. The write, the event and the audit line are one
78
+ * indivisible act in ``services.delete_workspace``, because a second
79
+ * door into this transition is a second chance to forget the record.
72
80
  *
73
81
  * **Permissions:** `IsAuthenticated`
74
82
  */
@@ -76,13 +84,21 @@ export interface paths {
76
84
  options?: never;
77
85
  head?: never;
78
86
  /**
79
- * @description Overridable serializer seams for API views.
87
+ * @description Overridable request/response serializer seam for an API view.
80
88
  *
81
- * Subclasses (or downstream projects) can swap the request/response
82
- * serializers without copying method bodies:
89
+ * Declare the classes as attributes, read them through the getters::
83
90
  *
84
- * class MyWorkspaceDetailView(WorkspaceDetailView):
85
- * response_serializer_class = MyWorkspaceResponseSerializer
91
+ * class WalletView(SerializerSeamMixin, APIView):
92
+ * response_serializer_class = WalletResponseSerializer
93
+ *
94
+ * def get(self, request):
95
+ * response_cls = self.get_response_serializer_class()
96
+ * return StapelResponse(response_cls(wallet_to_dto(...)))
97
+ *
98
+ * A host project swaps either direction by subclassing and setting the
99
+ * attribute, or overrides the getter for a per-request decision — no HTTP
100
+ * method body is ever copied. ``None`` means the direction carries no
101
+ * serializer (raw ``request.FILES``, an empty 204 body).
86
102
  *
87
103
  * **Permissions:** `IsAuthenticated`
88
104
  */
@@ -292,13 +308,21 @@ export interface paths {
292
308
  cookie?: never;
293
309
  };
294
310
  /**
295
- * @description Overridable serializer seams for API views.
311
+ * @description Overridable request/response serializer seam for an API view.
312
+ *
313
+ * Declare the classes as attributes, read them through the getters::
296
314
  *
297
- * Subclasses (or downstream projects) can swap the request/response
298
- * serializers without copying method bodies:
315
+ * class WalletView(SerializerSeamMixin, APIView):
316
+ * response_serializer_class = WalletResponseSerializer
299
317
  *
300
- * class MyWorkspaceDetailView(WorkspaceDetailView):
301
- * response_serializer_class = MyWorkspaceResponseSerializer
318
+ * def get(self, request):
319
+ * response_cls = self.get_response_serializer_class()
320
+ * return StapelResponse(response_cls(wallet_to_dto(...)))
321
+ *
322
+ * A host project swaps either direction by subclassing and setting the
323
+ * attribute, or overrides the getter for a per-request decision — no HTTP
324
+ * method body is ever copied. ``None`` means the direction carries no
325
+ * serializer (raw ``request.FILES``, an empty 204 body).
302
326
  *
303
327
  * **Permissions:** `IsAuthenticated`
304
328
  */
@@ -322,13 +346,21 @@ export interface paths {
322
346
  put?: never;
323
347
  post?: never;
324
348
  /**
325
- * @description Overridable serializer seams for API views.
349
+ * @description Overridable request/response serializer seam for an API view.
350
+ *
351
+ * Declare the classes as attributes, read them through the getters::
326
352
  *
327
- * Subclasses (or downstream projects) can swap the request/response
328
- * serializers without copying method bodies:
353
+ * class WalletView(SerializerSeamMixin, APIView):
354
+ * response_serializer_class = WalletResponseSerializer
329
355
  *
330
- * class MyWorkspaceDetailView(WorkspaceDetailView):
331
- * response_serializer_class = MyWorkspaceResponseSerializer
356
+ * def get(self, request):
357
+ * response_cls = self.get_response_serializer_class()
358
+ * return StapelResponse(response_cls(wallet_to_dto(...)))
359
+ *
360
+ * A host project swaps either direction by subclassing and setting the
361
+ * attribute, or overrides the getter for a per-request decision — no HTTP
362
+ * method body is ever copied. ``None`` means the direction carries no
363
+ * serializer (raw ``request.FILES``, an empty 204 body).
332
364
  *
333
365
  * **Permissions:** `IsAuthenticated`
334
366
  */
@@ -336,13 +368,21 @@ export interface paths {
336
368
  options?: never;
337
369
  head?: never;
338
370
  /**
339
- * @description Overridable serializer seams for API views.
371
+ * @description Overridable request/response serializer seam for an API view.
372
+ *
373
+ * Declare the classes as attributes, read them through the getters::
374
+ *
375
+ * class WalletView(SerializerSeamMixin, APIView):
376
+ * response_serializer_class = WalletResponseSerializer
340
377
  *
341
- * Subclasses (or downstream projects) can swap the request/response
342
- * serializers without copying method bodies:
378
+ * def get(self, request):
379
+ * response_cls = self.get_response_serializer_class()
380
+ * return StapelResponse(response_cls(wallet_to_dto(...)))
343
381
  *
344
- * class MyWorkspaceDetailView(WorkspaceDetailView):
345
- * response_serializer_class = MyWorkspaceResponseSerializer
382
+ * A host project swaps either direction by subclassing and setting the
383
+ * attribute, or overrides the getter for a per-request decision — no HTTP
384
+ * method body is ever copied. ``None`` means the direction carries no
385
+ * serializer (raw ``request.FILES``, an empty 204 body).
346
386
  *
347
387
  * **Permissions:** `IsAuthenticated`
348
388
  */
@@ -480,13 +520,13 @@ export interface paths {
480
520
  get?: never;
481
521
  put?: never;
482
522
  /**
483
- * @description Overridable serializer seams for API views.
523
+ * @description Turns an unaskable plan ceiling into 503 for the whole view.
484
524
  *
485
- * Subclasses (or downstream projects) can swap the request/response
486
- * serializers without copying method bodies:
487
- *
488
- * class MyWorkspaceDetailView(WorkspaceDetailView):
489
- * response_serializer_class = MyWorkspaceResponseSerializer
525
+ * Mixed in rather than written as an ``except`` at each call site because
526
+ * the seam is consulted from views AND from inside ``services`` under the
527
+ * workspace lock, and the set of call sites grows: a view that gains a
528
+ * method later inherits the mapping instead of re-deriving it. The 402
529
+ * denials stay per-call-site — those carry a limit the screen renders.
490
530
  *
491
531
  * **Permissions:** `IsAuthenticated`
492
532
  */
@@ -520,7 +560,8 @@ export interface paths {
520
560
  * Gate stack, in order: HIGH step-up (``@requires_verification``, scope
521
561
  * ``sensitive`` — the same store as admin step-up) → capability
522
562
  * ``members.provision`` (403) → entitlement ``workspaces.provision_user``
523
- * (402; degrades to allow without billing) → optional per-user debit
563
+ * (402; an unreachable billing answers 503, not allow) → optional
564
+ * per-user debit
524
565
  * (``STAPEL_WORKSPACES["PROVISION_USER_CREDITS"]`` > 0).
525
566
  *
526
567
  * Credentials: a synthetic account normally has no email — when the
@@ -653,6 +694,11 @@ export interface paths {
653
694
  * NOT consumed here: accept stays a separate, deliberate step after
654
695
  * setup. Neither the invite token nor the grant token is ever logged.
655
696
  *
697
+ * ONE live grant at a time (WORK-03): while the previous grant is inside
698
+ * its TTL this answers 429 ``error.429.invitation_grant_pending`` with a
699
+ * ``Retry-After``, so a leaked invite link cannot be replayed into an
700
+ * endless supply of sign-in credentials for the invited mailbox.
701
+ *
656
702
  * **Permissions:** `AllowAny`
657
703
  */
658
704
  post: operations["workspaces_api_v1_invitations_claim_create"];
@@ -674,11 +720,36 @@ export interface paths {
674
720
  /**
675
721
  * @description Decline an invitation — the invitee's terminal "no" (spec §B2).
676
722
  *
677
- * Authenticated + email-match, exactly like accept: only the invited
678
- * account may resolve the invitation, in either direction. Decline ≠
679
- * revoke — both states stay distinguishable in the preview ``status``.
723
+ * AllowAny, on the token, exactly like preview and claim — and this is a
724
+ * CHANGE from the original authenticated-only rule, made because that rule
725
+ * made the common "no" impossible to say.
726
+ *
727
+ * Saying no used to require an account. The invitee who has none is the
728
+ * majority case on this path (that is why ``claim`` exists at all), so
729
+ * declining meant first creating the very account the person was declining
730
+ * to create: the flow asked for the commitment before the decision, and
731
+ * the refusal left a live, empty account behind it. Verified on the stand.
732
+ *
733
+ * The token is the proof, and it is the same proof the module already
734
+ * accepts elsewhere for strictly more: ``claim`` takes this token from an
735
+ * anonymous caller and mints a LOGIN GRANT for the invited mailbox.
736
+ * Anything a token holder could do by declining, they could already do —
737
+ * and more — by claiming. What the token cannot do is act as somebody
738
+ * else: a request that arrives WITH a session naming an address must still
739
+ * be the invited address, so a signed-in person cannot resolve an
740
+ * invitation belonging to someone else. That check is unchanged.
741
+ *
742
+ * A session with no address at all (a guest session — this product mints
743
+ * them at meeting doors) is treated as the anonymous case rather than as a
744
+ * mismatched account: it names nobody, so it can neither prove nor
745
+ * disprove ownership, and the token stands on its own. Before this it was
746
+ * refused outright by ``ANONYMOUS_DENIED``, which is how a guest who was
747
+ * also an invitee could not decline at all.
748
+ *
749
+ * Decline ≠ revoke — both states stay distinguishable in the preview
750
+ * ``status``. Throttled and token-never-logged like its AllowAny siblings.
680
751
  *
681
- * **Permissions:** `IsAuthenticated`
752
+ * **Permissions:** `AllowAny`
682
753
  */
683
754
  post: operations["workspaces_api_v1_invitations_decline_create"];
684
755
  delete?: never;
@@ -697,13 +768,13 @@ export interface paths {
697
768
  get?: never;
698
769
  put?: never;
699
770
  /**
700
- * @description Overridable serializer seams for API views.
701
- *
702
- * Subclasses (or downstream projects) can swap the request/response
703
- * serializers without copying method bodies:
771
+ * @description Turns an unaskable plan ceiling into 503 for the whole view.
704
772
  *
705
- * class MyWorkspaceDetailView(WorkspaceDetailView):
706
- * response_serializer_class = MyWorkspaceResponseSerializer
773
+ * Mixed in rather than written as an ``except`` at each call site because
774
+ * the seam is consulted from views AND from inside ``services`` under the
775
+ * workspace lock, and the set of call sites grows: a view that gains a
776
+ * method later inherits the mapping instead of re-deriving it. The 402
777
+ * denials stay per-call-site — those carry a limit the screen renders.
707
778
  *
708
779
  * **Permissions:** `IsAuthenticated`
709
780
  */
@@ -999,6 +1070,46 @@ export interface components {
999
1070
  */
1000
1071
  last_sent_at?: string | null;
1001
1072
  };
1073
+ /** @description How far the workspace's require_mfa policy has actually got. */
1074
+ MFAEnforcementStatus: {
1075
+ /**
1076
+ * @description pending / enforcing / enforced / failed. Only "enforced" means every active member's second factor has been confirmed — a settings flag saying require_mfa=true says somebody asked for it, not that it holds
1077
+ * @example enforcing
1078
+ */
1079
+ state: string;
1080
+ /**
1081
+ * @description How many sweeps have run since the policy was switched on
1082
+ * @example 3
1083
+ */
1084
+ attempts?: number;
1085
+ /**
1086
+ * @description Members auth answered about in the last sweep
1087
+ * @example 12
1088
+ */
1089
+ checked_members?: number;
1090
+ /**
1091
+ * @description Of those, members with no strong factor — suspended with reason no_mfa
1092
+ * @example 2
1093
+ */
1094
+ noncompliant_members?: number;
1095
+ /**
1096
+ * @description Active members whose factor nobody has confirmed yet. They are NOT admitted while the policy is on; this is the number an administrator has to get to zero
1097
+ * @example 1
1098
+ */
1099
+ unverified_members?: number;
1100
+ /**
1101
+ * @description ISO 8601 time of the last sweep; null before the first
1102
+ * @example 2026-08-11T10:00:00Z
1103
+ */
1104
+ last_attempt_at?: string | null;
1105
+ /** @description ISO 8601 time coverage last became complete; null while it is not */
1106
+ completed_at?: string | null;
1107
+ /**
1108
+ * @description What auth answered when the last sweep failed, verbatim; empty otherwise. Never credential material
1109
+ * @example FunctionCallError: mfa_status timed out
1110
+ */
1111
+ last_error?: string;
1112
+ };
1002
1113
  /** @description Invite payload. */
1003
1114
  MemberInviteRequest: {
1004
1115
  /**
@@ -1109,6 +1220,13 @@ export interface components {
1109
1220
  * @example Ada Lovelace
1110
1221
  */
1111
1222
  display_name?: string | null;
1223
+ /**
1224
+ * @description Whether this member was PROVEN to hold a strong second factor — true, false, or null for "nobody has asked yet". Under a require_mfa policy the null is what an administrator acts on: those members are not admitted until the answer arrives
1225
+ * @example true
1226
+ */
1227
+ mfa_compliant?: boolean | null;
1228
+ /** @description Whether this row IS the caller. Server-derived — the client is never asked to compare ids it may not have. Always false on the service-to-service membership read, where the caller is a service and not a member of anything */
1229
+ is_self?: boolean;
1112
1230
  };
1113
1231
  PaginatedAuditEventResponseList: {
1114
1232
  items: components["schemas"]["AuditEventResponse"][];
@@ -1396,6 +1514,15 @@ export interface components {
1396
1514
  * @example Victor P.
1397
1515
  */
1398
1516
  owner_display_name?: string;
1517
+ /** @description State of the security.require_mfa policy — present on the single-workspace responses (GET/PATCH detail) when the policy is on, null otherwise. The settings block says what was asked for; this says what actually holds, so an administrator who switched MFA on is not told "done" while half the organization was never checked */
1518
+ mfa_enforcement?: components["schemas"]["MFAEnforcementStatus"] | null;
1519
+ /** @description Whether THIS caller may delete THIS workspace right now. The ANSWER, not the inputs: a client deriving it from my_role alone draws a delete button on the instance's default workspace and on a personal workspace the next sign-in re-mints — both of which the endpoint refuses, which is the "button that leads to a refusal" defect. Same shape and same reason as can_create_workspace on the list response */
1520
+ can_delete?: boolean;
1521
+ /**
1522
+ * @description The error code the DELETE would return, or "" when can_delete is true. The CODE rather than a sentence, so the client renders the refusal in the caller's own language through the error map it already ships. Server verdict and screen explanation are ONE evaluation (services.deletion_block_reason), so a screen cannot promise what the endpoint denies
1523
+ * @example error.409.workspace_is_instance_default
1524
+ */
1525
+ delete_blocked_reason?: string;
1399
1526
  };
1400
1527
  };
1401
1528
  responses: never;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * "Which workspace is this screen about" — the seam between the NAV contract
3
+ * and the four workspace-scoped admin screens.
4
+ *
5
+ * The nav contract routes a screen; it does not hand it an ambient scope, and
6
+ * the active workspace is not a path segment of a settings URL — it is
7
+ * RUNTIME state, the same state the container writes when a person switches
8
+ * (`WorkspaceSelection.switchTo`). So a screen declared in nav takes NO
9
+ * `workspaceId` from the route and reads it from
10
+ * {@link useOptionalWorkspaceSelection} instead; a host that mounts the same
11
+ * component itself keeps passing `workspaceId` explicitly and never touches
12
+ * the selection at all.
13
+ *
14
+ * The `Optional` accessor is load-bearing. `useWorkspaceSelection()` throws
15
+ * outside its provider, which is right for application code and wrong for a
16
+ * screen the shell mounts from a manifest: a shell that has not wired the
17
+ * provider would render a white page with an error in the console. Here the
18
+ * three answers a person can actually get are drawn instead:
19
+ *
20
+ * - the list is still loading / failed → the substrate's own arms (a
21
+ * skeleton, and the failure WITH a retry — never "you have no workspaces"),
22
+ * - the person belongs to none → "you are not in a workspace yet",
23
+ * - there is no selection at all (no provider, or nothing resolved) →
24
+ * "choose a workspace".
25
+ *
26
+ * None of them is blank, and none of them claims something the client does
27
+ * not know.
28
+ */
29
+ import type { ReactElement, ReactNode } from "react";
30
+ import { useT } from "@stapel/core";
31
+ import { EmptyState, LoadBoundary } from "@stapel/tokens-antd/skin";
32
+ import { useOptionalWorkspaceSelection } from "../model/selection.js";
33
+ import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
34
+
35
+ export interface ActiveWorkspaceBoundaryProps {
36
+ /** The workspace the HOST named. Given, the selection is never consulted. */
37
+ readonly workspaceId?: string | undefined;
38
+ /** Rendered once a workspace id is known. */
39
+ readonly children: (workspaceId: string) => ReactNode;
40
+ /** Test id of the chooser / empty arms (the ready arm has no wrapper). */
41
+ readonly testId: string;
42
+ }
43
+
44
+ /**
45
+ * Resolve the workspace this screen is about, or draw the reason it cannot be
46
+ * resolved. Renders `children(workspaceId)` unchanged once there is one.
47
+ */
48
+ export function ActiveWorkspaceBoundary(
49
+ props: ActiveWorkspaceBoundaryProps
50
+ ): ReactElement {
51
+ const t = useT();
52
+ // Unconditional, as a hook must be: reading the context is cheap and it
53
+ // returns null outside a provider rather than throwing.
54
+ const selection = useOptionalWorkspaceSelection();
55
+
56
+ if (props.workspaceId !== undefined) {
57
+ return <>{props.children(props.workspaceId)}</>;
58
+ }
59
+
60
+ const chooser = (
61
+ <EmptyState
62
+ title={t(WORKSPACES_I18N_KEYS.activeChooseTitle)}
63
+ hint={t(WORKSPACES_I18N_KEYS.activeChooseHint)}
64
+ testId={`${props.testId}-choose`}
65
+ />
66
+ );
67
+
68
+ if (selection === null) return chooser;
69
+
70
+ return (
71
+ <LoadBoundary
72
+ state={selection.state}
73
+ testId={props.testId}
74
+ onRetry={() => {
75
+ void selection.refetch();
76
+ }}
77
+ >
78
+ {(workspaces) => {
79
+ if (selection.current !== null) {
80
+ return <>{props.children(selection.current.id)}</>;
81
+ }
82
+ // The list arrived and the chain still answered nobody: either the
83
+ // person belongs to no workspace (a different sentence from "pick
84
+ // one"), or a host is driving the URL layer at something unresolved.
85
+ return workspaces.length === 0 ? (
86
+ <EmptyState
87
+ title={t(WORKSPACES_I18N_KEYS.activeNoneTitle)}
88
+ hint={t(WORKSPACES_I18N_KEYS.activeNoneHint)}
89
+ testId={`${props.testId}-none`}
90
+ />
91
+ ) : (
92
+ chooser
93
+ );
94
+ }}
95
+ </LoadBoundary>
96
+ );
97
+ }