@cosmicdrift/kumiko-bundled-features 0.68.0 → 0.70.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.68.0",
3
+ "version": "0.70.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -84,11 +84,11 @@
84
84
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
85
85
  },
86
86
  "dependencies": {
87
- "@cosmicdrift/kumiko-dispatcher-live": "0.68.0",
88
- "@cosmicdrift/kumiko-framework": "0.68.0",
89
- "@cosmicdrift/kumiko-headless": "0.68.0",
90
- "@cosmicdrift/kumiko-renderer": "0.68.0",
91
- "@cosmicdrift/kumiko-renderer-web": "0.68.0",
87
+ "@cosmicdrift/kumiko-dispatcher-live": "0.70.0",
88
+ "@cosmicdrift/kumiko-framework": "0.70.0",
89
+ "@cosmicdrift/kumiko-headless": "0.70.0",
90
+ "@cosmicdrift/kumiko-renderer": "0.70.0",
91
+ "@cosmicdrift/kumiko-renderer-web": "0.70.0",
92
92
  "@mollie/api-client": "^4.5.0",
93
93
  "@node-rs/argon2": "^2.0.2",
94
94
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { deserializeValue } from "../resolver";
3
+
4
+ // deserializeValue is the read boundary for every config value: the DB stores
5
+ // the JSON-encoded raw string, this turns it back into a typed primitive per the
6
+ // key's declared `type`. The coercion has non-obvious paths worth pinning so a
7
+ // refactor can't quietly change them: a stored value whose JSON type disagrees
8
+ // with the declared type is NOT rejected — it is coerced (number via Number(),
9
+ // boolean only via literal true / the string "true", text via String()).
10
+
11
+ describe("deserializeValue", () => {
12
+ test("null raw short-circuits to undefined before any parse", () => {
13
+ expect(deserializeValue(null, "text")).toBeUndefined();
14
+ expect(deserializeValue(null, "number")).toBeUndefined();
15
+ });
16
+
17
+ test("invalid JSON throws (never returns a half-coerced value)", () => {
18
+ expect(() => deserializeValue("{not json", "text")).toThrow();
19
+ expect(() => deserializeValue("", "number")).toThrow();
20
+ });
21
+
22
+ describe("number", () => {
23
+ test("a JSON number passes through verbatim", () => {
24
+ expect(deserializeValue("42", "number")).toBe(42);
25
+ expect(deserializeValue("3.14", "number")).toBe(3.14);
26
+ expect(deserializeValue("0", "number")).toBe(0);
27
+ expect(deserializeValue("-5", "number")).toBe(-5);
28
+ });
29
+
30
+ test("a stringified number is coerced via Number() — not rejected", () => {
31
+ expect(deserializeValue('"42"', "number")).toBe(42);
32
+ expect(deserializeValue("true", "number")).toBe(1);
33
+ });
34
+
35
+ test("an uncoercible value yields NaN rather than throwing", () => {
36
+ expect(deserializeValue('"abc"', "number")).toBeNaN();
37
+ });
38
+ });
39
+
40
+ describe("boolean", () => {
41
+ test("a JSON boolean passes through verbatim", () => {
42
+ expect(deserializeValue("true", "boolean")).toBe(true);
43
+ expect(deserializeValue("false", "boolean")).toBe(false);
44
+ });
45
+
46
+ test('only the string "true" coerces truthy — every other non-boolean is false', () => {
47
+ expect(deserializeValue('"true"', "boolean")).toBe(true);
48
+ expect(deserializeValue('"false"', "boolean")).toBe(false);
49
+ expect(deserializeValue('"1"', "boolean")).toBe(false);
50
+ expect(deserializeValue("1", "boolean")).toBe(false);
51
+ expect(deserializeValue("0", "boolean")).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe("text / select", () => {
56
+ test("a JSON string passes through verbatim", () => {
57
+ expect(deserializeValue('"hello"', "text")).toBe("hello");
58
+ expect(deserializeValue('"hello"', "select")).toBe("hello");
59
+ });
60
+
61
+ test("non-string JSON is stringified via String()", () => {
62
+ expect(deserializeValue("42", "text")).toBe("42");
63
+ expect(deserializeValue("true", "select")).toBe("true");
64
+ expect(deserializeValue("null", "text")).toBe("null");
65
+ });
66
+ });
67
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import { parseRetentionOverrideOrNull } from "../_internal/parse-override";
3
+
4
+ // parseRetentionOverrideOrNull is the read boundary for a tenant's stored
5
+ // data-retention override (DSGVO-relevant policy in a config column). It must
6
+ // never let a corrupt or schema-violating value reach the retention decision:
7
+ // invalid JSON and schema drift both collapse to null (the resolver then falls
8
+ // back to preset/entity defaults) AND surface one operator warning. The schema
9
+ // itself is covered by override-schema.test.ts — this pins the parser's
10
+ // defensive wrapping: empty guard, no-throw on corruption, drop-not-leak on drift.
11
+
12
+ const parse = (raw: string | null) => parseRetentionOverrideOrNull(raw, "tenant-1", "test");
13
+
14
+ describe("parseRetentionOverrideOrNull", () => {
15
+ test("null / empty / whitespace-only raw → null before any parse", () => {
16
+ expect(parse(null)).toBeNull();
17
+ expect(parse("")).toBeNull();
18
+ expect(parse(" ")).toBeNull();
19
+ });
20
+
21
+ test("a valid override returns the parsed, schema-checked object", () => {
22
+ expect(parse('{"keepFor":"30d","strategy":"hardDelete","reference":"completedAt"}')).toEqual({
23
+ keepFor: "30d",
24
+ strategy: "hardDelete",
25
+ reference: "completedAt",
26
+ });
27
+ });
28
+
29
+ test("empty object is a valid override (every field optional)", () => {
30
+ expect(parse("{}")).toEqual({});
31
+ });
32
+
33
+ test("corrupt JSON returns null without throwing", () => {
34
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
35
+ expect(() => parse("{not json")).not.toThrow();
36
+ expect(parse("{not json")).toBeNull();
37
+ warn.mockRestore();
38
+ });
39
+
40
+ test("JSON that parses but violates the schema is dropped to null — never leaked through", () => {
41
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
42
+ expect(parse('{"strategy":"delete"}')).toBeNull(); // enum drift
43
+ expect(parse('{"keepFor":"30days"}')).toBeNull(); // keepFor format drift
44
+ expect(parse('{"keepFor":42}')).toBeNull(); // wrong type
45
+ expect(parse('{"unknownKey":1}')).toBeNull(); // strict() rejects extra keys
46
+ warn.mockRestore();
47
+ });
48
+
49
+ test("each dropped value surfaces exactly one operator warning", () => {
50
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
51
+ parse("{not json");
52
+ parse('{"strategy":"delete"}');
53
+ expect(warn).toHaveBeenCalledTimes(2);
54
+ warn.mockRestore();
55
+ });
56
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type BrandingTokens, EMPTY_BRANDING } from "../../page-render";
3
+ import { coerceBranding } from "../branding";
4
+
5
+ // coerceBranding is the IO boundary for the branding query's wire response:
6
+ // untrusted `unknown` → BrandingTokens with no `as` cast. Every missing or
7
+ // non-string field must collapse to "" so a malformed/empty response renders
8
+ // the unbranded default rather than throwing — and an attacker-controlled
9
+ // non-string (e.g. a logoUrl object) can never leak through as a live render
10
+ // token. Exercised only indirectly by the integration path before this.
11
+
12
+ describe("coerceBranding", () => {
13
+ const FULL: BrandingTokens = {
14
+ title: "Acme",
15
+ description: "We make things",
16
+ siteUrl: "https://acme.test",
17
+ accentColor: "#abcdef",
18
+ logoUrl: "https://acme.test/logo.png",
19
+ layoutPreset: "wide",
20
+ customCss: ":root{--brand:1}",
21
+ };
22
+
23
+ test("passes a fully-populated response through verbatim", () => {
24
+ expect(coerceBranding({ ...FULL })).toEqual(FULL);
25
+ });
26
+
27
+ test("null / undefined / primitives collapse to EMPTY_BRANDING", () => {
28
+ for (const bad of [null, undefined, "string", 42, true, Symbol("x")]) {
29
+ expect(coerceBranding(bad)).toEqual(EMPTY_BRANDING);
30
+ }
31
+ });
32
+
33
+ test("empty object yields the all-empty token set", () => {
34
+ expect(coerceBranding({})).toEqual(EMPTY_BRANDING);
35
+ });
36
+
37
+ test("missing fields fall back to '' (partial response)", () => {
38
+ expect(coerceBranding({ title: "Acme", logoUrl: "https://acme.test/l.png" })).toEqual({
39
+ ...EMPTY_BRANDING,
40
+ title: "Acme",
41
+ logoUrl: "https://acme.test/l.png",
42
+ });
43
+ });
44
+
45
+ test("non-string field values are dropped to '' — never stringified or leaked", () => {
46
+ const hostile = {
47
+ title: 123,
48
+ description: null,
49
+ siteUrl: { toString: () => "https://evil.test" },
50
+ accentColor: ["#fff"],
51
+ logoUrl: true,
52
+ layoutPreset: undefined,
53
+ customCss: { malicious: "body{}" },
54
+ };
55
+ expect(coerceBranding(hostile)).toEqual(EMPTY_BRANDING);
56
+ });
57
+
58
+ test("one hostile non-string field does not poison its valid siblings", () => {
59
+ const result = coerceBranding({ ...FULL, logoUrl: { href: "javascript:alert(1)" } });
60
+ expect(result.logoUrl).toBe("");
61
+ expect(result.title).toBe("Acme");
62
+ expect(result.siteUrl).toBe("https://acme.test");
63
+ });
64
+
65
+ test("unknown extra keys are ignored — only the known tokens are extracted", () => {
66
+ const result = coerceBranding({ ...FULL, evil: "<script>", extra: 1 });
67
+ expect(result).toEqual(FULL);
68
+ expect(Object.keys(result).sort()).toEqual(Object.keys(EMPTY_BRANDING).sort());
69
+ });
70
+
71
+ test("inherited (non-own) properties are not picked up", () => {
72
+ const withInheritedTitle = Object.create({ title: "from-prototype" });
73
+ expect(coerceBranding(withInheritedTitle)).toEqual(EMPTY_BRANDING);
74
+ });
75
+
76
+ test("array input is treated as a fieldless object → all-empty tokens", () => {
77
+ expect(coerceBranding(["title", "x"])).toEqual(EMPTY_BRANDING);
78
+ });
79
+ });
@@ -168,7 +168,7 @@ export function TagSection({
168
168
  };
169
169
 
170
170
  return (
171
- <div data-testid="tags-section">
171
+ <div data-testid="tags-section" className="flex flex-col gap-4">
172
172
  <Field id="tags-section-select" label={t("tags.section.label")}>
173
173
  <Input
174
174
  kind="combobox"
@@ -184,26 +184,32 @@ export function TagSection({
184
184
  />
185
185
  </Field>
186
186
 
187
- {/* ponytail: separate create row the shared combobox has no create-on-type
188
- affordance. Fold create into the dropdown's Command.Empty if/when the
189
- renderer-web combobox grows a freeSolo/onCreate prop. */}
190
- <Field id="tags-section-new" label={t("tags.section.newLabel")}>
191
- <Input
192
- kind="text"
193
- id="tags-section-new"
194
- name="newTag"
195
- value={newName}
196
- onChange={setNewName}
197
- />
198
- </Field>
199
- <Button
200
- variant="secondary"
201
- disabled={busy || newName.trim() === ""}
202
- onClick={() => createAndAssign()}
203
- testId="tags-section-create"
204
- >
205
- {busy ? t("tags.section.working") : t("tags.section.create")}
206
- </Button>
187
+ {/* Inline create-row: das Label-Input wächst, der Add-Button sitzt
188
+ rechts daneben (items-end bündig zur Input-Unterkante).
189
+ ponytail: separate row, weil die Combobox keine create-on-type-
190
+ Affordance hat. Fold-in, wenn der renderer-web-Combobox ein
191
+ freeSolo/onCreate-Prop bekommt. */}
192
+ <div className="flex items-end gap-2">
193
+ <div className="flex-1">
194
+ <Field id="tags-section-new" label={t("tags.section.newLabel")}>
195
+ <Input
196
+ kind="text"
197
+ id="tags-section-new"
198
+ name="newTag"
199
+ value={newName}
200
+ onChange={setNewName}
201
+ />
202
+ </Field>
203
+ </div>
204
+ <Button
205
+ variant="secondary"
206
+ disabled={busy || newName.trim() === ""}
207
+ onClick={() => createAndAssign()}
208
+ testId="tags-section-create"
209
+ >
210
+ {busy ? t("tags.section.working") : t("tags.section.create")}
211
+ </Button>
212
+ </div>
207
213
 
208
214
  {errorKey !== null && (
209
215
  <Banner variant="error" testId="tags-section-action-error">
@@ -9,6 +9,7 @@ import { createTemplateResolverApi, TemplateNotFoundError } from "../api";
9
9
  import { createTemplateResolverFeature } from "../feature";
10
10
  import { templateResourceEntity } from "../table";
11
11
  import {
12
+ assertConsumerHandlesMissingResourceKeys,
12
13
  assertConsumerHandlesNotFound,
13
14
  runTemplateConsumerConformance,
14
15
  type TemplateConsumer,
@@ -62,6 +63,24 @@ describe("template-resolver :: conformance harness", () => {
62
63
  ).rejects.toThrow("expected TemplateNotFoundError, received Error");
63
64
  });
64
65
 
66
+ // 446#1: a consumer whose resolveResources throws a non-TypeError used to
67
+ // fall through the catch and pass — the assertion was effectively a no-op.
68
+ test("harness detects a consumer that throws (non-TypeError) on missing resource keys", async () => {
69
+ const badConsumer: TemplateConsumer = {
70
+ resolve: (args) => createTemplateResolverApi(db).resolveTemplate(args),
71
+ resolveResources: async () => {
72
+ throw new Error("blew up on a missing key instead of degrading");
73
+ },
74
+ };
75
+
76
+ await expect(
77
+ assertConsumerHandlesMissingResourceKeys(badConsumer, {
78
+ getDb: () => db,
79
+ tenantId: TENANT_A,
80
+ }),
81
+ ).rejects.toThrow("threw unexpectedly");
82
+ });
83
+
65
84
  test("conformant consumer propagates TemplateNotFoundError", async () => {
66
85
  const apiConsumer: TemplateConsumer = {
67
86
  resolve: (args) => createTemplateResolverApi(db).resolveTemplate(args),
@@ -169,6 +169,12 @@ export async function assertConsumerHandlesMissingResourceKeys(
169
169
  `resolveResources threw TypeError (unhandled missing key?): ${err.message}`,
170
170
  );
171
171
  }
172
+ // Any other throw means the consumer did not handle missing keys
173
+ // gracefully. Falling through here silently passed the assertion — a
174
+ // broken consumer looked conformant.
175
+ throw new ConformanceAssertionError(
176
+ `resolveResources threw unexpectedly (should handle missing keys gracefully): ${err.message}`,
177
+ );
172
178
  }
173
179
  }
174
180
 
@@ -220,8 +220,9 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
220
220
  // Bestand wieder in den divergenten Live-State bringen (der Rebuild hat ihn
221
221
  // auf Active gesetzt) und den Reconcile laufen lassen.
222
222
  await updateMany(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: created.id });
223
- const backfilled = await backfillUserLifecycleEvents(stack.db);
223
+ const { backfilled, failed } = await backfillUserLifecycleEvents(stack.db);
224
224
  expect(backfilled).toBeGreaterThanOrEqual(1);
225
+ expect(failed).toEqual([]);
225
226
 
226
227
  // Jetzt traegt das Event-Log den State -> Rebuild bewahrt ihn.
227
228
  await rebuildProjection(USER_PROJECTION, { db: stack.db, registry });
@@ -73,7 +73,12 @@ export async function updateUserLifecycle(
73
73
  // user.updated an — harmlos, last-write-wins beim Replay).
74
74
  // ponytail: full read_users-Scan, in JS gefiltert — einmalige Migration, kein
75
75
  // Index/Streaming noetig. Bei Millionen-Rows: batchen.
76
- export async function backfillUserLifecycleEvents(conn: DbRunner): Promise<number> {
76
+ export type BackfillResult = {
77
+ readonly backfilled: number;
78
+ readonly failed: ReadonlyArray<{ readonly id: string; readonly error: string }>;
79
+ };
80
+
81
+ export async function backfillUserLifecycleEvents(conn: DbRunner): Promise<BackfillResult> {
77
82
  const rows = (await selectMany(conn, userTable, {})) as Array<{
78
83
  id: string;
79
84
  status: string;
@@ -82,6 +87,7 @@ export async function backfillUserLifecycleEvents(conn: DbRunner): Promise<numbe
82
87
  }>;
83
88
 
84
89
  let backfilled = 0;
90
+ const failed: Array<{ id: string; error: string }> = [];
85
91
  for (const row of rows) {
86
92
  const divergent =
87
93
  row.status !== USER_STATUS.Active ||
@@ -89,12 +95,19 @@ export async function backfillUserLifecycleEvents(conn: DbRunner): Promise<numbe
89
95
  row.pendingDeletionRequestId != null;
90
96
  if (!divergent) continue;
91
97
 
92
- await updateUserLifecycle(conn, row.id, {
93
- status: row.status,
94
- gracePeriodEnd: row.gracePeriodEnd,
95
- pendingDeletionRequestId: row.pendingDeletionRequestId,
96
- });
97
- backfilled++;
98
+ // One bad row must not abort the run: the rows after it would then never
99
+ // get their user.updated event and stay vulnerable to the rebuild wipe
100
+ // (DSGVO-Datenverlust). Collect failures, finish the estate, report them.
101
+ try {
102
+ await updateUserLifecycle(conn, row.id, {
103
+ status: row.status,
104
+ gracePeriodEnd: row.gracePeriodEnd,
105
+ pendingDeletionRequestId: row.pendingDeletionRequestId,
106
+ });
107
+ backfilled++;
108
+ } catch (e) {
109
+ failed.push({ id: row.id, error: e instanceof Error ? e.message : String(e) });
110
+ }
98
111
  }
99
- return backfilled;
112
+ return { backfilled, failed };
100
113
  }
@@ -121,6 +121,9 @@ function ExportSection(): ReactNode {
121
121
  <p className="text-sm text-muted-foreground">
122
122
  {t("userDataRights.privacyCenter.export.intro")}
123
123
  </p>
124
+ {statusQuery.error && (
125
+ <Banner variant="error">{t("userDataRights.privacyCenter.errors.generic")}</Banner>
126
+ )}
124
127
  {inProgress && (
125
128
  <Banner variant="info" testId="privacy-export-pending">
126
129
  {t("userDataRights.privacyCenter.export.pending")}
@@ -189,7 +192,7 @@ function AuditSection(): ReactNode {
189
192
  {logQuery.error && (
190
193
  <Banner variant="error">{t("userDataRights.privacyCenter.errors.generic")}</Banner>
191
194
  )}
192
- {rows.length === 0 ? (
195
+ {logQuery.error ? null : rows.length === 0 ? (
193
196
  <p className="text-sm text-muted-foreground" data-testid="privacy-audit-empty">
194
197
  {t("userDataRights.privacyCenter.audit.empty")}
195
198
  </p>
@@ -80,6 +80,12 @@ describe("ProfileScreen", () => {
80
80
  expect(view.getByTestId("profile-danger-delete")).toBeTruthy();
81
81
  // Echte i18n: kein einziger roher Key im sichtbaren Text.
82
82
  expect(view.container.textContent).not.toContain("profile.");
83
+ // Card-Standard: jede Konto-Section ist GENAU eine Card (self + descendants)
84
+ // — nicht mehr das alte <section bg-card> um eine Form-Card = doppelt.
85
+ const cardCount = (el: Element): number =>
86
+ (el.matches(".bg-card") ? 1 : 0) + el.querySelectorAll(".bg-card").length;
87
+ expect(cardCount(view.getByTestId("profile-email"))).toBe(1);
88
+ expect(cardCount(view.getByTestId("profile-password"))).toBe(1);
83
89
  });
84
90
 
85
91
  test("deletionRequested: Frist-Banner + Abbrechen statt Lösch-Button", async () => {
@@ -113,7 +119,7 @@ describe("ProfileScreen", () => {
113
119
  try {
114
120
  const view = renderProfile(activeMe);
115
121
  await waitFor(() => {
116
- if (view.queryByTestId("profile-email-form") === null) throw new Error("not mounted yet");
122
+ if (view.queryByTestId("profile-email") === null) throw new Error("not mounted yet");
117
123
  });
118
124
 
119
125
  const emailInput = view.container.querySelector<HTMLInputElement>("#profile-new-email");
@@ -121,7 +127,7 @@ describe("ProfileScreen", () => {
121
127
  if (!emailInput || !pwInput) throw new Error("email form inputs not found");
122
128
  fireEvent.change(emailInput, { target: { value: "new@example.com" } });
123
129
  fireEvent.change(pwInput, { target: { value: "current-pw" } });
124
- fireEvent.submit(view.getByTestId("profile-email-form"));
130
+ fireEvent.click(view.getByTestId("profile-email-submit"));
125
131
 
126
132
  // De-Swallow: der fehlgeschlagene Verification-Versand wird geloggt.
127
133
  await waitFor(() => {
@@ -12,7 +12,7 @@ import {
12
12
  useQuery,
13
13
  useTranslation,
14
14
  } from "@cosmicdrift/kumiko-renderer";
15
- import { type FormEvent, type ReactNode, useState } from "react";
15
+ import { type ReactNode, useState } from "react";
16
16
  import { AuthHandlers } from "../../auth-email-password/constants";
17
17
  import { requestEmailVerification } from "../../auth-email-password/web";
18
18
  import { UserDataRightsHandlers, UserProfileHandlers, UserProfileQueries } from "../constants";
@@ -61,15 +61,14 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
61
61
 
62
62
  function ChangePasswordSection(): ReactNode {
63
63
  const t = useTranslation();
64
- const { Form, Field, Input, Button, Heading } = usePrimitives();
64
+ const { Section, Field, Input, Button } = usePrimitives();
65
65
  const dispatcher = useDispatcher();
66
66
  const [oldPassword, setOldPassword] = useState("");
67
67
  const [newPassword, setNewPassword] = useState("");
68
68
  const [confirm, setConfirm] = useState("");
69
69
  const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
70
70
 
71
- const onSubmit = (e?: FormEvent): void => {
72
- e?.preventDefault();
71
+ const onSubmit = (): void => {
73
72
  void (async () => {
74
73
  if (newPassword !== confirm) {
75
74
  setStatus({ kind: "error", messageKey: "profile.password.mismatch" });
@@ -93,54 +92,53 @@ function ChangePasswordSection(): ReactNode {
93
92
 
94
93
  const submitting = status.kind === "submitting";
95
94
  return (
96
- <section
97
- data-testid="profile-password"
98
- className="flex flex-col gap-4 rounded-lg border bg-card p-6"
99
- >
100
- <Heading variant="section">{t("profile.password.title")}</Heading>
101
- <Form onSubmit={onSubmit} testId="profile-password-form">
102
- <Field id="profile-old-password" label={t("profile.password.old")} required>
103
- <Input
104
- kind="password"
105
- id="profile-old-password"
106
- name="profile-old-password"
107
- value={oldPassword}
108
- onChange={setOldPassword}
109
- disabled={submitting}
110
- required
111
- autoComplete="current-password"
112
- />
113
- </Field>
114
- <Field id="profile-new-password" label={t("profile.password.new")} required>
115
- <Input
116
- kind="password"
117
- id="profile-new-password"
118
- name="profile-new-password"
119
- value={newPassword}
120
- onChange={setNewPassword}
121
- disabled={submitting}
122
- required
123
- autoComplete="new-password"
124
- />
125
- </Field>
126
- <Field id="profile-confirm-password" label={t("profile.password.confirm")} required>
127
- <Input
128
- kind="password"
129
- id="profile-confirm-password"
130
- name="profile-confirm-password"
131
- value={confirm}
132
- onChange={setConfirm}
133
- disabled={submitting}
134
- required
135
- autoComplete="new-password"
136
- />
137
- </Field>
138
- <StatusBanner status={status} />
139
- <Button type="submit" disabled={submitting} testId="profile-password-submit">
95
+ <Section
96
+ testId="profile-password"
97
+ title={t("profile.password.title")}
98
+ actions={
99
+ <Button onClick={() => onSubmit()} disabled={submitting} testId="profile-password-submit">
140
100
  {t("profile.password.submit")}
141
101
  </Button>
142
- </Form>
143
- </section>
102
+ }
103
+ >
104
+ <Field id="profile-old-password" label={t("profile.password.old")} required>
105
+ <Input
106
+ kind="password"
107
+ id="profile-old-password"
108
+ name="profile-old-password"
109
+ value={oldPassword}
110
+ onChange={setOldPassword}
111
+ disabled={submitting}
112
+ required
113
+ autoComplete="current-password"
114
+ />
115
+ </Field>
116
+ <Field id="profile-new-password" label={t("profile.password.new")} required>
117
+ <Input
118
+ kind="password"
119
+ id="profile-new-password"
120
+ name="profile-new-password"
121
+ value={newPassword}
122
+ onChange={setNewPassword}
123
+ disabled={submitting}
124
+ required
125
+ autoComplete="new-password"
126
+ />
127
+ </Field>
128
+ <Field id="profile-confirm-password" label={t("profile.password.confirm")} required>
129
+ <Input
130
+ kind="password"
131
+ id="profile-confirm-password"
132
+ name="profile-confirm-password"
133
+ value={confirm}
134
+ onChange={setConfirm}
135
+ disabled={submitting}
136
+ required
137
+ autoComplete="new-password"
138
+ />
139
+ </Field>
140
+ <StatusBanner status={status} />
141
+ </Section>
144
142
  );
145
143
  }
146
144
 
@@ -152,14 +150,13 @@ function ChangeEmailSection({
152
150
  readonly onChanged: () => void;
153
151
  }): ReactNode {
154
152
  const t = useTranslation();
155
- const { Form, Field, Input, Button, Heading } = usePrimitives();
153
+ const { Section, Field, Input, Button } = usePrimitives();
156
154
  const dispatcher = useDispatcher();
157
155
  const [newEmail, setNewEmail] = useState("");
158
156
  const [currentPassword, setCurrentPassword] = useState("");
159
157
  const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
160
158
 
161
- const onSubmit = (e?: FormEvent): void => {
162
- e?.preventDefault();
159
+ const onSubmit = (): void => {
163
160
  void (async () => {
164
161
  setStatus({ kind: "submitting" });
165
162
  const res = await dispatcher.write(UserProfileHandlers.changeEmail, {
@@ -197,45 +194,46 @@ function ChangeEmailSection({
197
194
 
198
195
  const submitting = status.kind === "submitting";
199
196
  return (
200
- <section
201
- data-testid="profile-email"
202
- className="flex flex-col gap-4 rounded-lg border bg-card p-6"
203
- >
204
- <Heading variant="section">{t("profile.email.title")}</Heading>
205
- <p className="text-sm text-muted-foreground" data-testid="profile-email-current">
206
- {t("profile.email.current")}: {me.email}
207
- </p>
208
- <Form onSubmit={onSubmit} testId="profile-email-form">
209
- <Field id="profile-new-email" label={t("profile.email.new")} required>
210
- <Input
211
- kind="email"
212
- id="profile-new-email"
213
- name="profile-new-email"
214
- value={newEmail}
215
- onChange={setNewEmail}
216
- disabled={submitting}
217
- required
218
- autoComplete="email"
219
- />
220
- </Field>
221
- <Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
222
- <Input
223
- kind="password"
224
- id="profile-email-password"
225
- name="profile-email-password"
226
- value={currentPassword}
227
- onChange={setCurrentPassword}
228
- disabled={submitting}
229
- required
230
- autoComplete="current-password"
231
- />
232
- </Field>
233
- <StatusBanner status={status} />
234
- <Button type="submit" disabled={submitting} testId="profile-email-submit">
197
+ <Section
198
+ testId="profile-email"
199
+ title={t("profile.email.title")}
200
+ subtitle={
201
+ <span data-testid="profile-email-current">
202
+ {t("profile.email.current")}: {me.email}
203
+ </span>
204
+ }
205
+ actions={
206
+ <Button onClick={() => onSubmit()} disabled={submitting} testId="profile-email-submit">
235
207
  {t("profile.email.submit")}
236
208
  </Button>
237
- </Form>
238
- </section>
209
+ }
210
+ >
211
+ <Field id="profile-new-email" label={t("profile.email.new")} required>
212
+ <Input
213
+ kind="email"
214
+ id="profile-new-email"
215
+ name="profile-new-email"
216
+ value={newEmail}
217
+ onChange={setNewEmail}
218
+ disabled={submitting}
219
+ required
220
+ autoComplete="email"
221
+ />
222
+ </Field>
223
+ <Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
224
+ <Input
225
+ kind="password"
226
+ id="profile-email-password"
227
+ name="profile-email-password"
228
+ value={currentPassword}
229
+ onChange={setCurrentPassword}
230
+ disabled={submitting}
231
+ required
232
+ autoComplete="current-password"
233
+ />
234
+ </Field>
235
+ <StatusBanner status={status} />
236
+ </Section>
239
237
  );
240
238
  }
241
239
 
@@ -349,10 +347,14 @@ export function ProfileScreen(): ReactNode {
349
347
  };
350
348
 
351
349
  return (
352
- <div className="p-6 flex flex-col gap-6 max-w-2xl" data-testid="profile-screen">
350
+ <div className="flex max-w-5xl flex-col gap-6 p-6" data-testid="profile-screen">
353
351
  <Heading variant="page">{t("profile.title")}</Heading>
354
- <ChangeEmailSection me={me} onChanged={refetch} />
355
- <ChangePasswordSection />
352
+ {/* Die zwei kurzen Konto-Forms teilen sich eine Reihe (md+); die
353
+ Danger-Zone bleibt volle Breite darunter. */}
354
+ <div className="grid items-start gap-6 md:grid-cols-2">
355
+ <ChangeEmailSection me={me} onChanged={refetch} />
356
+ <ChangePasswordSection />
357
+ </div>
356
358
  <DangerZoneSection me={me} onChanged={refetch} />
357
359
  </div>
358
360
  );