@cosmicdrift/kumiko-bundled-features 0.240.0 → 0.241.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/admin-shell/__tests__/admin-shell-security.integration.test.ts +16 -7
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +30 -0
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +31 -23
- package/src/admin-shell/changes.json +6 -0
- package/src/admin-shell/feature.ts +60 -6
- package/src/admin-shell/i18n.ts +0 -7
- package/src/admin-shell/web/client-plugin.tsx +1 -11
- package/src/audit/__tests__/audit-screens.boot.test.ts +26 -6
- package/src/audit/changes.json +7 -0
- package/src/audit/feature.ts +57 -7
- package/src/audit/handlers/list.query.ts +50 -14
- package/src/audit/i18n.ts +3 -18
- package/src/audit/web/client-plugin.tsx +1 -7
- package/src/audit/web/index.ts +0 -2
- package/src/config/__tests__/config.integration.test.ts +31 -0
- package/src/config/handlers/readiness.query.ts +8 -1
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
- package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
- package/src/jobs/changes.json +7 -0
- package/src/jobs/feature.ts +101 -9
- package/src/jobs/handlers/list.query.ts +21 -4
- package/src/jobs/handlers/trigger.write.ts +16 -3
- package/src/jobs/i18n.ts +3 -25
- package/src/jobs/index.ts +1 -0
- package/src/jobs/web/client-plugin.tsx +1 -7
- package/src/jobs/web/index.ts +0 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
- package/src/tenant/handlers/list.query.ts +1 -0
- package/src/tier-engine/__tests__/drift.test.ts +1 -1
- package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
- package/src/tier-engine/changes.json +9 -1
- package/src/tier-engine/constants.ts +5 -5
- package/src/tier-engine/feature.ts +36 -10
- package/src/tier-engine/i18n.ts +19 -22
- package/src/tier-engine/web/client-plugin.tsx +5 -11
- package/src/tier-engine/web/index.ts +0 -1
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
- package/src/user-data-rights/changes.json +7 -0
- package/src/user-data-rights/constants.ts +20 -6
- package/src/user-data-rights/feature.ts +113 -14
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
- package/src/user-data-rights/web/client-plugin.tsx +19 -21
- package/src/user-data-rights/web/i18n.ts +6 -11
- package/src/user-data-rights/web/index.ts +1 -1
- package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
- package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
- package/src/user-profile/changes.json +7 -0
- package/src/user-profile/constants.ts +15 -7
- package/src/user-profile/feature.ts +96 -9
- package/src/user-profile/i18n.ts +8 -12
- package/src/user-profile/web/client-plugin.ts +22 -5
- package/src/user-profile/web/index.ts +1 -1
- package/src/user-profile/web/profile-screen.tsx +122 -265
- package/src/admin-shell/web/overview-layout.tsx +0 -69
- package/src/admin-shell/web/overview-query.ts +0 -15
- package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
- package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
- package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
- package/src/audit/web/audit-log-detail-screen.tsx +0 -169
- package/src/audit/web/audit-log-screen.tsx +0 -290
- package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
- package/src/jobs/web/job-run-detail-screen.tsx +0 -181
- package/src/jobs/web/job-runs-screen.tsx +0 -290
- package/src/tier-engine/web/tier-admin-screen.tsx +0 -168
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";
|
|
4
|
+
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
5
|
+
import { authFoundationFeature } from "../../auth-foundation";
|
|
6
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
|
|
7
|
+
import { createConfigFeature } from "../../config/feature";
|
|
8
|
+
import { createDataRetentionFeature } from "../../data-retention/feature";
|
|
9
|
+
import { createSessionsFeature } from "../../sessions/feature";
|
|
10
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
11
|
+
import { createUserFeature } from "../../user/feature";
|
|
12
|
+
import { createUserDataRightsFeature } from "../../user-data-rights/feature";
|
|
13
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults/feature";
|
|
14
|
+
import { createUserProfileFeature } from "../feature";
|
|
15
|
+
|
|
16
|
+
// fw#2312 — the `profile` screen converted from a custom React component to
|
|
17
|
+
// a declarative projectionDetail (change-password/change-email stay
|
|
18
|
+
// EditExtensionSection components, deletion is fields+actions). `validateBoot`
|
|
19
|
+
// below proves the screen clears every boot-validator rule for
|
|
20
|
+
// projectionDetail extension sections (entityName set, contributesToFormSubmit
|
|
21
|
+
// not true, action handlers resolve) — same pattern as user-data-rights'
|
|
22
|
+
// privacy-center screen (inspector-screens.boot.test.ts), which this mirrors.
|
|
23
|
+
describe("user-profile screen (fw#2312 projectionDetail conversion)", () => {
|
|
24
|
+
const features = [
|
|
25
|
+
createConfigFeature(),
|
|
26
|
+
createUserFeature(),
|
|
27
|
+
createTenantFeature(),
|
|
28
|
+
createAuthEmailPasswordFeature(),
|
|
29
|
+
createDataRetentionFeature(),
|
|
30
|
+
createComplianceProfilesFeature(),
|
|
31
|
+
authFoundationFeature,
|
|
32
|
+
createSessionsFeature(),
|
|
33
|
+
createFilesFeature(),
|
|
34
|
+
createUserDataRightsFeature(),
|
|
35
|
+
createUserDataRightsDefaultsFeature(),
|
|
36
|
+
createUserProfileFeature(),
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
test("the assembled feature set boot-validates", () => {
|
|
40
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("is a projectionDetail bound to the user's own `me` query", () => {
|
|
44
|
+
const f = createUserProfileFeature();
|
|
45
|
+
const screen = f.screens["profile"];
|
|
46
|
+
expect(screen?.type).toBe("projectionDetail");
|
|
47
|
+
if (screen?.type === "projectionDetail") {
|
|
48
|
+
expect(screen.query).toBe("user:query:user:me");
|
|
49
|
+
expect(screen.access).toEqual({ openToAll: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("change-email/change-password are self-persisting extensions scoped to `user`", () => {
|
|
54
|
+
const f = createUserProfileFeature();
|
|
55
|
+
const screen = f.screens["profile"];
|
|
56
|
+
if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
|
|
57
|
+
const extensionSections = screen.layout.sections.filter((s) => s.kind === "extension");
|
|
58
|
+
expect(extensionSections).toHaveLength(2);
|
|
59
|
+
for (const section of extensionSections) {
|
|
60
|
+
expect(section.entityName).toBe("user");
|
|
61
|
+
expect(section.contributesToFormSubmit).not.toBe(true);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("gracePeriodEnd is hidden unless a deletion is actually pending", () => {
|
|
66
|
+
const f = createUserProfileFeature();
|
|
67
|
+
const screen = f.screens["profile"];
|
|
68
|
+
if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
|
|
69
|
+
const graceField = screen.layout.sections
|
|
70
|
+
.flatMap((s) => ("fields" in s ? s.fields : []))
|
|
71
|
+
.find((field) => typeof field === "object" && field.field === "gracePeriodEnd");
|
|
72
|
+
expect(typeof graceField === "object" ? graceField.visible : undefined).toEqual({
|
|
73
|
+
field: "status",
|
|
74
|
+
eq: "deletionRequested",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("request-deletion/cancel-deletion actions dispatch user-data-rights handlers with the original confirm+visibility rules", () => {
|
|
79
|
+
const f = createUserProfileFeature();
|
|
80
|
+
const screen = f.screens["profile"];
|
|
81
|
+
if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
|
|
82
|
+
const byId = Object.fromEntries((screen.actions ?? []).map((a) => [a.id, a]));
|
|
83
|
+
|
|
84
|
+
expect(byId["request-deletion"]).toMatchObject({
|
|
85
|
+
handler: "user-data-rights:write:request-deletion",
|
|
86
|
+
confirm: "profile.danger.dialogDescription",
|
|
87
|
+
visible: { field: "status", ne: "deletionRequested" },
|
|
88
|
+
style: "danger",
|
|
89
|
+
});
|
|
90
|
+
// Cancel has no confirm — the pre-#2312 custom screen executed it directly.
|
|
91
|
+
expect(byId["cancel-deletion"]).toMatchObject({
|
|
92
|
+
handler: "user-data-rights:write:cancel-deletion",
|
|
93
|
+
visible: { field: "status", eq: "deletionRequested" },
|
|
94
|
+
});
|
|
95
|
+
expect((byId["cancel-deletion"] as { readonly confirm?: string }).confirm).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
// Render
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Render test against the real i18n bundles (catches missing keys — the
|
|
2
|
+
// sections must never show raw "profile.*" keys). Provider wrapper mirrors
|
|
3
|
+
// renderer-web/test-utils, duplicated locally because the renderer-web →
|
|
4
|
+
// bundled-features dependency direction is forbidden.
|
|
5
|
+
//
|
|
6
|
+
// fw#2312: the `profile` screen is no longer a React component (ProfileScreen)
|
|
7
|
+
// — it's a declarative projectionDetail (feature.ts, boot-tested in
|
|
8
|
+
// profile-screen.boot.test.ts). Only change-email/change-password stay
|
|
9
|
+
// EditExtensionSection components (re-auth flows a declarative action can't
|
|
10
|
+
// express); the danger zone is fields + actions.
|
|
5
11
|
|
|
6
12
|
import { describe, expect, spyOn, test } from "bun:test";
|
|
7
13
|
import { createStore, type Dispatcher, type DispatcherStatus } from "@cosmicdrift/kumiko-headless";
|
|
@@ -19,7 +25,7 @@ import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-w
|
|
|
19
25
|
import { fireEvent, render, waitFor } from "@testing-library/react";
|
|
20
26
|
import type { ReactNode } from "react";
|
|
21
27
|
import { defaultTranslations } from "../i18n";
|
|
22
|
-
import {
|
|
28
|
+
import { ChangeEmailSection, ChangePasswordSection } from "../web/profile-screen";
|
|
23
29
|
|
|
24
30
|
const stubLiveEvents: LiveEventSubscriber = () => () => {};
|
|
25
31
|
const stubTokens = {
|
|
@@ -30,11 +36,15 @@ const stubTokens = {
|
|
|
30
36
|
};
|
|
31
37
|
const stubResolver = createStaticLocaleResolver();
|
|
32
38
|
|
|
33
|
-
function makeDispatcher(
|
|
39
|
+
function makeDispatcher(writes: Array<{ type: string; payload: unknown }>): Dispatcher {
|
|
34
40
|
const statusStore = createStore<DispatcherStatus>("online");
|
|
41
|
+
const write = (async (type: string, payload: unknown) => {
|
|
42
|
+
writes.push({ type, payload });
|
|
43
|
+
return { isSuccess: true, data: {} };
|
|
44
|
+
}) as unknown as Dispatcher["write"];
|
|
35
45
|
return {
|
|
36
|
-
write
|
|
37
|
-
query: (async () => ({ isSuccess: true, data:
|
|
46
|
+
write,
|
|
47
|
+
query: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["query"],
|
|
38
48
|
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
39
49
|
statusStore,
|
|
40
50
|
async *stream() {},
|
|
@@ -43,8 +53,8 @@ function makeDispatcher(me: Record<string, unknown>): Dispatcher {
|
|
|
43
53
|
} as unknown as Dispatcher; // @cast-boundary test-stub
|
|
44
54
|
}
|
|
45
55
|
|
|
46
|
-
function
|
|
47
|
-
|
|
56
|
+
function withProviders(writes: Array<{ type: string; payload: unknown }>) {
|
|
57
|
+
return ({ children }: { readonly children: ReactNode }): ReactNode => (
|
|
48
58
|
<TokensProvider value={stubTokens}>
|
|
49
59
|
<LocaleProvider
|
|
50
60
|
resolver={stubResolver}
|
|
@@ -52,100 +62,93 @@ function renderProfile(me: Record<string, unknown>) {
|
|
|
52
62
|
>
|
|
53
63
|
<PrimitivesProvider value={defaultPrimitives}>
|
|
54
64
|
<LiveEventsProvider value={stubLiveEvents}>
|
|
55
|
-
<DispatcherProvider dispatcher={makeDispatcher(
|
|
65
|
+
<DispatcherProvider dispatcher={makeDispatcher(writes)}>{children}</DispatcherProvider>
|
|
56
66
|
</LiveEventsProvider>
|
|
57
67
|
</PrimitivesProvider>
|
|
58
68
|
</LocaleProvider>
|
|
59
69
|
</TokensProvider>
|
|
60
70
|
);
|
|
61
|
-
return render(<ProfileScreen />, { wrapper });
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
73
|
+
function renderChangeEmailSection(email: string) {
|
|
74
|
+
const writes: Array<{ type: string; payload: unknown }> = [];
|
|
75
|
+
const patchCalls: Array<Readonly<Record<string, unknown>>> = [];
|
|
76
|
+
const patch = (partial: Readonly<Record<string, unknown>>): void => {
|
|
77
|
+
patchCalls.push(partial);
|
|
78
|
+
};
|
|
79
|
+
// ChangeEmailSection renders no Section/title of its own in production
|
|
80
|
+
// (the renderer's ExtensionSectionMount supplies that) — a plain testId
|
|
81
|
+
// wrapper here is test-only scaffolding to detect "mounted".
|
|
82
|
+
const view = render(
|
|
83
|
+
<div data-testid="change-email-root">
|
|
84
|
+
<ChangeEmailSection entityName="user" entityId={null} values={{ email }} patch={patch} />
|
|
85
|
+
</div>,
|
|
86
|
+
{ wrapper: withProviders(writes) },
|
|
87
|
+
);
|
|
88
|
+
return { view, writes, patchCalls };
|
|
89
|
+
}
|
|
70
90
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
expect(view.getByTestId("profile-danger-delete")).toBeTruthy();
|
|
86
|
-
// Echte i18n: kein einziger roher Key im sichtbaren Text.
|
|
87
|
-
expect(view.container.textContent).not.toContain("profile.");
|
|
88
|
-
// Card-Standard: jede Konto-Section ist GENAU eine Card (self + descendants)
|
|
89
|
-
// — nicht mehr das alte <section bg-card> um eine Form-Card = doppelt.
|
|
90
|
-
const cardCount = (el: Element): number =>
|
|
91
|
-
(el.matches(".bg-card") ? 1 : 0) + el.querySelectorAll(".bg-card").length;
|
|
92
|
-
expect(cardCount(view.getByTestId("profile-email"))).toBe(1);
|
|
93
|
-
expect(cardCount(view.getByTestId("profile-password"))).toBe(1);
|
|
91
|
+
function renderChangePasswordSection() {
|
|
92
|
+
const writes: Array<{ type: string; payload: unknown }> = [];
|
|
93
|
+
const view = render(
|
|
94
|
+
<div data-testid="change-password-root">
|
|
95
|
+
<ChangePasswordSection />
|
|
96
|
+
</div>,
|
|
97
|
+
{ wrapper: withProviders(writes) },
|
|
98
|
+
);
|
|
99
|
+
return { view, writes };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function waitForMount(view: ReturnType<typeof render>, testId: string): Promise<void> {
|
|
103
|
+
await waitFor(() => {
|
|
104
|
+
if (view.queryByTestId(testId) === null) throw new Error("not mounted yet");
|
|
94
105
|
});
|
|
106
|
+
}
|
|
95
107
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
});
|
|
101
|
-
expect(view.getByTestId("profile-password").tagName).toBe("FORM");
|
|
108
|
+
describe("ChangeEmailSection", () => {
|
|
109
|
+
test("renders on a real <form> (Enter-to-submit, password-manager boundary), shows the current email, no raw i18n keys", async () => {
|
|
110
|
+
const { view } = renderChangeEmailSection("marc@example.com");
|
|
111
|
+
await waitForMount(view, "change-email-root");
|
|
102
112
|
expect(view.getByTestId("profile-email").tagName).toBe("FORM");
|
|
113
|
+
expect(view.getByTestId("profile-email-current").textContent).toContain("marc@example.com");
|
|
114
|
+
expect(view.container.textContent).not.toContain("profile.");
|
|
103
115
|
});
|
|
104
116
|
|
|
105
|
-
test("email
|
|
106
|
-
const view =
|
|
117
|
+
test("submits change-email with currentPassword + newEmail, patches the host's email on success", async () => {
|
|
118
|
+
const { view, writes, patchCalls } = renderChangeEmailSection("old@example.com");
|
|
119
|
+
await waitForMount(view, "profile-email");
|
|
120
|
+
|
|
121
|
+
const emailInput = view.container.querySelector<HTMLInputElement>("#profile-new-email");
|
|
122
|
+
const pwInput = view.container.querySelector<HTMLInputElement>("#profile-email-password");
|
|
123
|
+
if (!emailInput || !pwInput) throw new Error("email form inputs not found");
|
|
124
|
+
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
|
125
|
+
fireEvent.change(pwInput, { target: { value: "current-pw" } });
|
|
126
|
+
fireEvent.click(view.getByTestId("profile-email-submit"));
|
|
127
|
+
|
|
107
128
|
await waitFor(() => {
|
|
108
|
-
if (
|
|
129
|
+
if (writes.length === 0) throw new Error("no write dispatched yet");
|
|
109
130
|
});
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
expect(row?.className).not.toContain("items-start");
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("deletionRequested: Frist-Banner + Abbrechen statt Lösch-Button", async () => {
|
|
117
|
-
const view = renderProfile({
|
|
118
|
-
...activeMe,
|
|
119
|
-
status: "deletionRequested",
|
|
120
|
-
gracePeriodEnd: "2026-07-11T00:00:00Z",
|
|
131
|
+
expect(writes[0]).toEqual({
|
|
132
|
+
type: "user-profile:write:change-email",
|
|
133
|
+
payload: { currentPassword: "current-pw", newEmail: "new@example.com" },
|
|
121
134
|
});
|
|
122
135
|
await waitFor(() => {
|
|
123
|
-
if (
|
|
136
|
+
if (patchCalls.length === 0) throw new Error("patch not called yet");
|
|
124
137
|
});
|
|
125
|
-
|
|
126
|
-
expect(banner.textContent).toContain("2026-07-11");
|
|
127
|
-
expect(banner.textContent).not.toContain("{date}");
|
|
128
|
-
// #322/2: nur der Datums-Teil, kein roher ISO-Zeitstempel mehr.
|
|
129
|
-
expect(banner.textContent).not.toContain("T00:00");
|
|
130
|
-
expect(banner.textContent).not.toContain(":00:00");
|
|
131
|
-
expect(view.queryByTestId("profile-danger-delete")).toBeNull();
|
|
132
|
-
expect(view.getByTestId("profile-danger-cancel")).toBeTruthy();
|
|
133
|
-
expect(view.container.textContent).not.toContain("profile.");
|
|
138
|
+
expect(patchCalls[0]).toEqual({ email: "new@example.com" });
|
|
134
139
|
});
|
|
135
140
|
|
|
136
|
-
// #322/3:
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
test("
|
|
141
|
+
// #322/3: after a successful email change, the section triggers the
|
|
142
|
+
// verification-mail send. A failure there must not reverse the success —
|
|
143
|
+
// but it's no longer silently swallowed (otherwise the user waits for a
|
|
144
|
+
// mail that never arrives) and the success message no longer promises a
|
|
145
|
+
// send.
|
|
146
|
+
test("verification-send failure is surfaced (not swallowed), change still succeeds", async () => {
|
|
142
147
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
143
148
|
const fetchSpy = spyOn(globalThis, "fetch").mockRejectedValue(new Error("no network in test"));
|
|
144
149
|
try {
|
|
145
|
-
const view =
|
|
146
|
-
await
|
|
147
|
-
if (view.queryByTestId("profile-email") === null) throw new Error("not mounted yet");
|
|
148
|
-
});
|
|
150
|
+
const { view } = renderChangeEmailSection("old@example.com");
|
|
151
|
+
await waitForMount(view, "profile-email");
|
|
149
152
|
|
|
150
153
|
const emailInput = view.container.querySelector<HTMLInputElement>("#profile-new-email");
|
|
151
154
|
const pwInput = view.container.querySelector<HTMLInputElement>("#profile-email-password");
|
|
@@ -154,16 +157,16 @@ describe("ProfileScreen", () => {
|
|
|
154
157
|
fireEvent.change(pwInput, { target: { value: "current-pw" } });
|
|
155
158
|
fireEvent.click(view.getByTestId("profile-email-submit"));
|
|
156
159
|
|
|
157
|
-
// De-
|
|
160
|
+
// De-swallow: the failed verification-mail send is logged.
|
|
158
161
|
await waitFor(() => {
|
|
159
162
|
const warned = warnSpy.mock.calls.some((c) => String(c[0]).includes("[user-profile]"));
|
|
160
163
|
if (!warned) throw new Error("verification-send failure not surfaced");
|
|
161
164
|
});
|
|
162
|
-
//
|
|
165
|
+
// The change still succeeds: the input field is cleared.
|
|
163
166
|
await waitFor(() => {
|
|
164
167
|
if (emailInput.value !== "") throw new Error("email input not cleared after success");
|
|
165
168
|
});
|
|
166
|
-
//
|
|
169
|
+
// The success message no longer promises a link send.
|
|
167
170
|
expect(view.container.textContent).not.toContain("verification link");
|
|
168
171
|
expect(view.container.textContent).not.toContain("Bestätigungslink");
|
|
169
172
|
} finally {
|
|
@@ -172,19 +175,17 @@ describe("ProfileScreen", () => {
|
|
|
172
175
|
}
|
|
173
176
|
});
|
|
174
177
|
|
|
175
|
-
// #472/1:
|
|
176
|
-
// (
|
|
177
|
-
//
|
|
178
|
-
test("
|
|
178
|
+
// #472/1: the server answers the verification-mail send with ok:false
|
|
179
|
+
// (e.g. 4xx) WITHOUT throwing. That's a different branch than the catch
|
|
180
|
+
// above — it must be logged on its own, and the change still succeeds.
|
|
181
|
+
test("verification-send rejected by server (ok:false) is surfaced", async () => {
|
|
179
182
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
180
183
|
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
|
|
181
184
|
new Response("{}", { status: 400 }),
|
|
182
185
|
);
|
|
183
186
|
try {
|
|
184
|
-
const view =
|
|
185
|
-
await
|
|
186
|
-
if (view.queryByTestId("profile-email") === null) throw new Error("not mounted yet");
|
|
187
|
-
});
|
|
187
|
+
const { view } = renderChangeEmailSection("old@example.com");
|
|
188
|
+
await waitForMount(view, "profile-email");
|
|
188
189
|
|
|
189
190
|
const emailInput = view.container.querySelector<HTMLInputElement>("#profile-new-email");
|
|
190
191
|
const pwInput = view.container.querySelector<HTMLInputElement>("#profile-email-password");
|
|
@@ -193,14 +194,13 @@ describe("ProfileScreen", () => {
|
|
|
193
194
|
fireEvent.change(pwInput, { target: { value: "current-pw" } });
|
|
194
195
|
fireEvent.click(view.getByTestId("profile-email-submit"));
|
|
195
196
|
|
|
196
|
-
//
|
|
197
|
-
//
|
|
197
|
+
// The ok:false branch logs ITS OWN message ("could not be sent"),
|
|
198
|
+
// not the catch branch's ("send threw").
|
|
198
199
|
await waitFor(() => {
|
|
199
200
|
const hit = warnSpy.mock.calls.some((c) => String(c[0]).includes("could not be sent"));
|
|
200
201
|
if (!hit) throw new Error("ok:false verification failure not surfaced");
|
|
201
202
|
});
|
|
202
203
|
expect(warnSpy.mock.calls.some((c) => String(c[0]).includes("send threw"))).toBe(false);
|
|
203
|
-
// Wechsel bleibt erfolgreich: das Eingabefeld wird zurückgesetzt.
|
|
204
204
|
await waitFor(() => {
|
|
205
205
|
if (emailInput.value !== "") throw new Error("email input not cleared after success");
|
|
206
206
|
});
|
|
@@ -211,18 +211,54 @@ describe("ProfileScreen", () => {
|
|
|
211
211
|
});
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
-
describe("
|
|
215
|
-
test("
|
|
216
|
-
|
|
214
|
+
describe("ChangePasswordSection", () => {
|
|
215
|
+
test("renders on a real <form>, no raw i18n keys", async () => {
|
|
216
|
+
const { view } = renderChangePasswordSection();
|
|
217
|
+
await waitForMount(view, "change-password-root");
|
|
218
|
+
expect(view.getByTestId("profile-password").tagName).toBe("FORM");
|
|
219
|
+
expect(view.container.textContent).not.toContain("profile.");
|
|
217
220
|
});
|
|
218
221
|
|
|
219
|
-
test("
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
222
|
+
test("mismatched confirm password blocks the submit (no write dispatched)", async () => {
|
|
223
|
+
const { view, writes } = renderChangePasswordSection();
|
|
224
|
+
await waitForMount(view, "profile-password");
|
|
225
|
+
|
|
226
|
+
const oldPw = view.container.querySelector<HTMLInputElement>("#profile-old-password");
|
|
227
|
+
const newPw = view.container.querySelector<HTMLInputElement>("#profile-new-password");
|
|
228
|
+
const confirmPw = view.container.querySelector<HTMLInputElement>("#profile-confirm-password");
|
|
229
|
+
if (!oldPw || !newPw || !confirmPw) throw new Error("password form inputs not found");
|
|
230
|
+
fireEvent.change(oldPw, { target: { value: "current-pw" } });
|
|
231
|
+
fireEvent.change(newPw, { target: { value: "new-pw-1" } });
|
|
232
|
+
fireEvent.change(confirmPw, { target: { value: "new-pw-2" } });
|
|
233
|
+
fireEvent.click(view.getByTestId("profile-password-submit"));
|
|
234
|
+
|
|
235
|
+
await waitFor(() => {
|
|
236
|
+
if (!view.container.textContent?.includes("do not match")) {
|
|
237
|
+
throw new Error("mismatch error not shown yet");
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
expect(writes).toHaveLength(0);
|
|
223
241
|
});
|
|
224
242
|
|
|
225
|
-
test("
|
|
226
|
-
|
|
243
|
+
test("matching passwords dispatch change-password with old/new", async () => {
|
|
244
|
+
const { view, writes } = renderChangePasswordSection();
|
|
245
|
+
await waitForMount(view, "profile-password");
|
|
246
|
+
|
|
247
|
+
const oldPw = view.container.querySelector<HTMLInputElement>("#profile-old-password");
|
|
248
|
+
const newPw = view.container.querySelector<HTMLInputElement>("#profile-new-password");
|
|
249
|
+
const confirmPw = view.container.querySelector<HTMLInputElement>("#profile-confirm-password");
|
|
250
|
+
if (!oldPw || !newPw || !confirmPw) throw new Error("password form inputs not found");
|
|
251
|
+
fireEvent.change(oldPw, { target: { value: "current-pw" } });
|
|
252
|
+
fireEvent.change(newPw, { target: { value: "new-pw-1" } });
|
|
253
|
+
fireEvent.change(confirmPw, { target: { value: "new-pw-1" } });
|
|
254
|
+
fireEvent.click(view.getByTestId("profile-password-submit"));
|
|
255
|
+
|
|
256
|
+
await waitFor(() => {
|
|
257
|
+
if (writes.length === 0) throw new Error("no write dispatched yet");
|
|
258
|
+
});
|
|
259
|
+
expect(writes[0]).toEqual({
|
|
260
|
+
type: "auth-email-password:write:change-password",
|
|
261
|
+
payload: { oldPassword: "current-pw", newPassword: "new-pw-1" },
|
|
262
|
+
});
|
|
227
263
|
});
|
|
228
264
|
});
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.241.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "user-profile now registers its own profile screen (id user-profile:screen:profile); app-side profile screen registrations must be removed (fw#2312).",
|
|
6
|
+
"detail": "`profile` is now a declarative `projectionDetail` screen bound to `user:query:user:me` instead of a custom React component: change-password and change-email stay `EditExtensionSection` components (re-auth flows a declarative action can't express), account deletion (request/cancel via `user-data-rights`, grace period) is now fully declarative fields + `actions: RowAction[]`, preserving the original confirm dialog and `visible: {field, eq/ne}` toggle 1:1. `userProfileClient()` now registers the two extension-section components via `extensionSectionComponents` instead of exposing a `ProfileScreen` component for apps to place in a `components` map. `ProfileScreen` is a dead export. New exports: `ChangeEmailSection`, `ChangePasswordSection` (the two surviving extension components, importable for tests but not meant to be placed manually). Known UI regression, accepted for this pass (same trade-off as `privacy-center`): the deletion confirm-dialog title is now always the action's `label`; the dynamically composed grace-period banner sentence is gone, replaced by a plain `gracePeriodEnd` date field; there is no cancel-deletion success toast.",
|
|
7
|
+
"migration": "The bundled feature now registers the `profile` screen itself (id `user-profile:screen:profile`) — an app's own `profile` custom-screen registration collides on boot with a duplicate short-id error. Delete the app-side registration entirely: `r.screen({ id: \"profile\", type: \"custom\", renderer: { react: { __component: \"UserProfileScreen\" } }, access: { openToAll: true } })` has no replacement to write, just remove the call. If you have an `r.nav({ screen: \"profile\" })` (or any other reference to the app-local `profile` screen id), repoint it at `\"user-profile:screen:profile\"`. Also remove `components: { UserProfileScreen: ProfileScreen }` from your renderer/client setup and the now-unused `ProfileScreen` import — the client no longer exposes that component. Known affected consumers (not modified here, out of this PR's scope): `money-horse` (`src/features/money-horse/feature.ts:286-292`) and `offlot-app` (`src/features/account/feature.ts:24-30`) both currently register their own `profile` custom screen this way."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.123.2",
|
|
4
11
|
"type": "fix",
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// client-Dateien ohnehin ziehen.
|
|
2
|
+
// Plain string constants — client-marked so web/profile-screen.tsx can
|
|
3
|
+
// import them; runtime code (handlers/feature) may pull client files anyway.
|
|
5
4
|
|
|
6
5
|
export const USER_PROFILE_FEATURE = "user-profile" as const;
|
|
7
6
|
|
|
7
|
+
export const PROFILE_SCREEN_ID = "profile" as const;
|
|
8
|
+
|
|
9
|
+
// EditExtensionSection `__component` markers (fw#2312) — change-password and
|
|
10
|
+
// change-email stay React components (re-auth flows a declarative action
|
|
11
|
+
// can't express); deletion moved to declarative fields/actions on the screen
|
|
12
|
+
// itself (feature.ts), same split as user-data-rights' privacy-center.
|
|
13
|
+
export const CHANGE_EMAIL_SECTION_EXTENSION_NAME = "UserProfileChangeEmailSection" as const;
|
|
14
|
+
export const CHANGE_PASSWORD_SECTION_EXTENSION_NAME = "UserProfileChangePasswordSection" as const;
|
|
15
|
+
|
|
8
16
|
export const UserProfileHandlers = {
|
|
9
17
|
changeEmail: "user-profile:write:change-email",
|
|
10
18
|
} as const;
|
|
11
19
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
20
|
+
// Foreign QNs the profile screen dispatches, pinned here instead of as
|
|
21
|
+
// magic strings in the screen (and instead of runtime-barrel imports, which
|
|
22
|
+
// would violate runtime isolation). Drift guard: the integration test
|
|
23
|
+
// compares these against the owning features' original constants.
|
|
16
24
|
export const UserProfileQueries = {
|
|
17
25
|
me: "user:query:user:me",
|
|
18
26
|
} as const;
|
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import {
|
|
3
|
+
CHANGE_EMAIL_SECTION_EXTENSION_NAME,
|
|
4
|
+
CHANGE_PASSWORD_SECTION_EXTENSION_NAME,
|
|
5
|
+
PROFILE_SCREEN_ID,
|
|
6
|
+
UserDataRightsHandlers,
|
|
7
|
+
UserProfileQueries,
|
|
8
|
+
} from "./constants";
|
|
2
9
|
import { changeEmailWrite } from "./handlers/change-email.write";
|
|
3
10
|
|
|
4
11
|
export function createUserProfileFeature(): FeatureDefinition {
|
|
5
12
|
return defineFeature("user-profile", (r) => {
|
|
6
13
|
r.describe(
|
|
7
|
-
"Self-service account page
|
|
14
|
+
"Self-service account page (fw#2312: declarative `projectionDetail` bound to " +
|
|
15
|
+
"the signed-in user's own `me` row) plus its `change-email` write handler " +
|
|
8
16
|
"(re-auth via current password, uniqueness check, resets emailVerified and " +
|
|
9
|
-
"expects the app to trigger the verification flow)
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"grace period)
|
|
13
|
-
'
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
+
"expects the app to trigger the verification flow). Change-password and " +
|
|
18
|
+
"change-email stay custom `EditExtensionSection` components (re-auth flows a " +
|
|
19
|
+
"declarative action can't express); account deletion (user-data-rights " +
|
|
20
|
+
"request/cancel with grace period) is declarative fields + actions. Apps " +
|
|
21
|
+
'place the screen (id "profile") in their logged-in area via r.nav — no ' +
|
|
22
|
+
'`type: "custom"` / `__component` registration needed on the app side ' +
|
|
23
|
+
"anymore. Requires `user`, `auth-email-password`, `user-data-rights`, and " +
|
|
24
|
+
"`user-data-rights-defaults` (so the GDPR boot-validator finds export/delete " +
|
|
25
|
+
"hooks for `user`'s PII fields once user-data-rights is mounted).",
|
|
17
26
|
);
|
|
18
27
|
r.uiHints({
|
|
19
28
|
displayLabel: "User Profile · Self-Service",
|
|
@@ -29,6 +38,84 @@ export function createUserProfileFeature(): FeatureDefinition {
|
|
|
29
38
|
changeEmail: r.writeHandler(changeEmailWrite),
|
|
30
39
|
};
|
|
31
40
|
|
|
41
|
+
// Boot-validator requires a "screen:<id>.title" key per r.screen() —
|
|
42
|
+
// kept inline (not imported from ./i18n) so feature.ts, part of the
|
|
43
|
+
// server barrel, stays importable from a server-only sample without a
|
|
44
|
+
// jsx tsconfig (i18n.ts pulls @cosmicdrift/kumiko-renderer's types, see
|
|
45
|
+
// index.ts). "Profile" is duplicated in i18n.ts's client bundle.
|
|
46
|
+
r.translations({ keys: { "screen:profile.title": { en: "Profile" } } });
|
|
47
|
+
|
|
48
|
+
// Self-service account screen: change-email/change-password stay
|
|
49
|
+
// self-persisting extension sections (re-auth, own dispatcher writes —
|
|
50
|
+
// contributesToFormSubmit omitted since projectionDetail has no form
|
|
51
|
+
// submit of its own, see the boot-validator's check for that). Deletion
|
|
52
|
+
// is declarative fields + actions, same split as user-data-rights'
|
|
53
|
+
// privacy-center screen (../user-data-rights/feature.ts) which this
|
|
54
|
+
// mirrors 1:1 for the confirm/visible semantics. access is openToAll
|
|
55
|
+
// because no app role name is portable; the per-user handlers enforce
|
|
56
|
+
// auth server-side.
|
|
57
|
+
r.screen({
|
|
58
|
+
id: PROFILE_SCREEN_ID,
|
|
59
|
+
type: "projectionDetail",
|
|
60
|
+
query: UserProfileQueries.me,
|
|
61
|
+
singleton: true,
|
|
62
|
+
access: { openToAll: true },
|
|
63
|
+
description:
|
|
64
|
+
"Self-service account page: change password, change email (with re-auth and " +
|
|
65
|
+
"a verification-mail follow-up), and request or cancel account deletion " +
|
|
66
|
+
"(user-data-rights grace period).",
|
|
67
|
+
fieldLabels: {
|
|
68
|
+
gracePeriodEnd: "profile.danger.gracePeriodEnd",
|
|
69
|
+
},
|
|
70
|
+
layout: {
|
|
71
|
+
sections: [
|
|
72
|
+
{
|
|
73
|
+
kind: "extension",
|
|
74
|
+
title: "profile.email.title",
|
|
75
|
+
component: { react: { __component: CHANGE_EMAIL_SECTION_EXTENSION_NAME } },
|
|
76
|
+
entityName: "user",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
kind: "extension",
|
|
80
|
+
title: "profile.password.title",
|
|
81
|
+
component: { react: { __component: CHANGE_PASSWORD_SECTION_EXTENSION_NAME } },
|
|
82
|
+
entityName: "user",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
title: "profile.danger.title",
|
|
86
|
+
description: "profile.danger.explainer",
|
|
87
|
+
// gracePeriodEnd is only meaningful once a deletion is actually
|
|
88
|
+
// pending — hide it instead of showing an empty date when status
|
|
89
|
+
// isn't deletionRequested (same rule as privacy-center).
|
|
90
|
+
fields: [
|
|
91
|
+
{
|
|
92
|
+
field: "gracePeriodEnd",
|
|
93
|
+
renderer: { format: "date" as const },
|
|
94
|
+
visible: { field: "status", eq: "deletionRequested" },
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
actions: [
|
|
101
|
+
{
|
|
102
|
+
id: "request-deletion",
|
|
103
|
+
label: "profile.danger.delete",
|
|
104
|
+
handler: UserDataRightsHandlers.requestDeletion,
|
|
105
|
+
confirm: "profile.danger.dialogDescription",
|
|
106
|
+
visible: { field: "status", ne: "deletionRequested" },
|
|
107
|
+
style: "danger",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "cancel-deletion",
|
|
111
|
+
label: "profile.danger.cancelDeletion",
|
|
112
|
+
handler: UserDataRightsHandlers.cancelDeletion,
|
|
113
|
+
visible: { field: "status", eq: "deletionRequested" },
|
|
114
|
+
style: "secondary",
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
32
119
|
return { handlers };
|
|
33
120
|
});
|
|
34
121
|
}
|