@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.
- package/CHANGELOG.md +183 -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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- 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/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 +281 -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 +1 -0
- package/src/model/format.ts +158 -0
- 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
|
@@ -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
|
+
}
|