@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.
- package/package.json +8 -5
- package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +57 -1
- package/src/auth-email-password/i18n.ts +4 -14
- package/src/auth-email-password/web/index.ts +1 -1
- package/src/config/__tests__/config.integration.test.ts +21 -6
- package/src/config/handlers/readiness.query.ts +29 -3
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +1 -1
- package/src/custom-fields/db/queries/retention.ts +0 -12
- package/src/custom-fields/handlers/update-tenant-field.write.ts +8 -0
- package/src/custom-fields/index.ts +4 -0
- package/src/custom-fields/run-retention.ts +3 -6
- package/src/custom-fields/web/i18n.ts +4 -4
- package/src/custom-fields/wire-user-data-rights.ts +6 -2
- package/src/mail-foundation/feature.ts +4 -0
- package/src/readiness/__tests__/readiness.integration.test.ts +35 -0
- package/src/readiness/handlers/status.query.ts +4 -0
- package/src/template-resolver/__tests__/handlers.integration.test.ts +59 -0
- package/src/user-data-rights/run-forget-cleanup.ts +9 -2
- package/src/user-profile/__tests__/change-email.integration.test.ts +222 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +101 -0
- package/src/user-profile/constants.ts +27 -0
- package/src/user-profile/feature.ts +26 -0
- package/src/user-profile/handlers/change-email.write.ts +83 -0
- package/src/user-profile/i18n.ts +83 -0
- package/src/user-profile/index.ts +11 -0
- package/src/user-profile/web/client-plugin.ts +28 -0
- package/src/user-profile/web/index.ts +6 -0
- 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
|
+
}
|