@cosmicdrift/kumiko-bundled-features 0.209.1 → 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.
Files changed (58) hide show
  1. package/package.json +8 -8
  2. package/src/auth-mfa/schema/user-mfa.ts +8 -5
  3. package/src/auth-mfa-user-data/hooks.ts +9 -8
  4. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -2
  5. package/src/billing-foundation/entities.ts +5 -3
  6. package/src/billing-foundation/tenant-destroy-hook.ts +1 -1
  7. package/src/compliance-profiles/schema/profile-selection.ts +2 -1
  8. package/src/config/table.ts +4 -1
  9. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +4 -4
  10. package/src/custom-fields/feature.ts +2 -2
  11. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
  12. package/src/data-retention/schema/tenant-retention-override.ts +6 -3
  13. package/src/delivery/tables.ts +1 -1
  14. package/src/document-ingest-foundation/entity.ts +6 -3
  15. package/src/file-derivatives/changes.json +9 -1
  16. package/src/files/README.md +11 -11
  17. package/src/form-draft/entity.ts +6 -3
  18. package/src/inbound-mail-foundation/entities.ts +13 -11
  19. package/src/inbound-mail-foundation/feature.ts +5 -5
  20. package/src/ledger/entity.ts +5 -3
  21. package/src/managed-pages/screens/branding-screen.ts +2 -1
  22. package/src/managed-pages/table.ts +3 -2
  23. package/src/notes-history/entity.ts +8 -4
  24. package/src/notes-history-user-data/hooks.ts +6 -5
  25. package/src/personal-access-tokens/changes.json +7 -0
  26. package/src/personal-access-tokens/schema/api-token.ts +2 -1
  27. package/src/sessions/feature.ts +0 -4
  28. package/src/sessions/schema/user-session.ts +4 -2
  29. package/src/tags/entity.ts +11 -5
  30. package/src/template-resolver/__tests__/collection-ownership.integration.test.ts +1 -1
  31. package/src/template-resolver/changes.json +9 -1
  32. package/src/template-resolver/table.ts +4 -1
  33. package/src/template-resolver/user-content-table.ts +5 -2
  34. package/src/template-resolver-user-data/hooks.ts +6 -5
  35. package/src/tenant/__tests__/members-screens.boot.test.ts +79 -4
  36. package/src/tenant/__tests__/tenant-security.integration.test.ts +260 -3
  37. package/src/tenant/changes.json +6 -0
  38. package/src/tenant/constants.ts +17 -4
  39. package/src/tenant/feature.ts +28 -11
  40. package/src/tenant/handlers/team-list.query.ts +250 -0
  41. package/src/tenant/i18n.ts +20 -0
  42. package/src/tenant/invitation-table.ts +3 -3
  43. package/src/tenant/screens.ts +111 -3
  44. package/src/tenant/web/client-plugin.tsx +11 -6
  45. package/src/tenant/web/index.ts +2 -2
  46. package/src/tenant/web/member-roles-cell.tsx +12 -0
  47. package/src/tenant/web/member-status-cell.tsx +23 -0
  48. package/src/tenant-lifecycle/boot-checks.ts +2 -2
  49. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +58 -0
  50. package/src/tenant-settings/feature.ts +12 -0
  51. package/src/user/schema/user.ts +5 -5
  52. package/src/user-data-rights/__tests__/boot-checks.test.ts +73 -9
  53. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +2 -2
  54. package/src/user-data-rights/boot-checks.ts +2 -2
  55. package/src/user-data-rights/schema/download-token.ts +12 -10
  56. package/src/user-data-rights/schema/export-job.ts +5 -4
  57. package/src/tenant/web/i18n.ts +0 -24
  58. package/src/tenant/web/members-screen.tsx +0 -270
@@ -321,7 +321,7 @@ describe("GDPR :: the owner column is what makes erasure possible", () => {
321
321
 
322
322
  test("the tenant-wide table keeps its business-data declaration", () => {
323
323
  const content = templateResourceEntity.fields["content"];
324
- expect((content as { allowPlaintext?: string }).allowPlaintext).toBe("is-business-data");
324
+ expect((content as { allowPlaintext?: string }).allowPlaintext).toBe("is_business_data");
325
325
  expect((content as { userOwned?: unknown }).userOwned).toBeUndefined();
326
326
  });
327
327
 
@@ -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
+ ]
@@ -30,7 +30,10 @@ export const templateResourceEntity = createEntity({
30
30
  folder: createTextField({}),
31
31
  // Template-body is authored by TenantAdmin/Operator (email-templates etc.),
32
32
  // business data — kein end-user UGC.
33
- content: createLongTextField({ allowPlaintext: "is-business-data" }),
33
+ content: createLongTextField({
34
+ personal: false,
35
+ reason: "is_business_data",
36
+ }),
34
37
  contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
35
38
  variableSchema: createLongTextField({}),
36
39
  linkedResources: createLongTextField({}),
@@ -24,13 +24,16 @@ import { CONTENT_FORMATS, TEMPLATE_KINDS } from "./constants";
24
24
  export const userContentEntryEntity = createEntity({
25
25
  table: "read_user_content_entries",
26
26
  fields: {
27
- ownerId: createTextField({ required: true, subjectRef: true }),
27
+ ownerId: createTextField({ required: true, personal: "ref" }),
28
28
  slug: createTextField({ required: true }),
29
29
  kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
30
30
  locale: createTextField({ required: true }),
31
31
  title: createTextField({}),
32
32
  folder: createTextField({}),
33
- content: createLongTextField({ userOwned: { ownerField: "ownerId" } }),
33
+ content: createLongTextField({
34
+ personal: { of: "ownerId" },
35
+ find: "none",
36
+ }),
34
37
  contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
35
38
  },
36
39
  indexes: [
@@ -29,11 +29,12 @@ export const userContentExportHook: UserDataExportHook = async (ctx) => {
29
29
  };
30
30
  };
31
31
 
32
- // Deliberate no-op: `content` is annotated `userOwned`, so erasure runs via
33
- // crypto-shredding — destroying the owner's subject key makes every event AND
34
- // the projected row unreadable at once. A physical DELETE would not be
35
- // rebuild-safe here: user-content-entry is event-sourced, so a replay would
36
- // bring the row back (with unreadable content, but back).
32
+ // Deliberate no-op: `content` is annotated `personal: { of: "ownerId" }`, so
33
+ // erasure runs via crypto-shredding — destroying the owner's subject key
34
+ // makes every event AND the projected row unreadable at once. A physical
35
+ // DELETE would not be rebuild-safe here: user-content-entry is
36
+ // event-sourced, so a replay would bring the row back (with unreadable
37
+ // content, but back).
37
38
  // Precondition: this only erases anything if the app mounts a KMS adapter —
38
39
  // without one, userOwned fields fall back to plaintext framework-wide and
39
40
  // forget is a true no-op for `content`. Same property as notes-history.
@@ -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",