@cosmicdrift/kumiko-bundled-features 0.181.0 → 0.182.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 +7 -7
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +20 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +12 -3
- package/src/tags/__tests__/tags.integration.test.ts +21 -12
- package/src/tags/handlers/assign-tag.write.ts +8 -0
- package/src/template-resolver/web/client-plugin.tsx +91 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.182.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>",
|
|
@@ -121,12 +121,12 @@
|
|
|
121
121
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
122
122
|
},
|
|
123
123
|
"dependencies": {
|
|
124
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
125
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
126
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
127
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
128
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
124
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.182.1",
|
|
125
|
+
"@cosmicdrift/kumiko-framework": "0.182.1",
|
|
126
|
+
"@cosmicdrift/kumiko-headless": "0.182.1",
|
|
127
|
+
"@cosmicdrift/kumiko-renderer": "0.182.1",
|
|
128
|
+
"@cosmicdrift/kumiko-renderer-web": "0.182.1",
|
|
129
|
+
"@cosmicdrift/kumiko-types": "0.182.1",
|
|
130
130
|
"@mollie/api-client": "^4.5.0",
|
|
131
131
|
"imapflow": "^1.3.3",
|
|
132
132
|
"mailparser": "^3.9.8",
|
|
@@ -185,6 +185,26 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
185
185
|
expect(revokedForUserIds).toContain(body.user.id);
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
test("a stored user timezone survives the preauth-confirm login (fw#1760)", async () => {
|
|
189
|
+
const email = "tz-preauth@example.com";
|
|
190
|
+
const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
|
|
191
|
+
await asRawClient(stack.db).unsafe(
|
|
192
|
+
`UPDATE "${userTable.tableName}" SET timezone = $1 WHERE id = $2`,
|
|
193
|
+
["Asia/Tokyo", userId],
|
|
194
|
+
);
|
|
195
|
+
const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
|
|
196
|
+
|
|
197
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
198
|
+
setupToken,
|
|
199
|
+
code: currentTotpCode(secret),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(res.status).toBe(200);
|
|
203
|
+
const body = await res.json();
|
|
204
|
+
const payload = JSON.parse(Buffer.from(body.token.split(".")[1], "base64url").toString());
|
|
205
|
+
expect(payload.timezone).toBe("Asia/Tokyo");
|
|
206
|
+
});
|
|
207
|
+
|
|
188
208
|
test("wrong TOTP code → invalid_totp_code, no enrollment persisted", async () => {
|
|
189
209
|
const email = "wrongcode@example.com";
|
|
190
210
|
const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
|
|
@@ -9,7 +9,7 @@ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/error
|
|
|
9
9
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
10
|
import { Temporal } from "temporal-polyfill";
|
|
11
11
|
import { z } from "zod";
|
|
12
|
-
import { burnToken } from "../../shared";
|
|
12
|
+
import { burnToken, sessionTimezoneField } from "../../shared";
|
|
13
13
|
import { USER_STATUS, UserQueries } from "../../user";
|
|
14
14
|
import { base32Decode } from "../base32";
|
|
15
15
|
import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
|
|
@@ -134,7 +134,7 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
134
134
|
const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
|
|
135
135
|
const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
|
|
136
136
|
id: userId,
|
|
137
|
-
})) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
|
|
137
|
+
})) as { roles?: string | null; status?: string; timezone?: string | null } | null; // @cast-boundary engine-payload
|
|
138
138
|
|
|
139
139
|
if (!userRow) return invalidSetupToken();
|
|
140
140
|
if (
|
|
@@ -179,7 +179,16 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
179
179
|
// verify.write.ts.
|
|
180
180
|
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
181
181
|
|
|
182
|
-
const baseSession: SessionUser = {
|
|
182
|
+
const baseSession: SessionUser = {
|
|
183
|
+
id: userId,
|
|
184
|
+
tenantId,
|
|
185
|
+
roles: mergedRoles,
|
|
186
|
+
// This endpoint logs the user in directly (fw#1760), so a stored
|
|
187
|
+
// timezone must survive the preauth-MFA-enrollment login just like
|
|
188
|
+
// a plain password login does — same helper as verify.write.ts /
|
|
189
|
+
// invite-accept-with-login.write.ts (#1759).
|
|
190
|
+
...sessionTimezoneField(userRow?.timezone),
|
|
191
|
+
};
|
|
183
192
|
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
184
193
|
const session: SessionUser =
|
|
185
194
|
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
@@ -307,19 +307,28 @@ describe("tags integration — idempotency", () => {
|
|
|
307
307
|
expect(rows[0]?.["tagId"]).toBe(tagId);
|
|
308
308
|
});
|
|
309
309
|
|
|
310
|
+
// 20 iterations, fresh (tag, entity) per run: this race has two distinct
|
|
311
|
+
// windows (create()-vs-create() unique-violation, and the narrower
|
|
312
|
+
// detail()-miss/restore()-hits window fixed alongside kumiko-framework#1778)
|
|
313
|
+
// that each fire in only a fraction of attempts — a single run isn't a
|
|
314
|
+
// reliable regression guard for either.
|
|
310
315
|
test("two concurrent first-time assigns of the same (tag, entity) both succeed — one row", async () => {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
316
|
+
for (let i = 0; i < 20; i++) {
|
|
317
|
+
// Both requests read `existing === null` before either write lands, so
|
|
318
|
+
// both fall through to create(); the loser's create() version_conflicts.
|
|
319
|
+
// The handler must converge that into success instead of surfacing a 409
|
|
320
|
+
// for what is, from the caller's perspective, an idempotent operation.
|
|
321
|
+
const tagId = await createTag(`racy-${i}`);
|
|
322
|
+
const entityId = `credit-race-${i}`;
|
|
323
|
+
const [a, b] = await Promise.all([
|
|
324
|
+
assign(tagId, "credit", entityId),
|
|
325
|
+
assign(tagId, "credit", entityId),
|
|
326
|
+
]);
|
|
327
|
+
expect(a).toBeDefined();
|
|
328
|
+
expect(b).toBeDefined();
|
|
329
|
+
const rows = await listAssignments({ field: "entityId", op: "eq", value: entityId });
|
|
330
|
+
expect(rows).toHaveLength(1);
|
|
331
|
+
}
|
|
323
332
|
});
|
|
324
333
|
});
|
|
325
334
|
|
|
@@ -42,6 +42,14 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
|
|
|
42
42
|
|
|
43
43
|
const restored = await tagAssignmentExecutor.restore({ id }, event.user, ctx.db);
|
|
44
44
|
if (restored.isSuccess) return { isSuccess: true as const, data: { id } };
|
|
45
|
+
// A concurrent first-time assign can also land here: the other caller's
|
|
46
|
+
// create() already wrote an active row between our `existing` read
|
|
47
|
+
// above and this restore() call, so restore() correctly refuses with
|
|
48
|
+
// "not_deleted" — but the desired end state is already true. Converge
|
|
49
|
+
// like the create() race below instead of surfacing a spurious error.
|
|
50
|
+
if ((restored.error.details as { reason?: string } | undefined)?.reason === "not_deleted") {
|
|
51
|
+
return { isSuccess: true as const, data: { id } };
|
|
52
|
+
}
|
|
45
53
|
if (restored.error.code !== "not_found") return restored;
|
|
46
54
|
|
|
47
55
|
const tag = await tagExecutor.detail({ id: payload.tagId }, event.user, ctx.db);
|
|
@@ -18,12 +18,17 @@ import type {
|
|
|
18
18
|
TreeNode,
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
20
|
import {
|
|
21
|
+
CONTENT_EDITOR_ELEMENT_ID,
|
|
22
|
+
ContentPreview,
|
|
23
|
+
type FeatureSchema,
|
|
24
|
+
useAppFeatures,
|
|
25
|
+
useContentEditor,
|
|
21
26
|
useDispatcher,
|
|
22
27
|
usePrimitives,
|
|
23
28
|
useQuery,
|
|
24
29
|
useTranslation,
|
|
25
30
|
} from "@cosmicdrift/kumiko-renderer";
|
|
26
|
-
import type
|
|
31
|
+
import { type ClientFeatureDefinition, PlainContentEditor } from "@cosmicdrift/kumiko-renderer-web";
|
|
27
32
|
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
|
|
28
33
|
import {
|
|
29
34
|
collectionHandlerName,
|
|
@@ -197,6 +202,46 @@ function makeTreeProvider(tenantIdOverride?: string, collectionId?: string): Tre
|
|
|
197
202
|
};
|
|
198
203
|
}
|
|
199
204
|
|
|
205
|
+
// contentFormat lives on the collection the node came from, not on the
|
|
206
|
+
// entity — a node without a collectionId (the hand-wired text-block tree)
|
|
207
|
+
// has none, useContentEditor then falls back to "plain" on its own.
|
|
208
|
+
function findContentFormat(
|
|
209
|
+
features: readonly FeatureSchema[],
|
|
210
|
+
collectionId?: string,
|
|
211
|
+
): string | undefined {
|
|
212
|
+
if (collectionId === undefined) return undefined;
|
|
213
|
+
for (const feature of features) {
|
|
214
|
+
const match = feature.contentCollections?.find((c) => c.id === collectionId);
|
|
215
|
+
if (match !== undefined) return match.contentFormat;
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Same lookup as findContentFormat, for the collection's fixed variable
|
|
221
|
+
// names (ContentCollectionDefinition.variableSchema) — the chip bar's data
|
|
222
|
+
// source. A node without a collectionId gets no chips: the hand-wired
|
|
223
|
+
// text-block tree has no collection to declare variables on.
|
|
224
|
+
function findVariableSchema(
|
|
225
|
+
features: readonly FeatureSchema[],
|
|
226
|
+
collectionId?: string,
|
|
227
|
+
): readonly string[] {
|
|
228
|
+
return Object.keys(findVariableExamples(features, collectionId));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Same lookup, for the example values the Preview substitutes in for
|
|
232
|
+
// `{{name}}` — the values half of the same variableSchema map.
|
|
233
|
+
function findVariableExamples(
|
|
234
|
+
features: readonly FeatureSchema[],
|
|
235
|
+
collectionId?: string,
|
|
236
|
+
): Readonly<Record<string, string>> {
|
|
237
|
+
if (collectionId === undefined) return {};
|
|
238
|
+
for (const feature of features) {
|
|
239
|
+
const match = feature.contentCollections?.find((c) => c.id === collectionId);
|
|
240
|
+
if (match !== undefined) return match.variableSchema ?? {};
|
|
241
|
+
}
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
244
|
+
|
|
200
245
|
// Edit form: loads the current values via by-slug, lets TenantAdmin and
|
|
201
246
|
// SystemAdmin edit title + content, dispatches set on submit. Other users see
|
|
202
247
|
// the form read-only with a hint banner — write permission stays opinionated
|
|
@@ -240,6 +285,11 @@ function TextBlockEditor({
|
|
|
240
285
|
const dispatcher = useDispatcher();
|
|
241
286
|
const user = useShellUser();
|
|
242
287
|
const t = useTranslation();
|
|
288
|
+
const features = useAppFeatures();
|
|
289
|
+
const contentFormat = findContentFormat(features, collectionId);
|
|
290
|
+
const variables = findVariableSchema(features, collectionId);
|
|
291
|
+
const variableExamples = findVariableExamples(features, collectionId);
|
|
292
|
+
const ContentEditor = useContentEditor(contentFormat);
|
|
243
293
|
const canWrite =
|
|
244
294
|
user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
|
|
245
295
|
|
|
@@ -263,6 +313,7 @@ function TextBlockEditor({
|
|
|
263
313
|
|
|
264
314
|
const [title, setTitle] = useState("");
|
|
265
315
|
const [content, setContent] = useState("");
|
|
316
|
+
const [previewing, setPreviewing] = useState(false);
|
|
266
317
|
const [submitting, setSubmitting] = useState(false);
|
|
267
318
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
268
319
|
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
|
@@ -351,16 +402,34 @@ function TextBlockEditor({
|
|
|
351
402
|
required
|
|
352
403
|
/>
|
|
353
404
|
</Field>
|
|
354
|
-
<Field
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
405
|
+
<Field
|
|
406
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
407
|
+
label={t("template-resolver.editor.contentLabel")}
|
|
408
|
+
labelAppendix={
|
|
409
|
+
<Button
|
|
410
|
+
type="button"
|
|
411
|
+
variant="secondary"
|
|
412
|
+
size="sm"
|
|
413
|
+
onClick={() => setPreviewing((p) => !p)}
|
|
414
|
+
>
|
|
415
|
+
{previewing ? t("kumiko.contentEditor.editMode") : t("kumiko.contentEditor.preview")}
|
|
416
|
+
</Button>
|
|
417
|
+
}
|
|
418
|
+
>
|
|
419
|
+
{previewing ? (
|
|
420
|
+
<ContentPreview
|
|
421
|
+
content={content}
|
|
422
|
+
variables={variableExamples}
|
|
423
|
+
contentFormat={contentFormat}
|
|
424
|
+
/>
|
|
425
|
+
) : (
|
|
426
|
+
<ContentEditor
|
|
427
|
+
value={content}
|
|
428
|
+
onChange={setContent}
|
|
429
|
+
variables={variables}
|
|
430
|
+
readOnly={disabled}
|
|
431
|
+
/>
|
|
432
|
+
)}
|
|
364
433
|
</Field>
|
|
365
434
|
{saveError !== null && <Banner variant="error">{saveError}</Banner>}
|
|
366
435
|
{savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
|
|
@@ -406,6 +475,17 @@ export function textBlocksClient(opts?: {
|
|
|
406
475
|
resolvers: {
|
|
407
476
|
"template-resolver:edit": TextBlockEditor,
|
|
408
477
|
},
|
|
478
|
+
// The default for every "plain" collection (ai-prompt, mail-html without
|
|
479
|
+
// an explicit rich override) — an app-registered "plain" editor on
|
|
480
|
+
// another clientFeature still wins, last-wins same as columnRenderers.
|
|
481
|
+
contentEditors: {
|
|
482
|
+
plain: PlainContentEditor,
|
|
483
|
+
// ponytail: same textarea+chips as "plain" — markdown is stored as
|
|
484
|
+
// text either way. Shares PlainContentEditor's fixed-DOM-id ceiling
|
|
485
|
+
// (see its own file comment); swap for a live-preview widget or a
|
|
486
|
+
// forwarded-ref variant if a collection actually needs one.
|
|
487
|
+
markdown: PlainContentEditor,
|
|
488
|
+
},
|
|
409
489
|
translations: defaultTranslations,
|
|
410
490
|
};
|
|
411
491
|
}
|