@cosmicdrift/kumiko-bundled-features 0.38.0 → 0.40.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 (29) hide show
  1. package/package.json +8 -5
  2. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +57 -1
  3. package/src/auth-email-password/i18n.ts +4 -14
  4. package/src/auth-email-password/web/index.ts +1 -1
  5. package/src/config/__tests__/config.integration.test.ts +21 -6
  6. package/src/config/handlers/readiness.query.ts +29 -3
  7. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  8. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +1 -1
  9. package/src/custom-fields/db/queries/retention.ts +0 -12
  10. package/src/custom-fields/handlers/update-tenant-field.write.ts +8 -0
  11. package/src/custom-fields/index.ts +4 -0
  12. package/src/custom-fields/run-retention.ts +3 -6
  13. package/src/custom-fields/web/i18n.ts +4 -4
  14. package/src/custom-fields/wire-user-data-rights.ts +6 -2
  15. package/src/mail-foundation/feature.ts +4 -0
  16. package/src/readiness/__tests__/readiness.integration.test.ts +35 -0
  17. package/src/readiness/handlers/status.query.ts +4 -0
  18. package/src/template-resolver/__tests__/handlers.integration.test.ts +59 -0
  19. package/src/user-data-rights/run-forget-cleanup.ts +9 -2
  20. package/src/user-profile/__tests__/change-email.integration.test.ts +222 -0
  21. package/src/user-profile/__tests__/profile-screen.test.tsx +101 -0
  22. package/src/user-profile/constants.ts +27 -0
  23. package/src/user-profile/feature.ts +26 -0
  24. package/src/user-profile/handlers/change-email.write.ts +83 -0
  25. package/src/user-profile/i18n.ts +83 -0
  26. package/src/user-profile/index.ts +11 -0
  27. package/src/user-profile/web/client-plugin.ts +28 -0
  28. package/src/user-profile/web/index.ts +6 -0
  29. package/src/user-profile/web/profile-screen.tsx +326 -0
@@ -0,0 +1,326 @@
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" } } })
8
+
9
+ import {
10
+ useDispatcher,
11
+ usePrimitives,
12
+ useQuery,
13
+ useTranslation,
14
+ } from "@cosmicdrift/kumiko-renderer";
15
+ import { type FormEvent, type ReactNode, useState } from "react";
16
+ import { AuthHandlers } from "../../auth-email-password/constants";
17
+ import { requestEmailVerification } from "../../auth-email-password/web";
18
+ import { UserDataRightsHandlers, UserProfileHandlers, UserProfileQueries } from "../constants";
19
+
20
+ type MeRow = {
21
+ readonly id: string;
22
+ readonly email: string;
23
+ readonly status?: string;
24
+ readonly gracePeriodEnd?: string | null;
25
+ };
26
+
27
+ type SectionStatus =
28
+ | { kind: "idle" }
29
+ | { kind: "submitting" }
30
+ | { kind: "success"; messageKey: string }
31
+ | { kind: "error"; messageKey: string };
32
+
33
+ // Dispatcher-Failures tragen i18nKey nur wenn der Handler einen setzt —
34
+ // Boundary-Read mit generischem Fallback.
35
+ function failureKey(error: unknown): string {
36
+ const key = (error as { i18nKey?: unknown } | null)?.i18nKey; // @cast-boundary dispatcher-error
37
+ return typeof key === "string" ? key : "profile.errors.generic";
38
+ }
39
+
40
+ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode {
41
+ const t = useTranslation();
42
+ const { Banner } = usePrimitives();
43
+ if (status.kind === "success") {
44
+ return <Banner variant="info">{t(status.messageKey)}</Banner>;
45
+ }
46
+ if (status.kind === "error") {
47
+ return <Banner variant="error">{t(status.messageKey)}</Banner>;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function ChangePasswordSection(): ReactNode {
53
+ const t = useTranslation();
54
+ const { Form, Field, Input, Button, Heading } = usePrimitives();
55
+ const dispatcher = useDispatcher();
56
+ const [oldPassword, setOldPassword] = useState("");
57
+ const [newPassword, setNewPassword] = useState("");
58
+ const [confirm, setConfirm] = useState("");
59
+ const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
60
+
61
+ const onSubmit = (e?: FormEvent): void => {
62
+ e?.preventDefault();
63
+ void (async () => {
64
+ if (newPassword !== confirm) {
65
+ setStatus({ kind: "error", messageKey: "profile.password.mismatch" });
66
+ return;
67
+ }
68
+ setStatus({ kind: "submitting" });
69
+ const res = await dispatcher.write(AuthHandlers.changePassword, {
70
+ oldPassword,
71
+ newPassword,
72
+ });
73
+ if (!res.isSuccess) {
74
+ setStatus({ kind: "error", messageKey: failureKey(res.error) });
75
+ return;
76
+ }
77
+ setOldPassword("");
78
+ setNewPassword("");
79
+ setConfirm("");
80
+ setStatus({ kind: "success", messageKey: "profile.password.success" });
81
+ })();
82
+ };
83
+
84
+ const submitting = status.kind === "submitting";
85
+ return (
86
+ <section data-testid="profile-password" className="flex flex-col gap-4">
87
+ <Heading variant="section">{t("profile.password.title")}</Heading>
88
+ <Form onSubmit={onSubmit} testId="profile-password-form">
89
+ <Field id="profile-old-password" label={t("profile.password.old")} required>
90
+ <Input
91
+ kind="password"
92
+ id="profile-old-password"
93
+ name="profile-old-password"
94
+ value={oldPassword}
95
+ onChange={setOldPassword}
96
+ disabled={submitting}
97
+ required
98
+ autoComplete="current-password"
99
+ />
100
+ </Field>
101
+ <Field id="profile-new-password" label={t("profile.password.new")} required>
102
+ <Input
103
+ kind="password"
104
+ id="profile-new-password"
105
+ name="profile-new-password"
106
+ value={newPassword}
107
+ onChange={setNewPassword}
108
+ disabled={submitting}
109
+ required
110
+ autoComplete="new-password"
111
+ />
112
+ </Field>
113
+ <Field id="profile-confirm-password" label={t("profile.password.confirm")} required>
114
+ <Input
115
+ kind="password"
116
+ id="profile-confirm-password"
117
+ name="profile-confirm-password"
118
+ value={confirm}
119
+ onChange={setConfirm}
120
+ disabled={submitting}
121
+ required
122
+ autoComplete="new-password"
123
+ />
124
+ </Field>
125
+ <StatusBanner status={status} />
126
+ <Button type="submit" disabled={submitting} testId="profile-password-submit">
127
+ {t("profile.password.submit")}
128
+ </Button>
129
+ </Form>
130
+ </section>
131
+ );
132
+ }
133
+
134
+ function ChangeEmailSection({
135
+ me,
136
+ onChanged,
137
+ }: {
138
+ readonly me: MeRow;
139
+ readonly onChanged: () => void;
140
+ }): ReactNode {
141
+ const t = useTranslation();
142
+ const { Form, Field, Input, Button, Heading } = usePrimitives();
143
+ const dispatcher = useDispatcher();
144
+ const [newEmail, setNewEmail] = useState("");
145
+ const [currentPassword, setCurrentPassword] = useState("");
146
+ const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
147
+
148
+ const onSubmit = (e?: FormEvent): void => {
149
+ e?.preventDefault();
150
+ void (async () => {
151
+ setStatus({ kind: "submitting" });
152
+ const res = await dispatcher.write(UserProfileHandlers.changeEmail, {
153
+ currentPassword,
154
+ newEmail,
155
+ });
156
+ if (!res.isSuccess) {
157
+ setStatus({ kind: "error", messageKey: failureKey(res.error) });
158
+ return;
159
+ }
160
+ // Verification-Mail an die neue Adresse — silent-success wie beim
161
+ // Login-Resend; ein Fehler hier darf den Email-Wechsel nicht als
162
+ // gescheitert anzeigen (der Wechsel ist bereits persistiert).
163
+ await requestEmailVerification(newEmail).catch(() => undefined);
164
+ setNewEmail("");
165
+ setCurrentPassword("");
166
+ setStatus({ kind: "success", messageKey: "profile.email.success" });
167
+ onChanged();
168
+ })();
169
+ };
170
+
171
+ const submitting = status.kind === "submitting";
172
+ return (
173
+ <section data-testid="profile-email" className="flex flex-col gap-4">
174
+ <Heading variant="section">{t("profile.email.title")}</Heading>
175
+ <p className="text-sm text-muted-foreground" data-testid="profile-email-current">
176
+ {t("profile.email.current")}: {me.email}
177
+ </p>
178
+ <Form onSubmit={onSubmit} testId="profile-email-form">
179
+ <Field id="profile-new-email" label={t("profile.email.new")} required>
180
+ <Input
181
+ kind="email"
182
+ id="profile-new-email"
183
+ name="profile-new-email"
184
+ value={newEmail}
185
+ onChange={setNewEmail}
186
+ disabled={submitting}
187
+ required
188
+ autoComplete="email"
189
+ />
190
+ </Field>
191
+ <Field id="profile-email-password" label={t("profile.email.currentPassword")} required>
192
+ <Input
193
+ kind="password"
194
+ id="profile-email-password"
195
+ name="profile-email-password"
196
+ value={currentPassword}
197
+ onChange={setCurrentPassword}
198
+ disabled={submitting}
199
+ required
200
+ autoComplete="current-password"
201
+ />
202
+ </Field>
203
+ <StatusBanner status={status} />
204
+ <Button type="submit" disabled={submitting} testId="profile-email-submit">
205
+ {t("profile.email.submit")}
206
+ </Button>
207
+ </Form>
208
+ </section>
209
+ );
210
+ }
211
+
212
+ function DangerZoneSection({
213
+ me,
214
+ onChanged,
215
+ }: {
216
+ readonly me: MeRow;
217
+ readonly onChanged: () => void;
218
+ }): ReactNode {
219
+ const t = useTranslation();
220
+ const { Button, Banner, Dialog, Heading } = usePrimitives();
221
+ const dispatcher = useDispatcher();
222
+ const [dialogOpen, setDialogOpen] = useState(false);
223
+ const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
224
+
225
+ const deletionRequested = me.status === "deletionRequested";
226
+
227
+ const requestDeletion = async (): Promise<void> => {
228
+ const res = await dispatcher.write(UserDataRightsHandlers.requestDeletion, {});
229
+ if (!res.isSuccess) {
230
+ setStatus({ kind: "error", messageKey: failureKey(res.error) });
231
+ return;
232
+ }
233
+ setStatus({ kind: "idle" });
234
+ onChanged();
235
+ };
236
+
237
+ const cancelDeletion = async (): Promise<void> => {
238
+ const res = await dispatcher.write(UserDataRightsHandlers.cancelDeletion, {});
239
+ if (!res.isSuccess) {
240
+ setStatus({ kind: "error", messageKey: failureKey(res.error) });
241
+ return;
242
+ }
243
+ setStatus({ kind: "success", messageKey: "profile.danger.cancelSuccess" });
244
+ onChanged();
245
+ };
246
+
247
+ return (
248
+ <section data-testid="profile-danger" className="flex flex-col gap-4">
249
+ <Heading variant="section">{t("profile.danger.title")}</Heading>
250
+ {deletionRequested ? (
251
+ <>
252
+ <Banner variant="error" testId="profile-danger-requested">
253
+ {t("profile.danger.requested", {
254
+ date: me.gracePeriodEnd ?? "—",
255
+ })}
256
+ </Banner>
257
+ <StatusBanner status={status} />
258
+ <Button
259
+ variant="secondary"
260
+ onClick={() => void cancelDeletion()}
261
+ testId="profile-danger-cancel"
262
+ >
263
+ {t("profile.danger.cancelDeletion")}
264
+ </Button>
265
+ </>
266
+ ) : (
267
+ <>
268
+ <p className="text-sm text-muted-foreground">{t("profile.danger.explainer")}</p>
269
+ <StatusBanner status={status} />
270
+ <Button
271
+ variant="danger"
272
+ onClick={() => setDialogOpen(true)}
273
+ testId="profile-danger-delete"
274
+ >
275
+ {t("profile.danger.delete")}
276
+ </Button>
277
+ <Dialog
278
+ open={dialogOpen}
279
+ onOpenChange={setDialogOpen}
280
+ title={t("profile.danger.dialogTitle")}
281
+ description={t("profile.danger.dialogDescription")}
282
+ variant="danger"
283
+ confirmLabel={t("profile.danger.delete")}
284
+ onConfirm={requestDeletion}
285
+ testId="profile-danger-dialog"
286
+ />
287
+ </>
288
+ )}
289
+ </section>
290
+ );
291
+ }
292
+
293
+ export function ProfileScreen(): ReactNode {
294
+ const t = useTranslation();
295
+ const { Banner, Heading } = usePrimitives();
296
+ const meQuery = useQuery<MeRow | null>(UserProfileQueries.me, {});
297
+
298
+ if (meQuery.error) {
299
+ return (
300
+ <Banner padded variant="error" testId="profile-error">
301
+ {meQuery.error.i18nKey}
302
+ </Banner>
303
+ );
304
+ }
305
+ const me = meQuery.data;
306
+ if (me === null || me === undefined) {
307
+ return (
308
+ <Banner padded variant="loading" testId="profile-loading">
309
+ Loading…
310
+ </Banner>
311
+ );
312
+ }
313
+
314
+ const refetch = (): void => {
315
+ void meQuery.refetch?.();
316
+ };
317
+
318
+ return (
319
+ <div className="p-6 flex flex-col gap-10 max-w-xl" data-testid="profile-screen">
320
+ <Heading variant="page">{t("profile.title")}</Heading>
321
+ <ChangeEmailSection me={me} onChanged={refetch} />
322
+ <ChangePasswordSection />
323
+ <DangerZoneSection me={me} onChanged={refetch} />
324
+ </div>
325
+ );
326
+ }