@cosmicdrift/kumiko-bundled-features 0.167.0 → 0.167.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 +7 -7
- package/src/auth-email-password/__tests__/login-gates.test.ts +35 -1
- package/src/auth-email-password/auth-user-row.ts +2 -0
- package/src/auth-email-password/handlers/login.write.ts +9 -1
- package/src/config/__tests__/tz-resolution.integration.test.ts +101 -0
- package/src/user/__tests__/user.integration.test.ts +2 -1
- package/src/user/handlers/create.write.ts +2 -0
- package/src/user/handlers/update.write.ts +2 -0
- package/src/user/i18n.ts +1 -0
- package/src/user/schema/user.ts +6 -1
- package/src/user/screens.ts +1 -1
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.167.
|
|
3
|
+
"version": "0.167.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>",
|
|
@@ -119,12 +119,12 @@
|
|
|
119
119
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
120
120
|
},
|
|
121
121
|
"dependencies": {
|
|
122
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.167.
|
|
123
|
-
"@cosmicdrift/kumiko-framework": "0.167.
|
|
124
|
-
"@cosmicdrift/kumiko-headless": "0.167.
|
|
125
|
-
"@cosmicdrift/kumiko-renderer": "0.167.
|
|
126
|
-
"@cosmicdrift/kumiko-renderer-web": "0.167.
|
|
127
|
-
"@cosmicdrift/kumiko-types": "0.167.
|
|
122
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.167.1",
|
|
123
|
+
"@cosmicdrift/kumiko-framework": "0.167.1",
|
|
124
|
+
"@cosmicdrift/kumiko-headless": "0.167.1",
|
|
125
|
+
"@cosmicdrift/kumiko-renderer": "0.167.1",
|
|
126
|
+
"@cosmicdrift/kumiko-renderer-web": "0.167.1",
|
|
127
|
+
"@cosmicdrift/kumiko-types": "0.167.1",
|
|
128
128
|
"@mollie/api-client": "^4.5.0",
|
|
129
129
|
"imapflow": "^1.3.3",
|
|
130
130
|
"mailparser": "^3.9.8",
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { USER_STATUS } from "../../user";
|
|
3
4
|
import type { AuthUserRow } from "../auth-user-row";
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
gateBuildSession,
|
|
7
|
+
gateEnforceAccountStatus,
|
|
8
|
+
gateEnforceEmailVerified,
|
|
9
|
+
} from "../handlers/login.write";
|
|
10
|
+
|
|
11
|
+
// resolveAuthClaims is the only ctx member gateBuildSession touches.
|
|
12
|
+
const authClaimsCtx = {
|
|
13
|
+
resolveAuthClaims: async () => ({}),
|
|
14
|
+
} as unknown as HandlerContext;
|
|
5
15
|
|
|
6
16
|
function row(over: Partial<AuthUserRow> = {}): AuthUserRow {
|
|
7
17
|
return { id: "00000000-0000-4000-8000-000000000001", passwordHash: "x", ...over };
|
|
@@ -36,4 +46,28 @@ describe("login.write gates (fw#1284)", () => {
|
|
|
36
46
|
test("gateEnforceAccountStatus passes active", () => {
|
|
37
47
|
expect(gateEnforceAccountStatus(row({ status: USER_STATUS.Active })).ok).toBe(true);
|
|
38
48
|
});
|
|
49
|
+
|
|
50
|
+
test("gateBuildSession carries the user's timezone into the session", async () => {
|
|
51
|
+
const g = await gateBuildSession(
|
|
52
|
+
authClaimsCtx,
|
|
53
|
+
"00000000-0000-4000-8000-000000000001",
|
|
54
|
+
"00000000-0000-4000-8000-000000000002",
|
|
55
|
+
["User"],
|
|
56
|
+
"Asia/Tokyo",
|
|
57
|
+
);
|
|
58
|
+
expect(g.ok).toBe(true);
|
|
59
|
+
if (g.ok) expect(g.value.session.timezone).toBe("Asia/Tokyo");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("gateBuildSession omits timezone when the user never set one (fw#1636)", async () => {
|
|
63
|
+
const g = await gateBuildSession(
|
|
64
|
+
authClaimsCtx,
|
|
65
|
+
"00000000-0000-4000-8000-000000000001",
|
|
66
|
+
"00000000-0000-4000-8000-000000000002",
|
|
67
|
+
["User"],
|
|
68
|
+
null,
|
|
69
|
+
);
|
|
70
|
+
expect(g.ok).toBe(true);
|
|
71
|
+
if (g.ok) expect(g.value.session.timezone).toBeUndefined();
|
|
72
|
+
});
|
|
39
73
|
});
|
|
@@ -17,6 +17,8 @@ export type AuthUserRow = {
|
|
|
17
17
|
readonly isDeleted?: boolean | null;
|
|
18
18
|
readonly emailVerified?: boolean | null;
|
|
19
19
|
readonly lastActiveTenantId?: TenantId | string | null;
|
|
20
|
+
// IANA zone — threaded into SessionUser.timezone at login (fw#1636).
|
|
21
|
+
readonly timezone?: string | null;
|
|
20
22
|
// JSON-encoded string[] — globale Rollen die parallel zu tenant-membership-
|
|
21
23
|
// roles gelten (z.B. SystemAdmin, BillingAdmin). Caller deserialisiert via
|
|
22
24
|
// parseRoles() vor dem Merge in die Session.
|
|
@@ -224,11 +224,13 @@ export async function gateBuildSession(
|
|
|
224
224
|
userId: string,
|
|
225
225
|
tenantId: TenantId,
|
|
226
226
|
mergedRoles: readonly string[],
|
|
227
|
+
timezone?: string | null,
|
|
227
228
|
): Promise<GateOutcome<{ readonly kind: "auth-session"; readonly session: SessionUser }>> {
|
|
228
229
|
const baseSession: SessionUser = {
|
|
229
230
|
id: userId,
|
|
230
231
|
tenantId,
|
|
231
232
|
roles: mergedRoles,
|
|
233
|
+
...(timezone !== null && timezone !== undefined && { timezone }),
|
|
232
234
|
};
|
|
233
235
|
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
234
236
|
const session: SessionUser =
|
|
@@ -294,7 +296,13 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
294
296
|
return { isSuccess: true, data: mfaGate.value };
|
|
295
297
|
}
|
|
296
298
|
|
|
297
|
-
const sessionGate = await gateBuildSession(
|
|
299
|
+
const sessionGate = await gateBuildSession(
|
|
300
|
+
ctx,
|
|
301
|
+
found.id,
|
|
302
|
+
chosen.tenantId,
|
|
303
|
+
mergedRoles,
|
|
304
|
+
found.timezone,
|
|
305
|
+
);
|
|
298
306
|
if (!sessionGate.ok) return sessionGate.result;
|
|
299
307
|
return { isSuccess: true, data: sessionGate.value };
|
|
300
308
|
},
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// buildHandlerContext resolves ctx.tz.tenant from tenant:config:timezone and
|
|
2
|
+
// ctx.tz.user from SessionUser.timezone (fw#1636). "tenant" here is a
|
|
3
|
+
// standalone probe feature registering the same qualified config key the
|
|
4
|
+
// real bundled `tenant` feature uses — decoupled from that feature's
|
|
5
|
+
// entities/handlers, but exercising the identical runtime path.
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import {
|
|
9
|
+
createTestUser,
|
|
10
|
+
setupTestStack,
|
|
11
|
+
type TestStack,
|
|
12
|
+
unsafePushTables,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { createConfigAccessorFactory, createConfigFeature } from "../feature";
|
|
16
|
+
import { createConfigResolver } from "../resolver";
|
|
17
|
+
import { configValuesTable } from "../table";
|
|
18
|
+
|
|
19
|
+
const tenantFeature = defineFeature("tenant", (r) => {
|
|
20
|
+
r.requires("config");
|
|
21
|
+
r.config({
|
|
22
|
+
keys: {
|
|
23
|
+
timezone: createTenantConfig("select", {
|
|
24
|
+
default: "UTC",
|
|
25
|
+
options: ["UTC", "Europe/Berlin", "Asia/Tokyo"],
|
|
26
|
+
write: access.roles("Admin"),
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const probeFeature = defineFeature("probe", (r) => {
|
|
33
|
+
r.requires("tenant");
|
|
34
|
+
r.writeHandler(
|
|
35
|
+
"read-tz",
|
|
36
|
+
z.object({}),
|
|
37
|
+
async (_event, ctx) => ({
|
|
38
|
+
isSuccess: true,
|
|
39
|
+
data: { tenant: ctx.tz.tenant, user: ctx.tz.user },
|
|
40
|
+
}),
|
|
41
|
+
{ access: { openToAll: true } },
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("buildHandlerContext ctx.tz resolution", () => {
|
|
46
|
+
let stack: TestStack;
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
const resolver = createConfigResolver();
|
|
50
|
+
stack = await setupTestStack({
|
|
51
|
+
features: [createConfigFeature(), tenantFeature, probeFeature],
|
|
52
|
+
extraContext: ({ registry }) => ({
|
|
53
|
+
configResolver: resolver,
|
|
54
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
await stack.cleanup();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("defaults to UTC when no tenant config value is set and SessionUser has no timezone", async () => {
|
|
65
|
+
const user = createTestUser({ id: 10 });
|
|
66
|
+
const res = await stack.http.writeOk<{ tenant: string; user: string }>(
|
|
67
|
+
"probe:write:read-tz",
|
|
68
|
+
{},
|
|
69
|
+
user,
|
|
70
|
+
);
|
|
71
|
+
expect(res).toEqual({ tenant: "UTC", user: "UTC" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("ctx.tz.tenant reads tenant:config:timezone", async () => {
|
|
75
|
+
const admin = createTestUser({ id: 11, roles: ["Admin"] });
|
|
76
|
+
await stack.http.writeOk(
|
|
77
|
+
"config:write:set",
|
|
78
|
+
{ key: "tenant:config:timezone", value: "Europe/Berlin" },
|
|
79
|
+
admin,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const res = await stack.http.writeOk<{ tenant: string; user: string }>(
|
|
83
|
+
"probe:write:read-tz",
|
|
84
|
+
{},
|
|
85
|
+
admin,
|
|
86
|
+
);
|
|
87
|
+
expect(res).toEqual({ tenant: "Europe/Berlin", user: "Europe/Berlin" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("ctx.tz.user reads SessionUser.timezone independently of tenant", async () => {
|
|
91
|
+
// Same tenant as the previous test (Europe/Berlin already set) — proves
|
|
92
|
+
// user overrides without needing to touch tenant config.
|
|
93
|
+
const user = createTestUser({ id: 12, timezone: "Asia/Tokyo" });
|
|
94
|
+
const res = await stack.http.writeOk<{ tenant: string; user: string }>(
|
|
95
|
+
"probe:write:read-tz",
|
|
96
|
+
{},
|
|
97
|
+
user,
|
|
98
|
+
);
|
|
99
|
+
expect(res).toEqual({ tenant: "Europe/Berlin", user: "Asia/Tokyo" });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -64,8 +64,9 @@ describe("scenario 1: create + me", () => {
|
|
|
64
64
|
id: created.id,
|
|
65
65
|
email: "marc@example.com",
|
|
66
66
|
displayName: "Marc",
|
|
67
|
-
locale: "de", // comes from the entity's default — client didn't send it
|
|
68
67
|
});
|
|
68
|
+
// No entity-level default — client didn't send a locale, so it stays unset.
|
|
69
|
+
expect(me["locale"] == null).toBe(true);
|
|
69
70
|
});
|
|
70
71
|
|
|
71
72
|
test("normal user cannot create another user", async () => {
|
|
@@ -2,6 +2,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
|
+
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { UserErrors } from "../constants";
|
|
7
8
|
import { userEntity, userTable } from "../schema/user";
|
|
@@ -23,6 +24,7 @@ export const createWrite = defineWriteHandler({
|
|
|
23
24
|
passwordHash: z.string().optional(),
|
|
24
25
|
displayName: z.string().min(1).max(100),
|
|
25
26
|
locale: z.string().min(2).max(10).optional(),
|
|
27
|
+
timezone: z.string().max(64).refine(isValidIanaTimeZone, "invalid IANA time zone").optional(),
|
|
26
28
|
// Globale Rollen — JSON-encoded string[]. Optional weil der Default
|
|
27
29
|
// im Entity-Schema "[]" ist; setzen wenn man einen SystemAdmin (oder
|
|
28
30
|
// andere globale Rollen) anlegt. Field-Access (write: privileged) auf
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { access, defineWriteHandler, hasAccess } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { UserErrors } from "../constants";
|
|
6
7
|
import { userEntity, userTable } from "../schema/user";
|
|
@@ -22,6 +23,7 @@ export const updateWrite = defineWriteHandler({
|
|
|
22
23
|
changes: z.object({
|
|
23
24
|
displayName: z.string().min(1).max(100).optional(),
|
|
24
25
|
locale: z.string().min(2).max(10).optional(),
|
|
26
|
+
timezone: z.string().max(64).refine(isValidIanaTimeZone, "invalid IANA time zone").optional(),
|
|
25
27
|
email: z.email().optional(),
|
|
26
28
|
passwordHash: z.string().optional(),
|
|
27
29
|
lastActiveTenantId: z.string().optional(),
|
package/src/user/i18n.ts
CHANGED
|
@@ -8,6 +8,7 @@ export const USER_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
8
8
|
"user:entity:user:field:status": { de: "Status", en: "Status" },
|
|
9
9
|
"user:entity:user:field:emailVerified": { de: "E-Mail bestätigt", en: "Email verified" },
|
|
10
10
|
"user:entity:user:field:locale": { de: "Sprache", en: "Locale" },
|
|
11
|
+
"user:entity:user:field:timezone": { de: "Zeitzone", en: "Timezone" },
|
|
11
12
|
"user:entity:user:field:status:option:active": { de: "Aktiv", en: "Active" },
|
|
12
13
|
"user:entity:user:field:status:option:restricted": { de: "Eingeschränkt", en: "Restricted" },
|
|
13
14
|
"user:entity:user:field:status:option:deletionRequested": {
|
package/src/user/schema/user.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createSelectField,
|
|
7
7
|
createTextField,
|
|
8
8
|
createTimestampField,
|
|
9
|
+
createTzField,
|
|
9
10
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -87,7 +88,11 @@ export const userEntity = createEntity({
|
|
|
87
88
|
pii: true,
|
|
88
89
|
searchable: true,
|
|
89
90
|
}),
|
|
90
|
-
|
|
91
|
+
// No default here — tenant-settings owns the app-wide locale default; see #1637.
|
|
92
|
+
locale: createTextField({ maxLength: 10 }),
|
|
93
|
+
// No default here — mirrors locale: ctx.tz falls back to tenant.timezone
|
|
94
|
+
// (then "UTC") when unset, see buildHandlerContext (fw#1636).
|
|
95
|
+
timezone: createTzField(),
|
|
91
96
|
|
|
92
97
|
// Which tenant should this user land in on next login. Set by the login
|
|
93
98
|
// handler (SYSTEM), read by the login flow + UI for deep-linking.
|
package/src/user/screens.ts
CHANGED