@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.
Files changed (66) hide show
  1. package/package.json +9 -9
  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/config/__tests__/config.integration.test.ts +31 -0
  17. package/src/config/handlers/readiness.query.ts +8 -1
  18. package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
  19. package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
  20. package/src/jobs/changes.json +7 -0
  21. package/src/jobs/feature.ts +101 -9
  22. package/src/jobs/handlers/list.query.ts +21 -4
  23. package/src/jobs/handlers/trigger.write.ts +16 -3
  24. package/src/jobs/i18n.ts +3 -25
  25. package/src/jobs/index.ts +1 -0
  26. package/src/jobs/web/client-plugin.tsx +1 -7
  27. package/src/jobs/web/index.ts +0 -2
  28. package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
  29. package/src/tenant/handlers/list.query.ts +1 -0
  30. package/src/tier-engine/__tests__/drift.test.ts +1 -1
  31. package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
  32. package/src/tier-engine/changes.json +9 -1
  33. package/src/tier-engine/constants.ts +5 -5
  34. package/src/tier-engine/feature.ts +36 -10
  35. package/src/tier-engine/i18n.ts +19 -22
  36. package/src/tier-engine/web/client-plugin.tsx +5 -11
  37. package/src/tier-engine/web/index.ts +0 -1
  38. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
  39. package/src/user-data-rights/changes.json +7 -0
  40. package/src/user-data-rights/constants.ts +20 -6
  41. package/src/user-data-rights/feature.ts +113 -14
  42. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
  43. package/src/user-data-rights/web/client-plugin.tsx +19 -21
  44. package/src/user-data-rights/web/i18n.ts +6 -11
  45. package/src/user-data-rights/web/index.ts +1 -1
  46. package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
  47. package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
  48. package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
  49. package/src/user-profile/changes.json +7 -0
  50. package/src/user-profile/constants.ts +15 -7
  51. package/src/user-profile/feature.ts +96 -9
  52. package/src/user-profile/i18n.ts +8 -12
  53. package/src/user-profile/web/client-plugin.ts +22 -5
  54. package/src/user-profile/web/index.ts +1 -1
  55. package/src/user-profile/web/profile-screen.tsx +122 -265
  56. package/src/admin-shell/web/overview-layout.tsx +0 -69
  57. package/src/admin-shell/web/overview-query.ts +0 -15
  58. package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
  59. package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
  60. package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
  61. package/src/audit/web/audit-log-detail-screen.tsx +0 -169
  62. package/src/audit/web/audit-log-screen.tsx +0 -290
  63. package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
  64. package/src/jobs/web/job-run-detail-screen.tsx +0 -181
  65. package/src/jobs/web/job-runs-screen.tsx +0 -290
  66. package/src/tier-engine/web/tier-admin-screen.tsx +0 -168
@@ -1,17 +1,16 @@
1
1
  // @runtime client
2
- // Default-Bundles für den ProfileScreen. Werden vom userProfileClient()
3
- // als Fallback-Bundle in den LocaleProvider gehängt — Apps überschreiben
4
- // einzelne Keys via `userProfileClient({ translations })`.
5
- // `auth.errors.invalidCredentials` + `user.errors.emailAlreadyExists`
6
- // sind hier gedoppelt, damit der Screen auch ohne die jeweiligen
7
- // Feature-Bundles vollständig übersetzt.
2
+ // Default bundles for the `profile` screen. userProfileClient() hangs this
3
+ // in the LocaleProvider as a fallback bundle — apps override individual keys
4
+ // via `userProfileClient({ translations })`.
5
+ // `auth.errors.invalidCredentials` + `user.errors.emailAlreadyExists` are
6
+ // duplicated here so the screen stays fully translated even without those
7
+ // features' own bundles.
8
8
 
9
9
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
10
10
 
11
11
  export const defaultTranslations: TranslationsByLocale = {
12
12
  en: {
13
- "profile.title": "Profile",
14
- "profile.loading": "Loading…",
13
+ "screen:profile.title": "Profile",
15
14
 
16
15
  "profile.email.title": "Email address",
17
16
  "profile.email.current": "Current email",
@@ -32,13 +31,10 @@ export const defaultTranslations: TranslationsByLocale = {
32
31
  "profile.danger.explainer":
33
32
  "Your account will be permanently deleted after a grace period. Until then you can cancel the deletion at any time.",
34
33
  "profile.danger.delete": "Delete account",
35
- "profile.danger.dialogTitle": "Really delete your account?",
36
34
  "profile.danger.dialogDescription":
37
35
  "After the grace period your data will be permanently deleted. Until then you can cancel.",
38
- "profile.danger.requested":
39
- "Deletion requested — your account will be permanently deleted on {date}.",
36
+ "profile.danger.gracePeriodEnd": "Deletion date",
40
37
  "profile.danger.cancelDeletion": "Cancel deletion",
41
- "profile.danger.cancelSuccess": "Deletion cancelled. Your account stays.",
42
38
 
43
39
  "profile.errors.generic": "Something went wrong.",
44
40
  "profile.errors.emailUnchanged": "That is already your email address.",
@@ -1,10 +1,19 @@
1
1
  // @runtime client
2
- import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
2
+ import {
3
+ type ExtensionSectionComponent,
4
+ mergeTranslations,
5
+ type TranslationsByLocale,
6
+ } from "@cosmicdrift/kumiko-renderer";
3
7
  import type { ComponentType, ReactNode } from "react";
8
+ import {
9
+ CHANGE_EMAIL_SECTION_EXTENSION_NAME,
10
+ CHANGE_PASSWORD_SECTION_EXTENSION_NAME,
11
+ } from "../constants";
4
12
  import { defaultTranslations } from "../i18n";
13
+ import { ChangeEmailSection, ChangePasswordSection } from "./profile-screen";
5
14
 
6
15
  export type UserProfileClientOptions = {
7
- /** Key-weise Overrides über die Default-Bundles (de/en). */
16
+ /** Per-key overrides on top of the default bundles (de/en). */
8
17
  readonly translations?: TranslationsByLocale;
9
18
  };
10
19
 
@@ -13,16 +22,24 @@ export type UserProfileClientFeature = {
13
22
  readonly providers: readonly ComponentType<{ children: ReactNode }>[];
14
23
  readonly gates: readonly ComponentType<{ children: ReactNode }>[];
15
24
  readonly translations: TranslationsByLocale;
25
+ readonly extensionSectionComponents: Readonly<Record<string, ExtensionSectionComponent>>;
16
26
  };
17
27
 
18
- // Liefert nur Translations — der ProfileScreen selbst wird von der App
19
- // als custom-Screen registriert (__component: "UserProfileScreen"),
20
- // damit Nav-Platzierung + Access bei der App bleiben.
28
+ // Supplies the ChangeEmail/ChangePassword extension components for the
29
+ // declarative `profile` projectionDetail screen (fw#2312, ../feature.ts,
30
+ // registered there server-side) plus the default translations. Apps add it
31
+ // via createKumikoApp({ clientFeatures: [userProfileClient()] }) and place
32
+ // the screen (id "profile") via r.nav — the screen itself has no r.nav of
33
+ // its own, same as user-data-rights' privacy-center.
21
34
  export function userProfileClient(options?: UserProfileClientOptions): UserProfileClientFeature {
22
35
  return {
23
36
  name: "user-profile",
24
37
  providers: [],
25
38
  gates: [],
26
39
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
40
+ extensionSectionComponents: {
41
+ [CHANGE_EMAIL_SECTION_EXTENSION_NAME]: ChangeEmailSection,
42
+ [CHANGE_PASSWORD_SECTION_EXTENSION_NAME]: ChangePasswordSection,
43
+ },
27
44
  };
28
45
  }
@@ -3,4 +3,4 @@ export {
3
3
  type UserProfileClientOptions,
4
4
  userProfileClient,
5
5
  } from "./client-plugin";
6
- export { ProfileScreen } from "./profile-screen";
6
+ export { ChangeEmailSection, ChangePasswordSection } from "./profile-screen";
@@ -1,29 +1,23 @@
1
1
  // @runtime client
2
- // ProfileScreen — Self-Service-Kontoseite: Passwort ändern, E-Mail
3
- // ändern (mit Re-Auth + anschließendem Verification-Mail-Trigger),
4
- // Konto löschen / Löschung abbrechen (user-data-rights Grace-Period).
5
- // Apps registrieren die Komponente als custom-Screen:
6
- // r.screen({ id: "profile", type: "custom",
7
- // renderer: { react: { __component: "UserProfileScreen" } } })
2
+ // Change-password and change-email sections for the declarative `profile`
3
+ // projectionDetail screen (fw#2312, ../feature.ts). Both keep re-auth flows
4
+ // a declarative action can't express, so they stay React `EditExtensionSection`
5
+ // components; account deletion moved to declarative fields/actions on the
6
+ // screen itself — same split as user-data-rights' privacy-center
7
+ // (../../user-data-rights/web/privacy-center-screen.tsx), which this file
8
+ // mirrors.
8
9
 
9
10
  import {
11
+ type ExtensionSectionProps,
10
12
  useDispatcher,
11
13
  usePrimitives,
12
- useQuery,
13
14
  useTranslation,
14
15
  } from "@cosmicdrift/kumiko-renderer";
15
- import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
16
+ import { BareFormProvider } from "@cosmicdrift/kumiko-renderer-web";
16
17
  import { type ReactNode, useState } from "react";
17
18
  import { AuthHandlers } from "../../auth-email-password/constants";
18
19
  import { requestEmailVerification } from "../../auth-email-password/web";
19
- import { UserDataRightsHandlers, UserProfileHandlers, UserProfileQueries } from "../constants";
20
-
21
- type MeRow = {
22
- readonly id: string;
23
- readonly email: string;
24
- readonly status?: string;
25
- readonly gracePeriodEnd?: string | null;
26
- };
20
+ import { UserProfileHandlers } from "../constants";
27
21
 
28
22
  type SectionStatus =
29
23
  | { kind: "idle" }
@@ -31,21 +25,13 @@ type SectionStatus =
31
25
  | { kind: "success"; messageKey: string }
32
26
  | { kind: "error"; messageKey: string };
33
27
 
34
- // Dispatcher-Failures tragen i18nKey nur wenn der Handler einen setzt —
35
- // Boundary-Read mit generischem Fallback.
28
+ // Dispatcher failures carry an i18nKey only when the handler sets one —
29
+ // boundary read with a generic fallback.
36
30
  function failureKey(error: unknown): string {
37
31
  const key = (error as { i18nKey?: unknown } | null)?.i18nKey; // @cast-boundary dispatcher-error
38
32
  return typeof key === "string" ? key : "profile.errors.generic";
39
33
  }
40
34
 
41
- // Date-only slice of the raw ISO instant — the time/Z part is noise to the user.
42
- // Pure string-slice: no Date API (no-date-api guard), universal (RN+Web). Empty → "—".
43
- export function formatDeletionDate(iso: string | null | undefined): string {
44
- if (!iso) return "—";
45
- const tIndex = iso.indexOf("T");
46
- return tIndex > 0 ? iso.slice(0, tIndex) : iso;
47
- }
48
-
49
35
  function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode {
50
36
  const t = useTranslation();
51
37
  const { Banner } = usePrimitives();
@@ -58,7 +44,12 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
58
44
  return null;
59
45
  }
60
46
 
61
- function ChangePasswordSection(): ReactNode {
47
+ // Mounted by the renderer's ExtensionSectionMount (already wrapped in
48
+ // <Section title={...} testId="section-extension-...">) — takes no props of
49
+ // its own. BareFormProvider keeps <Form> from rendering a second card/title
50
+ // inside that Section (same pattern as AuthCard, see
51
+ // auth-email-password/web/auth-form-primitives.tsx).
52
+ export function ChangePasswordSection(): ReactNode {
62
53
  const t = useTranslation();
63
54
  const { Form, Field, Input, Button } = usePrimitives();
64
55
  const dispatcher = useDispatcher();
@@ -91,71 +82,74 @@ function ChangePasswordSection(): ReactNode {
91
82
 
92
83
  const submitting = status.kind === "submitting";
93
84
  return (
94
- <Form
95
- testId="profile-password"
96
- title={t("profile.password.title")}
97
- onSubmit={onSubmit}
98
- actions={
99
- <Button type="submit" disabled={submitting} testId="profile-password-submit">
100
- {t("profile.password.submit")}
101
- </Button>
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
- </Form>
85
+ <BareFormProvider>
86
+ <Form
87
+ testId="profile-password"
88
+ onSubmit={onSubmit}
89
+ actions={
90
+ <Button type="submit" disabled={submitting} testId="profile-password-submit">
91
+ {t("profile.password.submit")}
92
+ </Button>
93
+ }
94
+ >
95
+ <Field id="profile-old-password" label={t("profile.password.old")} required>
96
+ <Input
97
+ kind="password"
98
+ id="profile-old-password"
99
+ name="profile-old-password"
100
+ value={oldPassword}
101
+ onChange={setOldPassword}
102
+ disabled={submitting}
103
+ required
104
+ autoComplete="current-password"
105
+ />
106
+ </Field>
107
+ <Field id="profile-new-password" label={t("profile.password.new")} required>
108
+ <Input
109
+ kind="password"
110
+ id="profile-new-password"
111
+ name="profile-new-password"
112
+ value={newPassword}
113
+ onChange={setNewPassword}
114
+ disabled={submitting}
115
+ required
116
+ autoComplete="new-password"
117
+ />
118
+ </Field>
119
+ <Field id="profile-confirm-password" label={t("profile.password.confirm")} required>
120
+ <Input
121
+ kind="password"
122
+ id="profile-confirm-password"
123
+ name="profile-confirm-password"
124
+ value={confirm}
125
+ onChange={setConfirm}
126
+ disabled={submitting}
127
+ required
128
+ autoComplete="new-password"
129
+ />
130
+ </Field>
131
+ <StatusBanner status={status} />
132
+ </Form>
133
+ </BareFormProvider>
142
134
  );
143
135
  }
144
136
 
145
- function ChangeEmailSection({
146
- me,
147
- onChanged,
148
- }: {
149
- readonly me: MeRow;
150
- readonly onChanged: () => void;
151
- }): ReactNode {
137
+ // Mounted by the renderer's ExtensionSectionMount analogous to
138
+ // ChangePasswordSection above. `values.email` is the host projectionDetail's
139
+ // current `me` row (seeded from the `user:query:user:me` query, ../feature.ts)
140
+ // — ExtensionSectionProps carries no refetch hook, so a successful change
141
+ // pushes the new address back into the host form via `patch` instead of
142
+ // relying on a full-screen refetch to un-stale the "current email" line.
143
+ export function ChangeEmailSection({ values, patch }: ExtensionSectionProps): ReactNode {
152
144
  const t = useTranslation();
153
- const { Form, Field, Input, Button } = usePrimitives();
145
+ const { Form, Field, Input, Button, Text } = usePrimitives();
154
146
  const dispatcher = useDispatcher();
155
147
  const [newEmail, setNewEmail] = useState("");
156
148
  const [currentPassword, setCurrentPassword] = useState("");
157
149
  const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
158
150
 
151
+ const currentEmail = typeof values?.["email"] === "string" ? values["email"] : "";
152
+
159
153
  const onSubmit = (): void => {
160
154
  void (async () => {
161
155
  setStatus({ kind: "submitting" });
@@ -167,10 +161,10 @@ function ChangeEmailSection({
167
161
  setStatus({ kind: "error", messageKey: failureKey(res.error) });
168
162
  return;
169
163
  }
170
- // Verification-Mail an die neue Adresse. Der Wechsel ist bereits
171
- // persistiert → ein Versand-Fehler darf den Erfolg nicht umkehren, wird
172
- // aber NICHT verschluckt (sonst wartet der User auf eine Mail, die nie
173
- // kommt). Die Success-Message verspricht entsprechend keinen Versand.
164
+ // Verification mail to the new address. The change is already
165
+ // persisted → a send failure must not reverse the success, but it is
166
+ // NOT swallowed (otherwise the user waits for a mail that never
167
+ // arrives). The success message accordingly promises no send.
174
168
  try {
175
169
  const verification = await requestEmailVerification(newEmail);
176
170
  if (!verification.ok) {
@@ -185,191 +179,54 @@ function ChangeEmailSection({
185
179
  `[user-profile] email changed but the verification email send threw: ${err instanceof Error ? err.message : String(err)}`,
186
180
  );
187
181
  }
182
+ patch?.({ email: newEmail });
188
183
  setNewEmail("");
189
184
  setCurrentPassword("");
190
185
  setStatus({ kind: "success", messageKey: "profile.email.success" });
191
- onChanged();
192
186
  })();
193
187
  };
194
188
 
195
189
  const submitting = status.kind === "submitting";
196
190
  return (
197
- <Form
198
- testId="profile-email"
199
- title={t("profile.email.title")}
200
- onSubmit={onSubmit}
201
- subtitle={
202
- <span data-testid="profile-email-current">
203
- {t("profile.email.current")}: {me.email}
204
- </span>
205
- }
206
- actions={
207
- <Button type="submit" disabled={submitting} testId="profile-email-submit">
208
- {t("profile.email.submit")}
209
- </Button>
210
- }
211
- >
212
- <Field id="profile-new-email" label={t("profile.email.new")} required>
213
- <Input
214
- kind="email"
215
- id="profile-new-email"
216
- name="profile-new-email"
217
- value={newEmail}
218
- onChange={setNewEmail}
219
- disabled={submitting}
220
- required
221
- autoComplete="email"
222
- />
223
- </Field>
224
- <Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
225
- <Input
226
- kind="password"
227
- id="profile-email-password"
228
- name="profile-email-password"
229
- value={currentPassword}
230
- onChange={setCurrentPassword}
231
- disabled={submitting}
232
- required
233
- autoComplete="current-password"
234
- />
235
- </Field>
236
- <StatusBanner status={status} />
237
- </Form>
238
- );
239
- }
240
-
241
- function DangerZoneSection({
242
- me,
243
- onChanged,
244
- }: {
245
- readonly me: MeRow;
246
- readonly onChanged: () => void;
247
- }): ReactNode {
248
- const t = useTranslation();
249
- const { Button, Banner, Dialog, Card } = usePrimitives();
250
- // Card.slots.title always renders <h3> — with the page's <h1> (variant="page")
251
- // as this screen's only other heading, that skips <h2> (607/4). An explicit
252
- // header slot with a real <h2> keeps the levels contiguous; layout mirrors
253
- // DefaultCard's own default-header markup.
254
- const dangerZoneHeader = (
255
- <div className="flex flex-wrap items-start justify-between gap-3 px-6 pt-6 pb-4">
256
- <h2 className="text-base font-semibold leading-none tracking-tight">
257
- {t("profile.danger.title")}
258
- </h2>
259
- </div>
260
- );
261
- const dispatcher = useDispatcher();
262
- const [dialogOpen, setDialogOpen] = useState(false);
263
- const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
264
-
265
- const deletionRequested = me.status === "deletionRequested";
266
-
267
- const requestDeletion = async (): Promise<void> => {
268
- const res = await dispatcher.write(UserDataRightsHandlers.requestDeletion, {});
269
- if (!res.isSuccess) {
270
- setStatus({ kind: "error", messageKey: failureKey(res.error) });
271
- return;
272
- }
273
- setStatus({ kind: "idle" });
274
- onChanged();
275
- };
276
-
277
- const cancelDeletion = async (): Promise<void> => {
278
- const res = await dispatcher.write(UserDataRightsHandlers.cancelDeletion, {});
279
- if (!res.isSuccess) {
280
- setStatus({ kind: "error", messageKey: failureKey(res.error) });
281
- return;
282
- }
283
- setStatus({ kind: "success", messageKey: "profile.danger.cancelSuccess" });
284
- onChanged();
285
- };
286
-
287
- const footer = deletionRequested ? (
288
- <Button
289
- variant="secondary"
290
- onClick={() => void cancelDeletion()}
291
- testId="profile-danger-cancel"
292
- >
293
- {t("profile.danger.cancelDeletion")}
294
- </Button>
295
- ) : (
296
- <Button variant="danger" onClick={() => setDialogOpen(true)} testId="profile-danger-delete">
297
- {t("profile.danger.delete")}
298
- </Button>
299
- );
300
-
301
- return (
302
- <Card
303
- testId="profile-danger"
304
- className="border-destructive/40"
305
- slots={{ header: dangerZoneHeader, footer }}
306
- >
307
- <div className="flex flex-col gap-4">
308
- {deletionRequested ? (
309
- <>
310
- <Banner variant="error" testId="profile-danger-requested">
311
- {t("profile.danger.requested", {
312
- date: formatDeletionDate(me.gracePeriodEnd),
313
- })}
314
- </Banner>
315
- <StatusBanner status={status} />
316
- </>
317
- ) : (
318
- <>
319
- <p className="text-sm text-muted-foreground">{t("profile.danger.explainer")}</p>
320
- <StatusBanner status={status} />
321
- <Dialog
322
- open={dialogOpen}
323
- onOpenChange={setDialogOpen}
324
- title={t("profile.danger.dialogTitle")}
325
- description={t("profile.danger.dialogDescription")}
326
- variant="danger"
327
- confirmLabel={t("profile.danger.delete")}
328
- onConfirm={requestDeletion}
329
- testId="profile-danger-dialog"
330
- />
331
- </>
332
- )}
333
- </div>
334
- </Card>
335
- );
336
- }
337
-
338
- export function ProfileScreen(): ReactNode {
339
- const t = useTranslation();
340
- const { Banner, Heading } = usePrimitives();
341
- const meQuery = useQuery<MeRow | null>(UserProfileQueries.me, {});
342
-
343
- if (meQuery.error) {
344
- return (
345
- <Banner padded variant="error" testId="profile-error">
346
- {meQuery.error.i18nKey}
347
- </Banner>
348
- );
349
- }
350
- const me = meQuery.data;
351
- if (me === null || me === undefined) {
352
- return (
353
- <Banner padded variant="loading" testId="profile-loading">
354
- {t("profile.loading")}
355
- </Banner>
356
- );
357
- }
358
-
359
- const refetch = (): void => {
360
- void meQuery.refetch?.();
361
- };
362
-
363
- return (
364
- <FormScreenShell className="flex flex-col gap-6" testId="profile-screen">
365
- <Heading variant="page">{t("profile.title")}</Heading>
366
- {/* The two short account forms share a row from md up; the
367
- danger zone stays full width below. */}
368
- <div className="grid gap-6 md:grid-cols-2">
369
- <ChangeEmailSection me={me} onChanged={refetch} />
370
- <ChangePasswordSection />
371
- </div>
372
- <DangerZoneSection me={me} onChanged={refetch} />
373
- </FormScreenShell>
191
+ <BareFormProvider>
192
+ <Form
193
+ testId="profile-email"
194
+ onSubmit={onSubmit}
195
+ actions={
196
+ <Button type="submit" disabled={submitting} testId="profile-email-submit">
197
+ {t("profile.email.submit")}
198
+ </Button>
199
+ }
200
+ >
201
+ <Text variant="muted" testId="profile-email-current">
202
+ {t("profile.email.current")}: {currentEmail}
203
+ </Text>
204
+ <Field id="profile-new-email" label={t("profile.email.new")} required>
205
+ <Input
206
+ kind="email"
207
+ id="profile-new-email"
208
+ name="profile-new-email"
209
+ value={newEmail}
210
+ onChange={setNewEmail}
211
+ disabled={submitting}
212
+ required
213
+ autoComplete="email"
214
+ />
215
+ </Field>
216
+ <Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
217
+ <Input
218
+ kind="password"
219
+ id="profile-email-password"
220
+ name="profile-email-password"
221
+ value={currentPassword}
222
+ onChange={setCurrentPassword}
223
+ disabled={submitting}
224
+ required
225
+ autoComplete="current-password"
226
+ />
227
+ </Field>
228
+ <StatusBanner status={status} />
229
+ </Form>
230
+ </BareFormProvider>
374
231
  );
375
232
  }
@@ -1,69 +0,0 @@
1
- // @runtime client
2
- import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
3
- import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
4
- import type { ReactNode } from "react";
5
-
6
- export type OverviewCard = {
7
- readonly label: string;
8
- readonly value: string;
9
- readonly hint?: string;
10
- /** When true and hint is set, render an attention Banner inside the card. */
11
- readonly attention?: boolean;
12
- };
13
-
14
- export type OverviewState =
15
- | { readonly kind: "loading" }
16
- | { readonly kind: "error"; readonly message: string }
17
- | { readonly kind: "ready"; readonly cards: readonly OverviewCard[] };
18
-
19
- export function OverviewLayout({
20
- testId,
21
- title,
22
- state,
23
- loadingLabel,
24
- columns = 3,
25
- }: {
26
- readonly testId: string;
27
- readonly title: string;
28
- readonly state: OverviewState;
29
- readonly loadingLabel: string;
30
- readonly columns?: 2 | 3;
31
- }): ReactNode {
32
- const { Banner, Card, Heading, Text } = usePrimitives();
33
- const gridClass = columns === 2 ? "sm:grid-cols-2" : "sm:grid-cols-3";
34
-
35
- return (
36
- <FormScreenShell testId={testId} className="flex flex-col gap-6">
37
- <Heading variant="page">{title}</Heading>
38
-
39
- {state.kind === "loading" && (
40
- <Text variant="small" testId={`${testId}-loading`}>
41
- {loadingLabel}
42
- </Text>
43
- )}
44
-
45
- {state.kind === "error" && (
46
- <Banner variant="error" testId={`${testId}-error`}>
47
- {state.message}
48
- </Banner>
49
- )}
50
-
51
- {state.kind === "ready" && (
52
- <div className={`grid grid-cols-1 gap-4 ${gridClass}`}>
53
- {state.cards.map((card) => (
54
- <Card key={card.label} testId={`${testId}-card`} slots={{ title: card.label }}>
55
- <div className="flex flex-col gap-2" data-overview-card={card.label}>
56
- <span className="text-3xl font-semibold tabular-nums">{card.value}</span>
57
- {card.attention === true && card.hint !== undefined ? (
58
- <Banner variant="error">{card.hint}</Banner>
59
- ) : (
60
- card.hint !== undefined && <Text variant="small">{card.hint}</Text>
61
- )}
62
- </div>
63
- </Card>
64
- ))}
65
- </div>
66
- )}
67
- </FormScreenShell>
68
- );
69
- }
@@ -1,15 +0,0 @@
1
- // @runtime client
2
- import type { Dispatcher, QueryResult } from "@cosmicdrift/kumiko-headless";
3
- import { isOverviewQueryAllowed, type OverviewWorkspaceKind } from "../overview-allowlist";
4
-
5
- export async function overviewQuery<T>(
6
- kind: OverviewWorkspaceKind,
7
- dispatcher: Dispatcher,
8
- queryName: string,
9
- payload: Record<string, unknown>,
10
- ): Promise<QueryResult<T>> {
11
- if (!isOverviewQueryAllowed(kind, queryName)) {
12
- throw new Error(`admin-shell:overview query not allowlisted: ${queryName}`);
13
- }
14
- return dispatcher.query<T>(queryName, payload);
15
- }