@cosmicdrift/kumiko-bundled-features 0.210.0 → 0.212.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 +8 -8
- package/src/admin-shell/feature.ts +2 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +1 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +1 -0
- package/src/audit/__tests__/audit-log-screen.test.tsx +228 -0
- package/src/audit/constants.ts +3 -0
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +1 -0
- package/src/audit/web/audit-log-detail-screen.tsx +28 -6
- package/src/audit/web/audit-log-screen.tsx +51 -23
- package/src/auth-email-password/feature.ts +1 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
- package/src/auth-email-password/web/auth-gate.tsx +1 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
- package/src/auth-email-password/web/session.tsx +1 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
- package/src/auth-mfa/feature.ts +1 -0
- package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
- package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
- package/src/compliance-profiles/feature.ts +1 -5
- package/src/crypto-shredding/changes.json +2 -1
- package/src/custom-fields/__tests__/feature.test.ts +42 -0
- package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
- package/src/custom-fields/constants.ts +6 -0
- package/src/custom-fields/feature.ts +56 -10
- package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
- package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
- package/src/delivery/feature.ts +1 -3
- package/src/feature-toggles/feature.ts +1 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
- package/src/file-derivatives/changes.json +9 -1
- package/src/folders/web/folder-manager.tsx +1 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
- package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
- package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
- package/src/jobs/feature.ts +25 -1
- package/src/jobs/handlers/detail.query.ts +13 -1
- package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
- package/src/jobs/job-run-logger.ts +68 -18
- package/src/jobs/web/job-run-detail-screen.tsx +1 -0
- package/src/page-render/web.ts +1 -0
- package/src/personal-access-tokens/changes.json +7 -0
- package/src/personal-access-tokens/feature.ts +1 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
- package/src/secrets/constants.ts +12 -0
- package/src/secrets/feature.ts +35 -9
- package/src/secrets/handlers/delete.write.ts +23 -16
- package/src/secrets/handlers/list.query.ts +37 -30
- package/src/secrets/handlers/set.write.ts +43 -36
- package/src/secrets/index.ts +5 -0
- package/src/seo/feature.ts +1 -0
- package/src/tags/feature.ts +1 -0
- package/src/tags/web/tag-chip.tsx +2 -0
- package/src/tags/web/tag-manager.tsx +2 -1
- package/src/tags/web/tag-picker.tsx +1 -0
- package/src/template-resolver/changes.json +9 -1
- package/src/template-resolver/web/client-plugin.tsx +2 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +79 -4
- package/src/tenant/__tests__/tenant-security.integration.test.ts +260 -3
- package/src/tenant/changes.json +6 -0
- package/src/tenant/constants.ts +17 -4
- package/src/tenant/feature.ts +28 -11
- package/src/tenant/handlers/team-list.query.ts +250 -0
- package/src/tenant/i18n.ts +20 -0
- package/src/tenant/screens.ts +111 -3
- package/src/tenant/web/client-plugin.tsx +11 -6
- package/src/tenant/web/index.ts +2 -2
- package/src/tenant/web/member-roles-cell.tsx +12 -0
- package/src/tenant/web/member-status-cell.tsx +23 -0
- package/src/tier-engine/feature.ts +2 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
- package/src/user-data-rights/feature.ts +2 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/src/user-profile/i18n.ts +1 -0
- package/src/user-profile/web/profile-screen.tsx +1 -1
- package/src/tenant/web/i18n.ts +0 -24
- package/src/tenant/web/members-screen.tsx +0 -270
|
@@ -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
|
+
});
|
package/src/tenant/i18n.ts
CHANGED
|
@@ -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" },
|
package/src/tenant/screens.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
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(
|
|
16
|
-
|
|
17
|
-
[
|
|
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
|
}
|
package/src/tenant/web/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
export { type TenantClientOptions, tenantClient } from "./client-plugin";
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
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
|
+
}
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
//
|
|
41
41
|
// **Boot-Dependencies:** config + tenant.
|
|
42
42
|
|
|
43
|
+
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312 (feature.ts > 300 lines)
|
|
43
44
|
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
44
45
|
import {
|
|
45
46
|
buildEntityTable,
|
|
@@ -236,6 +237,7 @@ export function createTierEngineFeature<
|
|
|
236
237
|
// platziert ihn nur via r.nav("tier-engine:screen:tier-admin"); die
|
|
237
238
|
// Komponente liefert tierEngineClient() aus dem ./web-subpath.
|
|
238
239
|
r.translations({ keys: TIER_ENGINE_I18N });
|
|
240
|
+
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
239
241
|
r.screen({
|
|
240
242
|
id: TIER_ADMIN_SCREEN_ID,
|
|
241
243
|
type: "custom",
|
|
@@ -56,11 +56,11 @@ export function TierAdminScreen(): ReactNode {
|
|
|
56
56
|
{ enabled: tenantId !== "" },
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
-
// Tenant
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: tenantId
|
|
59
|
+
// Tenant switch: reset selection + status so no other tenant's tier stays
|
|
60
|
+
// shown (UI-level mis-grant guard). tenantId is a pure trigger here (the
|
|
61
|
+
// body doesn't read it) — Biome's "extra dep" autofix would empty the deps
|
|
62
|
+
// and limit the reset to mount.
|
|
63
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: tenantId is the intended reset trigger, do not remove. kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
64
64
|
useEffect(() => {
|
|
65
65
|
setTier("");
|
|
66
66
|
setStatus({ kind: "idle" });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312 (feature.ts > 300 lines)
|
|
1
2
|
import {
|
|
2
3
|
createSystemConfig,
|
|
3
4
|
defineFeature,
|
|
@@ -291,6 +292,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
291
292
|
// its authed area. dormant: true so createKumikoApp's boot diagnostic
|
|
292
293
|
// (#2025) doesn't flag apps that haven't (yet) navved the screen as
|
|
293
294
|
// having a missing client plugin (#2034).
|
|
295
|
+
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
294
296
|
r.screen({
|
|
295
297
|
id: PRIVACY_CENTER_SCREEN_ID,
|
|
296
298
|
type: "custom",
|
|
@@ -121,8 +121,9 @@ function ExportSection(): ReactNode {
|
|
|
121
121
|
const done = job?.status === EXPORT_JOB_STATUS.Done;
|
|
122
122
|
const failed = job?.status === EXPORT_JOB_STATUS.Failed;
|
|
123
123
|
|
|
124
|
-
//
|
|
124
|
+
// While the job is running: poll until Done/Failed, then auto-stop.
|
|
125
125
|
const refetch = statusQuery.refetch;
|
|
126
|
+
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
126
127
|
useEffect(() => {
|
|
127
128
|
if (!inProgress || !refetch) return;
|
|
128
129
|
const id = setInterval(() => void refetch(), EXPORT_POLL_MS);
|
package/src/user-profile/i18n.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
|
11
11
|
export const defaultTranslations: TranslationsByLocale = {
|
|
12
12
|
en: {
|
|
13
13
|
"profile.title": "Profile",
|
|
14
|
+
"profile.loading": "Loading…",
|
|
14
15
|
|
|
15
16
|
"profile.email.title": "Email address",
|
|
16
17
|
"profile.email.current": "Current email",
|
package/src/tenant/web/i18n.ts
DELETED
|
@@ -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
|
-
};
|