@cosmicdrift/kumiko-bundled-features 0.219.0 → 0.220.1
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/web/auth-client.ts +1 -7
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
- package/src/feature-toggles/feature.ts +24 -4
- package/src/feature-toggles/handlers/registered.query.ts +27 -16
- package/src/feature-toggles/i18n.ts +5 -0
- package/src/feature-toggles/web/client-plugin.tsx +1 -5
- package/src/feature-toggles/web/index.ts +0 -1
- package/src/file-derivatives/feature.ts +1 -5
- package/src/secrets/handlers/delete.write.ts +0 -4
- package/src/secrets/handlers/list.query.ts +0 -4
- package/src/secrets/handlers/set.write.ts +0 -4
- package/src/secrets/index.ts +3 -3
- package/src/tags/__tests__/feature.test.ts +18 -5
- package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
- package/src/tags/constants.ts +3 -2
- package/src/tags/entity.ts +2 -0
- package/src/tags/feature.ts +16 -10
- package/src/tags/handlers/create-tag.write.ts +5 -2
- package/src/tags/handlers/delete-tag.write.ts +5 -2
- package/src/tags/handlers/update-tag.write.ts +5 -2
- package/src/tags/i18n.ts +4 -0
- package/src/tags/index.ts +2 -0
- package/src/tags/screens.ts +66 -0
- package/src/tags/web/client-plugin.tsx +1 -6
- package/src/tags/web/index.ts +1 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
- package/src/tenant/__tests__/tenant-security.integration.test.ts +34 -0
- package/src/tenant/handlers/team-list.query.ts +3 -1
- package/src/tenant/handlers/update-member-roles.write.ts +15 -1
- package/src/tenant/i18n.ts +0 -1
- package/src/tenant/screens.ts +3 -2
- package/src/user/handlers/update-roles.ts +5 -1
- package/src/user/i18n.ts +0 -6
- package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.220.1",
|
|
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.220.1",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.220.1",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.220.1",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.220.1",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.220.1",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.220.1",
|
|
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.220.1",
|
|
164
|
+
"@cosmicdrift/kumiko-locale-es": "0.220.1"
|
|
165
165
|
}
|
|
166
166
|
}
|
|
@@ -411,12 +411,6 @@ export async function fetchCurrentUser(): Promise<CurrentUserProfile | null> {
|
|
|
411
411
|
email: body.data.email,
|
|
412
412
|
displayName: body.data.displayName,
|
|
413
413
|
...(body.data.locale !== undefined && { locale: body.data.locale }),
|
|
414
|
-
globalRoles:
|
|
414
|
+
globalRoles: parseRoles(body.data.roles),
|
|
415
415
|
};
|
|
416
416
|
}
|
|
417
|
-
|
|
418
|
-
// Defensive parse — roles may be jsonb string[] (0.217+) or a legacy
|
|
419
|
-
// JSON-encoded string; malformed → [] so SessionProvider mount never throws.
|
|
420
|
-
function parseGlobalRoles(raw: unknown): readonly string[] {
|
|
421
|
-
return parseRoles(raw);
|
|
422
|
-
}
|
|
@@ -568,7 +568,7 @@ describe("feature-toggles queries + audit automation", () => {
|
|
|
568
568
|
test("registered query reports metadata + override + effective for every feature", async () => {
|
|
569
569
|
runtime.apply("widget", false);
|
|
570
570
|
const data = await stack.http.queryOk<{
|
|
571
|
-
|
|
571
|
+
rows: Array<{
|
|
572
572
|
name: string;
|
|
573
573
|
toggleable: boolean;
|
|
574
574
|
default: boolean | null;
|
|
@@ -577,7 +577,7 @@ describe("feature-toggles queries + audit automation", () => {
|
|
|
577
577
|
effective: boolean | null;
|
|
578
578
|
}>;
|
|
579
579
|
}>("feature-toggles:query:registered", {}, admin);
|
|
580
|
-
const byName = new Map(data.
|
|
580
|
+
const byName = new Map(data.rows.map((i) => [i.name, i]));
|
|
581
581
|
|
|
582
582
|
expect(byName.get("widget")).toMatchObject({
|
|
583
583
|
toggleable: true,
|
|
@@ -11,11 +11,19 @@ describe("feature-toggles screen + handler access alignment", () => {
|
|
|
11
11
|
expect(() => validateBoot(features)).not.toThrow();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
test("toggle-admin screen is SystemAdmin-gated", () => {
|
|
14
|
+
test("toggle-admin screen is projectionList and SystemAdmin-gated", () => {
|
|
15
15
|
const toggles = createFeatureTogglesFeature();
|
|
16
16
|
const screen = toggles.screens[TOGGLE_ADMIN_SCREEN_ID];
|
|
17
|
-
expect(screen?.type).toBe("
|
|
18
|
-
if (screen
|
|
17
|
+
expect(screen?.type).toBe("projectionList");
|
|
18
|
+
if (screen?.type !== "projectionList") throw new Error("expected projectionList");
|
|
19
|
+
expect(screen.query).toBe(FeatureToggleQueries.registered);
|
|
20
|
+
const toggleAction = screen.rowActions?.find((a) => a.id === "toggle");
|
|
21
|
+
if (toggleAction?.kind !== "writeHandler") throw new Error("expected a writeHandler rowAction");
|
|
22
|
+
expect(toggleAction.handler).toBe(FeatureToggleHandlers.set);
|
|
23
|
+
expect(toggleAction.payload).toEqual({
|
|
24
|
+
map: { featureName: "name", enabled: "nextEnabled" },
|
|
25
|
+
});
|
|
26
|
+
if ("access" in screen && screen.access && "roles" in screen.access) {
|
|
19
27
|
expect(screen.access.roles).toEqual(["SystemAdmin"]);
|
|
20
28
|
}
|
|
21
29
|
});
|
|
@@ -36,6 +36,7 @@ describe("feature-toggles access matrix", () => {
|
|
|
36
36
|
]);
|
|
37
37
|
const feature = createFeatureTogglesFeature();
|
|
38
38
|
const screen = feature.screens[TOGGLE_ADMIN_SCREEN_ID];
|
|
39
|
+
expect(screen?.type).toBe("projectionList");
|
|
39
40
|
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
40
41
|
expect(screen.access.roles).toEqual(["SystemAdmin"]);
|
|
41
42
|
}
|
|
@@ -49,12 +50,12 @@ describe("feature-toggles HTTP access", () => {
|
|
|
49
50
|
{ items?: readonly unknown[] } | { rows?: readonly unknown[] }
|
|
50
51
|
>(FeatureToggleQueries.list, {}, admin);
|
|
51
52
|
expect(typeof list).toBe("object");
|
|
52
|
-
const registered = await stack.http.queryOk<{
|
|
53
|
+
const registered = await stack.http.queryOk<{ rows: readonly unknown[] }>(
|
|
53
54
|
FeatureToggleQueries.registered,
|
|
54
55
|
{},
|
|
55
56
|
admin,
|
|
56
57
|
);
|
|
57
|
-
expect(Array.isArray(registered.
|
|
58
|
+
expect(Array.isArray(registered.rows)).toBe(true);
|
|
58
59
|
});
|
|
59
60
|
|
|
60
61
|
test("TenantAdmin gets 403 on list, registered, and set", async () => {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
FEATURE_TOGGLE_SET_EVENT_NAME,
|
|
4
|
+
FeatureToggleHandlers,
|
|
5
|
+
FeatureToggleQueries,
|
|
6
|
+
TOGGLE_ADMIN_SCREEN_ID,
|
|
7
|
+
} from "./constants";
|
|
3
8
|
import { featureToggleSetSchema } from "./events";
|
|
4
9
|
import { globalFeatureStateTableMeta } from "./global-feature-state-table";
|
|
5
10
|
import { listQuery } from "./handlers/list.query";
|
|
@@ -67,11 +72,26 @@ export function createFeatureTogglesFeature(
|
|
|
67
72
|
registered: r.queryHandler(registeredQuery),
|
|
68
73
|
};
|
|
69
74
|
|
|
70
|
-
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
71
75
|
r.screen({
|
|
72
76
|
id: TOGGLE_ADMIN_SCREEN_ID,
|
|
73
|
-
type: "
|
|
74
|
-
|
|
77
|
+
type: "projectionList",
|
|
78
|
+
query: FeatureToggleQueries.registered,
|
|
79
|
+
columns: [
|
|
80
|
+
{ field: "name", label: "feature-toggles.admin.col.feature" },
|
|
81
|
+
{ field: "defaultLabel", label: "feature-toggles.admin.col.default" },
|
|
82
|
+
{ field: "overrideLabel", label: "feature-toggles.admin.col.override" },
|
|
83
|
+
{ field: "effectiveLabel", label: "feature-toggles.admin.col.effective" },
|
|
84
|
+
],
|
|
85
|
+
rowActions: [
|
|
86
|
+
{
|
|
87
|
+
kind: "writeHandler",
|
|
88
|
+
id: "toggle",
|
|
89
|
+
label: "feature-toggles.admin.toggle",
|
|
90
|
+
handler: FeatureToggleHandlers.set,
|
|
91
|
+
payload: { map: { featureName: "name", enabled: "nextEnabled" } },
|
|
92
|
+
visible: { field: "toggleable", eq: true },
|
|
93
|
+
},
|
|
94
|
+
],
|
|
75
95
|
access: { roles: ["SystemAdmin"] },
|
|
76
96
|
});
|
|
77
97
|
r.nav({
|
|
@@ -12,6 +12,12 @@ import { globalFeatureStateTable } from "../global-feature-state-table";
|
|
|
12
12
|
// Design: registry introspection (toggleable/default/requires) + a single
|
|
13
13
|
// DB read of overrides. No per-feature DB calls. Scales to however many
|
|
14
14
|
// features an app registers — currently tens, never thousands.
|
|
15
|
+
|
|
16
|
+
function formatFlag(value: boolean | null): string {
|
|
17
|
+
if (value === null) return "inherit";
|
|
18
|
+
return value ? "on" : "off";
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
export const registeredQuery = defineQueryHandler({
|
|
16
22
|
name: "registered",
|
|
17
23
|
schema: z.object({}),
|
|
@@ -38,38 +44,43 @@ export const registeredQuery = defineQueryHandler({
|
|
|
38
44
|
// dokumentiert in DispatcherOptions.effectiveFeatures.
|
|
39
45
|
const effective = ctx.effectiveFeatures?.(SYSTEM_TENANT_ID);
|
|
40
46
|
|
|
41
|
-
const
|
|
47
|
+
const rows: Array<{
|
|
48
|
+
id: string;
|
|
42
49
|
name: string;
|
|
43
50
|
toggleable: boolean;
|
|
44
51
|
default: boolean | null;
|
|
45
52
|
override: boolean | null;
|
|
46
53
|
requires: readonly string[];
|
|
47
54
|
effective: boolean | null;
|
|
55
|
+
defaultLabel: string;
|
|
56
|
+
overrideLabel: string;
|
|
57
|
+
effectiveLabel: string;
|
|
58
|
+
nextEnabled: boolean;
|
|
48
59
|
}> = [];
|
|
60
|
+
|
|
49
61
|
for (const feature of ctx.registry.features.values()) {
|
|
50
62
|
const toggleable = feature.toggleableDefault !== undefined;
|
|
51
63
|
const override = overrides.get(feature.name);
|
|
52
|
-
|
|
64
|
+
const defaultValue = feature.toggleableDefault ?? null;
|
|
65
|
+
const effectiveValue = effective ? effective.has(feature.name) : null;
|
|
66
|
+
const effectiveBool = effectiveValue ?? defaultValue ?? false;
|
|
67
|
+
|
|
68
|
+
rows.push({
|
|
69
|
+
id: feature.name,
|
|
53
70
|
name: feature.name,
|
|
54
71
|
toggleable,
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
// control (flipping them would be rejected by the set-handler).
|
|
58
|
-
default: feature.toggleableDefault ?? null,
|
|
59
|
-
// `override` is null when no explicit row exists. That's distinct
|
|
60
|
-
// from "override says on" or "override says off" so the UI can show
|
|
61
|
-
// an "inherits default" indicator.
|
|
72
|
+
// Raw booleans for API consumers; label fields for projectionList columns.
|
|
73
|
+
default: defaultValue,
|
|
62
74
|
override: override ?? null,
|
|
63
75
|
requires: feature.requires,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
effective: effective ? effective.has(feature.name) : null,
|
|
76
|
+
effective: effectiveValue,
|
|
77
|
+
defaultLabel: formatFlag(defaultValue),
|
|
78
|
+
overrideLabel: formatFlag(override ?? null),
|
|
79
|
+
effectiveLabel: formatFlag(effectiveValue),
|
|
80
|
+
nextEnabled: !effectiveBool,
|
|
70
81
|
});
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
return {
|
|
84
|
+
return { rows, nextCursor: null };
|
|
74
85
|
},
|
|
75
86
|
});
|
|
@@ -3,4 +3,9 @@ type LocalizedString = { readonly en: string };
|
|
|
3
3
|
export const FEATURE_TOGGLES_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
4
|
"screen:toggle-admin.title": { en: "Feature toggles" },
|
|
5
5
|
"feature-toggles:nav.toggleAdmin": { en: "Feature toggles" },
|
|
6
|
+
"feature-toggles.admin.col.feature": { en: "Feature" },
|
|
7
|
+
"feature-toggles.admin.col.default": { en: "Default" },
|
|
8
|
+
"feature-toggles.admin.col.override": { en: "Override" },
|
|
9
|
+
"feature-toggles.admin.col.effective": { en: "Effective" },
|
|
10
|
+
"feature-toggles.admin.toggle": { en: "Toggle" },
|
|
6
11
|
};
|
|
@@ -1,9 +1,8 @@
|
|
|
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 { FEATURE_TOGGLES_FEATURE
|
|
4
|
+
import { FEATURE_TOGGLES_FEATURE } from "../constants";
|
|
5
5
|
import { defaultTranslations } from "./i18n";
|
|
6
|
-
import { ToggleAdminScreen } from "./toggle-admin-screen";
|
|
7
6
|
|
|
8
7
|
export type FeatureTogglesClientOptions = {
|
|
9
8
|
readonly translations?: TranslationsByLocale;
|
|
@@ -15,8 +14,5 @@ export function featureTogglesClient(
|
|
|
15
14
|
return {
|
|
16
15
|
name: FEATURE_TOGGLES_FEATURE,
|
|
17
16
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
18
|
-
components: {
|
|
19
|
-
[TOGGLE_ADMIN_SCREEN_ID]: ToggleAdminScreen,
|
|
20
|
-
},
|
|
21
17
|
};
|
|
22
18
|
}
|
|
@@ -112,12 +112,10 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
112
112
|
// dispatch even when the httpRoute below isn't mounted, bypassing the
|
|
113
113
|
// "tenantId comes from the host, never the payload" invariant that only
|
|
114
114
|
// `resolveApexTenant` enforces.
|
|
115
|
-
const queries: { publicVariant?: ReturnType<typeof r.queryHandler> } = {};
|
|
116
|
-
|
|
117
115
|
if (opts.resolveApexTenant) {
|
|
118
116
|
const resolveApexTenant = opts.resolveApexTenant;
|
|
119
117
|
|
|
120
|
-
|
|
118
|
+
r.queryHandler(publicVariantQuery);
|
|
121
119
|
|
|
122
120
|
r.httpRoute({
|
|
123
121
|
method: "GET",
|
|
@@ -180,8 +178,6 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
180
178
|
},
|
|
181
179
|
});
|
|
182
180
|
}
|
|
183
|
-
|
|
184
|
-
return { queries };
|
|
185
181
|
});
|
|
186
182
|
}
|
|
187
183
|
|
|
@@ -21,7 +21,3 @@ export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS)
|
|
|
21
21
|
},
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
// Backwards-compat: existing imports of `deleteWrite` keep working —
|
|
26
|
-
// identical to createDeleteHandler() with the default access.
|
|
27
|
-
export const deleteWrite = createDeleteHandler();
|
|
@@ -38,7 +38,3 @@ export function createListHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
|
|
|
38
38
|
},
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
// Backwards-compat: existing imports of `listQuery` keep working — identical
|
|
43
|
-
// to createListHandler() with the default access.
|
|
44
|
-
export const listQuery = createListHandler();
|
|
@@ -44,10 +44,6 @@ export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
|
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
// Backwards-compat: existing imports of `setWrite` keep working — identical
|
|
48
|
-
// to createSetHandler() with the default access.
|
|
49
|
-
export const setWrite = createSetHandler();
|
|
50
|
-
|
|
51
47
|
// Fallback redaction: show at most the first 3 chars + trailing bullets.
|
|
52
48
|
// Deliberately conservative — a too-generous preview defeats the point.
|
|
53
49
|
function defaultRedact(value: string): string {
|
package/src/secrets/index.ts
CHANGED
|
@@ -13,11 +13,11 @@ export {
|
|
|
13
13
|
TENANT_SECRET_READ_EVENT,
|
|
14
14
|
tenantSecretsTable,
|
|
15
15
|
} from "./feature";
|
|
16
|
-
export { createDeleteHandler
|
|
17
|
-
export { createListHandler
|
|
16
|
+
export { createDeleteHandler } from "./handlers/delete.write";
|
|
17
|
+
export { createListHandler } from "./handlers/list.query";
|
|
18
18
|
export {
|
|
19
19
|
type RotateJobPayload,
|
|
20
20
|
type RotateJobResult,
|
|
21
21
|
rotateJob,
|
|
22
22
|
} from "./handlers/rotate.job";
|
|
23
|
-
export { createSetHandler
|
|
23
|
+
export { createSetHandler } from "./handlers/set.write";
|
|
@@ -48,7 +48,7 @@ function rawQueryAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
describe("createTagsFeature shape", () => {
|
|
51
|
-
test("registers tag + tag-assignment entities,
|
|
51
|
+
test("registers tag + tag-assignment entities, legacy + convention CRUD handlers", () => {
|
|
52
52
|
const feature = createTagsFeature();
|
|
53
53
|
|
|
54
54
|
expect(Object.keys(feature.entities ?? {})).toEqual(
|
|
@@ -62,17 +62,21 @@ describe("createTagsFeature shape", () => {
|
|
|
62
62
|
expect.stringMatching(/delete-tag/),
|
|
63
63
|
expect.stringMatching(/assign-tag/),
|
|
64
64
|
expect.stringMatching(/remove-tag/),
|
|
65
|
+
"tag:create",
|
|
66
|
+
"tag:update",
|
|
67
|
+
"tag:delete",
|
|
65
68
|
]),
|
|
66
69
|
);
|
|
67
|
-
expect(Object.keys(feature.writeHandlers)).toHaveLength(
|
|
70
|
+
expect(Object.keys(feature.writeHandlers)).toHaveLength(8);
|
|
68
71
|
|
|
69
72
|
expect(Object.keys(feature.queryHandlers)).toEqual(
|
|
70
73
|
expect.arrayContaining([
|
|
71
74
|
expect.stringMatching(/tag:list/),
|
|
72
75
|
expect.stringMatching(/tag-assignment:list/),
|
|
76
|
+
"tag:detail",
|
|
73
77
|
]),
|
|
74
78
|
);
|
|
75
|
-
expect(Object.keys(feature.queryHandlers)).toHaveLength(
|
|
79
|
+
expect(Object.keys(feature.queryHandlers)).toHaveLength(3);
|
|
76
80
|
});
|
|
77
81
|
});
|
|
78
82
|
|
|
@@ -102,10 +106,19 @@ describe("createTagsFeature access-options", () => {
|
|
|
102
106
|
|
|
103
107
|
test("access:{openToAll} applies to every write- and query-path", () => {
|
|
104
108
|
const feature = createTagsFeature({ access: { openToAll: true } });
|
|
105
|
-
for (const path of [
|
|
109
|
+
for (const path of [
|
|
110
|
+
"create-tag",
|
|
111
|
+
"update-tag",
|
|
112
|
+
"delete-tag",
|
|
113
|
+
"tag:create",
|
|
114
|
+
"tag:update",
|
|
115
|
+
"tag:delete",
|
|
116
|
+
"assign-tag",
|
|
117
|
+
"remove-tag",
|
|
118
|
+
]) {
|
|
106
119
|
expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
|
|
107
120
|
}
|
|
108
|
-
for (const query of ["tag:list", "tag-assignment:list"]) {
|
|
121
|
+
for (const query of ["tag:list", "tag:detail", "tag-assignment:list"]) {
|
|
109
122
|
expect(rawQueryAccess(feature, query)).toEqual({ openToAll: true });
|
|
110
123
|
}
|
|
111
124
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { TAGS_EDIT_SCREEN_ID, TAGS_SCREEN_ID } from "../constants";
|
|
4
|
+
import { createTagsFeature } from "../feature";
|
|
5
|
+
|
|
6
|
+
// QN convention: entityList loads tags:query:tag:list; entityEdit loads
|
|
7
|
+
// tags:query:tag:detail and saves via tags:write:tag:{create,update,delete}.
|
|
8
|
+
// Legacy create-tag/update-tag/delete-tag stay registered for TagManager.
|
|
9
|
+
|
|
10
|
+
describe("tags catalog screens (entityList + entityEdit)", () => {
|
|
11
|
+
const feature = createTagsFeature();
|
|
12
|
+
|
|
13
|
+
test("boot-validates with declarative tag-list + tag-edit", () => {
|
|
14
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("tag-list is entityList gated by default tag roles", () => {
|
|
18
|
+
const list = feature.screens[TAGS_SCREEN_ID];
|
|
19
|
+
expect(list?.type).toBe("entityList");
|
|
20
|
+
expect(list?.access).toEqual({ roles: ["TenantAdmin", "TenantMember"] });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("tag-edit is entityEdit with matching access", () => {
|
|
24
|
+
const edit = feature.screens[TAGS_EDIT_SCREEN_ID];
|
|
25
|
+
expect(edit?.type).toBe("entityEdit");
|
|
26
|
+
expect(edit?.access).toEqual({ roles: ["TenantAdmin", "TenantMember"] });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("convention handlers sit beside legacy create-tag/update-tag/delete-tag", () => {
|
|
30
|
+
expect(Object.keys(feature.writeHandlers)).toEqual(
|
|
31
|
+
expect.arrayContaining([
|
|
32
|
+
"create-tag",
|
|
33
|
+
"update-tag",
|
|
34
|
+
"delete-tag",
|
|
35
|
+
"tag:create",
|
|
36
|
+
"tag:update",
|
|
37
|
+
"tag:delete",
|
|
38
|
+
]),
|
|
39
|
+
);
|
|
40
|
+
expect(Object.keys(feature.queryHandlers)).toEqual(
|
|
41
|
+
expect.arrayContaining(["tag:list", "tag:detail", "tag-assignment:list"]),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("createTagsFeature({ roles }) threads access onto both screens", () => {
|
|
46
|
+
const custom = createTagsFeature({ roles: ["Admin"] });
|
|
47
|
+
expect(custom.screens[TAGS_SCREEN_ID]?.access).toEqual({ roles: ["Admin"] });
|
|
48
|
+
expect(custom.screens[TAGS_EDIT_SCREEN_ID]?.access).toEqual({ roles: ["Admin"] });
|
|
49
|
+
expect(custom.writeHandlers["tag:delete"]?.access).toEqual({ roles: ["Admin"] });
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/tags/constants.ts
CHANGED
|
@@ -25,9 +25,10 @@ export const TAGS_FILTER_EXTENSION_NAME = "TagFilter";
|
|
|
25
25
|
// after mounting tagsClient() (which registers it under clientFeatures.columnRenderers).
|
|
26
26
|
export const TAGS_COLUMN_RENDERER_NAME = "TagsCell";
|
|
27
27
|
|
|
28
|
-
// Screen-
|
|
29
|
-
//
|
|
28
|
+
// Screen-ids for the declarative tag catalog (entityList + entityEdit).
|
|
29
|
+
// Qualified list = "tags:screen:tag-list"; the app places it via r.nav.
|
|
30
30
|
export const TAGS_SCREEN_ID = "tag-list";
|
|
31
|
+
export const TAGS_EDIT_SCREEN_ID = "tag-edit";
|
|
31
32
|
|
|
32
33
|
// Qualified handler names (QN format: scope:type:name). Clients reference the
|
|
33
34
|
// object instead of magic strings (mirror custom-fields' Handlers/Queries).
|
package/src/tags/entity.ts
CHANGED
package/src/tags/feature.ts
CHANGED
|
@@ -14,15 +14,18 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Handlers: create-tag, update-tag (rename/recolor/re-scope), delete-tag
|
|
16
16
|
// (cascades over assignments), assign-tag, remove-tag, list tags, list assignments.
|
|
17
|
+
// Convention aliases tag:{create,update,delete} + tag:detail back the
|
|
18
|
+
// entityList/entityEdit catalog screens (legacy QNs stay for TagManager).
|
|
17
19
|
// Deferred: optional host-projection decoration (`wireTagsFor`), search indexing.
|
|
18
20
|
|
|
19
21
|
import {
|
|
20
22
|
type AccessRule,
|
|
23
|
+
defineEntityDetailHandler,
|
|
21
24
|
defineEntityListHandler,
|
|
22
25
|
defineFeature,
|
|
23
26
|
type FeatureRegistrar,
|
|
24
27
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
-
import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME
|
|
28
|
+
import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants";
|
|
26
29
|
import { tagAssignmentEntity, tagEntity } from "./entity";
|
|
27
30
|
import { createAssignTagHandler } from "./handlers/assign-tag.write";
|
|
28
31
|
import { createCreateTagHandler } from "./handlers/create-tag.write";
|
|
@@ -30,6 +33,7 @@ import { createDeleteTagHandler } from "./handlers/delete-tag.write";
|
|
|
30
33
|
import { createRemoveTagHandler } from "./handlers/remove-tag.write";
|
|
31
34
|
import { createUpdateTagHandler } from "./handlers/update-tag.write";
|
|
32
35
|
import { TAGS_FEATURE_I18N } from "./i18n";
|
|
36
|
+
import { createTagEditScreen, createTagListScreen } from "./screens";
|
|
33
37
|
|
|
34
38
|
// Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
|
|
35
39
|
// dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
|
|
@@ -66,18 +70,20 @@ function registerTags(
|
|
|
66
70
|
r.writeHandler(createAssignTagHandler(access));
|
|
67
71
|
r.writeHandler(createRemoveTagHandler(access));
|
|
68
72
|
|
|
73
|
+
// Convention aliases for entityList/entityEdit — same bodies as the legacy
|
|
74
|
+
// create-tag/update-tag/delete-tag QNs (TagManager/TagPicker keep those).
|
|
75
|
+
r.writeHandler(createCreateTagHandler(access, "tag:create"));
|
|
76
|
+
r.writeHandler(createUpdateTagHandler(access, "tag:update"));
|
|
77
|
+
r.writeHandler(createDeleteTagHandler(access, "tag:delete"));
|
|
78
|
+
|
|
69
79
|
r.queryHandler(defineEntityListHandler("tag", tagEntity, { access }));
|
|
70
80
|
r.queryHandler(defineEntityListHandler("tag-assignment", tagAssignmentEntity, { access }));
|
|
81
|
+
r.queryHandler(defineEntityDetailHandler("tag", tagEntity, { access }));
|
|
71
82
|
|
|
72
|
-
// Standalone
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
r.screen(
|
|
76
|
-
id: TAGS_SCREEN_ID,
|
|
77
|
-
type: "custom",
|
|
78
|
-
renderer: { react: { __component: "TagsScreen" } },
|
|
79
|
-
access,
|
|
80
|
-
});
|
|
83
|
+
// Standalone catalog: entityList + entityEdit. App navs via
|
|
84
|
+
// r.nav("tags:screen:tag-list"). TagManager stays for picker/section only.
|
|
85
|
+
r.screen(createTagListScreen(access));
|
|
86
|
+
r.screen(createTagEditScreen(access));
|
|
81
87
|
r.translations({ keys: TAGS_FEATURE_I18N });
|
|
82
88
|
}
|
|
83
89
|
|
|
@@ -7,9 +7,12 @@ import { type CreateTagPayload, createTagPayloadSchema } from "../schemas";
|
|
|
7
7
|
// UUIDv7 id (no explicit id passed). Tag names are not unique by design: the
|
|
8
8
|
// catalog is a free list and dedup is a UI concern (autocomplete from existing
|
|
9
9
|
// tags). Editing is update-tag.write.ts; delete (with cascade) is delete-tag.write.ts.
|
|
10
|
-
export function createCreateTagHandler(
|
|
10
|
+
export function createCreateTagHandler(
|
|
11
|
+
access: AccessRule = DEFAULT_TAG_ACCESS,
|
|
12
|
+
name = "create-tag",
|
|
13
|
+
): WriteHandlerDef {
|
|
11
14
|
return {
|
|
12
|
-
name
|
|
15
|
+
name,
|
|
13
16
|
schema: createTagPayloadSchema,
|
|
14
17
|
access,
|
|
15
18
|
handler: async (event, ctx) => {
|
|
@@ -16,9 +16,12 @@ const CASCADE_PAGE = 200;
|
|
|
16
16
|
// hides isDeleted rows, so a keyset cursor over the shrinking result set would
|
|
17
17
|
// silently skip rows. Re-reading the head always returns the remaining live
|
|
18
18
|
// rows and terminates when none are left.
|
|
19
|
-
export function createDeleteTagHandler(
|
|
19
|
+
export function createDeleteTagHandler(
|
|
20
|
+
access: AccessRule = DEFAULT_TAG_ACCESS,
|
|
21
|
+
name = "delete-tag",
|
|
22
|
+
): WriteHandlerDef {
|
|
20
23
|
return {
|
|
21
|
-
name
|
|
24
|
+
name,
|
|
22
25
|
schema: deleteTagPayloadSchema,
|
|
23
26
|
access,
|
|
24
27
|
handler: async (event, ctx) => {
|
|
@@ -8,9 +8,12 @@ import { type UpdateTagPayload, updateTagPayloadSchema } from "../schemas";
|
|
|
8
8
|
// the fields present in the payload go into `changes`, and the executor merges
|
|
9
9
|
// shallowly, so any omitted field is preserved (color/scope accept "" to clear).
|
|
10
10
|
// A stale version returns version_conflict; the UI refetches and retries.
|
|
11
|
-
export function createUpdateTagHandler(
|
|
11
|
+
export function createUpdateTagHandler(
|
|
12
|
+
access: AccessRule = DEFAULT_TAG_ACCESS,
|
|
13
|
+
name = "update-tag",
|
|
14
|
+
): WriteHandlerDef {
|
|
12
15
|
return {
|
|
13
|
-
name
|
|
16
|
+
name,
|
|
14
17
|
schema: updateTagPayloadSchema,
|
|
15
18
|
access,
|
|
16
19
|
handler: async (event, ctx) => {
|
package/src/tags/i18n.ts
CHANGED
|
@@ -2,4 +2,8 @@ type LocalizedString = { readonly en: string };
|
|
|
2
2
|
|
|
3
3
|
export const TAGS_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
4
|
"screen:tag-list.title": { en: "Tags" },
|
|
5
|
+
"screen:tag-edit.title": { en: "Tag" },
|
|
6
|
+
"tags:entity:tag:field:name": { en: "Name" },
|
|
7
|
+
"tags:entity:tag:field:color": { en: "Color" },
|
|
8
|
+
"tags:entity:tag:field:scope": { en: "Scope" },
|
|
5
9
|
};
|
package/src/tags/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
DEFAULT_TAG_ACCESS,
|
|
4
4
|
DEFAULT_TAG_ROLES,
|
|
5
5
|
TAGS_COLUMN_RENDERER_NAME,
|
|
6
|
+
TAGS_EDIT_SCREEN_ID,
|
|
6
7
|
TAGS_FEATURE_NAME,
|
|
7
8
|
TAGS_FILTER_EXTENSION_NAME,
|
|
8
9
|
TAGS_SCREEN_ID,
|
|
@@ -44,3 +45,4 @@ export {
|
|
|
44
45
|
type UpdateTagPayload,
|
|
45
46
|
updateTagPayloadSchema,
|
|
46
47
|
} from "./schemas";
|
|
48
|
+
export { createTagEditScreen, createTagListScreen } from "./screens";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccessRule,
|
|
3
|
+
EntityEditScreenDefinition,
|
|
4
|
+
EntityListScreenDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { DEFAULT_TAG_ACCESS, TAGS_EDIT_SCREEN_ID, TAGS_SCREEN_ID } from "./constants";
|
|
7
|
+
|
|
8
|
+
// Declarative catalog screens (Phase-3 / #2312). Access matches create/update/
|
|
9
|
+
// delete so createTagsFeature({ access|roles }) stays consistent. TagManager
|
|
10
|
+
// remains for TagPicker/TagSection — only the standalone admin screen moved.
|
|
11
|
+
|
|
12
|
+
export function createTagListScreen(
|
|
13
|
+
access: AccessRule = DEFAULT_TAG_ACCESS,
|
|
14
|
+
): EntityListScreenDefinition {
|
|
15
|
+
return {
|
|
16
|
+
id: TAGS_SCREEN_ID,
|
|
17
|
+
type: "entityList",
|
|
18
|
+
entity: "tag",
|
|
19
|
+
columns: ["name", "color", "scope"],
|
|
20
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
21
|
+
searchable: true,
|
|
22
|
+
rowActions: [
|
|
23
|
+
{
|
|
24
|
+
kind: "navigate",
|
|
25
|
+
id: "edit",
|
|
26
|
+
label: "kumiko.actions.edit",
|
|
27
|
+
screen: TAGS_EDIT_SCREEN_ID,
|
|
28
|
+
entityId: "id",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
kind: "writeHandler",
|
|
32
|
+
id: "delete",
|
|
33
|
+
label: "kumiko.actions.delete",
|
|
34
|
+
// Convention QN — same cascade body as legacy tags:write:delete-tag.
|
|
35
|
+
handler: "tags:write:tag:delete",
|
|
36
|
+
payload: { pick: ["id"] },
|
|
37
|
+
confirm: "kumiko.actions.delete-confirm",
|
|
38
|
+
style: "danger",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
access,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createTagEditScreen(
|
|
46
|
+
access: AccessRule = DEFAULT_TAG_ACCESS,
|
|
47
|
+
): EntityEditScreenDefinition {
|
|
48
|
+
return {
|
|
49
|
+
id: TAGS_EDIT_SCREEN_ID,
|
|
50
|
+
type: "entityEdit",
|
|
51
|
+
entity: "tag",
|
|
52
|
+
layout: {
|
|
53
|
+
sections: [
|
|
54
|
+
{
|
|
55
|
+
columns: 2,
|
|
56
|
+
fields: [
|
|
57
|
+
{ field: "name", span: 2 },
|
|
58
|
+
{ field: "color", span: 1 },
|
|
59
|
+
{ field: "scope", span: 1 },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
access,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -5,12 +5,10 @@ import {
|
|
|
5
5
|
TAGS_COLUMN_RENDERER_NAME,
|
|
6
6
|
TAGS_FEATURE_NAME,
|
|
7
7
|
TAGS_FILTER_EXTENSION_NAME,
|
|
8
|
-
TAGS_SCREEN_ID,
|
|
9
8
|
TAGS_SECTION_EXTENSION_NAME,
|
|
10
9
|
} from "../constants";
|
|
11
10
|
import { defaultTranslations } from "./i18n";
|
|
12
11
|
import { TagFilter } from "./tag-filter";
|
|
13
|
-
import { TagManager } from "./tag-manager";
|
|
14
12
|
import { TagSection } from "./tag-section";
|
|
15
13
|
import { TagsCell } from "./tags-cell";
|
|
16
14
|
|
|
@@ -26,10 +24,7 @@ export function tagsClient(): ClientFeatureDefinition {
|
|
|
26
24
|
columnRenderers: {
|
|
27
25
|
[TAGS_COLUMN_RENDERER_NAME]: TagsCell,
|
|
28
26
|
},
|
|
29
|
-
//
|
|
30
|
-
components: {
|
|
31
|
-
[TAGS_SCREEN_ID]: TagManager,
|
|
32
|
-
},
|
|
27
|
+
// Catalog screen is declarative entityList — no custom TagsScreen mount.
|
|
33
28
|
translations: defaultTranslations,
|
|
34
29
|
};
|
|
35
30
|
}
|
package/src/tags/web/index.ts
CHANGED
|
@@ -74,7 +74,7 @@ describe("tenant members screen + handler access alignment", () => {
|
|
|
74
74
|
const rowAction = screen.rowActions?.find((a) => a.id === "edit-roles");
|
|
75
75
|
if (rowAction?.kind !== "navigate") throw new Error("expected a navigate row action");
|
|
76
76
|
expect(rowAction.screen).toBe(MEMBER_ROLES_EDIT_SCREEN_ID);
|
|
77
|
-
expect(rowAction.params).toEqual({ map: { userId: "userId" } });
|
|
77
|
+
expect(rowAction.params).toEqual({ map: { userId: "userId", roles: "roles" } });
|
|
78
78
|
expect(rowAction.visible).toEqual({ field: "status", eq: "active" });
|
|
79
79
|
});
|
|
80
80
|
|
|
@@ -85,6 +85,11 @@ describe("tenant members screen + handler access alignment", () => {
|
|
|
85
85
|
throw new Error("expected member-roles-edit screen to be actionForm");
|
|
86
86
|
expect(screen.handler).toBe(TenantHandlers.updateMemberRoles);
|
|
87
87
|
expect(screen.fields["userId"]).toEqual({ type: "text", required: true });
|
|
88
|
+
const section = screen.layout.sections[0];
|
|
89
|
+
if (section === undefined || !("fields" in section)) {
|
|
90
|
+
throw new Error("expected member-roles-edit layout fields section");
|
|
91
|
+
}
|
|
92
|
+
expect(section.fields).toEqual([{ field: "userId", readOnly: true }, "roles"]);
|
|
88
93
|
expect(screen.fields["roles"]).toEqual({
|
|
89
94
|
type: "multiSelect",
|
|
90
95
|
options: DEFAULT_INVITE_ROLE_OPTIONS,
|
|
@@ -859,6 +859,40 @@ describe("updateMemberRoles — TenantAdmin session-scoped path and safety gates
|
|
|
859
859
|
expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
|
|
860
860
|
});
|
|
861
861
|
|
|
862
|
+
test("SystemAdmin omits payload.tenantId — uses session tenant (actionForm UI shape)", async () => {
|
|
863
|
+
const { id: memberUserId } = await seedUser(stack.db, {
|
|
864
|
+
email: "sysadmin-session-tenant@example.com",
|
|
865
|
+
displayName: "SysAdmin Session Tenant",
|
|
866
|
+
passwordHash: await hashPassword("pw-sys-sess-1234"),
|
|
867
|
+
emailVerified: true,
|
|
868
|
+
});
|
|
869
|
+
await seedTenantMembership(stack.db, {
|
|
870
|
+
userId: memberUserId,
|
|
871
|
+
tenantId: TENANT_A_ID,
|
|
872
|
+
roles: ["User"],
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// Mirrors prod: SystemAdmin with an active tenant opens Edit roles; the
|
|
876
|
+
// actionForm only posts userId+roles (no tenantId field).
|
|
877
|
+
const sysAdminInTenant: SessionUser = {
|
|
878
|
+
id: TestUsers.systemAdmin.id,
|
|
879
|
+
tenantId: TENANT_A_ID,
|
|
880
|
+
roles: ["SystemAdmin", "TenantAdmin"],
|
|
881
|
+
};
|
|
882
|
+
const res = await stack.http.writeOk(
|
|
883
|
+
TenantHandlers.updateMemberRoles,
|
|
884
|
+
{ userId: memberUserId, roles: ["Admin"] },
|
|
885
|
+
sysAdminInTenant,
|
|
886
|
+
);
|
|
887
|
+
expect(res).toMatchObject({ userId: memberUserId, tenantId: TENANT_A_ID, roles: ["Admin"] });
|
|
888
|
+
|
|
889
|
+
const rows = await selectMany(stack.db, tenantMembershipsTable, {
|
|
890
|
+
userId: memberUserId,
|
|
891
|
+
tenantId: TENANT_A_ID,
|
|
892
|
+
});
|
|
893
|
+
expect(JSON.parse(rows[0]?.["roles"] as string)).toEqual(["Admin"]);
|
|
894
|
+
});
|
|
895
|
+
|
|
862
896
|
test("updateMemberRoles appends audit event and invalidates active sessions for target user", async () => {
|
|
863
897
|
const { id: memberUserId } = await seedUser(stack.db, {
|
|
864
898
|
email: "session-target@example.com",
|
|
@@ -223,7 +223,9 @@ export const teamListQuery = definePagedQueryHandler({
|
|
|
223
223
|
// the order of a few thousand members per tenant; past that the fix is
|
|
224
224
|
// a real combined read-projection (a materialized view/table kept in
|
|
225
225
|
// sync by both tenant-membership and tenant-invitation events), not a
|
|
226
|
-
// cursor bolted onto this merge.
|
|
226
|
+
// cursor bolted onto this merge. Upgrade path: r.multiStreamProjection({
|
|
227
|
+
// name, apply }) — see custom-fields/wire-for-entity.ts:174,
|
|
228
|
+
// feature-toggles/feature.ts:109, step-dispatcher/feature.ts:41.
|
|
227
229
|
const search = query.payload.search?.trim().toLowerCase();
|
|
228
230
|
const merged = [...memberRows, ...invitationRows].filter((row) => {
|
|
229
231
|
if (search === undefined || search === "") return true;
|
|
@@ -41,6 +41,16 @@ const REVOKE_ALL_SESSIONS_QN = "sessions:write:user-session:revoke-all-for-user"
|
|
|
41
41
|
// (dispatcher batch wraps handlers in transaction — xact lock holds through update).
|
|
42
42
|
const LAST_TENANT_ADMIN_LOCK_NAMESPACE = 0x7461646d; // 'tadm'
|
|
43
43
|
|
|
44
|
+
/** SystemAdmin may pass payload.tenantId (cross-tenant); the members actionForm
|
|
45
|
+
* only sends userId+roles, so fall back to the active session tenant. */
|
|
46
|
+
function resolveTargetTenantId(
|
|
47
|
+
isSystem: boolean,
|
|
48
|
+
payloadTenantId: string | undefined,
|
|
49
|
+
sessionTenantId: string | undefined,
|
|
50
|
+
): string | undefined {
|
|
51
|
+
return isSystem ? (payloadTenantId ?? sessionTenantId) : sessionTenantId;
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
export const updateMemberRolesWrite = defineWriteHandler({
|
|
45
55
|
name: "updateMemberRoles",
|
|
46
56
|
schema: z.object({
|
|
@@ -63,7 +73,11 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
63
73
|
|
|
64
74
|
const isSystem =
|
|
65
75
|
event.user.roles.includes("SystemAdmin") || event.user.roles.includes("system");
|
|
66
|
-
const targetTenantId =
|
|
76
|
+
const targetTenantId = resolveTargetTenantId(
|
|
77
|
+
isSystem,
|
|
78
|
+
event.payload.tenantId,
|
|
79
|
+
event.user.tenantId,
|
|
80
|
+
);
|
|
67
81
|
if (!targetTenantId) {
|
|
68
82
|
return writeFailure(
|
|
69
83
|
new ValidationError({
|
package/src/tenant/i18n.ts
CHANGED
|
@@ -22,7 +22,6 @@ export const TENANT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
22
22
|
},
|
|
23
23
|
"tenant.members.invite.title": { en: "New invitation" },
|
|
24
24
|
"tenant.members.invite.submit": { en: "Send invitation" },
|
|
25
|
-
"tenant.members.roles.edit.title": { en: "Edit roles" },
|
|
26
25
|
"tenant.members.roles.edit.submit": { en: "Save roles" },
|
|
27
26
|
"tenant:entity:__action-form__:field:email": { en: "Email" },
|
|
28
27
|
"tenant:entity:__action-form__:field:role": { en: "Role" },
|
package/src/tenant/screens.ts
CHANGED
|
@@ -113,7 +113,7 @@ export function createMembersScreen(options?: {
|
|
|
113
113
|
id: "edit-roles",
|
|
114
114
|
label: "tenant.members.actions.editRoles",
|
|
115
115
|
screen: MEMBER_ROLES_EDIT_SCREEN_ID,
|
|
116
|
-
params: { map: { userId: "userId" } },
|
|
116
|
+
params: { map: { userId: "userId", roles: "roles" } },
|
|
117
117
|
visible: { field: "status", eq: "active" },
|
|
118
118
|
},
|
|
119
119
|
{
|
|
@@ -172,7 +172,8 @@ export const memberRolesEditScreen = {
|
|
|
172
172
|
roles: { type: "multiSelect", options: DEFAULT_INVITE_ROLE_OPTIONS, required: true },
|
|
173
173
|
},
|
|
174
174
|
layout: {
|
|
175
|
-
|
|
175
|
+
// Prefill userId from rowAction; readOnly so the operator cannot retarget.
|
|
176
|
+
sections: [{ fields: [{ field: "userId", readOnly: true }, "roles"] }],
|
|
176
177
|
},
|
|
177
178
|
submitLabel: "tenant.members.roles.edit.submit",
|
|
178
179
|
access: { roles: access.admin },
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import {
|
|
16
16
|
AccessDeniedError,
|
|
17
17
|
ConflictError,
|
|
18
|
+
InternalError,
|
|
18
19
|
NotFoundError,
|
|
19
20
|
writeFailure,
|
|
20
21
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -51,7 +52,10 @@ function isActiveSystemAdminRow(row: UserRolesRow): boolean {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
async function countOtherActiveSystemAdmins(db: TenantDb, excludeUserId: string): Promise<number> {
|
|
54
|
-
const tableName = asEntityTableMeta(userTable)?.tableName
|
|
55
|
+
const tableName = asEntityTableMeta(userTable)?.tableName;
|
|
56
|
+
if (!tableName) {
|
|
57
|
+
throw new InternalError({ message: "user read table meta missing" });
|
|
58
|
+
}
|
|
55
59
|
// kumiko-lint-ignore raw-sql jsonb @> prefilter for SystemAdmin roster under advisory lock
|
|
56
60
|
const rows = (await asRawClient(db.raw).unsafe(
|
|
57
61
|
`SELECT id, roles, status, is_deleted AS "isDeleted"
|
package/src/user/i18n.ts
CHANGED
|
@@ -7,12 +7,6 @@ export const USER_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
7
7
|
"user:entity:user:field:displayName": { en: "Display name" },
|
|
8
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
|
-
},
|
|
16
10
|
"user:entity:user:field:roles:option:SystemAdmin": { en: "SystemAdmin" },
|
|
17
11
|
"user:entity:user:field:status": { en: "Status" },
|
|
18
12
|
"user:entity:user:field:emailVerified": { en: "Email verified" },
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
// @runtime client
|
|
2
|
-
import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
-
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
5
|
-
import { FeatureToggleHandlers, FeatureToggleQueries } from "../constants";
|
|
6
|
-
|
|
7
|
-
type ToggleRow = {
|
|
8
|
-
readonly name: string;
|
|
9
|
-
readonly toggleable: boolean;
|
|
10
|
-
readonly default: boolean | null;
|
|
11
|
-
readonly override: boolean | null;
|
|
12
|
-
readonly effective: boolean | null;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
type RegisteredResponse = {
|
|
16
|
-
readonly items: readonly ToggleRow[];
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
type State =
|
|
20
|
-
| { readonly kind: "loading" }
|
|
21
|
-
| { readonly kind: "error"; readonly message: string }
|
|
22
|
-
| { readonly kind: "ready"; readonly items: readonly ToggleRow[] };
|
|
23
|
-
|
|
24
|
-
export function ToggleAdminScreen(): ReactNode {
|
|
25
|
-
const t = useTranslation();
|
|
26
|
-
const { Banner, Card, DataTable, Heading, Text } = usePrimitives();
|
|
27
|
-
const dispatcher = useDispatcher();
|
|
28
|
-
const [state, setState] = useState<State>({ kind: "loading" });
|
|
29
|
-
const [actionError, setActionError] = useState<string | null>(null);
|
|
30
|
-
|
|
31
|
-
const load = useCallback(async (): Promise<void> => {
|
|
32
|
-
setState({ kind: "loading" });
|
|
33
|
-
const res = await dispatcher.query<RegisteredResponse>(FeatureToggleQueries.registered, {});
|
|
34
|
-
if (!res.isSuccess) {
|
|
35
|
-
setState({ kind: "error", message: res.error.message });
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
setState({ kind: "ready", items: res.data.items });
|
|
39
|
-
}, [dispatcher]);
|
|
40
|
-
|
|
41
|
-
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
void load();
|
|
44
|
-
}, [load]);
|
|
45
|
-
|
|
46
|
-
const onSet = async (featureName: string, enabled: boolean): Promise<void> => {
|
|
47
|
-
setActionError(null);
|
|
48
|
-
const res = await dispatcher.write(FeatureToggleHandlers.set, { featureName, enabled });
|
|
49
|
-
if (!res.isSuccess) {
|
|
50
|
-
setActionError(res.error.message);
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
await load();
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
if (state.kind === "loading") {
|
|
57
|
-
return (
|
|
58
|
-
<FormScreenShell testId="toggle-admin-screen">
|
|
59
|
-
<Text variant="small">{t("feature-toggles.admin.loading")}</Text>
|
|
60
|
-
</FormScreenShell>
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (state.kind === "error") {
|
|
65
|
-
return (
|
|
66
|
-
<FormScreenShell testId="toggle-admin-screen">
|
|
67
|
-
<Banner variant="error">{state.message}</Banner>
|
|
68
|
-
</FormScreenShell>
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return (
|
|
73
|
-
<FormScreenShell testId="toggle-admin-screen" maxWidth="4xl" className="flex flex-col gap-6">
|
|
74
|
-
<Heading variant="page">{t("feature-toggles.admin.title")}</Heading>
|
|
75
|
-
|
|
76
|
-
{actionError !== null && <Banner variant="error">{actionError}</Banner>}
|
|
77
|
-
|
|
78
|
-
<Card options={{ padded: false }}>
|
|
79
|
-
<DataTable
|
|
80
|
-
testId="toggle-admin-table"
|
|
81
|
-
columns={[
|
|
82
|
-
{
|
|
83
|
-
field: "feature",
|
|
84
|
-
label: t("feature-toggles.admin.col.feature"),
|
|
85
|
-
type: "string",
|
|
86
|
-
sortable: false,
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
field: "default",
|
|
90
|
-
label: t("feature-toggles.admin.col.default"),
|
|
91
|
-
type: "string",
|
|
92
|
-
sortable: false,
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
field: "override",
|
|
96
|
-
label: t("feature-toggles.admin.col.override"),
|
|
97
|
-
type: "string",
|
|
98
|
-
sortable: false,
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
field: "effective",
|
|
102
|
-
label: t("feature-toggles.admin.col.effective"),
|
|
103
|
-
type: "string",
|
|
104
|
-
sortable: false,
|
|
105
|
-
},
|
|
106
|
-
]}
|
|
107
|
-
rows={state.items.map((row) => ({
|
|
108
|
-
id: row.name,
|
|
109
|
-
values: {
|
|
110
|
-
feature: row.name,
|
|
111
|
-
default: formatFlag(row.default),
|
|
112
|
-
override: formatFlag(row.override),
|
|
113
|
-
effective: formatFlag(row.effective),
|
|
114
|
-
toggleable: row.toggleable,
|
|
115
|
-
effectiveBool: row.effective ?? row.default ?? false,
|
|
116
|
-
},
|
|
117
|
-
}))}
|
|
118
|
-
rowActions={[
|
|
119
|
-
{
|
|
120
|
-
id: "toggle",
|
|
121
|
-
label: t("feature-toggles.admin.toggle"),
|
|
122
|
-
style: "secondary",
|
|
123
|
-
isVisible: (row) => row.values["toggleable"] === true,
|
|
124
|
-
onTrigger: (row) => void onSet(row.id, !(row.values["effectiveBool"] as boolean)),
|
|
125
|
-
},
|
|
126
|
-
]}
|
|
127
|
-
rowActionMode="inline"
|
|
128
|
-
/>
|
|
129
|
-
</Card>
|
|
130
|
-
</FormScreenShell>
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function formatFlag(value: boolean | null): string {
|
|
135
|
-
if (value === null) return "inherit";
|
|
136
|
-
return value ? "on" : "off";
|
|
137
|
-
}
|