@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 +8 -8
- package/src/file-derivatives/changes.json +9 -1
- package/src/personal-access-tokens/changes.json +7 -0
- package/src/template-resolver/changes.json +9 -1
- 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/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
|
+
}
|
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
|
-
};
|