@stapel/workspaces-react 0.16.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +194 -0
- package/README.md +39 -8
- package/dist/api/generated/schema.d.ts +187 -60
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/ActiveWorkspace.d.ts +43 -0
- package/dist/default/ActiveWorkspace.d.ts.map +1 -0
- package/dist/default/ActiveWorkspace.js +33 -0
- package/dist/default/ActiveWorkspace.js.map +1 -0
- package/dist/default/AuditTrailPane.d.ts +12 -0
- package/dist/default/AuditTrailPane.d.ts.map +1 -0
- package/dist/default/AuditTrailPane.js +154 -0
- package/dist/default/AuditTrailPane.js.map +1 -0
- package/dist/default/InvitationsPane.d.ts +19 -0
- package/dist/default/InvitationsPane.d.ts.map +1 -0
- package/dist/default/InvitationsPane.js +217 -0
- package/dist/default/InvitationsPane.js.map +1 -0
- package/dist/default/InviteAcceptPage.d.ts +7 -0
- package/dist/default/InviteAcceptPage.d.ts.map +1 -1
- package/dist/default/InviteAcceptPage.js +74 -38
- package/dist/default/InviteAcceptPage.js.map +1 -1
- package/dist/default/MembersManager.d.ts +12 -4
- package/dist/default/MembersManager.d.ts.map +1 -1
- package/dist/default/MembersManager.js +320 -84
- package/dist/default/MembersManager.js.map +1 -1
- package/dist/default/RoleSelectField.d.ts +64 -0
- package/dist/default/RoleSelectField.d.ts.map +1 -0
- package/dist/default/RoleSelectField.js +78 -0
- package/dist/default/RoleSelectField.js.map +1 -0
- package/dist/default/WorkspaceSettings.d.ts +7 -1
- package/dist/default/WorkspaceSettings.d.ts.map +1 -1
- package/dist/default/WorkspaceSettings.js +203 -45
- package/dist/default/WorkspaceSettings.js.map +1 -1
- package/dist/default/WorkspacesPage.d.ts +13 -0
- package/dist/default/WorkspacesPage.d.ts.map +1 -0
- package/dist/default/WorkspacesPage.js +108 -0
- package/dist/default/WorkspacesPage.js.map +1 -0
- package/dist/default/index.d.ts +28 -7
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +24 -7
- package/dist/default/index.js.map +1 -1
- package/dist/default/parts.d.ts +104 -0
- package/dist/default/parts.d.ts.map +1 -0
- package/dist/default/parts.js +140 -0
- package/dist/default/parts.js.map +1 -0
- package/dist/headless/Members.d.ts +59 -14
- package/dist/headless/Members.d.ts.map +1 -1
- package/dist/headless/Members.js +35 -10
- package/dist/headless/Members.js.map +1 -1
- package/dist/headless/RoleSelect.d.ts +9 -3
- package/dist/headless/RoleSelect.d.ts.map +1 -1
- package/dist/headless/RoleSelect.js +3 -2
- package/dist/headless/RoleSelect.js.map +1 -1
- package/dist/i18n/es.d.ts +11 -12
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +267 -14
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.es.gen.js +5 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +30 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +15 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +5 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/i18n/keys.d.ts +209 -1
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +410 -4
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +7 -2
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +209 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/model/format.d.ts +56 -0
- package/dist/model/format.d.ts.map +1 -0
- package/dist/model/format.js +125 -0
- package/dist/model/format.js.map +1 -0
- package/dist/model/mandate.d.ts +23 -1
- package/dist/model/mandate.d.ts.map +1 -1
- package/dist/model/mandate.js +24 -0
- package/dist/model/mandate.js.map +1 -1
- package/dist/model/selection.d.ts +15 -0
- package/dist/model/selection.d.ts.map +1 -1
- package/dist/model/selection.js +18 -1
- package/dist/model/selection.js.map +1 -1
- package/dist/nav/manifest.d.ts +45 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +77 -0
- package/dist/nav/manifest.js.map +1 -0
- package/llms.txt +11 -10
- package/manifest.json +282 -69
- package/package.json +18 -12
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +187 -60
- package/src/default/ActiveWorkspace.tsx +97 -0
- package/src/default/AuditTrailPane.tsx +309 -0
- package/src/default/InvitationsPane.tsx +501 -0
- package/src/default/InviteAcceptPage.tsx +187 -90
- package/src/default/MembersManager.tsx +832 -222
- package/src/default/RoleSelectField.tsx +187 -0
- package/src/default/WorkspaceSettings.tsx +540 -125
- package/src/default/WorkspacesPage.tsx +387 -0
- package/src/default/index.ts +28 -7
- package/src/default/parts.tsx +308 -0
- package/src/headless/Members.tsx +84 -16
- package/src/headless/RoleSelect.tsx +12 -5
- package/src/i18n/es.ts +326 -14
- package/src/i18n/generated/errors.es.gen.ts +5 -0
- package/src/i18n/generated/errors.gen.ts +15 -0
- package/src/i18n/generated/errors.json +37 -0
- package/src/i18n/generated/errors.ru.gen.ts +5 -0
- package/src/i18n/keys.ts +462 -5
- package/src/i18n/ru.ts +259 -5
- package/src/index.ts +6 -1
- package/src/model/format.ts +158 -0
- package/src/model/mandate.ts +26 -1
- package/src/model/selection.tsx +19 -1
- package/src/nav/manifest.ts +120 -0
- package/dist/default/ErrorAlert.d.ts +0 -8
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -30
- package/dist/default/ErrorAlert.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -47
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,199 @@
|
|
|
1
1
|
# @stapel/workspaces-react
|
|
2
2
|
|
|
3
|
+
## 0.19.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 57bd738: Visual pass 3: delete the legacy harness stories, and stop the screens saying
|
|
8
|
+
the same thing twice.
|
|
9
|
+
|
|
10
|
+
**The seven harness demos are gone** (`workspace-list`, `members--default`,
|
|
11
|
+
`accept-invitation`, `capability-gate`, `invite-accept-flow`,
|
|
12
|
+
`role-select--registry`, `workspaces-provider`). They shipped a debug card —
|
|
13
|
+
`state.step` chips, `1 workspace(s)`, "Grant exchanged", `members.invite
|
|
14
|
+
allowed` — beside the real skins that replaced them, so half of what this
|
|
15
|
+
package showed was the harness. The headless primitives they stood for are now
|
|
16
|
+
covered by the skin demo that renders each one (`covers:`), and `demo/_harness`
|
|
17
|
+
is the provider frame and nothing else. **Breaking for anyone who deep-links a
|
|
18
|
+
viewer story id.**
|
|
19
|
+
|
|
20
|
+
Screen fixes, each one a repetition or a contradiction:
|
|
21
|
+
|
|
22
|
+
- **Invitations.** A terminal row printed its refusal once per control — the
|
|
23
|
+
same sentence six times on one phone screen. `RowActions` states it once per
|
|
24
|
+
row, as a footnote spanning the row, with every switched-off control's
|
|
25
|
+
`aria-describedby` pointing at it.
|
|
26
|
+
- **Workspaces page.** A failed read said so twice, in two wordings, with two
|
|
27
|
+
recoveries (the create button's gate carried its own copy plus `HTTP 503`
|
|
28
|
+
above the alert that already had the retry). One alert now, the control
|
|
29
|
+
points at it, and the retry sits under the alert instead of squeezing its
|
|
30
|
+
text into ~110px at 390px. The empty state no longer repeats the restriction
|
|
31
|
+
the disabled control states.
|
|
32
|
+
- **Workspace settings.** "Require two-factor authentication" read ON while the
|
|
33
|
+
line under it said two-factor was not required here — the note derived from
|
|
34
|
+
the absence of an enforcement status instead of from the policy. Both now
|
|
35
|
+
read the same value, and "no check has run yet" is its own sentence.
|
|
36
|
+
- **Invitation page.** A dead expired link was drawn in antd's _info_ blue with
|
|
37
|
+
nothing to do next; it is the warning tone, names the next step, and takes an
|
|
38
|
+
optional `onExit` for the host's way out.
|
|
39
|
+
- **Members.** The refusals on the viewer's own row widened the action column
|
|
40
|
+
until that row wrapped to the phone layout in the middle of a desktop table.
|
|
41
|
+
One row geometry per breakpoint now. The empty roster has one "Invite", not
|
|
42
|
+
two, and no search field over nobody.
|
|
43
|
+
- **Roles.** "It is not a workspace without roles." was not English. An empty
|
|
44
|
+
registry no longer borrows the "we could not load" sentence.
|
|
45
|
+
- **Membership history.** `Role: admin` was the raw slug; it goes through the
|
|
46
|
+
same registry labels as the picker. Events are grouped under their day, so a
|
|
47
|
+
burst of three no longer prints one timestamp three times.
|
|
48
|
+
|
|
49
|
+
Additive API: `InviteAcceptPageProps.onExit`, `WorkspaceFormat.time()`,
|
|
50
|
+
`useRoleLabel()` (a role label with no `GET /roles` behind it — the public
|
|
51
|
+
invitation page cannot make that call).
|
|
52
|
+
|
|
53
|
+
## 0.18.0
|
|
54
|
+
|
|
55
|
+
### Minor Changes
|
|
56
|
+
|
|
57
|
+
- 80617e9: The pair becomes the product: the delete button stops lying, the MFA policy gets a screen, and the invitation console exists
|
|
58
|
+
|
|
59
|
+
**The refusal a screen used to promise.** `<WorkspaceSettings/>` drew "Delete
|
|
60
|
+
workspace" from `my_role === "owner"` — the exact derivation stapel-workspaces
|
|
61
|
+
0.26.0 added `WorkspaceResponse.can_delete` + `delete_blocked_reason` to
|
|
62
|
+
replace, because an owner of the instance's default workspace or of a personal
|
|
63
|
+
one gets a 409. The control now reads the server's verdict through
|
|
64
|
+
`GatedControl`, and prints the server's own refusal CODE as a sentence beside
|
|
65
|
+
it — translatable at last, because the generated error bundle was five codes
|
|
66
|
+
behind the backend (`error.409.workspace_is_instance_default`,
|
|
67
|
+
`workspace_is_personal`, `error.429.invitation_grant_pending`,
|
|
68
|
+
`error.503.billing_unavailable`, `error.503.mandate_unavailable`). The test
|
|
69
|
+
that cemented the old rule now asserts the new one. Name and security editing
|
|
70
|
+
are gated on the CAPABILITIES the server granted (`workspace.update`,
|
|
71
|
+
`workspace.security.manage`), falling back to the pre-0.6 owner rule only when
|
|
72
|
+
a backend sends no `my_capabilities` at all.
|
|
73
|
+
|
|
74
|
+
**Four screens that were hooks with no pixels.** `<WorkspacesPage/>` (the §54
|
|
75
|
+
default skin `WorkspaceList` never had: roster, create gated on
|
|
76
|
+
`can_create_workspace`, preferred-workspace choice, guest and closed-instance
|
|
77
|
+
copy), `<InvitationsPane/>` (list / revoke / resend / rename — four API methods
|
|
78
|
+
and zero pixels until now, each control gated on the invitation state the
|
|
79
|
+
endpoint would refuse), `<AuditTrailPane/>` (the membership history, action
|
|
80
|
+
vocabulary as sentences, timestamps as "3 days ago (23 Sept 2026, 09:00)"), and
|
|
81
|
+
`<RoleSelectField/>` (a real `<Select>` with an accessible name and the rank as
|
|
82
|
+
a caption, where the story used to show five bullet points).
|
|
83
|
+
|
|
84
|
+
**MFA enforcement, visible.** `mfa_enforcement` (state, coverage, unverified
|
|
85
|
+
members, last error) is drawn beside the `require_mfa` switch that
|
|
86
|
+
`useUpdateSecuritySettings` now actually has a consumer for, and
|
|
87
|
+
`MemberResponse.mfa_compliant` is a per-member tag with three states — true,
|
|
88
|
+
false, and "nobody has asked yet".
|
|
89
|
+
|
|
90
|
+
**Members reach page two.** `<Members>` takes `params` and exposes `page`
|
|
91
|
+
(anchor cursors + `has_next`/`has_prev`); the roster ships a pager and a
|
|
92
|
+
search box. `rosterComplete` now also requires `has_prev === false`, so the
|
|
93
|
+
last-owner claim is not made from the last page of a longer roster.
|
|
94
|
+
|
|
95
|
+
**Substrate migration.** Local `src/default/ErrorAlert.tsx` deleted; every
|
|
96
|
+
screen self-themes through `<SkinTheme>`, confirms are `SkinConfirm` (no
|
|
97
|
+
`Popconfirm`), load arms are `LoadList`/`LoadBoundary`, blocked controls are
|
|
98
|
+
`GatedButton`/`GatedControl`, and the unfilled `renderLoginPanel` slot renders
|
|
99
|
+
a `SlotPlaceholder` instead of a hole. Doctrine lint: 99 warnings → 0.
|
|
100
|
+
|
|
101
|
+
**i18n.** `es` went from 1 translated UI key to complete; `ru` stays complete
|
|
102
|
+
over a bundle that roughly tripled. Counts render through CLDR plurals
|
|
103
|
+
(`useTPlural`) instead of `1 workspace(s)`, and dates through a new
|
|
104
|
+
`useWorkspaceFormat()` in the model layer instead of raw ISO.
|
|
105
|
+
|
|
106
|
+
BREAKING (pre-1.0, hence minor): `MembersBag` gained `page`/`rename`/
|
|
107
|
+
`isRenaming`, `RoleSelect`'s `labelFor` now title-cases an unlabelled registry
|
|
108
|
+
role (`secretary` → `Secretary`) instead of returning the raw token, and
|
|
109
|
+
`src/default/ErrorAlert.tsx` is gone — import `ErrorAlert` from
|
|
110
|
+
`@stapel/tokens-antd/skin`.
|
|
111
|
+
|
|
112
|
+
- 308e3d6: `is_self` closes two lying controls, the four admin screens get doors, and every default-skin screen is finally photographed
|
|
113
|
+
|
|
114
|
+
**The row the roster could not identify.** stapel-workspaces 0.30.0 derives
|
|
115
|
+
`MemberResponse.is_self` server-side, and the pair now reads it off the
|
|
116
|
+
generated schema instead of a defensive cast. It gates TWO controls, not one.
|
|
117
|
+
"Remove" was already asking. "Reset password" was not — and it is the worse
|
|
118
|
+
case: `MemberPasswordResetView` refuses the caller's own row with the
|
|
119
|
+
byte-identical 404 it gives for a stranger ("Yourself is not in the set this
|
|
120
|
+
endpoint acts on"), so an ungated button reads the backend's correct refusal
|
|
121
|
+
as "this member has been removed". Both are switched off through `GatedButton`
|
|
122
|
+
with the reason beside them, in en/ru/es. A backend that sends no `is_self`
|
|
123
|
+
still claims nothing: the absence reads as "the server did not say".
|
|
124
|
+
|
|
125
|
+
**The administrative password reset now exists on the glass.**
|
|
126
|
+
`useResetMemberPassword` had no consumer at all — the endpoint (#110) was
|
|
127
|
+
unreachable from any shipped screen. `<MembersManager/>` grows the control and
|
|
128
|
+
the dialog around it, and the dialog states the three things this operation is:
|
|
129
|
+
the step-up is ANNOUNCED before the click (the capability is declared `high`,
|
|
130
|
+
so `requires_verification(scope="sensitive")` will demand one), the generated
|
|
131
|
+
password is labelled as the one-shot credential it is and leaves the screen
|
|
132
|
+
when the dialog closes (the mutation is reset), and `notified: false` is said
|
|
133
|
+
out loud — it means the account had no channel to be told on, which makes the
|
|
134
|
+
admin the only person who can tell them.
|
|
135
|
+
|
|
136
|
+
**Four finished screens stop being undeclared.** `<WorkspaceSettings/>`,
|
|
137
|
+
`<MembersManager/>`, `<InvitationsPane/>` and `<AuditTrailPane/>` are in the
|
|
138
|
+
nav manifest, on paths relative to the account section, and their `workspaceId`
|
|
139
|
+
prop became OPTIONAL. The architecture answer the pair was waiting for: the
|
|
140
|
+
active workspace is RUNTIME state — the same state the container writes when a
|
|
141
|
+
person switches — not a path param of a settings URL. So a nav-mounted screen
|
|
142
|
+
reads it from the selection seam (`useOptionalWorkspaceSelection`, new export)
|
|
143
|
+
and a screen with no active workspace renders a designed "choose a workspace"
|
|
144
|
+
(and "you are not in a workspace yet" for a person who belongs to none) rather
|
|
145
|
+
than a blank page or a throw from a provider a shell forgot to wire.
|
|
146
|
+
|
|
147
|
+
**The showcase stops showing the test bench.** Seven default-skin demos, one
|
|
148
|
+
per `/default` export, each with a phone variant and variants seeded at
|
|
149
|
+
distinct steps — the roster with the viewer's own row refused, the settings
|
|
150
|
+
screen an owner may not delete, the terminal invitations, the audit line
|
|
151
|
+
nobody performed, the role field with no registry to read, the invitation page
|
|
152
|
+
on the wrong account. `gen:demos` goes 0/7 → 7/7 skin covered. A render test
|
|
153
|
+
per new surface at 390 and 1024, in light and dark, plus the `is_self` and
|
|
154
|
+
chooser cases: 164 → 187 tests.
|
|
155
|
+
|
|
156
|
+
Peer floors raised to the substrate the pair actually imports:
|
|
157
|
+
`@stapel/core >=0.18.0 <1.0.0` (`useTPlural`, `STAPEL_UI_KEYS`) and
|
|
158
|
+
`@stapel/tokens-antd >=0.6.0` (the skin surface).
|
|
159
|
+
|
|
160
|
+
- 95e8eec: The invite dialog is a bottom sheet on a phone, and the members table stops
|
|
161
|
+
offering removals the backend will refuse.
|
|
162
|
+
|
|
163
|
+
`MembersManager`'s `Modal` renders through `@stapel/tokens-antd/skin`'s
|
|
164
|
+
`SkinDialog` — this package now declares `@stapel/tokens-antd` as an optional
|
|
165
|
+
peer, like every other antd-skinned pair. The table gained
|
|
166
|
+
`scroll={{ x: true }}`.
|
|
167
|
+
|
|
168
|
+
Remove was rendered identically on every row, including the last owner's, which
|
|
169
|
+
the backend enforces against. When the roster is COMPLETE (`has_next` is false
|
|
170
|
+
— now surfaced on the bag as `rosterComplete`) "exactly one row holds owner" is
|
|
171
|
+
a fact, so that row's Remove is disabled with the reason printed beside it. On a
|
|
172
|
+
paginated roster nothing is claimed: a count of a page is not a count of the
|
|
173
|
+
roster, and gating on it would refuse a removal the backend would allow.
|
|
174
|
+
|
|
175
|
+
The caller's OWN row is still not gated, and deliberately: `MemberResponse`
|
|
176
|
+
carries no `is_self`, and the pair has no caller identity to compare `user_id`
|
|
177
|
+
against (core's session exposes a status, the mandate axis a role — neither is
|
|
178
|
+
"who am I"). Guessing would grey out somebody else's row. The backend needs one
|
|
179
|
+
additive field: `is_self` on `MemberResponse`, or the caller's `user_id` on the
|
|
180
|
+
members page envelope.
|
|
181
|
+
|
|
182
|
+
When the role registry read fails, the per-row role `Select` no longer renders
|
|
183
|
+
enabled over an empty option list; the role still reads as text and the invite
|
|
184
|
+
dialog states the outage instead of offering a picker with nothing in it.
|
|
185
|
+
|
|
186
|
+
## 0.17.0
|
|
187
|
+
|
|
188
|
+
### Minor Changes
|
|
189
|
+
|
|
190
|
+
- ace82db: `useMandateSource()` — this module's derivation in the shape core's
|
|
191
|
+
`<MandateProvider>` takes. A screen reads the axis with `useMandate()` from
|
|
192
|
+
`@stapel/core` and no longer imports this package to do it, which is what
|
|
193
|
+
lets the same screen work on a public surface that has no workspace list at
|
|
194
|
+
all. `useMandateState()` and its behaviour are unchanged; the `@stapel/core`
|
|
195
|
+
floor rises to the release that carries the seam.
|
|
196
|
+
|
|
3
197
|
## 0.16.0
|
|
4
198
|
|
|
5
199
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -51,6 +51,38 @@ per-module override — `clients={{ workspaces: runtime.client }}` — and nest
|
|
|
51
51
|
providers (`StapelConfigProvider` + `QueryClientProvider` + `I18nProvider`)
|
|
52
52
|
remain exported for bespoke composition.
|
|
53
53
|
|
|
54
|
+
## The default skin (`/default`)
|
|
55
|
+
|
|
56
|
+
The pair ships the screens, not only the hooks (§83): seven antd surfaces
|
|
57
|
+
behind a separate entry point, so a host that brings its own visuals never
|
|
58
|
+
pulls `antd` into its bundle.
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import {
|
|
62
|
+
WorkspacesPage, WorkspaceSettings, MembersManager,
|
|
63
|
+
InvitationsPane, AuditTrailPane, RoleSelectField, InviteAcceptPage,
|
|
64
|
+
} from "@stapel/workspaces-react/default";
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Each one self-themes (light/dark from the live document), is a bottom sheet on
|
|
68
|
+
a phone and a modal above it, and states every refusal the backend would give
|
|
69
|
+
instead of offering a control that leads to one.
|
|
70
|
+
|
|
71
|
+
**Which workspace a screen is about.** `WorkspaceSettings`, `MembersManager`,
|
|
72
|
+
`InvitationsPane` and `AuditTrailPane` take an OPTIONAL `workspaceId`. Pass it
|
|
73
|
+
and they use it. Omit it — which is how the nav manifest mounts them, because
|
|
74
|
+
the nav contract routes a screen and does not hand it an ambient scope — and
|
|
75
|
+
they read the ACTIVE workspace from the runtime selection
|
|
76
|
+
(`<WorkspaceSelectionProvider>`, the same seam `switchTo` writes). With no
|
|
77
|
+
active workspace they render a designed "choose a workspace" state; they never
|
|
78
|
+
render blank and never throw at a host that has not wired the provider.
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
<WorkspaceSelectionProvider urlWorkspaceId={params.get("workspace")}>
|
|
82
|
+
<MembersManager /> {/* the workspace comes from the selection */}
|
|
83
|
+
</WorkspaceSelectionProvider>
|
|
84
|
+
```
|
|
85
|
+
|
|
54
86
|
## Layers
|
|
55
87
|
|
|
56
88
|
```
|
|
@@ -100,13 +132,13 @@ Backend error texts are generated from stapel-workspaces's
|
|
|
100
132
|
pair's UI keys carry hand-written ru copy. Register your own bundle AFTER the
|
|
101
133
|
pair's to override any key — registration order is override priority.
|
|
102
134
|
|
|
103
|
-
### Spanish locale (opt-in subpath
|
|
135
|
+
### Spanish locale (opt-in subpath)
|
|
104
136
|
|
|
105
|
-
The `es` bundle ships as its own subpath on the same terms as `ru`,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
Spanish texts, so a
|
|
109
|
-
English
|
|
137
|
+
The `es` bundle ships as its own subpath on the same terms as `ru`, and it is
|
|
138
|
+
complete on both halves: the generated backend error texts AND hand-written
|
|
139
|
+
Spanish for every UI key the pair owns. `registerWorkspacesI18nEs` registers
|
|
140
|
+
the en floor UNDER the Spanish texts, so a key added tomorrow degrades to
|
|
141
|
+
English rather than to a raw key.
|
|
110
142
|
|
|
111
143
|
```tsx
|
|
112
144
|
import { registerWorkspacesI18nEs } from "@stapel/workspaces-react/i18n/es";
|
|
@@ -119,8 +151,7 @@ await i18n.setLocale("es"); // live switch; untranslated UI keys read English
|
|
|
119
151
|
Error texts are generated from stapel-workspaces's `translations/errors.es.json`
|
|
120
152
|
catalog (`pnpm gen:errors`, drift-gated) and are complete over the error
|
|
121
153
|
registry by construction. The coverage boundary is asserted in
|
|
122
|
-
`test/i18nEs.test.ts
|
|
123
|
-
additively — this subpath and `workspacesI18nBundleEs` keep their names.
|
|
154
|
+
`test/i18nEs.test.ts`, which asserts parity with the en bundle key for key.
|
|
124
155
|
|
|
125
156
|
## Guardrails
|
|
126
157
|
|
|
@@ -7,26 +7,26 @@ export interface paths {
|
|
|
7
7
|
cookie?: never;
|
|
8
8
|
};
|
|
9
9
|
/**
|
|
10
|
-
* @description
|
|
10
|
+
* @description Turns an unaskable plan ceiling into 503 for the whole view.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* Mixed in rather than written as an ``except`` at each call site because
|
|
13
|
+
* the seam is consulted from views AND from inside ``services`` under the
|
|
14
|
+
* workspace lock, and the set of call sites grows: a view that gains a
|
|
15
|
+
* method later inherits the mapping instead of re-deriving it. The 402
|
|
16
|
+
* denials stay per-call-site — those carry a limit the screen renders.
|
|
17
17
|
*
|
|
18
18
|
* **Permissions:** `IsAuthenticated`
|
|
19
19
|
*/
|
|
20
20
|
get: operations["workspaces_api_v1_retrieve"];
|
|
21
21
|
put?: never;
|
|
22
22
|
/**
|
|
23
|
-
* @description
|
|
24
|
-
*
|
|
25
|
-
* Subclasses (or downstream projects) can swap the request/response
|
|
26
|
-
* serializers without copying method bodies:
|
|
23
|
+
* @description Turns an unaskable plan ceiling into 503 for the whole view.
|
|
27
24
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* Mixed in rather than written as an ``except`` at each call site because
|
|
26
|
+
* the seam is consulted from views AND from inside ``services`` under the
|
|
27
|
+
* workspace lock, and the set of call sites grows: a view that gains a
|
|
28
|
+
* method later inherits the mapping instead of re-deriving it. The 402
|
|
29
|
+
* denials stay per-call-site — those carry a limit the screen renders.
|
|
30
30
|
*
|
|
31
31
|
* **Permissions:** `IsAuthenticated`
|
|
32
32
|
*/
|
|
@@ -45,13 +45,21 @@ export interface paths {
|
|
|
45
45
|
cookie?: never;
|
|
46
46
|
};
|
|
47
47
|
/**
|
|
48
|
-
* @description Overridable serializer
|
|
48
|
+
* @description Overridable request/response serializer seam for an API view.
|
|
49
|
+
*
|
|
50
|
+
* Declare the classes as attributes, read them through the getters::
|
|
49
51
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
+
* class WalletView(SerializerSeamMixin, APIView):
|
|
53
|
+
* response_serializer_class = WalletResponseSerializer
|
|
52
54
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
+
* def get(self, request):
|
|
56
|
+
* response_cls = self.get_response_serializer_class()
|
|
57
|
+
* return StapelResponse(response_cls(wallet_to_dto(...)))
|
|
58
|
+
*
|
|
59
|
+
* A host project swaps either direction by subclassing and setting the
|
|
60
|
+
* attribute, or overrides the getter for a per-request decision — no HTTP
|
|
61
|
+
* method body is ever copied. ``None`` means the direction carries no
|
|
62
|
+
* serializer (raw ``request.FILES``, an empty 204 body).
|
|
55
63
|
*
|
|
56
64
|
* **Permissions:** `IsAuthenticated`
|
|
57
65
|
*/
|
|
@@ -59,13 +67,13 @@ export interface paths {
|
|
|
59
67
|
put?: never;
|
|
60
68
|
post?: never;
|
|
61
69
|
/**
|
|
62
|
-
* @description
|
|
63
|
-
*
|
|
64
|
-
* Subclasses (or downstream projects) can swap the request/response
|
|
65
|
-
* serializers without copying method bodies:
|
|
70
|
+
* @description End a workspace — the transition itself lives in the service.
|
|
66
71
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
72
|
+
* The view's whole job here is authority (owner, and only owner) and
|
|
73
|
+
* turning the service's typed refusal back into the keyed 409 the
|
|
74
|
+
* frontend routes on. The write, the event and the audit line are one
|
|
75
|
+
* indivisible act in ``services.delete_workspace``, because a second
|
|
76
|
+
* door into this transition is a second chance to forget the record.
|
|
69
77
|
*
|
|
70
78
|
* **Permissions:** `IsAuthenticated`
|
|
71
79
|
*/
|
|
@@ -73,13 +81,21 @@ export interface paths {
|
|
|
73
81
|
options?: never;
|
|
74
82
|
head?: never;
|
|
75
83
|
/**
|
|
76
|
-
* @description Overridable serializer
|
|
84
|
+
* @description Overridable request/response serializer seam for an API view.
|
|
77
85
|
*
|
|
78
|
-
*
|
|
79
|
-
* serializers without copying method bodies:
|
|
86
|
+
* Declare the classes as attributes, read them through the getters::
|
|
80
87
|
*
|
|
81
|
-
* class
|
|
82
|
-
* response_serializer_class =
|
|
88
|
+
* class WalletView(SerializerSeamMixin, APIView):
|
|
89
|
+
* response_serializer_class = WalletResponseSerializer
|
|
90
|
+
*
|
|
91
|
+
* def get(self, request):
|
|
92
|
+
* response_cls = self.get_response_serializer_class()
|
|
93
|
+
* return StapelResponse(response_cls(wallet_to_dto(...)))
|
|
94
|
+
*
|
|
95
|
+
* A host project swaps either direction by subclassing and setting the
|
|
96
|
+
* attribute, or overrides the getter for a per-request decision — no HTTP
|
|
97
|
+
* method body is ever copied. ``None`` means the direction carries no
|
|
98
|
+
* serializer (raw ``request.FILES``, an empty 204 body).
|
|
83
99
|
*
|
|
84
100
|
* **Permissions:** `IsAuthenticated`
|
|
85
101
|
*/
|
|
@@ -289,13 +305,21 @@ export interface paths {
|
|
|
289
305
|
cookie?: never;
|
|
290
306
|
};
|
|
291
307
|
/**
|
|
292
|
-
* @description Overridable serializer
|
|
308
|
+
* @description Overridable request/response serializer seam for an API view.
|
|
309
|
+
*
|
|
310
|
+
* Declare the classes as attributes, read them through the getters::
|
|
293
311
|
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
312
|
+
* class WalletView(SerializerSeamMixin, APIView):
|
|
313
|
+
* response_serializer_class = WalletResponseSerializer
|
|
296
314
|
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
315
|
+
* def get(self, request):
|
|
316
|
+
* response_cls = self.get_response_serializer_class()
|
|
317
|
+
* return StapelResponse(response_cls(wallet_to_dto(...)))
|
|
318
|
+
*
|
|
319
|
+
* A host project swaps either direction by subclassing and setting the
|
|
320
|
+
* attribute, or overrides the getter for a per-request decision — no HTTP
|
|
321
|
+
* method body is ever copied. ``None`` means the direction carries no
|
|
322
|
+
* serializer (raw ``request.FILES``, an empty 204 body).
|
|
299
323
|
*
|
|
300
324
|
* **Permissions:** `IsAuthenticated`
|
|
301
325
|
*/
|
|
@@ -319,13 +343,21 @@ export interface paths {
|
|
|
319
343
|
put?: never;
|
|
320
344
|
post?: never;
|
|
321
345
|
/**
|
|
322
|
-
* @description Overridable serializer
|
|
346
|
+
* @description Overridable request/response serializer seam for an API view.
|
|
347
|
+
*
|
|
348
|
+
* Declare the classes as attributes, read them through the getters::
|
|
323
349
|
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
350
|
+
* class WalletView(SerializerSeamMixin, APIView):
|
|
351
|
+
* response_serializer_class = WalletResponseSerializer
|
|
326
352
|
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
353
|
+
* def get(self, request):
|
|
354
|
+
* response_cls = self.get_response_serializer_class()
|
|
355
|
+
* return StapelResponse(response_cls(wallet_to_dto(...)))
|
|
356
|
+
*
|
|
357
|
+
* A host project swaps either direction by subclassing and setting the
|
|
358
|
+
* attribute, or overrides the getter for a per-request decision — no HTTP
|
|
359
|
+
* method body is ever copied. ``None`` means the direction carries no
|
|
360
|
+
* serializer (raw ``request.FILES``, an empty 204 body).
|
|
329
361
|
*
|
|
330
362
|
* **Permissions:** `IsAuthenticated`
|
|
331
363
|
*/
|
|
@@ -333,13 +365,21 @@ export interface paths {
|
|
|
333
365
|
options?: never;
|
|
334
366
|
head?: never;
|
|
335
367
|
/**
|
|
336
|
-
* @description Overridable serializer
|
|
368
|
+
* @description Overridable request/response serializer seam for an API view.
|
|
369
|
+
*
|
|
370
|
+
* Declare the classes as attributes, read them through the getters::
|
|
371
|
+
*
|
|
372
|
+
* class WalletView(SerializerSeamMixin, APIView):
|
|
373
|
+
* response_serializer_class = WalletResponseSerializer
|
|
337
374
|
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
375
|
+
* def get(self, request):
|
|
376
|
+
* response_cls = self.get_response_serializer_class()
|
|
377
|
+
* return StapelResponse(response_cls(wallet_to_dto(...)))
|
|
340
378
|
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
379
|
+
* A host project swaps either direction by subclassing and setting the
|
|
380
|
+
* attribute, or overrides the getter for a per-request decision — no HTTP
|
|
381
|
+
* method body is ever copied. ``None`` means the direction carries no
|
|
382
|
+
* serializer (raw ``request.FILES``, an empty 204 body).
|
|
343
383
|
*
|
|
344
384
|
* **Permissions:** `IsAuthenticated`
|
|
345
385
|
*/
|
|
@@ -477,13 +517,13 @@ export interface paths {
|
|
|
477
517
|
get?: never;
|
|
478
518
|
put?: never;
|
|
479
519
|
/**
|
|
480
|
-
* @description
|
|
520
|
+
* @description Turns an unaskable plan ceiling into 503 for the whole view.
|
|
481
521
|
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
522
|
+
* Mixed in rather than written as an ``except`` at each call site because
|
|
523
|
+
* the seam is consulted from views AND from inside ``services`` under the
|
|
524
|
+
* workspace lock, and the set of call sites grows: a view that gains a
|
|
525
|
+
* method later inherits the mapping instead of re-deriving it. The 402
|
|
526
|
+
* denials stay per-call-site — those carry a limit the screen renders.
|
|
487
527
|
*
|
|
488
528
|
* **Permissions:** `IsAuthenticated`
|
|
489
529
|
*/
|
|
@@ -517,7 +557,8 @@ export interface paths {
|
|
|
517
557
|
* Gate stack, in order: HIGH step-up (``@requires_verification``, scope
|
|
518
558
|
* ``sensitive`` — the same store as admin step-up) → capability
|
|
519
559
|
* ``members.provision`` (403) → entitlement ``workspaces.provision_user``
|
|
520
|
-
* (402;
|
|
560
|
+
* (402; an unreachable billing answers 503, not allow) → optional
|
|
561
|
+
* per-user debit
|
|
521
562
|
* (``STAPEL_WORKSPACES["PROVISION_USER_CREDITS"]`` > 0).
|
|
522
563
|
*
|
|
523
564
|
* Credentials: a synthetic account normally has no email — when the
|
|
@@ -650,6 +691,11 @@ export interface paths {
|
|
|
650
691
|
* NOT consumed here: accept stays a separate, deliberate step after
|
|
651
692
|
* setup. Neither the invite token nor the grant token is ever logged.
|
|
652
693
|
*
|
|
694
|
+
* ONE live grant at a time (WORK-03): while the previous grant is inside
|
|
695
|
+
* its TTL this answers 429 ``error.429.invitation_grant_pending`` with a
|
|
696
|
+
* ``Retry-After``, so a leaked invite link cannot be replayed into an
|
|
697
|
+
* endless supply of sign-in credentials for the invited mailbox.
|
|
698
|
+
*
|
|
653
699
|
* **Permissions:** `AllowAny`
|
|
654
700
|
*/
|
|
655
701
|
post: operations["workspaces_api_v1_invitations_claim_create"];
|
|
@@ -671,11 +717,36 @@ export interface paths {
|
|
|
671
717
|
/**
|
|
672
718
|
* @description Decline an invitation — the invitee's terminal "no" (spec §B2).
|
|
673
719
|
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
720
|
+
* AllowAny, on the token, exactly like preview and claim — and this is a
|
|
721
|
+
* CHANGE from the original authenticated-only rule, made because that rule
|
|
722
|
+
* made the common "no" impossible to say.
|
|
723
|
+
*
|
|
724
|
+
* Saying no used to require an account. The invitee who has none is the
|
|
725
|
+
* majority case on this path (that is why ``claim`` exists at all), so
|
|
726
|
+
* declining meant first creating the very account the person was declining
|
|
727
|
+
* to create: the flow asked for the commitment before the decision, and
|
|
728
|
+
* the refusal left a live, empty account behind it. Verified on the stand.
|
|
729
|
+
*
|
|
730
|
+
* The token is the proof, and it is the same proof the module already
|
|
731
|
+
* accepts elsewhere for strictly more: ``claim`` takes this token from an
|
|
732
|
+
* anonymous caller and mints a LOGIN GRANT for the invited mailbox.
|
|
733
|
+
* Anything a token holder could do by declining, they could already do —
|
|
734
|
+
* and more — by claiming. What the token cannot do is act as somebody
|
|
735
|
+
* else: a request that arrives WITH a session naming an address must still
|
|
736
|
+
* be the invited address, so a signed-in person cannot resolve an
|
|
737
|
+
* invitation belonging to someone else. That check is unchanged.
|
|
738
|
+
*
|
|
739
|
+
* A session with no address at all (a guest session — this product mints
|
|
740
|
+
* them at meeting doors) is treated as the anonymous case rather than as a
|
|
741
|
+
* mismatched account: it names nobody, so it can neither prove nor
|
|
742
|
+
* disprove ownership, and the token stands on its own. Before this it was
|
|
743
|
+
* refused outright by ``ANONYMOUS_DENIED``, which is how a guest who was
|
|
744
|
+
* also an invitee could not decline at all.
|
|
745
|
+
*
|
|
746
|
+
* Decline ≠ revoke — both states stay distinguishable in the preview
|
|
747
|
+
* ``status``. Throttled and token-never-logged like its AllowAny siblings.
|
|
677
748
|
*
|
|
678
|
-
* **Permissions:** `
|
|
749
|
+
* **Permissions:** `AllowAny`
|
|
679
750
|
*/
|
|
680
751
|
post: operations["workspaces_api_v1_invitations_decline_create"];
|
|
681
752
|
delete?: never;
|
|
@@ -694,13 +765,13 @@ export interface paths {
|
|
|
694
765
|
get?: never;
|
|
695
766
|
put?: never;
|
|
696
767
|
/**
|
|
697
|
-
* @description
|
|
698
|
-
*
|
|
699
|
-
* Subclasses (or downstream projects) can swap the request/response
|
|
700
|
-
* serializers without copying method bodies:
|
|
768
|
+
* @description Turns an unaskable plan ceiling into 503 for the whole view.
|
|
701
769
|
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
770
|
+
* Mixed in rather than written as an ``except`` at each call site because
|
|
771
|
+
* the seam is consulted from views AND from inside ``services`` under the
|
|
772
|
+
* workspace lock, and the set of call sites grows: a view that gains a
|
|
773
|
+
* method later inherits the mapping instead of re-deriving it. The 402
|
|
774
|
+
* denials stay per-call-site — those carry a limit the screen renders.
|
|
704
775
|
*
|
|
705
776
|
* **Permissions:** `IsAuthenticated`
|
|
706
777
|
*/
|
|
@@ -996,6 +1067,46 @@ export interface components {
|
|
|
996
1067
|
*/
|
|
997
1068
|
last_sent_at?: string | null;
|
|
998
1069
|
};
|
|
1070
|
+
/** @description How far the workspace's require_mfa policy has actually got. */
|
|
1071
|
+
MFAEnforcementStatus: {
|
|
1072
|
+
/**
|
|
1073
|
+
* @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
|
|
1074
|
+
* @example enforcing
|
|
1075
|
+
*/
|
|
1076
|
+
state: string;
|
|
1077
|
+
/**
|
|
1078
|
+
* @description How many sweeps have run since the policy was switched on
|
|
1079
|
+
* @example 3
|
|
1080
|
+
*/
|
|
1081
|
+
attempts?: number;
|
|
1082
|
+
/**
|
|
1083
|
+
* @description Members auth answered about in the last sweep
|
|
1084
|
+
* @example 12
|
|
1085
|
+
*/
|
|
1086
|
+
checked_members?: number;
|
|
1087
|
+
/**
|
|
1088
|
+
* @description Of those, members with no strong factor — suspended with reason no_mfa
|
|
1089
|
+
* @example 2
|
|
1090
|
+
*/
|
|
1091
|
+
noncompliant_members?: number;
|
|
1092
|
+
/**
|
|
1093
|
+
* @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
|
|
1094
|
+
* @example 1
|
|
1095
|
+
*/
|
|
1096
|
+
unverified_members?: number;
|
|
1097
|
+
/**
|
|
1098
|
+
* @description ISO 8601 time of the last sweep; null before the first
|
|
1099
|
+
* @example 2026-08-11T10:00:00Z
|
|
1100
|
+
*/
|
|
1101
|
+
last_attempt_at?: string | null;
|
|
1102
|
+
/** @description ISO 8601 time coverage last became complete; null while it is not */
|
|
1103
|
+
completed_at?: string | null;
|
|
1104
|
+
/**
|
|
1105
|
+
* @description What auth answered when the last sweep failed, verbatim; empty otherwise. Never credential material
|
|
1106
|
+
* @example FunctionCallError: mfa_status timed out
|
|
1107
|
+
*/
|
|
1108
|
+
last_error?: string;
|
|
1109
|
+
};
|
|
999
1110
|
/** @description Invite payload. */
|
|
1000
1111
|
MemberInviteRequest: {
|
|
1001
1112
|
/**
|
|
@@ -1106,6 +1217,13 @@ export interface components {
|
|
|
1106
1217
|
* @example Ada Lovelace
|
|
1107
1218
|
*/
|
|
1108
1219
|
display_name?: string | null;
|
|
1220
|
+
/**
|
|
1221
|
+
* @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
|
|
1222
|
+
* @example true
|
|
1223
|
+
*/
|
|
1224
|
+
mfa_compliant?: boolean | null;
|
|
1225
|
+
/** @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 */
|
|
1226
|
+
is_self?: boolean;
|
|
1109
1227
|
};
|
|
1110
1228
|
PaginatedAuditEventResponseList: {
|
|
1111
1229
|
items: components["schemas"]["AuditEventResponse"][];
|
|
@@ -1393,6 +1511,15 @@ export interface components {
|
|
|
1393
1511
|
* @example Victor P.
|
|
1394
1512
|
*/
|
|
1395
1513
|
owner_display_name?: string;
|
|
1514
|
+
/** @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 */
|
|
1515
|
+
mfa_enforcement?: components["schemas"]["MFAEnforcementStatus"] | null;
|
|
1516
|
+
/** @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 */
|
|
1517
|
+
can_delete?: boolean;
|
|
1518
|
+
/**
|
|
1519
|
+
* @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
|
|
1520
|
+
* @example error.409.workspace_is_instance_default
|
|
1521
|
+
*/
|
|
1522
|
+
delete_blocked_reason?: string;
|
|
1396
1523
|
};
|
|
1397
1524
|
};
|
|
1398
1525
|
responses: never;
|