@cosmicdrift/kumiko-bundled-features 0.217.0 → 0.219.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 +9 -9
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +2 -2
- package/src/auth-email-password/changes.json +14 -8
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -2
- package/src/auth-email-password/seeding.ts +3 -3
- package/src/tenant/__tests__/tenant-security.integration.test.ts +13 -0
- package/src/tenant/changes.json +12 -6
- package/src/tenant/constants.ts +1 -1
- package/src/tenant/i18n.ts +2 -0
- package/src/user/changes.json +6 -0
- package/src/user/handlers/list.query.ts +10 -3
- package/src/user/i18n.ts +7 -1
- package/src/user/schema/user.ts +3 -0
- package/src/user/screens.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.219.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.219.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.219.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.219.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.219.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.219.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.219.0",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
"devDependencies": {
|
|
161
161
|
"@testing-library/user-event": "^14.6.1",
|
|
162
162
|
"@types/qrcode": "^1.5.5",
|
|
163
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
164
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.219.0",
|
|
164
|
+
"@cosmicdrift/kumiko-locale-es": "0.219.0"
|
|
165
165
|
}
|
|
166
166
|
}
|
|
@@ -275,7 +275,7 @@ describe("POST /api/auth/signup-confirm", () => {
|
|
|
275
275
|
expect(body.token).toBeTruthy();
|
|
276
276
|
expect(body.user?.id).toBeTruthy();
|
|
277
277
|
expect(body.user?.tenantId).toBeTruthy();
|
|
278
|
-
expect(body.user?.roles).toContain("
|
|
278
|
+
expect(body.user?.roles).toContain("TenantAdmin");
|
|
279
279
|
expect(body.tenantKey).toBeTruthy();
|
|
280
280
|
|
|
281
281
|
// Cookies gesetzt (auth + csrf)
|
|
@@ -299,7 +299,7 @@ describe("POST /api/auth/signup-confirm", () => {
|
|
|
299
299
|
expect(memberships).toHaveLength(1);
|
|
300
300
|
const rolesRaw = memberships[0]?.["roles"];
|
|
301
301
|
if (typeof rolesRaw === "string") {
|
|
302
|
-
expect(JSON.parse(rolesRaw) as string[]).toContain("
|
|
302
|
+
expect(JSON.parse(rolesRaw) as string[]).toContain("TenantAdmin");
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
// #1463 regression: seedTenant fires the tenant entity's postSave
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.218.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Self-signup provisions TenantAdmin on the new tenant",
|
|
6
|
+
"detail": "INITIAL_SIGNUP_ROLES is TenantAdmin (was Admin) so the first user on a new tenant can invite peers and manage memberships without elevation dead-ends."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.165.1",
|
|
4
10
|
"type": "breaking",
|
|
@@ -10,8 +16,8 @@
|
|
|
10
16
|
"version": "0.167.1",
|
|
11
17
|
"type": "improvement",
|
|
12
18
|
"title": "accountLockout is reachable through runProdApp/runDevApp (fw#1627).",
|
|
13
|
-
"detail": "accountLockout was an option on createAuthEmailPasswordFeature, but both wrappers mount auth-email-password themselves and their auth options only knew accountUnlock
|
|
14
|
-
"migration": "Opt-in, nothing breaks without it. Two things to know before relying on it: without ctx.redis the lockout is silently skipped in login.write.ts
|
|
19
|
+
"detail": "accountLockout was an option on createAuthEmailPasswordFeature, but both wrappers mount auth-email-password themselves and their auth options only knew accountUnlock \u2014 an app on either entrypoint had no way to turn brute-force protection on. RunProdAppAuthOptions and RunDevAppAuthOptions now carry accountLockout and composeFeatures passes it through.",
|
|
20
|
+
"migration": "Opt-in, nothing breaks without it. Two things to know before relying on it: without ctx.redis the lockout is silently skipped in login.write.ts \u2014 no error, no warning, no protection \u2014 so treat Redis as a deploy invariant, and prove the lockout with a behavioural test instead of assuming the mount is enough."
|
|
15
21
|
},
|
|
16
22
|
{
|
|
17
23
|
"version": "0.162.0",
|
|
@@ -22,19 +28,19 @@
|
|
|
22
28
|
{
|
|
23
29
|
"version": "0.154.1",
|
|
24
30
|
"type": "fix",
|
|
25
|
-
"title": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled
|
|
26
|
-
"detail": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled-features/auth-email-password/web`. Every other auth screen (ForgotPasswordScreen, SignupScreen, ResetPasswordScreen,
|
|
31
|
+
"title": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled\u2026",
|
|
32
|
+
"detail": "Export `LoginScreen`/`LoginScreenProps`/`AuthLegalLink` from `@cosmicdrift/kumiko-bundled-features/auth-email-password/web`. Every other auth screen (ForgotPasswordScreen, SignupScreen, ResetPasswordScreen, \u2026) was already exported from the barrel with its props type; `LoginScreen` was missed. `makeAuthGate`'s second parameter is typed `LoginScreenProps`, so consumers passing a typed `loginScreenProps` override (e.g. `@cosmicdriftgamestudio/kumiko-designer`) couldn't import the type at all. - @cosmicdrift/kumiko-framework@0.154.1 - @cosmicdrift/kumiko-dispatcher-live@0.154.1 - @cosmicdrift/kumiko-headless@0.154.1 - @cosmicdrift/kumiko-renderer@0.154.1 - @cosmicdrift/kumiko-renderer-web@0.154.1"
|
|
27
33
|
},
|
|
28
34
|
{
|
|
29
35
|
"version": "0.151.0",
|
|
30
36
|
"type": "improvement",
|
|
31
|
-
"title": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly
|
|
32
|
-
"detail": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly
|
|
37
|
+
"title": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly \u2014 u\u2026",
|
|
38
|
+
"detail": "`auth-email-password/web` no longer exports `LoginScreen`/`LoginScreenProps` directly \u2014 use the new `createLoginRoute({ loginScreenProps, mfaVerifyScreen, unaffected (they already build on the same logic internally now). Why: a raw `<LoginScreen />` render has no MFA-challenge handling unless the caller remembers to hand-wire `onMfaChallenge` + swap in a verify screen itself \u2014 exactly how kumiko-framework#266's login-time MFA step went missing in a real app's standalone apex/marketing login route (it renders outside `emailPasswordClient`'s own gate, which already handled this correctly). `createLoginRoute` is the one place this logic lives now, for both the gated and standalone cases \u2014 there's no lower-level piece left to misuse. The `apex-surface-auth` recipe is updated to match."
|
|
33
39
|
},
|
|
34
40
|
{
|
|
35
41
|
"version": "0.131.0",
|
|
36
42
|
"type": "improvement",
|
|
37
|
-
"title": "App-Mounting 2.0
|
|
38
|
-
"detail": "App-Mounting 2.0
|
|
43
|
+
"title": "App-Mounting 2.0 S\u00e4ule A: Mid-Level-Widget-Kit in renderer-web (StatCard, MiniStat, Secti\u2026",
|
|
44
|
+
"detail": "App-Mounting 2.0 S\u00e4ule A: Mid-Level-Widget-Kit in renderer-web (StatCard, MiniStat, SectionCard, StatusBadge, ProgressBar, CollapsibleSection, DetailList, ModeSwitch, StatusBarChart, TimeseriesChart, EmptyState/LoadingState/ErrorState, QueryTable) + Status-Farb-Tokens (--color-status-\\*). Neue Hooks useMutation + useDisclosure. Neues Core-Primitive Link (default/button/muted), Button-Variant \"link\", Text-Variant \"muted\"; auth-email-password nutzt sie (authButtonClass/authMutedLinkClass entfernt)."
|
|
39
45
|
}
|
|
40
46
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Magic-Link-Signup, Step 2 (confirm).
|
|
2
2
|
//
|
|
3
3
|
// Token aus URL + Password vom User → wir lösen den Token in Redis ein
|
|
4
|
-
// und legen Tenant + User +
|
|
4
|
+
// und legen Tenant + User + TenantAdmin membership atomically. emailVerified
|
|
5
5
|
// wird sofort auf true gesetzt — der Magic-Link IST der Beweis.
|
|
6
6
|
//
|
|
7
7
|
// Pipeline:
|
|
@@ -141,7 +141,7 @@ export function createSignupConfirmHandler() {
|
|
|
141
141
|
|
|
142
142
|
// SessionUser für JWT-Mint. Roles aus INITIAL_SIGNUP_ROLES
|
|
143
143
|
// damit DB-write (provisionSignupAccount) und Session-claim
|
|
144
|
-
// dieselbe Quelle teilen — sonst hätten zwei Stellen "
|
|
144
|
+
// dieselbe Quelle teilen — sonst hätten zwei Stellen "TenantAdmin"
|
|
145
145
|
// hardcoded und ein Refactor würde role-mismatch zwischen DB
|
|
146
146
|
// und JWT erzeugen.
|
|
147
147
|
const session: SessionUser = {
|
|
@@ -103,12 +103,12 @@ export async function seedUserWithPassword(
|
|
|
103
103
|
* minten (Account-Takeover, #365). `seedUser` selbst bleibt idempotent
|
|
104
104
|
* add-only (für Bootstrap/Tests); die create-only-Garantie sitzt hier.
|
|
105
105
|
* tenantKey-Kollisionen löst der Caller via generateUniqueName. */
|
|
106
|
-
/** Default
|
|
106
|
+
/** Default membership roles for self-signup tenant owners. Shared between
|
|
107
107
|
* provisionSignupAccount (DB-write) und signup-confirm-handler
|
|
108
108
|
* (SessionUser-Konstruktion für JWT-Mint) — sonst hätten zwei
|
|
109
|
-
* Stellen unabhängig "
|
|
109
|
+
* Stellen unabhängig "TenantAdmin" hardcoded und würden bei einem
|
|
110
110
|
* Refactor zu role-mismatch zwischen DB und Session leiden. */
|
|
111
|
-
export const INITIAL_SIGNUP_ROLES = ["
|
|
111
|
+
export const INITIAL_SIGNUP_ROLES = ["TenantAdmin"] as const;
|
|
112
112
|
|
|
113
113
|
export type ProvisionSignupAccountOptions = {
|
|
114
114
|
readonly email: string;
|
|
@@ -321,6 +321,19 @@ describe("privilege escalation via invite role", () => {
|
|
|
321
321
|
expectErrorIncludes(err, "unassignable_membership_role");
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
+
test("TenantAdmin can invite as TenantAdmin (same-rank owner invite)", async () => {
|
|
325
|
+
await stack.http.write(
|
|
326
|
+
AuthHandlers.inviteCreate,
|
|
327
|
+
{ email: "co-owner@example.com", role: "TenantAdmin" },
|
|
328
|
+
tenantAdminA(),
|
|
329
|
+
);
|
|
330
|
+
const rows = await selectMany(stack.db, tenantInvitationsTable, {
|
|
331
|
+
email: "co-owner@example.com",
|
|
332
|
+
});
|
|
333
|
+
expect(rows).toHaveLength(1);
|
|
334
|
+
expect(rows[0]?.["role"]).toBe("TenantAdmin");
|
|
335
|
+
});
|
|
336
|
+
|
|
324
337
|
test("TenantAdmin cannot invite with unknown unranked role (elevation guard)", async () => {
|
|
325
338
|
const err = await stack.http.writeErr(
|
|
326
339
|
AuthHandlers.inviteCreate,
|
package/src/tenant/changes.json
CHANGED
|
@@ -1,33 +1,39 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.219.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Invite/member-roles pickers include TenantAdmin",
|
|
6
|
+
"detail": "DEFAULT_INVITE_ROLE_OPTIONS adds TenantAdmin so SystemAdmin/TenantAdmin can invite owners. SystemAdmin stays global-only (user.roles), never a membership invite option."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.209.0",
|
|
4
10
|
"type": "improvement",
|
|
5
11
|
"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)
|
|
12
|
+
"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) \u2014 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
13
|
},
|
|
8
14
|
{
|
|
9
15
|
"version": "0.165.0",
|
|
10
16
|
"type": "fix",
|
|
11
17
|
"title": "resolveRawDb: removed redundant null check",
|
|
12
|
-
"detail": "CodeQL flagged db !== null after if (!db)
|
|
18
|
+
"detail": "CodeQL flagged db !== null after if (!db) \u2014 truthiness already excludes null. Internal cleanup, no API break."
|
|
13
19
|
},
|
|
14
20
|
{
|
|
15
21
|
"version": "0.165.0",
|
|
16
22
|
"type": "fix",
|
|
17
23
|
"title": "Seed hook: explicit SessionUser typing",
|
|
18
|
-
"detail": "Fixed tenant seeding type annotation
|
|
24
|
+
"detail": "Fixed tenant seeding type annotation \u2014 prevents implicit any propagation in seed handlers."
|
|
19
25
|
},
|
|
20
26
|
{
|
|
21
27
|
"version": "0.165.0",
|
|
22
28
|
"type": "fix",
|
|
23
29
|
"title": "Seed afterCommit + real tenant rescope",
|
|
24
|
-
"detail": "ScheduleAfterCommit now available on HandlerContext
|
|
25
|
-
"migration": "Only affects custom seed handlers using fireEntityPostSave
|
|
30
|
+
"detail": "ScheduleAfterCommit now available on HandlerContext \u2014 fireEntityPostSave queues after TX commit instead of mid-transaction. DB/notify/hasFeature/_tenantId are correctly rescoped when targeting an entity's tenant.",
|
|
31
|
+
"migration": "Only affects custom seed handlers using fireEntityPostSave \u2014 now afterCommit-compatible."
|
|
26
32
|
},
|
|
27
33
|
{
|
|
28
34
|
"version": "0.163.0",
|
|
29
35
|
"type": "improvement",
|
|
30
36
|
"title": "tenant: seedTenant fires entity postSave hooks (#1463).",
|
|
31
|
-
"detail": "tenant: seedTenant fires entity postSave hooks (#1463). `seedTenant` writes through the raw event-store executor, which never fired postSave hooks
|
|
37
|
+
"detail": "tenant: seedTenant fires entity postSave hooks (#1463). `seedTenant` writes through the raw event-store executor, which never fired postSave hooks \u2014 self-signup (`provisionSignupAccount` \u2192 `signup-confirm`) silently skipped feature-registered entity hooks like tier-engine's auto-default-tier and app-level auto-default-compliance, leaving new tenants without a tier/compliance row. `seedTenant` and `provisionSignupAccount` take a new optional `hooks: { registry, context }` param; passing it fires the same entity-scoped postSave hooks the dispatcher would, without the full dispatch roundtrip. Existing call-sites are unaffected \u2014 the param is optional and append-only."
|
|
32
38
|
}
|
|
33
39
|
]
|
package/src/tenant/constants.ts
CHANGED
|
@@ -23,7 +23,7 @@ export const MEMBER_STATUS_CELL_COMPONENT = "MemberStatusCell" as const;
|
|
|
23
23
|
export const MEMBER_ROLES_CELL_COMPONENT = "MemberRolesCell" as const;
|
|
24
24
|
|
|
25
25
|
/** Closed allowlist for invite-role picker — never free text (escalation guard). */
|
|
26
|
-
export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Admin", "
|
|
26
|
+
export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin", "TenantAdmin"] as const;
|
|
27
27
|
|
|
28
28
|
// Qualified write handler names (QN format: scope:type:name)
|
|
29
29
|
export const TenantHandlers = {
|
package/src/tenant/i18n.ts
CHANGED
|
@@ -28,11 +28,13 @@ export const TENANT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
28
28
|
"tenant:entity:__action-form__:field:role": { en: "Role" },
|
|
29
29
|
"tenant:entity:__action-form__:field:role:option:User": { en: "User" },
|
|
30
30
|
"tenant:entity:__action-form__:field:role:option:Admin": { en: "Admin" },
|
|
31
|
+
"tenant:entity:__action-form__:field:role:option:TenantAdmin": { en: "TenantAdmin" },
|
|
31
32
|
"tenant:entity:__action-form__:field:role:option:Editor": { en: "Editor" },
|
|
32
33
|
"tenant:entity:__action-form__:field:userId": { en: "User ID" },
|
|
33
34
|
"tenant:entity:__action-form__:field:roles": { en: "Roles" },
|
|
34
35
|
"tenant:entity:__action-form__:field:roles:option:User": { en: "User" },
|
|
35
36
|
"tenant:entity:__action-form__:field:roles:option:Admin": { en: "Admin" },
|
|
37
|
+
"tenant:entity:__action-form__:field:roles:option:TenantAdmin": { en: "TenantAdmin" },
|
|
36
38
|
"tenant:entity:__action-form__:field:roles:option:Editor": { en: "Editor" },
|
|
37
39
|
"tenant:entity:tenant:field:key": { en: "Key" },
|
|
38
40
|
"tenant:entity:tenant:field:name": { en: "Name" },
|
package/src/user/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.218.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "user-list: system-scope search; membership roles on tenants; emailVerified sort",
|
|
6
|
+
"detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant → Meili miss/500). Tenants column shows membership roles (Offlot Demo (TenantAdmin)). emailVerified sortable; email also searchable for roster lookup. Global roles stay SystemAdmin-only — TenantAdmin/Admin live on membership (Team → Members)."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.217.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
defineEntityListHandler,
|
|
6
6
|
type HandlerContext,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
8
9
|
// kumiko-lint-ignore cross-feature-import SystemAdmin user-list joins memberships for tenants column
|
|
9
10
|
import { tenantMembershipsTable, tenantTable } from "../../tenant";
|
|
10
11
|
import { userEntity } from "../schema/user";
|
|
@@ -13,7 +14,7 @@ const baseList = defineEntityListHandler("user", userEntity, {
|
|
|
13
14
|
access: { roles: access.systemAdmin },
|
|
14
15
|
});
|
|
15
16
|
|
|
16
|
-
type MembershipRow = { userId: unknown; tenantId: unknown };
|
|
17
|
+
type MembershipRow = { userId: unknown; tenantId: unknown; roles?: unknown };
|
|
17
18
|
type TenantRow = { id: unknown; name?: unknown; key?: unknown };
|
|
18
19
|
|
|
19
20
|
function isMissingRelation(err: unknown): boolean {
|
|
@@ -30,7 +31,6 @@ async function loadMemberships(
|
|
|
30
31
|
userId: { in: [...userIds] },
|
|
31
32
|
});
|
|
32
33
|
} catch (err) {
|
|
33
|
-
// User-only test stacks (and mid-migration DBs) may lack membership tables.
|
|
34
34
|
if (isMissingRelation(err)) return null;
|
|
35
35
|
throw err;
|
|
36
36
|
}
|
|
@@ -46,6 +46,12 @@ function tenantLabelById(tenants: readonly TenantRow[]): Map<string, string> {
|
|
|
46
46
|
return map;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** "Offlot Demo (TenantAdmin)" — membership roles live here, not on user.roles. */
|
|
50
|
+
function membershipLabel(tenantLabel: string, membershipRoles: readonly string[]): string {
|
|
51
|
+
if (membershipRoles.length === 0) return tenantLabel;
|
|
52
|
+
return `${tenantLabel} (${membershipRoles.join("+")})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
function labelsByUserId(
|
|
50
56
|
memberships: readonly MembershipRow[],
|
|
51
57
|
labelByTenantId: Map<string, string>,
|
|
@@ -55,7 +61,8 @@ function labelsByUserId(
|
|
|
55
61
|
const userId = String(m.userId ?? "");
|
|
56
62
|
const tenantId = String(m.tenantId ?? "");
|
|
57
63
|
if (userId === "" || tenantId === "") continue;
|
|
58
|
-
const
|
|
64
|
+
const tenantLabel = labelByTenantId.get(tenantId) ?? tenantId;
|
|
65
|
+
const label = membershipLabel(tenantLabel, parseRoles(m.roles ?? null));
|
|
59
66
|
const list = map.get(userId) ?? [];
|
|
60
67
|
list.push(label);
|
|
61
68
|
map.set(userId, list);
|
package/src/user/i18n.ts
CHANGED
|
@@ -5,8 +5,14 @@ export const USER_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
5
5
|
"screen:user-edit.title": { en: "Edit user" },
|
|
6
6
|
"user:entity:user:field:email": { en: "Email" },
|
|
7
7
|
"user:entity:user:field:displayName": { en: "Display name" },
|
|
8
|
-
"user:entity:user:field:roles": { en: "
|
|
8
|
+
"user:entity:user:field:roles": { en: "Platform roles" },
|
|
9
9
|
"user:entity:user:field:tenants": { en: "Tenants" },
|
|
10
|
+
"user:entity:user:field:tenants:description": {
|
|
11
|
+
en: "Membership tenants with roles, e.g. Offlot Demo (TenantAdmin). Edit under Team → Members.",
|
|
12
|
+
},
|
|
13
|
+
"user:entity:user:field:roles:description": {
|
|
14
|
+
en: "Global platform roles only (SystemAdmin). TenantAdmin/Admin are membership roles under Team → Members.",
|
|
15
|
+
},
|
|
10
16
|
"user:entity:user:field:roles:option:SystemAdmin": { en: "SystemAdmin" },
|
|
11
17
|
"user:entity:user:field:status": { en: "Status" },
|
|
12
18
|
"user:entity:user:field:emailVerified": { en: "Email verified" },
|
package/src/user/schema/user.ts
CHANGED
|
@@ -72,6 +72,8 @@ export const userEntity = createEntity({
|
|
|
72
72
|
maxLength: 320,
|
|
73
73
|
personal: "self",
|
|
74
74
|
find: "exact",
|
|
75
|
+
// Roster search needs email hits; find:"exact" alone is blind-index only.
|
|
76
|
+
searchable: true,
|
|
75
77
|
access: { write: access.privileged },
|
|
76
78
|
}),
|
|
77
79
|
|
|
@@ -112,6 +114,7 @@ export const userEntity = createEntity({
|
|
|
112
114
|
// refuse a session while this is false (strict mode).
|
|
113
115
|
emailVerified: createBooleanField({
|
|
114
116
|
default: false,
|
|
117
|
+
sortable: true,
|
|
115
118
|
access: { write: access.privileged },
|
|
116
119
|
}),
|
|
117
120
|
|
package/src/user/screens.ts
CHANGED
|
@@ -27,7 +27,8 @@ export const userListScreen: EntityListScreenDefinition = {
|
|
|
27
27
|
},
|
|
28
28
|
],
|
|
29
29
|
defaultSort: { field: "status", dir: "asc" },
|
|
30
|
-
//
|
|
30
|
+
// Search uses SYSTEM_TENANT_ID for systemScope lists (see event-store-executor-read).
|
|
31
|
+
// Apps must boot a user search index on that tenant (offlot: bootWaitlistSearch).
|
|
31
32
|
searchable: true,
|
|
32
33
|
access: { roles: ["SystemAdmin"] },
|
|
33
34
|
};
|