@cosmicdrift/kumiko-bundled-features 0.239.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.
Files changed (68) hide show
  1. package/package.json +11 -11
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +16 -7
  3. package/src/admin-shell/__tests__/overview-allowlist.test.ts +30 -0
  4. package/src/admin-shell/__tests__/overview-screens.boot.test.ts +31 -23
  5. package/src/admin-shell/changes.json +6 -0
  6. package/src/admin-shell/feature.ts +60 -6
  7. package/src/admin-shell/i18n.ts +0 -7
  8. package/src/admin-shell/web/client-plugin.tsx +1 -11
  9. package/src/audit/__tests__/audit-screens.boot.test.ts +26 -6
  10. package/src/audit/changes.json +7 -0
  11. package/src/audit/feature.ts +57 -7
  12. package/src/audit/handlers/list.query.ts +50 -14
  13. package/src/audit/i18n.ts +3 -18
  14. package/src/audit/web/client-plugin.tsx +1 -7
  15. package/src/audit/web/index.ts +0 -2
  16. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -5
  17. package/src/config/__tests__/config.integration.test.ts +31 -0
  18. package/src/config/handlers/readiness.query.ts +8 -1
  19. package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
  20. package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
  21. package/src/jobs/changes.json +7 -0
  22. package/src/jobs/feature.ts +101 -9
  23. package/src/jobs/handlers/list.query.ts +21 -4
  24. package/src/jobs/handlers/trigger.write.ts +16 -3
  25. package/src/jobs/i18n.ts +3 -25
  26. package/src/jobs/index.ts +1 -0
  27. package/src/jobs/web/client-plugin.tsx +1 -7
  28. package/src/jobs/web/index.ts +0 -2
  29. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +1 -1
  30. package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
  31. package/src/tenant/handlers/list.query.ts +1 -0
  32. package/src/tier-engine/__tests__/drift.test.ts +1 -1
  33. package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
  34. package/src/tier-engine/changes.json +9 -1
  35. package/src/tier-engine/constants.ts +5 -5
  36. package/src/tier-engine/feature.ts +36 -10
  37. package/src/tier-engine/i18n.ts +19 -22
  38. package/src/tier-engine/web/client-plugin.tsx +5 -11
  39. package/src/tier-engine/web/index.ts +0 -1
  40. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
  41. package/src/user-data-rights/changes.json +7 -0
  42. package/src/user-data-rights/constants.ts +20 -6
  43. package/src/user-data-rights/feature.ts +113 -14
  44. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
  45. package/src/user-data-rights/web/client-plugin.tsx +19 -21
  46. package/src/user-data-rights/web/i18n.ts +6 -11
  47. package/src/user-data-rights/web/index.ts +1 -1
  48. package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
  49. package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
  50. package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
  51. package/src/user-profile/changes.json +7 -0
  52. package/src/user-profile/constants.ts +15 -7
  53. package/src/user-profile/feature.ts +96 -9
  54. package/src/user-profile/i18n.ts +8 -12
  55. package/src/user-profile/web/client-plugin.ts +22 -5
  56. package/src/user-profile/web/index.ts +1 -1
  57. package/src/user-profile/web/profile-screen.tsx +122 -265
  58. package/src/admin-shell/web/overview-layout.tsx +0 -69
  59. package/src/admin-shell/web/overview-query.ts +0 -15
  60. package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
  61. package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
  62. package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
  63. package/src/audit/web/audit-log-detail-screen.tsx +0 -173
  64. package/src/audit/web/audit-log-screen.tsx +0 -290
  65. package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
  66. package/src/jobs/web/job-run-detail-screen.tsx +0 -181
  67. package/src/jobs/web/job-runs-screen.tsx +0 -290
  68. 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-Test gegen echte i18n-Bundles (Welle-1-Prinzip: fängt fehlende
2
- // Keys — der Screen darf nie rohe "profile.*"-Keys zeigen). Provider-
3
- // Wrapper analog renderer-web/test-utils, hier lokal weil die
4
- // Dependency-Richtung renderer-web → bundled-features verbietet.
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 { formatDeletionDate, ProfileScreen } from "../web/profile-screen";
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(me: Record<string, unknown>): Dispatcher {
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: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
37
- query: (async () => ({ isSuccess: true, data: me })) as unknown as Dispatcher["query"],
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 renderProfile(me: Record<string, unknown>) {
47
- const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
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(me)}>{children}</DispatcherProvider>
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
- const activeMe = {
65
- id: "00000000-0000-4000-8000-000000000042",
66
- email: "marc@example.com",
67
- status: "active",
68
- gracePeriodEnd: null,
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
- describe("ProfileScreen", () => {
72
- test("aktiver User: alle drei Sektionen, Texte übersetzt (keine rohen Keys)", async () => {
73
- const view = renderProfile(activeMe);
74
- await waitFor(() => {
75
- if (view.queryByTestId("profile-screen") === null) throw new Error("not mounted yet");
76
- });
77
- expect(view.getByTestId("profile-email")).toBeTruthy();
78
- expect(view.getByTestId("profile-password")).toBeTruthy();
79
- expect(view.getByTestId("profile-danger")).toBeTruthy();
80
- // 607/4: page heading is <h1> (Heading variant="page") — the danger-zone
81
- // title must be <h2>, not Card's default <h3>, so the levels don't skip.
82
- expect(view.getByTestId("profile-danger").querySelector("h2")).not.toBeNull();
83
- expect(view.getByTestId("profile-danger").querySelector("h3")).toBeNull();
84
- expect(view.getByTestId("profile-email-current").textContent).toContain("marc@example.com");
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
- test("Passwort- und Email-Section rendern auf einem echten <form> (Enter-to-submit, Password-Manager-Boundary) (555/1)", async () => {
97
- const view = renderProfile(activeMe);
98
- await waitFor(() => {
99
- if (view.queryByTestId("profile-screen") === null) throw new Error("not mounted yet");
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/password row stretches equal-height cards instead of grid's items-start default (fw#2656)", async () => {
106
- const view = renderProfile(activeMe);
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 (view.queryByTestId("profile-screen") === null) throw new Error("not mounted yet");
129
+ if (writes.length === 0) throw new Error("no write dispatched yet");
109
130
  });
110
- const row = view.getByTestId("profile-email").parentElement;
111
- expect(row?.className).toContain("grid");
112
- expect(row?.className).toContain("md:grid-cols-2");
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 (view.queryByTestId("profile-screen") === null) throw new Error("not mounted yet");
136
+ if (patchCalls.length === 0) throw new Error("patch not called yet");
124
137
  });
125
- const banner = view.getByTestId("profile-danger-requested");
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: nach erfolgreichem Email-Wechsel triggert der Screen den
137
- // Verification-Versand. Schlägt der fehl, darf der Erfolg nicht umkehren —
138
- // aber der Fehler wird nicht mehr stumm verschluckt (sonst wartet der User
139
- // auf eine Mail, die nie kommt) und die Success-Message verspricht keinen
140
- // Versand mehr.
141
- test("email change: verification-send failure is surfaced (not swallowed), change still succeeds", async () => {
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 = renderProfile(activeMe);
146
- await waitFor(() => {
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-Swallow: der fehlgeschlagene Verification-Versand wird geloggt.
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
- // Wechsel bleibt erfolgreich: das Eingabefeld wird zurückgesetzt.
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
- // Die Success-Message verspricht keinen Link-Versand mehr.
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: der Server antwortet auf den Verification-Versand mit ok:false
176
- // (z.B. 4xx) OHNE zu werfen. Das ist ein anderer Zweig als das catch oben —
177
- // er muss eigenständig geloggt werden, der Wechsel bleibt erfolgreich.
178
- test("email change: verification-send rejected by server (ok:false) is surfaced", async () => {
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 = renderProfile(activeMe);
185
- await waitFor(() => {
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
- // Der ok:false-Zweig loggt SEINE Message ("could not be sent"),
197
- // nicht die des catch-Zweigs ("send threw").
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("formatDeletionDate", () => {
215
- test("ISO instant → date part only (strips time + Z)", () => {
216
- expect(formatDeletionDate("2026-07-11T00:00:00.000Z")).toBe("2026-07-11");
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("null / undefined / empty → em dash", () => {
220
- expect(formatDeletionDate(null)).toBe("—");
221
- expect(formatDeletionDate(undefined)).toBe("—");
222
- expect(formatDeletionDate("")).toBe("—");
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("date-only string without time → returned as-is", () => {
226
- expect(formatDeletionDate("2026-07-11")).toBe("2026-07-11");
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
- // Reine String-Konstanten — client-markiert, damit der ProfileScreen
3
- // (web/) sie importieren darf; runtime-Code (handlers/feature) darf
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
- // Fremde QNs die der ProfileScreen dispatcht, hier gepinnt statt als
13
- // Magic-Strings im Screen (und statt runtime-Barrel-Imports, die die
14
- // Runtime-Isolation verletzen würden). Drift-Schutz: der Integration-
15
- // Test vergleicht sie gegen die Original-Konstanten der Features.
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 building blocks: a `change-email` write handler " +
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) plus the ProfileScreen " +
10
- "web component that composes change-password (auth-email-password), " +
11
- "change-email and account deletion (user-data-rights request/cancel with " +
12
- "grace period) into one screen. Apps register the screen as " +
13
- '`type: "custom"` with `__component: "UserProfileScreen"`. Requires `user`, ' +
14
- "`auth-email-password`, `user-data-rights`, and `user-data-rights-defaults` " +
15
- "(so the GDPR boot-validator finds export/delete hooks for `user`'s PII " +
16
- "fields once user-data-rights is mounted).",
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
  }