@cosmicdrift/kumiko-bundled-features 0.210.0 → 0.211.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.210.0",
3
+ "version": "0.211.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.210.0",
130
- "@cosmicdrift/kumiko-framework": "0.210.0",
131
- "@cosmicdrift/kumiko-headless": "0.210.0",
132
- "@cosmicdrift/kumiko-renderer": "0.210.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.210.0",
134
- "@cosmicdrift/kumiko-types": "0.210.0",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.211.0",
130
+ "@cosmicdrift/kumiko-framework": "0.211.0",
131
+ "@cosmicdrift/kumiko-headless": "0.211.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.211.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.211.0",
134
+ "@cosmicdrift/kumiko-types": "0.211.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.210.0"
163
+ "@cosmicdrift/kumiko-locale-de": "0.211.0"
164
164
  }
165
165
  }
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.194.0",
4
+ "type": "breaking",
5
+ "title": "PRESET_VARIANT_NAMES removed from public exports; publicVariantQuery accepts any name.",
6
+ "detail": "The public GET /media/:fileRefId/:variant route now resolves the variant spec from the FileRef's field declaration (createImageField({ variants: {...} })) instead of a fixed spec table, so an app can declare and publicly serve its own variant names, and a field overriding a built-in preset name (thumb/card/hero/full) is served with its own spec instead of the frozen preset size. publicVariantQuery's schema now accepts any variant name (z.string().min(1).max(64)) instead of z.enum(PRESET_VARIANT_NAMES); resolution requires an exact match against the field's declared variants keys, and an unresolvable name answers 404, same as an unknown FileRef. The route's pre-DB path-param gate is now purely syntactic ([a-zA-Z0-9_-]{1,64}) instead of a name allow-list; a known-valid preset name plus a random fileRefId reached the same DB read as any other name, so the list only blocked the cheaper of two equally-costly attacks, and the actual defense is the existing per-IP rate limit and the UUID guard, both unchanged.",
7
+ "migration": "PRESET_VARIANT_NAMES only ever named the allow-list this release deletes, so there is nothing to migrate to. thumb/card/hero/full remain as ready-made specs to spread into a field's own variants."
8
+ }
9
+ ]
@@ -4,5 +4,12 @@
4
4
  "type": "improvement",
5
5
  "title": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated vi…",
6
6
  "detail": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated via the tier-engine (mirrors ledger/tags). Pass `{ toggleable: { default: false } }` for fail-closed gating — PAT is then off until a tier lists `\"personal-access-tokens\"` in its features. Omitting the option keeps PAT always-on (no behaviour change for existing consumers)."
7
+ },
8
+ {
9
+ "version": "0.202.0",
10
+ "type": "breaking",
11
+ "title": "personal-access-tokens: `write:create` now requires `currentPassword` (+ `mfaCode` if MFA enrolled) (e91d4cb).",
12
+ "detail": "`personal-access-tokens:write:create` now requires `currentPassword` (verified against the caller's password hash) before minting a token, and rejects when the caller has MFA enrolled and `mfaCode` is missing or wrong — a session cookie alone is no longer enough to stand up a durable API credential. `expiresInDays` now defaults to 90 days instead of never-expiring when omitted (the existing 3650-day cap is unchanged, so a genuinely long-lived token is still possible if requested explicitly). Changing a user's password, or enabling/disabling MFA, now revokes all of that user's live PAT tokens — mirrors the existing session auto-revoke-on-password-change behavior. `run-prod-app`/`run-dev-app` wire the new MFA↔PAT revoke callback automatically when both `auth-mfa` and `personal-access-tokens` are mounted; no app-level change needed for that part.",
13
+ "migration": "Apps that already mint PATs (their own client code, scripts, or tests) need to add `currentPassword` to the `create` request payload — this is a breaking change to the `create` request shape despite the minor bump (bundled-features doesn't follow strict semver across its handler schemas yet). If the caller has MFA enrolled, also include a valid `mfaCode`."
7
14
  }
8
15
  ]
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.195.0",
4
+ "type": "breaking",
5
+ "title": "ContentEditorProps gains a required id (fw#2001).",
6
+ "detail": "TextBlockEditor's Field wrapping a registered \"rich\"/\"plain\" editor pointed its htmlFor at the fixed CONTENT_EDITOR_ELEMENT_ID, which only the never-mounted textarea fallback actually used — the label was disconnected from the real input as soon as a collection declared contentFormat: \"rich\" or \"plain\". TextBlockEditor now generates a per-instance id via useId() and passes it to both the Field and the ContentEditor, so the label stays correctly associated and two editors mounted on the same page no longer collide on a shared DOM id.",
7
+ "migration": "A custom-registered content editor component now needs to accept and use this id prop, rendering it onto its own focusable root element. Consumers that don't touch the DOM id directly are unaffected. CONTENT_EDITOR_ELEMENT_ID stays exported as a default value for callers that don't need their own generated id."
8
+ }
9
+ ]
@@ -3,30 +3,105 @@ import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
4
  import { AuthHandlers } from "../../auth-email-password/constants";
5
5
  import { createConfigFeature } from "../../config/feature";
6
- import { MEMBERS_SCREEN_ID, TenantHandlers, TenantQueries } from "../constants";
6
+ import {
7
+ INVITE_CREATE_SCREEN_ID,
8
+ MEMBERS_SCREEN_ID,
9
+ TenantHandlers,
10
+ TenantQueries,
11
+ } from "../constants";
7
12
  import { createTenantFeature } from "../feature";
8
13
 
9
14
  describe("tenant members screen + handler access alignment", () => {
10
15
  const features = [createConfigFeature(), createTenantFeature()];
11
16
 
12
- test("boot-validates with members screen registered", () => {
17
+ test("boot-validates with members screen registered (inviteScreen off by default)", () => {
13
18
  expect(() => validateBoot(features)).not.toThrow();
14
19
  });
15
20
 
16
- test("members screen is custom, access.admin-gated", () => {
21
+ // fw-2223 regression: an actionForm screen bound to an OPTIONAL
22
+ // cross-feature handler (auth-email-password's invite-create, only
23
+ // registered when its `invite` option is configured) must not be
24
+ // unconditionally wired into `tenant` — apps that mount tenant with
25
+ // auth-email-password but without `invite` would otherwise fail boot.
26
+ // `inviteScreen` defaults to false so the test above keeps booting clean
27
+ // with no auth-email-password mounted at all.
28
+ test("without inviteScreen, the invite toolbar action and actionForm screen are absent", () => {
17
29
  const tenant = createTenantFeature();
18
30
  const screen = tenant.screens[MEMBERS_SCREEN_ID];
19
- expect(screen?.type).toBe("custom");
31
+ if (screen?.type !== "projectionList")
32
+ throw new Error("expected members screen to be projectionList");
33
+ expect(screen.toolbarActions ?? []).toHaveLength(0);
34
+ expect(tenant.screens[INVITE_CREATE_SCREEN_ID]).toBeUndefined();
35
+ });
36
+
37
+ // The opt-in path (inviteScreen: true + a real auth-email-password with
38
+ // `invite` configured) boots and works end to end — proven in
39
+ // tenant-security.integration.test.ts's setupTestStack, which composes the
40
+ // full realistic stack (delivery, channel-email, template-resolver, etc.)
41
+ // that a minimal validateBoot() here would otherwise have to duplicate.
42
+
43
+ test("members screen is a projectionList backed by team:list, access.admin-gated", () => {
44
+ const tenant = createTenantFeature();
45
+ const screen = tenant.screens[MEMBERS_SCREEN_ID];
46
+ expect(screen?.type).toBe("projectionList");
47
+ if (screen?.type === "projectionList") {
48
+ expect(screen.query).toBe(TenantQueries.teamList);
49
+ }
20
50
  if (screen && "access" in screen && screen.access && "roles" in screen.access) {
21
51
  expect(screen.access.roles).toEqual(access.admin);
22
52
  }
23
53
  });
24
54
 
55
+ test("cancel-invitation row action is only visible on pending rows and cancels via TenantHandlers.cancelInvitation", () => {
56
+ const tenant = createTenantFeature();
57
+ const screen = tenant.screens[MEMBERS_SCREEN_ID];
58
+ if (screen?.type !== "projectionList")
59
+ throw new Error("expected members screen to be projectionList");
60
+ const rowAction = screen.rowActions?.find((a) => a.id === "cancel-invitation");
61
+ if (rowAction?.kind !== "writeHandler") throw new Error("expected a writeHandler row action");
62
+ expect(rowAction.handler).toBe(TenantHandlers.cancelInvitation);
63
+ expect(rowAction.payload).toEqual({ map: { invitationId: "id" } });
64
+ expect(rowAction.visible).toEqual({ field: "status", eq: "pending" });
65
+ });
66
+
67
+ // The generic drawer-open/submit/close/refetch mechanics for kind:"drawer"
68
+ // toolbar actions are already covered end-to-end with a synthetic screen in
69
+ // renderer-web's projection-list-actions.test.tsx — this only proves OUR
70
+ // wiring (which screen, which handler, which fields) is correct. The
71
+ // "submit creates the invitation, list shows it afterward" behavior is
72
+ // proven at the backend layer in tenant-security.integration.test.ts.
73
+ test("invite toolbar action opens the same-feature invite-create actionForm, bound to AuthHandlers.inviteCreate", () => {
74
+ const tenant = createTenantFeature({ inviteScreen: true });
75
+ const membersScreenDef = tenant.screens[MEMBERS_SCREEN_ID];
76
+ if (membersScreenDef?.type !== "projectionList") {
77
+ throw new Error("expected members screen to be projectionList");
78
+ }
79
+ const toolbarAction = membersScreenDef.toolbarActions?.find((a) => a.id === "invite");
80
+ if (toolbarAction?.kind !== "drawer") throw new Error("expected a drawer toolbar action");
81
+ expect(toolbarAction.screen).toBe(INVITE_CREATE_SCREEN_ID);
82
+
83
+ const inviteScreen = tenant.screens[INVITE_CREATE_SCREEN_ID];
84
+ expect(inviteScreen?.type).toBe("actionForm");
85
+ if (inviteScreen?.type === "actionForm") {
86
+ expect(inviteScreen.handler).toBe(AuthHandlers.inviteCreate);
87
+ expect(Object.keys(inviteScreen.fields)).toEqual(["email", "role"]);
88
+ }
89
+ if (
90
+ inviteScreen &&
91
+ "access" in inviteScreen &&
92
+ inviteScreen.access &&
93
+ "roles" in inviteScreen.access
94
+ ) {
95
+ expect(inviteScreen.access.roles).toEqual(access.admin);
96
+ }
97
+ });
98
+
25
99
  test("members UI handlers share access.admin (screen ⊆ handler)", () => {
26
100
  const tenant = createTenantFeature();
27
101
  const adminRoles = [...access.admin];
28
102
  expect(rolesOf(tenant.queryHandlers["members"]?.access)).toEqual(adminRoles);
29
103
  expect(rolesOf(tenant.queryHandlers["invitations"]?.access)).toEqual(adminRoles);
104
+ expect(rolesOf(tenant.queryHandlers["team:list"]?.access)).toEqual(adminRoles);
30
105
  expect(rolesOf(tenant.writeHandlers["cancel-invitation"]?.access)).toEqual(adminRoles);
31
106
  // invite-create lives on auth feature — checked in tenant-security.integration.test.ts
32
107
  void AuthHandlers;
@@ -11,7 +11,8 @@ import {
11
11
  unsafeCreateEntityTable,
12
12
  unsafePushTables,
13
13
  } from "@cosmicdrift/kumiko-framework/stack";
14
- import { expectErrorIncludes, rolesOf } from "@cosmicdrift/kumiko-framework/testing";
14
+ import { expectErrorIncludes, rolesOf, seedRow } from "@cosmicdrift/kumiko-framework/testing";
15
+ import { Temporal } from "temporal-polyfill";
15
16
  import { AuthHandlers } from "../../auth-email-password/constants";
16
17
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
17
18
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
@@ -22,6 +23,7 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
22
23
  import { notificationPreferencesTable } from "../../delivery/tables";
23
24
  import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
24
25
  import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
26
+ import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
25
27
  import { hashPassword } from "../../shared";
26
28
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
27
29
  import { createUserFeature } from "../../user/feature";
@@ -61,7 +63,9 @@ beforeAll(async () => {
61
63
  features: [
62
64
  createConfigFeature(),
63
65
  createUserFeature(),
64
- createTenantFeature(),
66
+ // inviteScreen: this suite dispatches AuthHandlers.inviteCreate and
67
+ // needs /members' invite-create actionForm screen registered too.
68
+ createTenantFeature({ inviteScreen: true }),
65
69
  createTemplateResolverFeature(),
66
70
  createRendererFoundationFeature(),
67
71
  createDeliveryFeature(),
@@ -93,6 +97,7 @@ beforeAll(async () => {
93
97
  await unsafeCreateEntityTable(stack.db, userEntity);
94
98
  await unsafeCreateEntityTable(stack.db, tenantEntity);
95
99
  await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
100
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
96
101
  await unsafePushTables(stack.db, {
97
102
  configValuesTable,
98
103
  tenantMembershipsTable,
@@ -108,6 +113,7 @@ beforeEach(async () => {
108
113
  await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
109
114
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
110
115
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
116
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
111
117
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
112
118
  emailTransport.sent.length = 0;
113
119
  const keys = await stack.redis.redis.keys("invite:*");
@@ -229,10 +235,11 @@ describe("TenantAdmin can use members-admin HTTP surface", () => {
229
235
  });
230
236
 
231
237
  describe("regular User is denied members-admin surface", () => {
232
- test("403 on members, invitations, invite-create, cancel-invitation", async () => {
238
+ test("403 on members, invitations, team:list, invite-create, cancel-invitation", async () => {
233
239
  for (const [label, fn] of [
234
240
  ["members", () => stack.http.query(TenantQueries.members, {}, regularUserB())],
235
241
  ["invitations", () => stack.http.query(TenantQueries.invitations, {}, regularUserB())],
242
+ ["team:list", () => stack.http.query(TenantQueries.teamList, {}, regularUserB())],
236
243
  [
237
244
  "invite-create",
238
245
  () =>
@@ -247,6 +254,11 @@ describe("regular User is denied members-admin surface", () => {
247
254
  expect(res.status, label).toBe(403);
248
255
  }
249
256
  });
257
+
258
+ // The /members screen gates on the SAME access.admin roles as its query
259
+ // (see members-screens.boot.test.ts) — there is no separate HTTP surface
260
+ // for "screen access" beyond the query/handlers it dispatches, so denying
261
+ // the query above + the screen.access assertion together cover point 8.
250
262
  });
251
263
 
252
264
  describe("privilege escalation via invite role", () => {
@@ -298,6 +310,251 @@ describe("tenant isolation on cancel-invitation", () => {
298
310
  });
299
311
  });
300
312
 
313
+ type TeamListRow = {
314
+ readonly id: string;
315
+ readonly email: string | null;
316
+ readonly roles: readonly string[];
317
+ readonly status: "active" | "pending";
318
+ readonly createdAt: string;
319
+ readonly lastSeenAt: string | null;
320
+ };
321
+
322
+ async function queryTeamList(
323
+ payload: Record<string, unknown>,
324
+ user: SessionUser,
325
+ ): Promise<readonly TeamListRow[]> {
326
+ const result = await stack.http.queryOk<{
327
+ rows: readonly TeamListRow[];
328
+ nextCursor: string | null;
329
+ }>(TenantQueries.teamList, payload, user);
330
+ return result.rows;
331
+ }
332
+
333
+ describe("tenant:query:team:list — combined members + pending invitations (§2.6a)", () => {
334
+ test("returns both memberships and pending invitations with correct per-row status", async () => {
335
+ const { id: memberUserId } = await seedUser(stack.db, {
336
+ email: "member-x@example.com",
337
+ displayName: "Member X",
338
+ passwordHash: await hashPassword("pw-x-1234"),
339
+ emailVerified: true,
340
+ });
341
+ await seedTenantMembership(stack.db, {
342
+ userId: memberUserId,
343
+ tenantId: TENANT_A_ID,
344
+ roles: ["User"],
345
+ });
346
+ await stack.http.writeOk(
347
+ AuthHandlers.inviteCreate,
348
+ { email: "invitee-x@example.com", role: "Editor" },
349
+ tenantAdminA(),
350
+ );
351
+
352
+ const rows = await queryTeamList({}, tenantAdminA());
353
+ const admin = rows.find((r) => r.email === "admin-a@example.com");
354
+ const member = rows.find((r) => r.email === "member-x@example.com");
355
+ const invitee = rows.find((r) => r.email === "invitee-x@example.com");
356
+ expect(admin?.status).toBe("active");
357
+ expect(admin?.roles).toEqual(["TenantAdmin"]);
358
+ expect(member?.status).toBe("active");
359
+ expect(member?.roles).toEqual(["User"]);
360
+ expect(invitee?.status).toBe("pending");
361
+ expect(invitee?.roles).toEqual(["Editor"]);
362
+ });
363
+
364
+ test("status facet genuinely narrows to matching rows, not just the count", async () => {
365
+ const { id: memberUserId } = await seedUser(stack.db, {
366
+ email: "member-y@example.com",
367
+ displayName: "Member Y",
368
+ passwordHash: await hashPassword("pw-y-1234"),
369
+ emailVerified: true,
370
+ });
371
+ await seedTenantMembership(stack.db, {
372
+ userId: memberUserId,
373
+ tenantId: TENANT_A_ID,
374
+ roles: ["User"],
375
+ });
376
+ await stack.http.writeOk(
377
+ AuthHandlers.inviteCreate,
378
+ { email: "invitee-y1@example.com", role: "User" },
379
+ tenantAdminA(),
380
+ );
381
+ await stack.http.writeOk(
382
+ AuthHandlers.inviteCreate,
383
+ { email: "invitee-y2@example.com", role: "User" },
384
+ tenantAdminA(),
385
+ );
386
+
387
+ const pendingOnly = await queryTeamList(
388
+ { filters: [{ field: "status", op: "in", value: ["pending"] }] },
389
+ tenantAdminA(),
390
+ );
391
+ expect(pendingOnly.map((r) => r.email).sort()).toEqual([
392
+ "invitee-y1@example.com",
393
+ "invitee-y2@example.com",
394
+ ]);
395
+
396
+ const activeOnly = await queryTeamList(
397
+ { filters: [{ field: "status", op: "in", value: ["active"] }] },
398
+ tenantAdminA(),
399
+ );
400
+ expect(activeOnly.map((r) => r.email).sort()).toEqual([
401
+ "admin-a@example.com",
402
+ "member-y@example.com",
403
+ ]);
404
+
405
+ const unfiltered = await queryTeamList({}, tenantAdminA());
406
+ expect(unfiltered).toHaveLength(pendingOnly.length + activeOnly.length);
407
+ });
408
+
409
+ test("sort direction genuinely reverses row order across ≥3 differing rows", async () => {
410
+ for (const email of [
411
+ "aaa-member@example.com",
412
+ "mmm-member@example.com",
413
+ "zzz-member@example.com",
414
+ ]) {
415
+ const { id: userId } = await seedUser(stack.db, {
416
+ email,
417
+ displayName: email,
418
+ passwordHash: await hashPassword("pw-sort-1234"),
419
+ emailVerified: true,
420
+ });
421
+ await seedTenantMembership(stack.db, { userId, tenantId: TENANT_A_ID, roles: ["User"] });
422
+ }
423
+
424
+ const asc = await queryTeamList({ sort: "email", sortDirection: "asc" }, tenantAdminA());
425
+ const desc = await queryTeamList({ sort: "email", sortDirection: "desc" }, tenantAdminA());
426
+ expect(asc.map((r) => r.email)).toEqual([
427
+ "aaa-member@example.com",
428
+ "admin-a@example.com",
429
+ "mmm-member@example.com",
430
+ "zzz-member@example.com",
431
+ ]);
432
+ expect(desc.map((r) => r.email)).toEqual([
433
+ "zzz-member@example.com",
434
+ "mmm-member@example.com",
435
+ "admin-a@example.com",
436
+ "aaa-member@example.com",
437
+ ]);
438
+ });
439
+
440
+ test("pagination crosses both sources: memberships-only page, then a page including invitations", async () => {
441
+ for (const email of ["page-member-1@example.com", "page-member-2@example.com"]) {
442
+ const { id: userId } = await seedUser(stack.db, {
443
+ email,
444
+ displayName: email,
445
+ passwordHash: await hashPassword("pw-page-1234"),
446
+ emailVerified: true,
447
+ });
448
+ await seedTenantMembership(stack.db, { userId, tenantId: TENANT_A_ID, roles: ["User"] });
449
+ }
450
+ await stack.http.writeOk(
451
+ AuthHandlers.inviteCreate,
452
+ { email: "page-invitee-1@example.com", role: "User" },
453
+ tenantAdminA(),
454
+ );
455
+ await stack.http.writeOk(
456
+ AuthHandlers.inviteCreate,
457
+ { email: "page-invitee-2@example.com", role: "User" },
458
+ tenantAdminA(),
459
+ );
460
+ // 3 members (adminA + 2 seeded, created first) + 2 invitations (created
461
+ // after) = 5 rows. Sorted oldest-first, a page size of 3 lands exactly
462
+ // on the members/invitations boundary.
463
+ const page1 = await queryTeamList(
464
+ { sort: "createdAt", sortDirection: "asc", limit: 3, offset: 0 },
465
+ tenantAdminA(),
466
+ );
467
+ const page2 = await queryTeamList(
468
+ { sort: "createdAt", sortDirection: "asc", limit: 3, offset: 3 },
469
+ tenantAdminA(),
470
+ );
471
+ expect(page1).toHaveLength(3);
472
+ expect(page1.every((r) => r.status === "active")).toBe(true);
473
+ expect(page2).toHaveLength(2);
474
+ expect(page2.every((r) => r.status === "pending")).toBe(true);
475
+
476
+ const allIds = [...page1, ...page2].map((r) => r.id);
477
+ expect(new Set(allIds).size).toBe(5);
478
+ });
479
+
480
+ test("lastSeenAt is set for a member with a session, null for one without, and null for an invitation", async () => {
481
+ const { id: withSessionId } = await seedUser(stack.db, {
482
+ email: "with-session@example.com",
483
+ displayName: "With Session",
484
+ passwordHash: await hashPassword("pw-sess-1234"),
485
+ emailVerified: true,
486
+ });
487
+ await seedTenantMembership(stack.db, {
488
+ userId: withSessionId,
489
+ tenantId: TENANT_A_ID,
490
+ roles: ["User"],
491
+ });
492
+ const seededLastSeen = Temporal.Now.instant().subtract({ minutes: 5 });
493
+ await seedRow(stack.db, userSessionTable, {
494
+ id: crypto.randomUUID(),
495
+ userId: withSessionId,
496
+ tenantId: TENANT_A_ID,
497
+ createdAt: Temporal.Now.instant().subtract({ hours: 1 }),
498
+ expiresAt: Temporal.Now.instant().add({ hours: 1 }),
499
+ lastSeenAt: seededLastSeen,
500
+ });
501
+
502
+ const { id: withoutSessionId } = await seedUser(stack.db, {
503
+ email: "without-session@example.com",
504
+ displayName: "Without Session",
505
+ passwordHash: await hashPassword("pw-nosess-1234"),
506
+ emailVerified: true,
507
+ });
508
+ await seedTenantMembership(stack.db, {
509
+ userId: withoutSessionId,
510
+ tenantId: TENANT_A_ID,
511
+ roles: ["User"],
512
+ });
513
+ await stack.http.writeOk(
514
+ AuthHandlers.inviteCreate,
515
+ { email: "invitee-lastseen@example.com", role: "User" },
516
+ tenantAdminA(),
517
+ );
518
+
519
+ const rows = await queryTeamList({}, tenantAdminA());
520
+ const withSession = rows.find((r) => r.email === "with-session@example.com");
521
+ const withoutSession = rows.find((r) => r.email === "without-session@example.com");
522
+ const invitee = rows.find((r) => r.email === "invitee-lastseen@example.com");
523
+
524
+ expect(withSession?.lastSeenAt).not.toBeNull();
525
+ const driftMs = Math.abs(
526
+ Temporal.Instant.from(withSession?.lastSeenAt as string).epochMilliseconds -
527
+ seededLastSeen.epochMilliseconds,
528
+ );
529
+ expect(driftMs).toBeLessThan(1_000);
530
+ expect(withoutSession?.lastSeenAt).toBeNull();
531
+ expect(invitee?.lastSeenAt).toBeNull();
532
+ });
533
+ });
534
+
535
+ describe("cancel-invitation on the /members surface genuinely cancels", () => {
536
+ test("a pending invitation created via invite-create disappears from team:list after cancel-invitation", async () => {
537
+ await stack.http.writeOk(
538
+ AuthHandlers.inviteCreate,
539
+ { email: "cancel-me@example.com", role: "User" },
540
+ tenantAdminA(),
541
+ );
542
+ const before = await queryTeamList({}, tenantAdminA());
543
+ const pending = before.find((r) => r.email === "cancel-me@example.com");
544
+ expect(pending?.status).toBe("pending");
545
+ if (pending === undefined) throw new Error("expected the seeded invitation to be listed");
546
+
547
+ await stack.http.writeOk(
548
+ TenantHandlers.cancelInvitation,
549
+ { invitationId: pending.id },
550
+ tenantAdminA(),
551
+ );
552
+
553
+ const after = await queryTeamList({}, tenantAdminA());
554
+ expect(after.some((r) => r.email === "cancel-me@example.com")).toBe(false);
555
+ });
556
+ });
557
+
301
558
  describe("updateMemberRoles not reachable by TenantAdmin", () => {
302
559
  test("TenantAdmin gets access_denied on updateMemberRoles", async () => {
303
560
  const err = await stack.http.writeErr(
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.209.0",
4
+ "type": "improvement",
5
+ "title": "tenant: single /members screen replaces the members-card/pending-card split (#2223)",
6
+ "detail": "New `tenant:query:team:list` handler merges active memberships and pending invitations into one projectionList screen (`membersScreen`), with a `status` facet, sortable/searchable columns, and a danger-styled `cancel-invitation` row action visible only on pending rows. `createTenantFeature({ inviteScreen: true })` additionally wires a drawer-hosted invite actionForm onto the toolbar (bound to auth-email-password's `invite-create` handler) — off by default, and only safe to enable once the app also configures `createAuthEmailPasswordFeature({ invite: {...} })`, since the boot validator rejects the actionForm's cross-feature handler QN otherwise. The old `members`/`invitations` query handlers and their custom `MembersScreen` component are unaffected and stay registered for existing callers; `updateMemberRoles` stays SystemAdmin-only and off this screen."
7
+ },
2
8
  {
3
9
  "version": "0.165.0",
4
10
  "type": "fix",
@@ -1,14 +1,25 @@
1
1
  // @runtime client
2
- // Pure string-Konstanten — `@runtime client` damit auch Browser-Code
3
- // (Members-Screen) sie importieren kann (siehe auth-email-password/
4
- // constants.ts für die Begründung). Runtime importiert client → server
5
- // kann sie weiter nutzen.
2
+ // Pure string constants — `@runtime client` so browser code (e.g. the
3
+ // MemberStatusCell column renderer) can import them too (see
4
+ // auth-email-password/constants.ts for the rationale). Runtime imports
5
+ // client server can keep using them as well.
6
6
 
7
7
  // Feature name
8
8
  export const TENANT_FEATURE = "tenant" as const;
9
9
 
10
10
  export const MEMBERS_SCREEN_ID = "members" as const;
11
11
 
12
+ export const INVITE_CREATE_SCREEN_ID = "invite-create" as const;
13
+
14
+ /** Client column-renderer for the /members screen's status column — see
15
+ * `screen.columns[].renderer.react.__component` in screens.ts and
16
+ * `tenantClient()`'s `columnRenderers` map. */
17
+ export const MEMBER_STATUS_CELL_COMPONENT = "MemberStatusCell" as const;
18
+
19
+ /** Client column-renderer for the /members screen's roles column (joins the
20
+ * `roles: readonly string[]` field into one cell) — same wiring as above. */
21
+ export const MEMBER_ROLES_CELL_COMPONENT = "MemberRolesCell" as const;
22
+
12
23
  /** Closed allowlist for invite-role picker — never free text (escalation guard). */
13
24
  export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Admin", "Editor"] as const;
14
25
 
@@ -34,6 +45,8 @@ export const TenantQueries = {
34
45
  resolveUserIds: "tenant:query:resolve-user-ids",
35
46
  // Pending Invitations für den aktuellen Tenant (Admin-UI-Liste).
36
47
  invitations: "tenant:query:invitations",
48
+ // Combined active-members + pending-invitations list backing /members.
49
+ teamList: "tenant:query:team:list",
37
50
  } as const;
38
51
 
39
52
  // Error codes
@@ -8,7 +8,6 @@ import {
8
8
  defineFeature,
9
9
  type FeatureDefinition,
10
10
  } from "@cosmicdrift/kumiko-framework/engine";
11
- import { MEMBERS_SCREEN_ID } from "./constants";
12
11
  import { activeTenantIdsQuery } from "./handlers/active-tenant-ids.query";
13
12
  import { addMemberWrite } from "./handlers/add-member.write";
14
13
  import { cancelInvitationWrite } from "./handlers/cancel-invitation.write";
@@ -20,6 +19,7 @@ import { membersQuery } from "./handlers/members.query";
20
19
  import { membershipsQuery } from "./handlers/memberships.query";
21
20
  import { removeMemberWrite } from "./handlers/remove-member.write";
22
21
  import { resolveUserIdsQuery } from "./handlers/resolve-user-ids.query";
22
+ import { teamListQuery } from "./handlers/team-list.query";
23
23
  import { disableWrite, enableWrite } from "./handlers/toggle-enabled.write";
24
24
  import { updateWrite } from "./handlers/update.write";
25
25
  import { updateMemberRolesWrite } from "./handlers/update-member-roles.write";
@@ -27,13 +27,29 @@ import { TENANT_I18N } from "./i18n";
27
27
  import { tenantInvitationEntity } from "./invitation-table";
28
28
  import { tenantMembershipEntity } from "./membership-table";
29
29
  import { tenantEntity } from "./schema/tenant";
30
- import { tenantEditScreen, tenantListScreen } from "./screens";
30
+ import {
31
+ createMembersScreen,
32
+ inviteCreateScreen,
33
+ tenantEditScreen,
34
+ tenantListScreen,
35
+ } from "./screens";
31
36
 
32
37
  export { tenantEntity, tenantTable } from "./schema/tenant";
33
38
 
39
+ export type TenantFeatureOptions = {
40
+ /** Adds the /members "invite" drawer button + its `invite-create`
41
+ * actionForm, bound to auth-email-password's `invite-create` write-
42
+ * handler. That handler only exists when the app also mounts
43
+ * `createAuthEmailPasswordFeature({ invite: {...} })` — the boot
44
+ * validator rejects the actionForm's handler QN otherwise (cross-feature
45
+ * handler lookup fails). Off by default: `tenant` cannot see whether an
46
+ * app configured that optional auth-email-password flow. */
47
+ readonly inviteScreen?: boolean;
48
+ };
49
+
34
50
  // --- Feature ---
35
51
 
36
- export function createTenantFeature(): FeatureDefinition {
52
+ export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefinition {
37
53
  return defineFeature("tenant", (r) => {
38
54
  r.describe(
39
55
  "Registers the three core multi-tenancy entities \u2014 `tenant`, `tenant-membership`, and `tenant-invitation` (DB tables `read_tenants`, `read_tenant_memberships`, and `read_tenant_invitations`) \u2014 along with write handlers for create/update/disable/enable/addMember/removeMember/updateMemberRoles and the matching queries. It also declares a set of per-tenant config keys (companyName, timezone, locale, SMTP credentials) and system-only keys (priceModel, maxUsers) via `r.config({ keys: { ... } })`. Use this feature in every multi-tenant app; membership resolution and invitation flows depend on it, and `auth-email-password` requires it.",
@@ -117,6 +133,7 @@ export function createTenantFeature(): FeatureDefinition {
117
133
  activeTenantIds: r.queryHandler(activeTenantIdsQuery),
118
134
  resolveUserIds: r.queryHandler(resolveUserIdsQuery),
119
135
  invitations: r.queryHandler(invitationsQuery),
136
+ teamList: r.queryHandler(teamListQuery),
120
137
  };
121
138
 
122
139
  // Entity-convention handlers for the SystemAdmin entityList/entityEdit
@@ -136,14 +153,14 @@ export function createTenantFeature(): FeatureDefinition {
136
153
  );
137
154
  r.screen(tenantListScreen);
138
155
  r.screen(tenantEditScreen);
139
- // Tenant-admin team UI: members list + invite/cancel (no role-edit — updateMemberRoles
140
- // stays SystemAdmin-only). Screen access matches handler access.admin.
141
- r.screen({
142
- id: MEMBERS_SCREEN_ID,
143
- type: "custom",
144
- renderer: { react: { __component: "MembersScreen" } },
145
- access: { roles: access.admin },
146
- });
156
+ // Tenant-admin team UI: one list (active members + pending invitations,
157
+ // §2.6), invite via a drawer-hosted actionForm. No role-edit —
158
+ // updateMemberRoles stays SystemAdmin-only. Screen access matches
159
+ // handler access.admin.
160
+ r.screen(createMembersScreen(options));
161
+ if (options?.inviteScreen) {
162
+ r.screen(inviteCreateScreen);
163
+ }
147
164
  r.nav({
148
165
  id: "members",
149
166
  label: "tenant.nav.members",