@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.
@@ -0,0 +1,250 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ access,
4
+ definePagedQueryHandler,
5
+ MAX_LIST_LIMIT,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
8
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
+ import { Temporal } from "temporal-polyfill";
10
+ import { z } from "zod";
11
+ import { userSessionTable } from "../../sessions";
12
+ import { decryptStoredPii, mapWithConcurrency } from "../../shared";
13
+ import { userTable } from "../../user";
14
+ import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table";
15
+ import { tenantMembershipsTable } from "../membership-table";
16
+
17
+ const KMS_POOL_CONCURRENCY = 4;
18
+
19
+ type TeamStatus = "active" | "pending";
20
+
21
+ type TeamRow = {
22
+ readonly id: string;
23
+ readonly email: string | null;
24
+ readonly displayName: string | null;
25
+ readonly roles: readonly string[];
26
+ readonly status: TeamStatus;
27
+ readonly createdAt: Temporal.Instant;
28
+ readonly lastSeenAt: Temporal.Instant | null;
29
+ readonly expiresAt: Temporal.Instant | null;
30
+ };
31
+
32
+ // The /members screen exposes one column per field above under the SAME
33
+ // name — unlike delivery-log there is no display-alias to map, `sort` on
34
+ // the wire already names a TeamRow field directly.
35
+ const SORT_FIELDS = ["email", "status", "createdAt", "lastSeenAt"] as const;
36
+ type SortField = (typeof SORT_FIELDS)[number];
37
+
38
+ function isSortField(value: string): value is SortField {
39
+ return (SORT_FIELDS as readonly string[]).includes(value);
40
+ }
41
+
42
+ // Nulls (no session yet, invitation rows) sort last regardless of
43
+ // direction — "unknown" reads as "least recent", not as a magic minimum
44
+ // that flips to the top on `desc`.
45
+ function compareTeamRows(a: TeamRow, b: TeamRow, field: SortField, dir: "asc" | "desc"): number {
46
+ const sign = dir === "asc" ? 1 : -1;
47
+ if (field === "createdAt") return Temporal.Instant.compare(a.createdAt, b.createdAt) * sign;
48
+ if (field === "lastSeenAt") {
49
+ if (a.lastSeenAt === null && b.lastSeenAt === null) return 0;
50
+ if (a.lastSeenAt === null) return 1;
51
+ if (b.lastSeenAt === null) return -1;
52
+ return Temporal.Instant.compare(a.lastSeenAt, b.lastSeenAt) * sign;
53
+ }
54
+ const av = field === "email" ? (a.email ?? "") : a.status;
55
+ const bv = field === "email" ? (b.email ?? "") : b.status;
56
+ return av.localeCompare(bv) * sign;
57
+ }
58
+
59
+ // User-selected facet filters (payload.filters, fw#2224) — `status` is the
60
+ // only filterable field here: it's synthesized from which table a row came
61
+ // from, not a stored column, so there is nothing else meaningful to filter
62
+ // on server-side yet. `filter` (singular, screen-level static filter) is
63
+ // accepted for schema-shape parity but unused — no /members screen sets one.
64
+ function matchesStatusFilter(
65
+ row: TeamRow,
66
+ filters: readonly { field: string; value: unknown }[],
67
+ ): boolean {
68
+ const statusFilter = filters.find((f) => f.field === "status");
69
+ if (statusFilter === undefined) return true;
70
+ const allowed = Array.isArray(statusFilter.value) ? statusFilter.value : [statusFilter.value];
71
+ return allowed.includes(row.status);
72
+ }
73
+
74
+ export const teamListQuery = definePagedQueryHandler({
75
+ name: "team:list",
76
+ schema: z.object({
77
+ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(50),
78
+ offset: z.number().int().nonnegative().optional(),
79
+ sort: z.string().optional(),
80
+ sortDirection: z.enum(["asc", "desc"]).optional(),
81
+ search: z.string().optional(),
82
+ totalCount: z.boolean().optional(),
83
+ filter: z
84
+ .object({
85
+ field: z.string(),
86
+ op: z.enum(["eq", "ne", "lt", "gt", "in"]),
87
+ value: z.unknown(),
88
+ })
89
+ .optional(),
90
+ filters: z
91
+ .array(
92
+ z.object({
93
+ field: z.string(),
94
+ op: z.enum(["eq", "ne", "lt", "gt", "in"]),
95
+ value: z.unknown(),
96
+ }),
97
+ )
98
+ .optional(),
99
+ }),
100
+ access: { roles: access.admin },
101
+ handler: async (query, ctx) => {
102
+ if (!ctx.systemDb) {
103
+ throw new InternalError({
104
+ message:
105
+ "tenant:query:team:list requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
106
+ });
107
+ }
108
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
109
+ const tenantId = query.user.tenantId;
110
+
111
+ const membershipRows = await selectMany(db, tenantMembershipsTable, { tenantId });
112
+ const userIds = [...new Set(membershipRows.map((row) => String(row["userId"])))];
113
+ const users =
114
+ userIds.length > 0
115
+ ? await selectMany<{ id: unknown; email?: unknown; displayName?: unknown }>(db, userTable, {
116
+ id: userIds,
117
+ })
118
+ : [];
119
+
120
+ const lastSeenByUserId = new Map<string, Temporal.Instant>();
121
+ if (userIds.length > 0) {
122
+ try {
123
+ const sessions = await selectMany<{ userId: unknown; lastSeenAt: unknown }>(
124
+ db,
125
+ userSessionTable,
126
+ { tenantId, userId: userIds },
127
+ );
128
+ for (const session of sessions) {
129
+ const value = session["lastSeenAt"];
130
+ if (!(value instanceof Temporal.Instant)) continue;
131
+ const userId = String(session["userId"]);
132
+ const current = lastSeenByUserId.get(userId);
133
+ if (current === undefined || Temporal.Instant.compare(value, current) > 0) {
134
+ lastSeenByUserId.set(userId, value);
135
+ }
136
+ }
137
+ } catch (err) {
138
+ // ponytail: lean sample apps (admin-console) mount auth without
139
+ // sessions — store_user_sessions never gets pushed. Integration tests
140
+ // may seed the table without mounting the feature; prod mounts both.
141
+ if ((err as { code?: string }).code !== "42P01") throw err;
142
+ }
143
+ }
144
+
145
+ // Bounded, not Promise.all/sequential-per-item: shares the KMS
146
+ // adapter's small dedicated pool (max 4, see members.query.ts) with the
147
+ // invitations decrypt below — the two passes run one after the other
148
+ // (await, not Promise.all), so combined concurrency never exceeds 4.
149
+ const decryptedUsers = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
150
+ const email =
151
+ typeof user.email === "string"
152
+ ? await decryptStoredPii(user.email, "email", "tenant:team-list")
153
+ : null;
154
+ const displayName =
155
+ typeof user.displayName === "string"
156
+ ? await decryptStoredPii(user.displayName, "displayName", "tenant:team-list")
157
+ : null;
158
+ return { userId: String(user.id), email, displayName };
159
+ });
160
+ const decryptedByUserId = new Map(decryptedUsers.map((d) => [d.userId, d]));
161
+
162
+ const memberRows: TeamRow[] = membershipRows.map((row) => {
163
+ const userId = String(row["userId"]);
164
+ const decrypted = decryptedByUserId.get(userId);
165
+ const createdAt = row["createdAt"];
166
+ return {
167
+ id: String(row["id"]),
168
+ email: decrypted?.email ?? null,
169
+ displayName: decrypted?.displayName ?? null,
170
+ roles: parseRoles(row["roles"]),
171
+ status: "active",
172
+ createdAt: createdAt instanceof Temporal.Instant ? createdAt : Temporal.Now.instant(),
173
+ lastSeenAt: lastSeenByUserId.get(userId) ?? null,
174
+ expiresAt: null,
175
+ };
176
+ });
177
+
178
+ // Pending only — accepted/cancelled/expired invitations don't belong on
179
+ // this screen (accepted ones already surfaced as a membership row above;
180
+ // the rest is history for a separate audit feature). SQL-side filter,
181
+ // same rationale as invitations.query.ts.
182
+ const pendingInvitations = await selectMany<Record<string, unknown>>(
183
+ db,
184
+ tenantInvitationsTable,
185
+ { tenantId, status: INVITATION_STATUS.pending },
186
+ );
187
+ const invitationRows: TeamRow[] = await mapWithConcurrency(
188
+ pendingInvitations,
189
+ KMS_POOL_CONCURRENCY,
190
+ async (row): Promise<TeamRow> => {
191
+ const email = row["email"];
192
+ const decryptedEmail =
193
+ typeof email === "string"
194
+ ? await decryptStoredPii(email, "email", "tenant:team-list")
195
+ : null;
196
+ const createdAt = row["createdAt"];
197
+ const expiresAt = row["expiresAt"];
198
+ return {
199
+ id: String(row["id"]),
200
+ email: decryptedEmail,
201
+ displayName: null,
202
+ roles: typeof row["role"] === "string" ? [row["role"]] : [],
203
+ status: "pending",
204
+ createdAt: createdAt instanceof Temporal.Instant ? createdAt : Temporal.Now.instant(),
205
+ lastSeenAt: null,
206
+ expiresAt: expiresAt instanceof Temporal.Instant ? expiresAt : null,
207
+ };
208
+ },
209
+ );
210
+
211
+ // Merge → filter → sort → slice, in that order: `total` and the page
212
+ // window both have to be computed against the FILTERED set, not the raw
213
+ // merge, or the pager and the status-facet disagree with each other.
214
+ //
215
+ // ponytail: reads both tables fully and merges/sorts/slices in JS —
216
+ // there is no cursor. A cursor over a list merged from two tables in
217
+ // application code is either wrong (each source paginates
218
+ // independently, so page boundaries don't line up) or has to read
219
+ // everything anyway to know where the boundary falls. This holds up to
220
+ // the order of a few thousand members per tenant; past that the fix is
221
+ // a real combined read-projection (a materialized view/table kept in
222
+ // sync by both tenant-membership and tenant-invitation events), not a
223
+ // cursor bolted onto this merge.
224
+ const search = query.payload.search?.trim().toLowerCase();
225
+ const merged = [...memberRows, ...invitationRows].filter((row) => {
226
+ if (search === undefined || search === "") return true;
227
+ return (
228
+ (row.email?.toLowerCase().includes(search) ?? false) ||
229
+ (row.displayName?.toLowerCase().includes(search) ?? false)
230
+ );
231
+ });
232
+ const filtered = merged.filter((row) => matchesStatusFilter(row, query.payload.filters ?? []));
233
+
234
+ const requestedSort = query.payload.sort;
235
+ const sortField: SortField =
236
+ requestedSort !== undefined && isSortField(requestedSort) ? requestedSort : "createdAt";
237
+ const sortDirection = query.payload.sortDirection ?? "desc";
238
+ const sorted = [...filtered].sort((a, b) => compareTeamRows(a, b, sortField, sortDirection));
239
+
240
+ const offset = query.payload.offset ?? 0;
241
+ const limit = query.payload.limit;
242
+ const page = sorted.slice(offset, offset + limit);
243
+
244
+ return {
245
+ rows: page,
246
+ nextCursor: null,
247
+ ...(query.payload.totalCount === true && { total: filtered.length }),
248
+ };
249
+ },
250
+ });
@@ -4,7 +4,27 @@ export const TENANT_I18N: Readonly<Record<string, LocalizedString>> = {
4
4
  "screen:tenant-list.title": { en: "Tenants" },
5
5
  "screen:tenant-edit.title": { en: "Edit tenant" },
6
6
  "screen:members.title": { en: "Team" },
7
+ "screen:invite-create.title": { en: "New invitation" },
7
8
  "tenant.nav.members": { en: "Team" },
9
+ "tenant.members.col.email": { en: "Email" },
10
+ "tenant.members.col.roles": { en: "Roles" },
11
+ "tenant.members.col.status": { en: "Status" },
12
+ "tenant.members.col.created": { en: "Created" },
13
+ "tenant.members.col.lastActivity": { en: "Last activity" },
14
+ "tenant.members.filter.status": { en: "Status" },
15
+ "tenant.members.filter.status.option.active": { en: "Active" },
16
+ "tenant.members.filter.status.option.pending": { en: "Pending" },
17
+ "tenant.members.cancel": { en: "Cancel invitation" },
18
+ "tenant.members.cancel.confirm": {
19
+ en: "Cancel this invitation? The invite link stops working immediately.",
20
+ },
21
+ "tenant.members.invite.title": { en: "New invitation" },
22
+ "tenant.members.invite.submit": { en: "Send invitation" },
23
+ "tenant:entity:__action-form__:field:email": { en: "Email" },
24
+ "tenant:entity:__action-form__:field:role": { en: "Role" },
25
+ "tenant:entity:__action-form__:field:role:option:User": { en: "User" },
26
+ "tenant:entity:__action-form__:field:role:option:Admin": { en: "Admin" },
27
+ "tenant:entity:__action-form__:field:role:option:Editor": { en: "Editor" },
8
28
  "tenant:entity:tenant:field:key": { en: "Key" },
9
29
  "tenant:entity:tenant:field:name": { en: "Name" },
10
30
  "tenant:entity:tenant:field:isEnabled": { en: "Enabled" },
@@ -1,7 +1,19 @@
1
- import type {
2
- EntityEditScreenDefinition,
3
- EntityListScreenDefinition,
1
+ import { AuthHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/constants";
2
+ import {
3
+ access,
4
+ type EntityEditScreenDefinition,
5
+ type EntityListScreenDefinition,
6
+ type ScreenDefinition,
4
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
+ import {
9
+ DEFAULT_INVITE_ROLE_OPTIONS,
10
+ INVITE_CREATE_SCREEN_ID,
11
+ MEMBER_ROLES_CELL_COMPONENT,
12
+ MEMBER_STATUS_CELL_COMPONENT,
13
+ MEMBERS_SCREEN_ID,
14
+ TenantHandlers,
15
+ TenantQueries,
16
+ } from "./constants";
5
17
 
6
18
  // Cross-tenant SystemAdmin platform view of the tenants themselves. The tenant
7
19
  // feature runs with `r.systemScope()`, so the entityList returns every tenant.
@@ -45,3 +57,99 @@ export const tenantEditScreen: EntityEditScreenDefinition = {
45
57
  allowDelete: false,
46
58
  access: { roles: ["SystemAdmin"] },
47
59
  };
60
+
61
+ // Tenant-admin team UI (§2.6 bundled-features-screen-standardisierung.md):
62
+ // one list, active members and pending invitations merged into a single
63
+ // `status` column, pendings narrowed via the `status` facet instead of a
64
+ // separate card. Backed by tenant:query:team:list (no role-edit — that
65
+ // stays on updateMemberRoles, SystemAdmin-only, see feature.ts).
66
+ //
67
+ // `inviteScreen` gates the toolbar's "invite" drawer button: it targets
68
+ // `inviteCreateScreen`, whose write-handler is owned by auth-email-password
69
+ // and only registered when that feature's `invite` option is configured
70
+ // (see feature.ts's `TenantFeatureOptions`). `tenant` has no compile-time way
71
+ // to know whether an app configured that — the flag defaults to false so
72
+ // `createTenantFeature()` alone (no auth-email-password, or auth-email-password
73
+ // without `invite`) keeps booting; apps that DO configure invite opt in.
74
+ export function createMembersScreen(options?: {
75
+ readonly inviteScreen?: boolean;
76
+ }): ScreenDefinition {
77
+ return {
78
+ id: MEMBERS_SCREEN_ID,
79
+ type: "projectionList",
80
+ query: TenantQueries.teamList,
81
+ columns: [
82
+ { field: "email", label: "tenant.members.col.email" },
83
+ {
84
+ field: "roles",
85
+ label: "tenant.members.col.roles",
86
+ renderer: { react: { __component: MEMBER_ROLES_CELL_COMPONENT } },
87
+ },
88
+ {
89
+ field: "status",
90
+ label: "tenant.members.col.status",
91
+ renderer: { react: { __component: MEMBER_STATUS_CELL_COMPONENT } },
92
+ },
93
+ { field: "createdAt", label: "tenant.members.col.created" },
94
+ { field: "lastSeenAt", label: "tenant.members.col.lastActivity" },
95
+ ],
96
+ defaultSort: { field: "createdAt", dir: "desc" },
97
+ searchable: true,
98
+ facets: [
99
+ {
100
+ field: "status",
101
+ type: "select",
102
+ label: "tenant.members.filter.status",
103
+ options: [
104
+ { value: "active", label: "tenant.members.filter.status.option.active" },
105
+ { value: "pending", label: "tenant.members.filter.status.option.pending" },
106
+ ],
107
+ },
108
+ ],
109
+ rowActions: [
110
+ {
111
+ kind: "writeHandler",
112
+ id: "cancel-invitation",
113
+ label: "tenant.members.cancel",
114
+ handler: TenantHandlers.cancelInvitation,
115
+ payload: { map: { invitationId: "id" } },
116
+ confirm: "tenant.members.cancel.confirm",
117
+ style: "danger",
118
+ visible: { field: "status", eq: "pending" },
119
+ },
120
+ ],
121
+ ...(options?.inviteScreen && {
122
+ toolbarActions: [
123
+ {
124
+ kind: "drawer",
125
+ id: "invite",
126
+ label: "tenant.members.invite.title",
127
+ screen: INVITE_CREATE_SCREEN_ID,
128
+ style: "primary",
129
+ },
130
+ ],
131
+ }),
132
+ access: { roles: access.admin },
133
+ } satisfies ScreenDefinition;
134
+ }
135
+
136
+ // Drawer target for membersScreen's "invite" toolbar action — kind:"drawer"
137
+ // only resolves same-feature actionForm screens, so this lives here even
138
+ // though its handler is owned by auth-email-password. Not `r.nav()`'d: the
139
+ // only entry point is the drawer button on /members. Only registered by
140
+ // feature.ts when `TenantFeatureOptions.inviteScreen` is set — see
141
+ // createMembersScreen's doc comment above for why.
142
+ export const inviteCreateScreen = {
143
+ id: INVITE_CREATE_SCREEN_ID,
144
+ type: "actionForm",
145
+ handler: AuthHandlers.inviteCreate,
146
+ fields: {
147
+ email: { type: "text", required: true, format: "email" },
148
+ role: { type: "select", options: DEFAULT_INVITE_ROLE_OPTIONS, required: true },
149
+ },
150
+ layout: {
151
+ sections: [{ fields: ["email", "role"] }],
152
+ },
153
+ submitLabel: "tenant.members.invite.submit",
154
+ access: { roles: access.admin },
155
+ } satisfies ScreenDefinition;
@@ -1,9 +1,13 @@
1
1
  // @runtime client
2
2
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
3
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
- import { MEMBERS_SCREEN_ID, TENANT_FEATURE } from "../constants";
5
- import { defaultTranslations } from "./i18n";
6
- import { MembersScreen } from "./members-screen";
4
+ import {
5
+ MEMBER_ROLES_CELL_COMPONENT,
6
+ MEMBER_STATUS_CELL_COMPONENT,
7
+ TENANT_FEATURE,
8
+ } from "../constants";
9
+ import { MemberRolesCell } from "./member-roles-cell";
10
+ import { MemberStatusCell } from "./member-status-cell";
7
11
 
8
12
  export type TenantClientOptions = {
9
13
  readonly translations?: TranslationsByLocale;
@@ -12,9 +16,10 @@ export type TenantClientOptions = {
12
16
  export function tenantClient(options?: TenantClientOptions): ClientFeatureDefinition {
13
17
  return {
14
18
  name: TENANT_FEATURE,
15
- translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
16
- components: {
17
- [MEMBERS_SCREEN_ID]: MembersScreen,
19
+ translations: mergeTranslations({}, options?.translations ?? {}),
20
+ columnRenderers: {
21
+ [MEMBER_STATUS_CELL_COMPONENT]: MemberStatusCell,
22
+ [MEMBER_ROLES_CELL_COMPONENT]: MemberRolesCell,
18
23
  },
19
24
  };
20
25
  }
@@ -1,4 +1,4 @@
1
1
  // @runtime client
2
2
  export { type TenantClientOptions, tenantClient } from "./client-plugin";
3
- export { defaultTranslations } from "./i18n";
4
- export { MembersScreen, type MembersScreenProps } from "./members-screen";
3
+ export { MemberRolesCell } from "./member-roles-cell";
4
+ export { MemberStatusCell } from "./member-status-cell";
@@ -0,0 +1,12 @@
1
+ // @runtime client
2
+ // Column-renderer for the /members screen's roles column — `roles` is a
3
+ // `readonly string[]` on TeamRow (see team-list.query.ts), and DataTable has
4
+ // no default array-to-cell formatting, so this joins it into one string.
5
+
6
+ import type { ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
7
+ import type { ReactNode } from "react";
8
+
9
+ export function MemberRolesCell({ row }: ColumnRendererProps): ReactNode {
10
+ const roles = row["roles"];
11
+ return Array.isArray(roles) ? roles.join(", ") : "";
12
+ }
@@ -0,0 +1,23 @@
1
+ // @runtime client
2
+ // Column-renderer for the /members screen's status column, registered via
3
+ // `tenantClient()`'s `columnRenderers` map (screen.columns[].renderer in
4
+ // screens.ts). The list frame itself is the generic declarative
5
+ // projectionList renderer — only this one cell stays TSX.
6
+
7
+ import type { ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
8
+ import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer-web";
9
+ import type { ReactNode } from "react";
10
+
11
+ const STATUS_TONE: Readonly<Record<string, StatusTone>> = {
12
+ active: "ok",
13
+ pending: "muted",
14
+ };
15
+
16
+ export function MemberStatusCell({ row }: ColumnRendererProps): ReactNode {
17
+ const status = typeof row["status"] === "string" ? row["status"] : "";
18
+ return (
19
+ <StatusBadge tone={STATUS_TONE[status] ?? "muted"} testId="member-status-cell">
20
+ {status}
21
+ </StatusBadge>
22
+ );
23
+ }
@@ -1,24 +0,0 @@
1
- // @runtime client
2
- import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
-
4
- export const defaultTranslations: TranslationsByLocale = {
5
- en: {
6
- "tenant.members.title": "Members",
7
- "tenant.members.loading": "Loading members…",
8
- "tenant.members.active": "Active members",
9
- "tenant.members.invite.title": "New invitation",
10
- "tenant.members.invite.email": "Email",
11
- "tenant.members.invite.role": "Role",
12
- "tenant.members.invite.submit": "Send invitation",
13
- "tenant.members.invite.submitting": "Sending invitation…",
14
- "tenant.members.invite.success": "Invitation sent to {email}",
15
- "tenant.members.pending": "Pending invitations",
16
- "tenant.members.pending.empty": "No pending invitations.",
17
- "tenant.members.cancel": "Cancel",
18
- "tenant.members.col.userId": "User ID",
19
- "tenant.members.col.roles": "Roles",
20
- "tenant.members.col.email": "Email",
21
- "tenant.members.col.expires": "Valid until",
22
- "tenant.nav.members": "Team",
23
- },
24
- };