@cosmicdrift/kumiko-bundled-features 0.243.0 → 0.243.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.243.
|
|
3
|
+
"version": "0.243.1",
|
|
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>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.243.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.243.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.243.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.243.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.243.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.243.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.243.1",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.243.1",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.243.1",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.243.1",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.243.1",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.243.1",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.243.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.243.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.243.1",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.243.1"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -105,15 +105,62 @@ async function waitForMount(view: ReturnType<typeof render>, testId: string): Pr
|
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Reproduces the real host: RenderEdit renders exactly one <form
|
|
109
|
+
// testId="render-edit-form"> around every singleton projectionDetail screen
|
|
110
|
+
// (render-edit.tsx), and both sections mount as children of that form via
|
|
111
|
+
// ExtensionSectionMount. A section that renders its own <form> nests invalid
|
|
112
|
+
// DOM inside it — the security bug this guards against: the submit button
|
|
113
|
+
// falls back to a native GET navigation, putting the password(s) in the URL
|
|
114
|
+
// query instead of dispatching a write.
|
|
115
|
+
function renderInsideHostForm(node: ReactNode) {
|
|
116
|
+
const writes: Array<{ type: string; payload: unknown }> = [];
|
|
117
|
+
const view = render(<form data-testid="render-edit-form">{node}</form>, {
|
|
118
|
+
wrapper: withProviders(writes),
|
|
119
|
+
});
|
|
120
|
+
return { view, writes };
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
describe("ChangeEmailSection", () => {
|
|
109
|
-
test("renders
|
|
124
|
+
test("renders no <form> of its own (would nest inside the host RenderEdit <form>), shows the current email, no raw i18n keys", async () => {
|
|
110
125
|
const { view } = renderChangeEmailSection("marc@example.com");
|
|
111
126
|
await waitForMount(view, "change-email-root");
|
|
112
|
-
expect(view.
|
|
127
|
+
expect(view.container.querySelector("form")).toBeNull();
|
|
113
128
|
expect(view.getByTestId("profile-email-current").textContent).toContain("marc@example.com");
|
|
114
129
|
expect(view.container.textContent).not.toContain("profile.");
|
|
115
130
|
});
|
|
116
131
|
|
|
132
|
+
// Security regression guard: mounted inside the real host <form> (as
|
|
133
|
+
// RenderEdit does in production), a click on the submit button must still
|
|
134
|
+
// reach the write dispatcher — not fall back to the host form's native
|
|
135
|
+
// GET submission, which would put currentPassword/newEmail in the URL.
|
|
136
|
+
test("mounted inside the host RenderEdit <form>, a click on the submit button dispatches change-email (no nested <form>, no native submit fallback)", async () => {
|
|
137
|
+
const { view, writes } = renderInsideHostForm(
|
|
138
|
+
<ChangeEmailSection
|
|
139
|
+
entityName="user"
|
|
140
|
+
entityId={null}
|
|
141
|
+
values={{ email: "old@example.com" }}
|
|
142
|
+
patch={() => {}}
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
await waitForMount(view, "profile-email");
|
|
146
|
+
expect(view.container.querySelectorAll("form")).toHaveLength(1);
|
|
147
|
+
|
|
148
|
+
const emailInput = view.container.querySelector<HTMLInputElement>("#profile-new-email");
|
|
149
|
+
const pwInput = view.container.querySelector<HTMLInputElement>("#profile-email-password");
|
|
150
|
+
if (!emailInput || !pwInput) throw new Error("email form inputs not found");
|
|
151
|
+
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
|
152
|
+
fireEvent.change(pwInput, { target: { value: "current-pw" } });
|
|
153
|
+
fireEvent.click(view.getByTestId("profile-email-submit"));
|
|
154
|
+
|
|
155
|
+
await waitFor(() => {
|
|
156
|
+
if (writes.length === 0) throw new Error("no write dispatched yet");
|
|
157
|
+
});
|
|
158
|
+
expect(writes[0]).toEqual({
|
|
159
|
+
type: "user-profile:write:change-email",
|
|
160
|
+
payload: { currentPassword: "current-pw", newEmail: "new@example.com" },
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
117
164
|
test("submits change-email with currentPassword + newEmail, patches the host's email on success", async () => {
|
|
118
165
|
const { view, writes, patchCalls } = renderChangeEmailSection("old@example.com");
|
|
119
166
|
await waitForMount(view, "profile-email");
|
|
@@ -212,13 +259,40 @@ describe("ChangeEmailSection", () => {
|
|
|
212
259
|
});
|
|
213
260
|
|
|
214
261
|
describe("ChangePasswordSection", () => {
|
|
215
|
-
test("renders
|
|
262
|
+
test("renders no <form> of its own (would nest inside the host RenderEdit <form>), no raw i18n keys", async () => {
|
|
216
263
|
const { view } = renderChangePasswordSection();
|
|
217
264
|
await waitForMount(view, "change-password-root");
|
|
218
|
-
expect(view.
|
|
265
|
+
expect(view.container.querySelector("form")).toBeNull();
|
|
219
266
|
expect(view.container.textContent).not.toContain("profile.");
|
|
220
267
|
});
|
|
221
268
|
|
|
269
|
+
// Security regression guard: mounted inside the real host <form> (as
|
|
270
|
+
// RenderEdit does in production), a click on the submit button must still
|
|
271
|
+
// reach the write dispatcher — not fall back to the host form's native
|
|
272
|
+
// GET submission, which would put old+new passwords in the URL.
|
|
273
|
+
test("mounted inside the host RenderEdit <form>, a click on the submit button dispatches change-password (no nested <form>, no native submit fallback)", async () => {
|
|
274
|
+
const { view, writes } = renderInsideHostForm(<ChangePasswordSection />);
|
|
275
|
+
await waitForMount(view, "profile-password");
|
|
276
|
+
expect(view.container.querySelectorAll("form")).toHaveLength(1);
|
|
277
|
+
|
|
278
|
+
const oldPw = view.container.querySelector<HTMLInputElement>("#profile-old-password");
|
|
279
|
+
const newPw = view.container.querySelector<HTMLInputElement>("#profile-new-password");
|
|
280
|
+
const confirmPw = view.container.querySelector<HTMLInputElement>("#profile-confirm-password");
|
|
281
|
+
if (!oldPw || !newPw || !confirmPw) throw new Error("password form inputs not found");
|
|
282
|
+
fireEvent.change(oldPw, { target: { value: "current-pw" } });
|
|
283
|
+
fireEvent.change(newPw, { target: { value: "new-pw-1" } });
|
|
284
|
+
fireEvent.change(confirmPw, { target: { value: "new-pw-1" } });
|
|
285
|
+
fireEvent.click(view.getByTestId("profile-password-submit"));
|
|
286
|
+
|
|
287
|
+
await waitFor(() => {
|
|
288
|
+
if (writes.length === 0) throw new Error("no write dispatched yet");
|
|
289
|
+
});
|
|
290
|
+
expect(writes[0]).toEqual({
|
|
291
|
+
type: "auth-email-password:write:change-password",
|
|
292
|
+
payload: { oldPassword: "current-pw", newPassword: "new-pw-1" },
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
222
296
|
test("mismatched confirm password blocks the submit (no write dispatched)", async () => {
|
|
223
297
|
const { view, writes } = renderChangePasswordSection();
|
|
224
298
|
await waitForMount(view, "profile-password");
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.243.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Security fix: ChangePasswordSection/ChangeEmailSection no longer nest a <form> inside the host RenderEdit form.",
|
|
6
|
+
"detail": "Since `profile` became a declarative `projectionDetail` screen (fw#2312), RenderEdit wraps the whole singleton screen in its own host `<form>`. `ChangePasswordSection` and `ChangeEmailSection` were each rendering their own `<Form>` inside that host, creating invalid nested `<form>` DOM. The submit button silently fell back to a native GET submission of the outer form instead of dispatching a write, putting the entered password(s) in the URL query string (browser history, `Referer` header, proxy/server access logs — `ChangePasswordSection` leaked both the old and the new password). Both sections now follow the same no-nested-form pattern `write-form-section.tsx` uses: a plain container with a `type=\"button\"` that calls the write dispatcher directly. testIds are unchanged; Enter-to-submit inside these two sections is an accepted, deliberate trade-off (same one `write-form-section.tsx` already makes)."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.241.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
usePrimitives,
|
|
14
14
|
useTranslation,
|
|
15
15
|
} from "@cosmicdrift/kumiko-renderer";
|
|
16
|
-
import { BareFormProvider } from "@cosmicdrift/kumiko-renderer-web";
|
|
17
16
|
import { type ReactNode, useState } from "react";
|
|
18
17
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
19
18
|
import { requestEmailVerification } from "../../auth-email-password/web";
|
|
@@ -46,12 +45,15 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
|
|
|
46
45
|
|
|
47
46
|
// Mounted by the renderer's ExtensionSectionMount (already wrapped in
|
|
48
47
|
// <Section title={...} testId="section-extension-...">) — takes no props of
|
|
49
|
-
// its own.
|
|
50
|
-
//
|
|
51
|
-
//
|
|
48
|
+
// its own. Deliberately NOT a <Form>: RenderEdit already renders a host
|
|
49
|
+
// <form> around the whole singleton screen (render-edit.tsx), so a second
|
|
50
|
+
// nested <form> here is invalid DOM and silently degrades the submit button
|
|
51
|
+
// into a native GET navigation instead of a write dispatch (same failure
|
|
52
|
+
// mode write-form-section.tsx documents and avoids the same way — a plain
|
|
53
|
+
// button click, not a second <form>).
|
|
52
54
|
export function ChangePasswordSection(): ReactNode {
|
|
53
55
|
const t = useTranslation();
|
|
54
|
-
const {
|
|
56
|
+
const { Field, Input, Button } = usePrimitives();
|
|
55
57
|
const dispatcher = useDispatcher();
|
|
56
58
|
const [oldPassword, setOldPassword] = useState("");
|
|
57
59
|
const [newPassword, setNewPassword] = useState("");
|
|
@@ -82,55 +84,55 @@ export function ChangePasswordSection(): ReactNode {
|
|
|
82
84
|
|
|
83
85
|
const submitting = status.kind === "submitting";
|
|
84
86
|
return (
|
|
85
|
-
<
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
</
|
|
133
|
-
</
|
|
87
|
+
<div data-testid="profile-password" className="flex flex-col gap-4">
|
|
88
|
+
<Field id="profile-old-password" label={t("profile.password.old")} required>
|
|
89
|
+
<Input
|
|
90
|
+
kind="password"
|
|
91
|
+
id="profile-old-password"
|
|
92
|
+
name="profile-old-password"
|
|
93
|
+
value={oldPassword}
|
|
94
|
+
onChange={setOldPassword}
|
|
95
|
+
disabled={submitting}
|
|
96
|
+
required
|
|
97
|
+
autoComplete="current-password"
|
|
98
|
+
/>
|
|
99
|
+
</Field>
|
|
100
|
+
<Field id="profile-new-password" label={t("profile.password.new")} required>
|
|
101
|
+
<Input
|
|
102
|
+
kind="password"
|
|
103
|
+
id="profile-new-password"
|
|
104
|
+
name="profile-new-password"
|
|
105
|
+
value={newPassword}
|
|
106
|
+
onChange={setNewPassword}
|
|
107
|
+
disabled={submitting}
|
|
108
|
+
required
|
|
109
|
+
autoComplete="new-password"
|
|
110
|
+
/>
|
|
111
|
+
</Field>
|
|
112
|
+
<Field id="profile-confirm-password" label={t("profile.password.confirm")} required>
|
|
113
|
+
<Input
|
|
114
|
+
kind="password"
|
|
115
|
+
id="profile-confirm-password"
|
|
116
|
+
name="profile-confirm-password"
|
|
117
|
+
value={confirm}
|
|
118
|
+
onChange={setConfirm}
|
|
119
|
+
disabled={submitting}
|
|
120
|
+
required
|
|
121
|
+
autoComplete="new-password"
|
|
122
|
+
/>
|
|
123
|
+
</Field>
|
|
124
|
+
<StatusBanner status={status} />
|
|
125
|
+
<div className="flex items-center justify-end gap-2">
|
|
126
|
+
<Button
|
|
127
|
+
type="button"
|
|
128
|
+
disabled={submitting}
|
|
129
|
+
onClick={() => onSubmit()}
|
|
130
|
+
testId="profile-password-submit"
|
|
131
|
+
>
|
|
132
|
+
{t("profile.password.submit")}
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
134
136
|
);
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -142,7 +144,7 @@ export function ChangePasswordSection(): ReactNode {
|
|
|
142
144
|
// relying on a full-screen refetch to un-stale the "current email" line.
|
|
143
145
|
export function ChangeEmailSection({ values, patch }: ExtensionSectionProps): ReactNode {
|
|
144
146
|
const t = useTranslation();
|
|
145
|
-
const {
|
|
147
|
+
const { Field, Input, Button, Text } = usePrimitives();
|
|
146
148
|
const dispatcher = useDispatcher();
|
|
147
149
|
const [newEmail, setNewEmail] = useState("");
|
|
148
150
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
@@ -188,45 +190,45 @@ export function ChangeEmailSection({ values, patch }: ExtensionSectionProps): Re
|
|
|
188
190
|
|
|
189
191
|
const submitting = status.kind === "submitting";
|
|
190
192
|
return (
|
|
191
|
-
<
|
|
192
|
-
<
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
{
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
</
|
|
230
|
-
</
|
|
193
|
+
<div data-testid="profile-email" className="flex flex-col gap-4">
|
|
194
|
+
<Text variant="muted" testId="profile-email-current">
|
|
195
|
+
{t("profile.email.current")}: {currentEmail}
|
|
196
|
+
</Text>
|
|
197
|
+
<Field id="profile-new-email" label={t("profile.email.new")} required>
|
|
198
|
+
<Input
|
|
199
|
+
kind="email"
|
|
200
|
+
id="profile-new-email"
|
|
201
|
+
name="profile-new-email"
|
|
202
|
+
value={newEmail}
|
|
203
|
+
onChange={setNewEmail}
|
|
204
|
+
disabled={submitting}
|
|
205
|
+
required
|
|
206
|
+
autoComplete="email"
|
|
207
|
+
/>
|
|
208
|
+
</Field>
|
|
209
|
+
<Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
|
|
210
|
+
<Input
|
|
211
|
+
kind="password"
|
|
212
|
+
id="profile-email-password"
|
|
213
|
+
name="profile-email-password"
|
|
214
|
+
value={currentPassword}
|
|
215
|
+
onChange={setCurrentPassword}
|
|
216
|
+
disabled={submitting}
|
|
217
|
+
required
|
|
218
|
+
autoComplete="current-password"
|
|
219
|
+
/>
|
|
220
|
+
</Field>
|
|
221
|
+
<StatusBanner status={status} />
|
|
222
|
+
<div className="flex items-center justify-end gap-2">
|
|
223
|
+
<Button
|
|
224
|
+
type="button"
|
|
225
|
+
disabled={submitting}
|
|
226
|
+
onClick={() => onSubmit()}
|
|
227
|
+
testId="profile-email-submit"
|
|
228
|
+
>
|
|
229
|
+
{t("profile.email.submit")}
|
|
230
|
+
</Button>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
231
233
|
);
|
|
232
234
|
}
|