@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.181.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.181.0",
125
- "@cosmicdrift/kumiko-framework": "0.181.0",
126
- "@cosmicdrift/kumiko-headless": "0.181.0",
127
- "@cosmicdrift/kumiko-renderer": "0.181.0",
128
- "@cosmicdrift/kumiko-renderer-web": "0.181.0",
129
- "@cosmicdrift/kumiko-types": "0.181.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 = { id: userId, tenantId, roles: mergedRoles };
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
- // Both requests read `existing === null` before either write lands, so
312
- // both fall through to create(); the loser's create() version_conflicts.
313
- // The handler must converge that into success instead of surfacing a 409
314
- // for what is, from the caller's perspective, an idempotent operation.
315
- const tagId = await createTag("racy");
316
- const [a, b] = await Promise.all([
317
- assign(tagId, "credit", "credit-race"),
318
- assign(tagId, "credit", "credit-race"),
319
- ]);
320
- expect(a).toBeDefined();
321
- expect(b).toBeDefined();
322
- expect(await countAssignments(admin.tenantId)).toBe(1);
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 { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
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 id="text-block-content" label={t("template-resolver.editor.contentLabel")}>
355
- <Input
356
- kind="textarea"
357
- id="text-block-content"
358
- name="text-block-content"
359
- value={content}
360
- onChange={setContent}
361
- disabled={disabled}
362
- rows={14}
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
  }