@cosmicdrift/kumiko-bundled-features 0.210.0 → 0.212.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/package.json +8 -8
- package/src/admin-shell/feature.ts +2 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +1 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +1 -0
- package/src/audit/__tests__/audit-log-screen.test.tsx +228 -0
- package/src/audit/constants.ts +3 -0
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +1 -0
- package/src/audit/web/audit-log-detail-screen.tsx +28 -6
- package/src/audit/web/audit-log-screen.tsx +51 -23
- package/src/auth-email-password/feature.ts +1 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
- package/src/auth-email-password/web/auth-gate.tsx +1 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
- package/src/auth-email-password/web/session.tsx +1 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
- package/src/auth-mfa/feature.ts +1 -0
- package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
- package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
- package/src/compliance-profiles/feature.ts +1 -5
- package/src/crypto-shredding/changes.json +2 -1
- package/src/custom-fields/__tests__/feature.test.ts +42 -0
- package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
- package/src/custom-fields/constants.ts +6 -0
- package/src/custom-fields/feature.ts +56 -10
- package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
- package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
- package/src/delivery/feature.ts +1 -3
- package/src/feature-toggles/feature.ts +1 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
- package/src/file-derivatives/changes.json +9 -1
- package/src/folders/web/folder-manager.tsx +1 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
- package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
- package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
- package/src/jobs/feature.ts +25 -1
- package/src/jobs/handlers/detail.query.ts +13 -1
- package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
- package/src/jobs/job-run-logger.ts +68 -18
- package/src/jobs/web/job-run-detail-screen.tsx +1 -0
- package/src/page-render/web.ts +1 -0
- package/src/personal-access-tokens/changes.json +7 -0
- package/src/personal-access-tokens/feature.ts +1 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
- package/src/secrets/constants.ts +12 -0
- package/src/secrets/feature.ts +35 -9
- package/src/secrets/handlers/delete.write.ts +23 -16
- package/src/secrets/handlers/list.query.ts +37 -30
- package/src/secrets/handlers/set.write.ts +43 -36
- package/src/secrets/index.ts +5 -0
- package/src/seo/feature.ts +1 -0
- package/src/tags/feature.ts +1 -0
- package/src/tags/web/tag-chip.tsx +2 -0
- package/src/tags/web/tag-manager.tsx +2 -1
- package/src/tags/web/tag-picker.tsx +1 -0
- package/src/template-resolver/changes.json +9 -1
- package/src/template-resolver/web/client-plugin.tsx +2 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +79 -4
- package/src/tenant/__tests__/tenant-security.integration.test.ts +260 -3
- package/src/tenant/changes.json +6 -0
- package/src/tenant/constants.ts +17 -4
- package/src/tenant/feature.ts +28 -11
- package/src/tenant/handlers/team-list.query.ts +250 -0
- package/src/tenant/i18n.ts +20 -0
- package/src/tenant/screens.ts +111 -3
- package/src/tenant/web/client-plugin.tsx +11 -6
- package/src/tenant/web/index.ts +2 -2
- package/src/tenant/web/member-roles-cell.tsx +12 -0
- package/src/tenant/web/member-status-cell.tsx +23 -0
- package/src/tier-engine/feature.ts +2 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
- package/src/user-data-rights/feature.ts +2 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/src/user-profile/i18n.ts +1 -0
- package/src/user-profile/web/profile-screen.tsx +1 -1
- package/src/tenant/web/i18n.ts +0 -24
- package/src/tenant/web/members-screen.tsx +0 -270
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.212.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.212.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.212.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.212.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.212.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.212.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.212.0",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -160,6 +160,6 @@
|
|
|
160
160
|
"devDependencies": {
|
|
161
161
|
"@testing-library/user-event": "^14.6.1",
|
|
162
162
|
"@types/qrcode": "^1.5.5",
|
|
163
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.212.0"
|
|
164
164
|
}
|
|
165
165
|
}
|
|
@@ -63,6 +63,7 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
|
|
|
63
63
|
...(includeTierAdmin ? (["admin-shell:nav:tier-admin"] as const) : []),
|
|
64
64
|
];
|
|
65
65
|
|
|
66
|
+
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
66
67
|
r.screen({
|
|
67
68
|
id: TENANT_OVERVIEW_SCREEN_ID,
|
|
68
69
|
type: "custom",
|
|
@@ -77,6 +78,7 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
|
|
|
77
78
|
order: 1,
|
|
78
79
|
});
|
|
79
80
|
|
|
81
|
+
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
80
82
|
r.screen({
|
|
81
83
|
id: PLATFORM_OVERVIEW_SCREEN_ID,
|
|
82
84
|
type: "custom",
|
|
@@ -13,6 +13,7 @@ export function PlatformOverviewScreen(): ReactNode {
|
|
|
13
13
|
const dispatcher = useDispatcher();
|
|
14
14
|
const [state, setState] = useState<OverviewState>({ kind: "loading" });
|
|
15
15
|
|
|
16
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
16
17
|
useEffect(() => {
|
|
17
18
|
let cancelled = false;
|
|
18
19
|
void (async (): Promise<void> => {
|
|
@@ -12,6 +12,7 @@ export function TenantOverviewScreen(): ReactNode {
|
|
|
12
12
|
const dispatcher = useDispatcher();
|
|
13
13
|
const [state, setState] = useState<OverviewState>({ kind: "loading" });
|
|
14
14
|
|
|
15
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
15
16
|
useEffect(() => {
|
|
16
17
|
let cancelled = false;
|
|
17
18
|
void (async (): Promise<void> => {
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// DOM test for AuditLogScreen actor-name resolution (#audit-log-actor-name).
|
|
3
|
+
// Provider wrapper is local — renderer-web → bundled-features forbids importing
|
|
4
|
+
// renderer-web test-utils from here.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { createStore, type Dispatcher, type DispatcherStatus } from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import {
|
|
9
|
+
createStaticLocaleResolver,
|
|
10
|
+
DispatcherProvider,
|
|
11
|
+
kumikoDefaultTranslations,
|
|
12
|
+
type LiveEventSubscriber,
|
|
13
|
+
LiveEventsProvider,
|
|
14
|
+
LocaleProvider,
|
|
15
|
+
type NavApi,
|
|
16
|
+
NavProvider,
|
|
17
|
+
PrimitivesProvider,
|
|
18
|
+
TokensProvider,
|
|
19
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
20
|
+
import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-web";
|
|
21
|
+
import { render, waitFor } from "@testing-library/react";
|
|
22
|
+
import type { ReactNode } from "react";
|
|
23
|
+
import { TenantQueries } from "../../tenant/constants";
|
|
24
|
+
import { AuditQueries } from "../constants";
|
|
25
|
+
import { AuditLogScreen } from "../web/audit-log-screen";
|
|
26
|
+
import { defaultTranslations } from "../web/i18n";
|
|
27
|
+
|
|
28
|
+
if (typeof globalThis.ResizeObserver === "undefined") {
|
|
29
|
+
globalThis.ResizeObserver = class {
|
|
30
|
+
observe(): void {}
|
|
31
|
+
unobserve(): void {}
|
|
32
|
+
disconnect(): void {}
|
|
33
|
+
} as unknown as typeof ResizeObserver;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const stubLiveEvents: LiveEventSubscriber = () => () => {};
|
|
37
|
+
const stubTokens = {
|
|
38
|
+
tokens: defaultTokens,
|
|
39
|
+
mode: "light" as const,
|
|
40
|
+
setMode: () => {},
|
|
41
|
+
toggleMode: () => {},
|
|
42
|
+
};
|
|
43
|
+
const stubResolver = createStaticLocaleResolver();
|
|
44
|
+
const stubNav: NavApi = {
|
|
45
|
+
route: undefined,
|
|
46
|
+
navigate: () => {},
|
|
47
|
+
replace: () => {},
|
|
48
|
+
hrefFor: (target) => {
|
|
49
|
+
if (!("screenId" in target)) return "";
|
|
50
|
+
return target.entityId !== undefined
|
|
51
|
+
? `/${target.screenId}/${target.entityId}`
|
|
52
|
+
: `/${target.screenId}`;
|
|
53
|
+
},
|
|
54
|
+
searchParams: {},
|
|
55
|
+
setSearchParams: () => {},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type AuditRowFixture = {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
readonly aggregateId: string;
|
|
61
|
+
readonly aggregateType: string;
|
|
62
|
+
readonly type: string;
|
|
63
|
+
readonly createdBy: string;
|
|
64
|
+
readonly createdAt: string;
|
|
65
|
+
readonly payload: Record<string, unknown>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type MemberRowFixture = {
|
|
69
|
+
readonly userId: string;
|
|
70
|
+
readonly email: string | null;
|
|
71
|
+
readonly displayName: string | null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const AUDIT_ROWS: readonly AuditRowFixture[] = [
|
|
75
|
+
{
|
|
76
|
+
id: "1",
|
|
77
|
+
aggregateId: "agg-1",
|
|
78
|
+
aggregateType: "widget",
|
|
79
|
+
type: "widget.created",
|
|
80
|
+
createdBy: "user-1",
|
|
81
|
+
createdAt: "2026-08-01T00:00:00.000Z",
|
|
82
|
+
payload: {},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "2",
|
|
86
|
+
aggregateId: "agg-2",
|
|
87
|
+
aggregateType: "widget",
|
|
88
|
+
type: "widget.updated",
|
|
89
|
+
createdBy: "user-2",
|
|
90
|
+
createdAt: "2026-08-02T00:00:00.000Z",
|
|
91
|
+
payload: {},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "3",
|
|
95
|
+
aggregateId: "agg-3",
|
|
96
|
+
aggregateType: "widget",
|
|
97
|
+
type: "widget.deleted",
|
|
98
|
+
createdBy: "user-missing",
|
|
99
|
+
createdAt: "2026-08-03T00:00:00.000Z",
|
|
100
|
+
payload: {},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "4",
|
|
104
|
+
aggregateId: "agg-4",
|
|
105
|
+
aggregateType: "widget",
|
|
106
|
+
type: "widget.synced",
|
|
107
|
+
createdBy: "system",
|
|
108
|
+
createdAt: "2026-08-04T00:00:00.000Z",
|
|
109
|
+
payload: {},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: "5",
|
|
113
|
+
aggregateId: "agg-5",
|
|
114
|
+
aggregateType: "widget",
|
|
115
|
+
type: "widget.archived",
|
|
116
|
+
createdBy: "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
|
|
117
|
+
createdAt: "2026-08-05T00:00:00.000Z",
|
|
118
|
+
payload: {},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const MEMBERS: readonly MemberRowFixture[] = [
|
|
123
|
+
{ userId: "user-1", email: "alice@example.com", displayName: "Alice Example" },
|
|
124
|
+
{ userId: "user-2", email: "bob@example.com", displayName: null },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
function makeDispatcher(): Dispatcher {
|
|
128
|
+
const statusStore = createStore<DispatcherStatus>("online");
|
|
129
|
+
const query = (async (type: string) => {
|
|
130
|
+
if (type === AuditQueries.list) {
|
|
131
|
+
return { isSuccess: true, data: { rows: AUDIT_ROWS, nextBefore: null } };
|
|
132
|
+
}
|
|
133
|
+
if (type === TenantQueries.members) {
|
|
134
|
+
return { isSuccess: true, data: MEMBERS };
|
|
135
|
+
}
|
|
136
|
+
return { isSuccess: true, data: null };
|
|
137
|
+
}) as unknown as Dispatcher["query"];
|
|
138
|
+
return {
|
|
139
|
+
write: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["write"],
|
|
140
|
+
query,
|
|
141
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
142
|
+
statusStore,
|
|
143
|
+
async *stream() {},
|
|
144
|
+
pendingWrites: () => [],
|
|
145
|
+
pendingFiles: () => [],
|
|
146
|
+
} as unknown as Dispatcher; // @cast-boundary test-stub
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function renderScreen(): ReturnType<typeof render> {
|
|
150
|
+
const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
|
|
151
|
+
<TokensProvider value={stubTokens}>
|
|
152
|
+
<LocaleProvider
|
|
153
|
+
resolver={stubResolver}
|
|
154
|
+
fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
|
|
155
|
+
>
|
|
156
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
157
|
+
<NavProvider value={stubNav}>
|
|
158
|
+
<LiveEventsProvider value={stubLiveEvents}>
|
|
159
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>{children}</DispatcherProvider>
|
|
160
|
+
</LiveEventsProvider>
|
|
161
|
+
</NavProvider>
|
|
162
|
+
</PrimitivesProvider>
|
|
163
|
+
</LocaleProvider>
|
|
164
|
+
</TokensProvider>
|
|
165
|
+
);
|
|
166
|
+
return render(<AuditLogScreen />, { wrapper });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
describe("AuditLogScreen — actor name resolution", () => {
|
|
170
|
+
test("known member with displayName → cell shows the display name", async () => {
|
|
171
|
+
const view = renderScreen();
|
|
172
|
+
await waitFor(() => {
|
|
173
|
+
const cell = view.queryByTestId("cell-1-actor");
|
|
174
|
+
if (cell === null) throw new Error("actor cell not rendered yet");
|
|
175
|
+
});
|
|
176
|
+
expect(view.getByTestId("cell-1-actor").textContent).toBe("Alice Example");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("known member without displayName → cell falls back to email", async () => {
|
|
180
|
+
const view = renderScreen();
|
|
181
|
+
await waitFor(() => {
|
|
182
|
+
const cell = view.queryByTestId("cell-2-actor");
|
|
183
|
+
if (cell === null) throw new Error("actor cell not rendered yet");
|
|
184
|
+
});
|
|
185
|
+
expect(view.getByTestId("cell-2-actor").textContent).toBe("bob@example.com");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("createdBy not in members list → cell is empty, never the raw id", async () => {
|
|
189
|
+
const view = renderScreen();
|
|
190
|
+
await waitFor(() => {
|
|
191
|
+
const cell = view.queryByTestId("cell-3-actor");
|
|
192
|
+
if (cell === null) throw new Error("actor cell not rendered yet");
|
|
193
|
+
});
|
|
194
|
+
const cell = view.getByTestId("cell-3-actor");
|
|
195
|
+
expect(cell.textContent).toBe("");
|
|
196
|
+
expect(cell.textContent).not.toBe("user-missing");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("table has no aggregate column", async () => {
|
|
200
|
+
const view = renderScreen();
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
const cell = view.queryByTestId("cell-1-actor");
|
|
203
|
+
if (cell === null) throw new Error("table not rendered yet");
|
|
204
|
+
});
|
|
205
|
+
expect(view.queryByTestId("cell-1-aggregate")).toBeNull();
|
|
206
|
+
expect(view.container.textContent).not.toContain("agg-1");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("createdBy is the system literal → cell shows the System label", async () => {
|
|
210
|
+
const view = renderScreen();
|
|
211
|
+
await waitFor(() => {
|
|
212
|
+
const cell = view.queryByTestId("cell-4-actor");
|
|
213
|
+
if (cell === null) throw new Error("actor cell not rendered yet");
|
|
214
|
+
});
|
|
215
|
+
expect(view.getByTestId("cell-4-actor").textContent).toBe("System");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("createdBy is an unresolvable UUID → cell stays empty, raw UUID never rendered", async () => {
|
|
219
|
+
const view = renderScreen();
|
|
220
|
+
await waitFor(() => {
|
|
221
|
+
const cell = view.queryByTestId("cell-5-actor");
|
|
222
|
+
if (cell === null) throw new Error("actor cell not rendered yet");
|
|
223
|
+
});
|
|
224
|
+
const cell = view.getByTestId("cell-5-actor");
|
|
225
|
+
expect(cell.textContent).toBe("");
|
|
226
|
+
expect(view.container.textContent).not.toContain("3f2504e0-4f89-11d3-9a0c-0305e82c3301");
|
|
227
|
+
});
|
|
228
|
+
});
|
package/src/audit/constants.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// Qualified handler names + screen ids — shared by server feature and web client.
|
|
3
3
|
export const AUDIT_FEATURE = "audit" as const;
|
|
4
4
|
|
|
5
|
+
/** Literal `createdBy` written by system-authored events (e.g. delivery attempts). */
|
|
6
|
+
export const SYSTEM_ACTOR_ID = "system" as const;
|
|
7
|
+
|
|
5
8
|
export const AuditQueries = {
|
|
6
9
|
list: "audit:query:list",
|
|
7
10
|
details: "audit:query:details",
|
package/src/audit/feature.ts
CHANGED
|
@@ -37,12 +37,14 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
37
37
|
details: r.queryHandler(detailsQuery),
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
40
41
|
r.screen({
|
|
41
42
|
id: AUDIT_LOG_SCREEN_ID,
|
|
42
43
|
type: "custom",
|
|
43
44
|
renderer: { react: { __component: "AuditLogScreen" } },
|
|
44
45
|
access: { roles: access.admin },
|
|
45
46
|
});
|
|
47
|
+
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
46
48
|
r.screen({
|
|
47
49
|
id: AUDIT_LOG_DETAIL_SCREEN_ID,
|
|
48
50
|
type: "custom",
|
package/src/audit/i18n.ts
CHANGED
|
@@ -16,6 +16,7 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
16
16
|
"audit.log.col.type": { en: "Event" },
|
|
17
17
|
"audit.log.col.aggregate": { en: "Aggregate" },
|
|
18
18
|
"audit.log.col.actor": { en: "Actor" },
|
|
19
|
+
"audit.log.actor.system": { en: "System" },
|
|
19
20
|
"audit.log.filter.eventType": { en: "Event type" },
|
|
20
21
|
"audit.log.filter.aggregateType": { en: "Aggregate type" },
|
|
21
22
|
"audit.log.filter.from": { en: "From" },
|
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
} from "@cosmicdrift/kumiko-renderer";
|
|
11
11
|
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
12
12
|
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
13
|
-
import {
|
|
13
|
+
import { TenantQueries } from "../../tenant/constants";
|
|
14
|
+
import { AuditQueries, SYSTEM_ACTOR_ID } from "../constants";
|
|
14
15
|
|
|
15
16
|
type AuditDetail = {
|
|
16
17
|
readonly id: string;
|
|
@@ -23,11 +24,17 @@ type AuditDetail = {
|
|
|
23
24
|
readonly metadata: Record<string, unknown>;
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
type MemberRow = {
|
|
28
|
+
readonly userId: string;
|
|
29
|
+
readonly email: string | null;
|
|
30
|
+
readonly displayName: string | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
26
33
|
type State =
|
|
27
34
|
| { readonly kind: "loading" }
|
|
28
35
|
| { readonly kind: "missing" }
|
|
29
36
|
| { readonly kind: "error"; readonly message: string }
|
|
30
|
-
| { readonly kind: "ready"; readonly event: AuditDetail };
|
|
37
|
+
| { readonly kind: "ready"; readonly event: AuditDetail; readonly actorName: string };
|
|
31
38
|
|
|
32
39
|
export function AuditLogDetailScreen(): ReactNode {
|
|
33
40
|
const t = useTranslation();
|
|
@@ -52,9 +59,24 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
52
59
|
setState({ kind: "missing" });
|
|
53
60
|
return;
|
|
54
61
|
}
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
const event = res.data;
|
|
63
|
+
const membersRes = await dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {});
|
|
64
|
+
const member = membersRes.isSuccess
|
|
65
|
+
? membersRes.data.find((m) => m.userId === event.createdBy)
|
|
66
|
+
: undefined;
|
|
67
|
+
setState({
|
|
68
|
+
kind: "ready",
|
|
69
|
+
event,
|
|
70
|
+
actorName:
|
|
71
|
+
event.createdBy === SYSTEM_ACTOR_ID
|
|
72
|
+
? t("audit.log.actor.system")
|
|
73
|
+
: member
|
|
74
|
+
? (member.displayName ?? member.email ?? "")
|
|
75
|
+
: "",
|
|
76
|
+
});
|
|
77
|
+
}, [dispatcher, eventId, t]);
|
|
57
78
|
|
|
79
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
58
80
|
useEffect(() => {
|
|
59
81
|
void load();
|
|
60
82
|
}, [load]);
|
|
@@ -83,7 +105,7 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
83
105
|
);
|
|
84
106
|
}
|
|
85
107
|
|
|
86
|
-
const { event } = state;
|
|
108
|
+
const { event, actorName } = state;
|
|
87
109
|
|
|
88
110
|
return (
|
|
89
111
|
<FormScreenShell testId="audit-log-detail-screen" className="flex flex-col gap-6">
|
|
@@ -101,7 +123,7 @@ export function AuditLogDetailScreen(): ReactNode {
|
|
|
101
123
|
</div>
|
|
102
124
|
<div>
|
|
103
125
|
<dt className="font-medium">{t("audit.log.col.actor")}</dt>
|
|
104
|
-
<dd data-testid="audit-detail-actor">{
|
|
126
|
+
<dd data-testid="audit-detail-actor">{actorName}</dd>
|
|
105
127
|
</div>
|
|
106
128
|
<div>
|
|
107
129
|
<dt className="font-medium">{t("audit.log.detail.field.id")}</dt>
|
|
@@ -11,8 +11,10 @@ import {
|
|
|
11
11
|
usePrimitives,
|
|
12
12
|
useTranslation,
|
|
13
13
|
} from "@cosmicdrift/kumiko-renderer";
|
|
14
|
+
import { Filter, RotateCcw } from "lucide-react";
|
|
14
15
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
15
|
-
import {
|
|
16
|
+
import { TenantQueries } from "../../tenant/constants";
|
|
17
|
+
import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries, SYSTEM_ACTOR_ID } from "../constants";
|
|
16
18
|
|
|
17
19
|
type AuditRow = {
|
|
18
20
|
readonly id: string;
|
|
@@ -26,6 +28,12 @@ type AuditRow = {
|
|
|
26
28
|
|
|
27
29
|
type AuditResponse = { readonly rows: readonly AuditRow[]; readonly nextBefore: string | null };
|
|
28
30
|
|
|
31
|
+
type MemberRow = {
|
|
32
|
+
readonly userId: string;
|
|
33
|
+
readonly email: string | null;
|
|
34
|
+
readonly displayName: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
29
37
|
type Filters = {
|
|
30
38
|
readonly eventType: string;
|
|
31
39
|
readonly aggregateType: string;
|
|
@@ -40,6 +48,7 @@ type State =
|
|
|
40
48
|
readonly kind: "ready";
|
|
41
49
|
readonly rows: readonly AuditRow[];
|
|
42
50
|
readonly nextBefore: string | null;
|
|
51
|
+
readonly names: ReadonlyMap<string, string>;
|
|
43
52
|
};
|
|
44
53
|
|
|
45
54
|
const EMPTY_FILTERS: Filters = { eventType: "", aggregateType: "", from: "", to: "" };
|
|
@@ -57,29 +66,44 @@ export function AuditLogScreen(): ReactNode {
|
|
|
57
66
|
const filtersRef = useRef(filters);
|
|
58
67
|
filtersRef.current = filters;
|
|
59
68
|
|
|
69
|
+
// Loaded once per screen lifetime, not per page — members rarely change
|
|
70
|
+
// mid-session and refetching on every "older" click is wasted work.
|
|
71
|
+
const membersRef = useRef<Promise<ReadonlyMap<string, string>> | null>(null);
|
|
72
|
+
|
|
60
73
|
const load = useCallback(
|
|
61
74
|
async (cursor?: string, overrideFilters?: Filters): Promise<void> => {
|
|
62
75
|
setState({ kind: "loading" });
|
|
63
76
|
const f = overrideFilters ?? filtersRef.current;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
membersRef.current ??= dispatcher
|
|
78
|
+
.query<readonly MemberRow[]>(TenantQueries.members, {})
|
|
79
|
+
.then((res) =>
|
|
80
|
+
res.isSuccess
|
|
81
|
+
? new Map(res.data.map((m) => [m.userId, m.displayName ?? m.email ?? ""] as const))
|
|
82
|
+
: new Map<string, string>(),
|
|
83
|
+
);
|
|
84
|
+
const [res, names] = await Promise.all([
|
|
85
|
+
dispatcher.query<AuditResponse>(AuditQueries.list, {
|
|
86
|
+
limit: 50,
|
|
87
|
+
...(cursor !== undefined && { before: cursor }),
|
|
88
|
+
...(f.eventType.trim() !== "" && { eventType: f.eventType.trim() }),
|
|
89
|
+
...(f.aggregateType.trim() !== "" && {
|
|
90
|
+
aggregateType: f.aggregateType.trim(),
|
|
91
|
+
}),
|
|
92
|
+
...(f.from !== "" && { from: toIsoStart(f.from) }),
|
|
93
|
+
...(f.to !== "" && { to: toIsoEnd(f.to) }),
|
|
70
94
|
}),
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
});
|
|
95
|
+
membersRef.current,
|
|
96
|
+
]);
|
|
74
97
|
if (!res.isSuccess) {
|
|
75
98
|
setState({ kind: "error", message: res.error.message });
|
|
76
99
|
return;
|
|
77
100
|
}
|
|
78
|
-
setState({ kind: "ready", rows: res.data.rows, nextBefore: res.data.nextBefore });
|
|
101
|
+
setState({ kind: "ready", rows: res.data.rows, nextBefore: res.data.nextBefore, names });
|
|
79
102
|
},
|
|
80
103
|
[dispatcher],
|
|
81
104
|
);
|
|
82
105
|
|
|
106
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
83
107
|
useEffect(() => {
|
|
84
108
|
void load(before);
|
|
85
109
|
}, [load, before]);
|
|
@@ -105,8 +129,8 @@ export function AuditLogScreen(): ReactNode {
|
|
|
105
129
|
|
|
106
130
|
return (
|
|
107
131
|
<div className="w-full" data-testid="audit-log-screen">
|
|
108
|
-
<div className="flex flex-
|
|
109
|
-
<div className="
|
|
132
|
+
<div className="flex flex-wrap items-end gap-4 px-6 pt-6">
|
|
133
|
+
<div className="min-w-40 flex-1">
|
|
110
134
|
<Field id="audit-filter-event" label={t("audit.log.filter.eventType")}>
|
|
111
135
|
<Input
|
|
112
136
|
kind="text"
|
|
@@ -116,6 +140,8 @@ export function AuditLogScreen(): ReactNode {
|
|
|
116
140
|
onChange={(v: string) => setFilters((f) => ({ ...f, eventType: v }))}
|
|
117
141
|
/>
|
|
118
142
|
</Field>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="min-w-40 flex-1">
|
|
119
145
|
<Field id="audit-filter-aggregate" label={t("audit.log.filter.aggregateType")}>
|
|
120
146
|
<Input
|
|
121
147
|
kind="text"
|
|
@@ -125,6 +151,8 @@ export function AuditLogScreen(): ReactNode {
|
|
|
125
151
|
onChange={(v) => setFilters((f) => ({ ...f, aggregateType: v }))}
|
|
126
152
|
/>
|
|
127
153
|
</Field>
|
|
154
|
+
</div>
|
|
155
|
+
<div className="min-w-40 flex-1">
|
|
128
156
|
<Field id="audit-filter-from" label={t("audit.log.filter.from")}>
|
|
129
157
|
<Input
|
|
130
158
|
kind="date"
|
|
@@ -134,6 +162,8 @@ export function AuditLogScreen(): ReactNode {
|
|
|
134
162
|
onChange={(v) => setFilters((f) => ({ ...f, from: v ?? "" }))}
|
|
135
163
|
/>
|
|
136
164
|
</Field>
|
|
165
|
+
</div>
|
|
166
|
+
<div className="min-w-40 flex-1">
|
|
137
167
|
<Field id="audit-filter-to" label={t("audit.log.filter.to")}>
|
|
138
168
|
<Input
|
|
139
169
|
kind="date"
|
|
@@ -154,7 +184,8 @@ export function AuditLogScreen(): ReactNode {
|
|
|
154
184
|
}}
|
|
155
185
|
testId="audit-log-apply-filters"
|
|
156
186
|
>
|
|
157
|
-
|
|
187
|
+
<Filter className="h-4 w-4" />
|
|
188
|
+
<span>{t("audit.log.filter.apply")}</span>
|
|
158
189
|
</Button>
|
|
159
190
|
<Button
|
|
160
191
|
type="button"
|
|
@@ -166,7 +197,8 @@ export function AuditLogScreen(): ReactNode {
|
|
|
166
197
|
}}
|
|
167
198
|
testId="audit-log-reset-filters"
|
|
168
199
|
>
|
|
169
|
-
|
|
200
|
+
<RotateCcw className="h-4 w-4" />
|
|
201
|
+
<span>{t("audit.log.filter.reset")}</span>
|
|
170
202
|
</Button>
|
|
171
203
|
</div>
|
|
172
204
|
</div>
|
|
@@ -176,12 +208,6 @@ export function AuditLogScreen(): ReactNode {
|
|
|
176
208
|
columns={[
|
|
177
209
|
{ field: "when", label: t("audit.log.col.when"), type: "string", sortable: true },
|
|
178
210
|
{ field: "type", label: t("audit.log.col.type"), type: "string", sortable: true },
|
|
179
|
-
{
|
|
180
|
-
field: "aggregate",
|
|
181
|
-
label: t("audit.log.col.aggregate"),
|
|
182
|
-
type: "string",
|
|
183
|
-
sortable: false,
|
|
184
|
-
},
|
|
185
211
|
{ field: "actor", label: t("audit.log.col.actor"), type: "string", sortable: false },
|
|
186
212
|
]}
|
|
187
213
|
sort={sort}
|
|
@@ -191,8 +217,10 @@ export function AuditLogScreen(): ReactNode {
|
|
|
191
217
|
values: {
|
|
192
218
|
when: formatWhen(row.createdAt),
|
|
193
219
|
type: row.type,
|
|
194
|
-
|
|
195
|
-
|
|
220
|
+
actor:
|
|
221
|
+
row.createdBy === SYSTEM_ACTOR_ID
|
|
222
|
+
? t("audit.log.actor.system")
|
|
223
|
+
: (state.names.get(row.createdBy) ?? ""),
|
|
196
224
|
},
|
|
197
225
|
}))}
|
|
198
226
|
onRowClick={(row) => openDetail(row.id)}
|
|
@@ -169,6 +169,7 @@ export function createAuthEmailPasswordFeature(
|
|
|
169
169
|
r.describe(
|
|
170
170
|
"Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows — password reset, email verification, magic-link self-signup, tenant invite, and account-unlock — are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`, `accountUnlock`) to `createAuthEmailPasswordFeature(opts)`. All five magic-link flows dispatch their mail through the `delivery` feature via `ctx.notify`, so mounting any of them additionally requires `delivery`. Tokens are HMAC-signed (reset/verify/unlock) or opaque-random in Redis (signup/invite). `accountUnlock` is the self-service escape hatch for `accountLockout`'s monotonic failure-counter (#1266): confirming the mailed token clears the Redis lockout state without touching the user entity. Requires the `user` and `tenant` features, and declares `JWT_SECRET` (≥ 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
|
|
171
171
|
);
|
|
172
|
+
// kumiko-lint-ignore i18n-ui-strings uiHints labels are feature-registry metadata for the admin feature catalog, not yet wired to the i18n system, see #2312
|
|
172
173
|
r.uiHints({
|
|
173
174
|
displayLabel: "Auth \u00b7 Email + Password",
|
|
174
175
|
category: "identity",
|
|
@@ -50,6 +50,7 @@ export type AuthCardProps = {
|
|
|
50
50
|
readonly children: ReactNode;
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
// kumiko-lint-ignore no-custom-primitives Wraps usePrimitives().Card, pure domain name for the auth-screen chrome, not a primitive reimplementation
|
|
53
54
|
export function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNode {
|
|
54
55
|
const { Card } = usePrimitives();
|
|
55
56
|
const shell = useAuthShell() ?? defaultAuthShell;
|
|
@@ -98,6 +99,7 @@ export function parseUrlToken(paramName = "token"): string {
|
|
|
98
99
|
// (server-injected token) short-circuits both the URL read and the scrub.
|
|
99
100
|
export function useUrlToken(override?: string, paramName = "token"): string {
|
|
100
101
|
const [token] = useState(() => override ?? parseUrlToken(paramName));
|
|
102
|
+
// kumiko-lint-ignore no-raw-hooks DOM integration: history.replaceState scrubs the token from browser history once, no framework query/mutation equivalent
|
|
101
103
|
useEffect(() => {
|
|
102
104
|
if (override !== undefined) return;
|
|
103
105
|
if (typeof window === "undefined") return;
|
|
@@ -33,6 +33,7 @@ export function ConfirmAccountUnlockScreen({
|
|
|
33
33
|
const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
|
|
34
34
|
const startedRef = useRef(false);
|
|
35
35
|
|
|
36
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
36
37
|
useEffect(() => {
|
|
37
38
|
if (status !== "verifying") return;
|
|
38
39
|
if (startedRef.current) return;
|
|
@@ -61,6 +61,7 @@ export function InviteAcceptScreen({
|
|
|
61
61
|
const acceptLoggedIn = async (): Promise<void> => {
|
|
62
62
|
setSubmitting(true);
|
|
63
63
|
setError(null);
|
|
64
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
64
65
|
const res = await fetch("/api/auth/invite-accept", {
|
|
65
66
|
method: "POST",
|
|
66
67
|
credentials: "same-origin",
|
|
@@ -83,6 +84,7 @@ export function InviteAcceptScreen({
|
|
|
83
84
|
const acceptAnonExisting = async (): Promise<void> => {
|
|
84
85
|
setSubmitting(true);
|
|
85
86
|
setError(null);
|
|
87
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
86
88
|
const res = await fetch("/api/auth/invite-accept-with-login", {
|
|
87
89
|
method: "POST",
|
|
88
90
|
credentials: "same-origin",
|
|
@@ -105,6 +107,7 @@ export function InviteAcceptScreen({
|
|
|
105
107
|
const acceptAnonNew = async (): Promise<void> => {
|
|
106
108
|
setSubmitting(true);
|
|
107
109
|
setError(null);
|
|
110
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
108
111
|
const res = await fetch("/api/auth/invite-signup-complete", {
|
|
109
112
|
method: "POST",
|
|
110
113
|
credentials: "same-origin",
|
|
@@ -128,6 +128,7 @@ export function SessionProvider({ children }: { readonly children: ReactNode }):
|
|
|
128
128
|
setState(next);
|
|
129
129
|
}, []);
|
|
130
130
|
|
|
131
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
131
132
|
useEffect(() => {
|
|
132
133
|
void doRefresh();
|
|
133
134
|
}, [doRefresh]);
|
|
@@ -36,6 +36,7 @@ export function VerifyEmailScreen({
|
|
|
36
36
|
const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
|
|
37
37
|
const startedRef = useRef(false);
|
|
38
38
|
|
|
39
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
39
40
|
useEffect(() => {
|
|
40
41
|
if (status !== "verifying") return;
|
|
41
42
|
if (startedRef.current) return;
|