@stapel/workspaces-react 0.17.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +183 -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 +108 -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 +140 -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 +387 -0
  104. package/src/default/index.ts +28 -7
  105. package/src/default/parts.tsx +308 -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
@@ -0,0 +1,309 @@
1
+ /**
2
+ * `<AuditTrailPane/>` — the workspace's membership history (GET
3
+ * `{ws}/audit`), the record nothing kept before stapel-workspaces 0.24: who
4
+ * let this person in, who took them out, and when.
5
+ *
6
+ * Three things this screen refuses to do, all of them things a log viewer
7
+ * does and a product must not:
8
+ *
9
+ * - **No raw enum.** `member_role_changed` is a vocabulary term, not a
10
+ * sentence. The closed `AuditAction` vocabulary has a label per value; a
11
+ * value from a newer backend falls back to the key title-cased, never to a
12
+ * dotted i18n key and never to the raw snake_case token.
13
+ * - **No ISO timestamps.** "3 days ago (23 Sept 2026, 09:00)" — the relative
14
+ * reading a person wants and the absolute one a support agent quotes, in
15
+ * the reader's own calendar.
16
+ * - **No ids on the glass.** An actor the journal has no name for is "the
17
+ * system", which is what an actorless line actually means (a sweep, an
18
+ * expiry), rather than a truncated UUID.
19
+ *
20
+ * The read is gated server-side on `members.view`: every role that may see
21
+ * who is in the room may see how they got there.
22
+ */
23
+ import { useState } from "react";
24
+ import type { ReactElement } from "react";
25
+ import { Card, Flex, Select, Typography, theme as antdTheme } from "antd";
26
+ import { loadStateFromQuery, mapLoad, useI18n, useT } from "@stapel/core";
27
+ import {
28
+ EmptyState,
29
+ LoadList,
30
+ SkinTheme,
31
+ } from "@stapel/tokens-antd/skin";
32
+ import { spacing } from "@stapel/tokens";
33
+ import { useAudit } from "../model/queries.js";
34
+ import { titleCaseKey, useWorkspaceFormat } from "../model/format.js";
35
+ import type { AuditEvent, AuditParams } from "../api/types.js";
36
+ import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
37
+ import { AnchorPager, Muted, PersonLine, StatusTag } from "./parts.js";
38
+ import { ActiveWorkspaceBoundary } from "./ActiveWorkspace.js";
39
+ import { useRoleLabel } from "./RoleSelectField.js";
40
+
41
+ export interface AuditTrailPaneProps {
42
+ /**
43
+ * The workspace whose membership history this is. OPTIONAL: omitted (the
44
+ * way the nav contract mounts this screen), the active workspace comes from
45
+ * the runtime selection, and a screen with none renders the designed
46
+ * "choose a workspace" state.
47
+ */
48
+ workspaceId?: string;
49
+ }
50
+
51
+ /**
52
+ * The CLOSED action vocabulary (`models.AuditAction`), in lifecycle order —
53
+ * an invitation, then what became of it, then what became of the membership,
54
+ * then the end of the workspace. The filter offers exactly these because the
55
+ * server matches nothing for a value outside them, and a filter that silently
56
+ * does not apply is worse than an empty page.
57
+ */
58
+ const ACTIONS: readonly string[] = [
59
+ "invitation_created",
60
+ "invitation_accepted",
61
+ "invitation_declined",
62
+ "invitation_revoked",
63
+ "account_created_by_invitation",
64
+ "member_joined",
65
+ "member_provisioned",
66
+ "member_role_changed",
67
+ "member_suspended",
68
+ "member_unsuspended",
69
+ "member_removed",
70
+ "deleted",
71
+ ];
72
+
73
+ interface Walk {
74
+ readonly anchor: string | undefined;
75
+ readonly direction: "next" | "prev" | undefined;
76
+ readonly index: number;
77
+ }
78
+
79
+ const FIRST_PAGE: Walk = { anchor: undefined, direction: undefined, index: 1 };
80
+
81
+ /** The tone a line reads in: a removal is not the same news as a join. */
82
+ function toneFor(action: string): "neutral" | "success" | "warning" | "danger" {
83
+ if (action === "member_removed" || action === "deleted") return "danger";
84
+ if (action === "member_suspended" || action === "invitation_revoked") return "warning";
85
+ if (
86
+ action === "member_joined" ||
87
+ action === "invitation_accepted" ||
88
+ action === "member_unsuspended"
89
+ ) {
90
+ return "success";
91
+ }
92
+ return "neutral";
93
+ }
94
+
95
+ /**
96
+ * Consecutive events under the day they happened on.
97
+ *
98
+ * The wire returns the page already ordered, so grouping is a fold over
99
+ * neighbours — never a re-sort, which would put a client's opinion of the
100
+ * order above the server's cursor. An instant the formatter cannot read keeps
101
+ * its own group rather than being folded into the previous day.
102
+ */
103
+ function groupByDay(
104
+ events: readonly AuditEvent[],
105
+ dateOf: (iso: string | null | undefined) => string | null
106
+ ): readonly { readonly label: string; readonly events: readonly AuditEvent[] }[] {
107
+ const groups: { label: string; events: AuditEvent[] }[] = [];
108
+ for (const event of events) {
109
+ const label = dateOf(event.created_at) ?? "";
110
+ const current = groups[groups.length - 1];
111
+ if (current !== undefined && current.label === label) current.events.push(event);
112
+ else groups.push({ label, events: [event] });
113
+ }
114
+ return groups;
115
+ }
116
+
117
+ export function AuditTrailPane(props: AuditTrailPaneProps): ReactElement {
118
+ return (
119
+ <SkinTheme data-testid="audit-trail">
120
+ <ActiveWorkspaceBoundary
121
+ workspaceId={props.workspaceId}
122
+ testId="audit-trail-workspace"
123
+ >
124
+ {(workspaceId) => <AuditTrailBody workspaceId={workspaceId} />}
125
+ </ActiveWorkspaceBoundary>
126
+ </SkinTheme>
127
+ );
128
+ }
129
+
130
+ function AuditTrailBody(props: { readonly workspaceId: string }): ReactElement {
131
+ const t = useT();
132
+ const i18n = useI18n();
133
+ const format = useWorkspaceFormat();
134
+ const [action, setAction] = useState<string | null>(null);
135
+ const [walk, setWalk] = useState<Walk>(FIRST_PAGE);
136
+
137
+ const params: AuditParams = {
138
+ ...(action !== null ? { action } : {}),
139
+ ...(walk.anchor !== undefined ? { anchor: walk.anchor } : {}),
140
+ ...(walk.direction !== undefined ? { direction: walk.direction } : {}),
141
+ };
142
+ const query = useAudit(props.workspaceId, params);
143
+ const page = query.data ?? null;
144
+
145
+ // Raw bundle lookup, not `t()`: `t` falls back to the key itself, and a
146
+ // dotted i18n key is the one thing this must never print. Same contract as
147
+ // RoleSelect's `labelFor`.
148
+ const bundle = i18n.getBundle();
149
+ const labelForAction = (value: string): string =>
150
+ bundle[`workspaces.audit.action.${value}`] ?? titleCaseKey(value);
151
+
152
+ return (
153
+ <Card>
154
+ <Flex vertical gap={spacing["1"]}>
155
+ <Typography.Title level={4} style={{ marginTop: 0, marginBottom: 0 }}>
156
+ {t(WORKSPACES_I18N_KEYS.auditTitle)}
157
+ </Typography.Title>
158
+ <Typography.Text type="secondary">
159
+ {t(WORKSPACES_I18N_KEYS.auditSubtitle)}
160
+ </Typography.Text>
161
+ </Flex>
162
+
163
+ <Flex gap={spacing["2"]} align="center" style={{ marginTop: spacing["4"] }} wrap>
164
+ <Muted>{t(WORKSPACES_I18N_KEYS.auditFilterLabel)}</Muted>
165
+ <Select<string>
166
+ value={action ?? ""}
167
+ onChange={(next) => {
168
+ // A new filter is a new walk: the anchor belongs to the old one.
169
+ setAction(next === "" ? null : next);
170
+ setWalk(FIRST_PAGE);
171
+ }}
172
+ aria-label={t(WORKSPACES_I18N_KEYS.auditFilterLabel)}
173
+ style={{ flex: "1 1 14rem", maxWidth: "20rem" }}
174
+ data-testid="audit-filter"
175
+ options={[
176
+ { value: "", label: t(WORKSPACES_I18N_KEYS.auditFilterAll) },
177
+ ...ACTIONS.map((value) => ({ value, label: labelForAction(value) })),
178
+ ]}
179
+ />
180
+ </Flex>
181
+
182
+ <div style={{ marginTop: spacing["4"] }}>
183
+ <LoadList
184
+ state={mapLoad(loadStateFromQuery(query), (loaded) => loaded.items)}
185
+ testId="audit-list"
186
+ onRetry={() => {
187
+ void query.refetch();
188
+ }}
189
+ empty={
190
+ <EmptyState
191
+ title={t(WORKSPACES_I18N_KEYS.auditEmpty)}
192
+ testId="audit-list-empty"
193
+ />
194
+ }
195
+ >
196
+ {(events) => (
197
+ <div role="list" data-testid="audit-rows">
198
+ {groupByDay(events, format.date).map((day) => (
199
+ <section key={day.label} data-testid={`audit-day-${day.label}`}>
200
+ {/* The DAY is said once, above its events. A journal that
201
+ repeats "22 Aug 2026, 09:00" on every line of a burst
202
+ spends its widest column on the one thing every row in
203
+ the group has in common. */}
204
+ <div style={{ marginTop: spacing["4"], marginBottom: spacing["1"] }}>
205
+ <Typography.Text strong>{day.label}</Typography.Text>
206
+ </div>
207
+ {day.events.map((event) => (
208
+ <AuditRow
209
+ key={event.id}
210
+ event={event}
211
+ label={labelForAction(event.action)}
212
+ />
213
+ ))}
214
+ </section>
215
+ ))}
216
+ </div>
217
+ )}
218
+ </LoadList>
219
+ </div>
220
+
221
+ {page !== null && (
222
+ <AnchorPager
223
+ hasPrev={page.has_prev}
224
+ hasNext={page.has_next}
225
+ prevLabel={t(WORKSPACES_I18N_KEYS.pagerPrev)}
226
+ nextLabel={t(WORKSPACES_I18N_KEYS.pagerNext)}
227
+ position={t(WORKSPACES_I18N_KEYS.pagerPosition, { page: walk.index })}
228
+ testId="audit-pager"
229
+ onPrev={() =>
230
+ setWalk({
231
+ anchor: page.prev_anchor ?? undefined,
232
+ direction: "prev",
233
+ index: Math.max(1, walk.index - 1),
234
+ })
235
+ }
236
+ onNext={() =>
237
+ setWalk({
238
+ anchor: page.next_anchor ?? undefined,
239
+ direction: "next",
240
+ index: walk.index + 1,
241
+ })
242
+ }
243
+ />
244
+ )}
245
+ </Card>
246
+ );
247
+ }
248
+
249
+ function AuditRow(props: {
250
+ readonly event: AuditEvent;
251
+ readonly label: string;
252
+ }): ReactElement {
253
+ const t = useT();
254
+ const format = useWorkspaceFormat();
255
+ const labelForRole = useRoleLabel();
256
+ const { token } = antdTheme.useToken();
257
+ const { event } = props;
258
+ const actor = event.actor_display_name?.trim();
259
+
260
+ return (
261
+ <div
262
+ role="listitem"
263
+ data-testid={`audit-row-${event.id}`}
264
+ style={{
265
+ display: "flex",
266
+ flexWrap: "wrap",
267
+ alignItems: "flex-start",
268
+ justifyContent: "space-between",
269
+ gap: spacing["3"],
270
+ paddingTop: spacing["3"],
271
+ paddingBottom: spacing["3"],
272
+ borderBottom: `1px solid ${token.colorSplit}`,
273
+ }}
274
+ >
275
+ <PersonLine
276
+ name={event.subject_display_name ?? null}
277
+ email={event.subject_email ?? null}
278
+ tags={
279
+ <StatusTag tone={toneFor(event.action)} testId={`audit-action-${event.id}`}>
280
+ {props.label}
281
+ </StatusTag>
282
+ }
283
+ caption={
284
+ <>
285
+ {t(WORKSPACES_I18N_KEYS.auditBy, {
286
+ actor:
287
+ actor !== undefined && actor !== ""
288
+ ? actor
289
+ : t(WORKSPACES_I18N_KEYS.auditActorUnknown),
290
+ })}
291
+ {event.role !== null && event.role !== undefined && event.role !== "" && (
292
+ <>
293
+ {" · "}
294
+ {/* The registry's word, not the wire's key: `admin` lowercase
295
+ beside the title-cased roles the picker shows two screens
296
+ over reads as broken data. */}
297
+ {t(WORKSPACES_I18N_KEYS.auditRoleLine, {
298
+ role: labelForRole(event.role),
299
+ })}
300
+ </>
301
+ )}
302
+ </>
303
+ }
304
+ />
305
+ {/* The day is the group's heading; the row carries only the clock. */}
306
+ <Muted testId={`audit-when-${event.id}`}>{format.time(event.created_at)}</Muted>
307
+ </div>
308
+ );
309
+ }