@cosmicdrift/kumiko-bundled-features 0.38.0 → 0.39.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 +7 -5
- package/src/auth-email-password/i18n.ts +4 -14
- package/src/auth-email-password/web/index.ts +1 -1
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.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>",
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"./user": "./src/user/index.ts",
|
|
49
49
|
"./user/seeding": "./src/user/seeding.ts",
|
|
50
50
|
"./user/testing": "./src/user/testing.ts",
|
|
51
|
+
"./user-profile": "./src/user-profile/index.ts",
|
|
52
|
+
"./user-profile/web": "./src/user-profile/web/index.ts",
|
|
51
53
|
"./auth-email-password": "./src/auth-email-password/index.ts",
|
|
52
54
|
"./auth-email-password/constants": "./src/auth-email-password/constants.ts",
|
|
53
55
|
"./auth-email-password/seeding": "./src/auth-email-password/seeding.ts",
|
|
@@ -74,10 +76,10 @@
|
|
|
74
76
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
75
77
|
},
|
|
76
78
|
"dependencies": {
|
|
77
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
78
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
79
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
80
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
79
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.38.0",
|
|
80
|
+
"@cosmicdrift/kumiko-framework": "0.38.0",
|
|
81
|
+
"@cosmicdrift/kumiko-renderer": "0.38.0",
|
|
82
|
+
"@cosmicdrift/kumiko-renderer-web": "0.38.0",
|
|
81
83
|
"@mollie/api-client": "^4.5.0",
|
|
82
84
|
"@node-rs/argon2": "^2.0.2",
|
|
83
85
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -207,17 +207,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
207
207
|
},
|
|
208
208
|
};
|
|
209
209
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
export
|
|
214
|
-
base: TranslationsByLocale,
|
|
215
|
-
override: TranslationsByLocale,
|
|
216
|
-
): TranslationsByLocale {
|
|
217
|
-
const locales = new Set([...Object.keys(base), ...Object.keys(override)]);
|
|
218
|
-
const merged: Record<string, Record<string, string>> = {};
|
|
219
|
-
for (const locale of locales) {
|
|
220
|
-
merged[locale] = { ...(base[locale] ?? {}), ...(override[locale] ?? {}) };
|
|
221
|
-
}
|
|
222
|
-
return merged;
|
|
223
|
-
}
|
|
210
|
+
// Kanonische Implementierung lebt jetzt im Renderer (neben
|
|
211
|
+
// TranslationsByLocale) — Re-Export hält die bestehende Import-Surface
|
|
212
|
+
// (auth-email-password/web) stabil.
|
|
213
|
+
export { mergeTranslations } from "@cosmicdrift/kumiko-renderer";
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// `@cosmicdrift/kumiko-bundled-features/auth-email-password` und hat keine
|
|
6
6
|
// React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
|
|
7
7
|
|
|
8
|
-
export { defaultTranslations } from "../i18n";
|
|
8
|
+
export { defaultTranslations, mergeTranslations } from "../i18n";
|
|
9
9
|
export type {
|
|
10
10
|
AuthTokenFailure,
|
|
11
11
|
CurrentUserProfile,
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// user-profile change-email — Re-Auth + Uniqueness + Verified-Reset,
|
|
2
|
+
// bewiesen über echten Login (alte Email 401, neue Email 200). Plus
|
|
3
|
+
// QN-Pin der user-data-rights-Konstanten (Danger-Zone des ProfileScreen
|
|
4
|
+
// dispatcht genau diese Strings).
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
|
+
import {
|
|
11
|
+
createTestUser,
|
|
12
|
+
setupTestStack,
|
|
13
|
+
type TestStack,
|
|
14
|
+
TestUsers,
|
|
15
|
+
unsafeCreateEntityTable,
|
|
16
|
+
unsafePushTables,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
18
|
+
import { expectErrorIncludes, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
19
|
+
import { AuthErrors, AuthHandlers } from "../../auth-email-password/constants";
|
|
20
|
+
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
21
|
+
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
22
|
+
import {
|
|
23
|
+
createComplianceProfilesFeature,
|
|
24
|
+
tenantComplianceProfileEntity,
|
|
25
|
+
tenantComplianceProfileTable,
|
|
26
|
+
} from "../../compliance-profiles";
|
|
27
|
+
import { createConfigFeature } from "../../config";
|
|
28
|
+
import { configValuesTable } from "../../config/table";
|
|
29
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
30
|
+
import { createSessionsFeature } from "../../sessions";
|
|
31
|
+
import { createTenantFeature } from "../../tenant";
|
|
32
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
33
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
34
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
35
|
+
import { UserErrors, UserHandlers, UserQueries } from "../../user";
|
|
36
|
+
import { createUserFeature } from "../../user/feature";
|
|
37
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
38
|
+
import { createUserDataRightsFeature } from "../../user-data-rights";
|
|
39
|
+
import {
|
|
40
|
+
UserDataRightsHandlers,
|
|
41
|
+
UserProfileErrors,
|
|
42
|
+
UserProfileHandlers,
|
|
43
|
+
UserProfileQueries,
|
|
44
|
+
} from "../constants";
|
|
45
|
+
import { createUserProfileFeature } from "../feature";
|
|
46
|
+
|
|
47
|
+
let stack: TestStack;
|
|
48
|
+
|
|
49
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
50
|
+
const TENANT: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId; // @cast-boundary test-fixture
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
stack = await setupTestStack({
|
|
54
|
+
features: [
|
|
55
|
+
createConfigFeature(),
|
|
56
|
+
createUserFeature(),
|
|
57
|
+
createTenantFeature(),
|
|
58
|
+
createAuthEmailPasswordFeature(),
|
|
59
|
+
createDataRetentionFeature(),
|
|
60
|
+
createComplianceProfilesFeature(),
|
|
61
|
+
createSessionsFeature(),
|
|
62
|
+
createUserDataRightsFeature(),
|
|
63
|
+
createUserProfileFeature(),
|
|
64
|
+
],
|
|
65
|
+
authConfig: {
|
|
66
|
+
membershipQuery: "tenant:query:memberships",
|
|
67
|
+
loginHandler: AuthHandlers.login,
|
|
68
|
+
loginErrorStatusMap: {
|
|
69
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
70
|
+
[AuthErrors.noMembership]: 403,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
75
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
76
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
77
|
+
await createEventsTable(stack.db);
|
|
78
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterAll(async () => {
|
|
82
|
+
await stack.cleanup();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
await resetTestTables(stack.db, [
|
|
87
|
+
userTable,
|
|
88
|
+
tenantMembershipsTable,
|
|
89
|
+
tenantComplianceProfileTable,
|
|
90
|
+
eventsTable,
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
async function seedLoginUser(opts: {
|
|
95
|
+
email: string;
|
|
96
|
+
password: string;
|
|
97
|
+
}): Promise<{ id: string; tenantId: TenantId }> {
|
|
98
|
+
const hash = await hashPassword(opts.password);
|
|
99
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
100
|
+
UserHandlers.create,
|
|
101
|
+
{
|
|
102
|
+
email: opts.email,
|
|
103
|
+
passwordHash: hash,
|
|
104
|
+
displayName: opts.email.split("@")[0] ?? "user",
|
|
105
|
+
},
|
|
106
|
+
systemAdmin,
|
|
107
|
+
);
|
|
108
|
+
await seedTenantMembership(stack.db, {
|
|
109
|
+
userId: created.id,
|
|
110
|
+
tenantId: TENANT,
|
|
111
|
+
roles: ["User"],
|
|
112
|
+
});
|
|
113
|
+
return { id: created.id, tenantId: TENANT };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("change-email happy path", () => {
|
|
117
|
+
test("re-auth ok → Login nur noch mit neuer Email, emailVerified=false", async () => {
|
|
118
|
+
const seed = await seedLoginUser({ email: "old@example.com", password: "secret-pw-1" });
|
|
119
|
+
const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
|
|
120
|
+
|
|
121
|
+
const result = await stack.http.writeOk<{ kind: string; email: string }>(
|
|
122
|
+
UserProfileHandlers.changeEmail,
|
|
123
|
+
{ currentPassword: "secret-pw-1", newEmail: "New@Example.com" },
|
|
124
|
+
signedIn,
|
|
125
|
+
);
|
|
126
|
+
expect(result.kind).toBe("email-changed");
|
|
127
|
+
expect(result.email).toBe("new@example.com");
|
|
128
|
+
|
|
129
|
+
const row = (await fetchOne(stack.db, userTable, { id: seed.id })) as {
|
|
130
|
+
email: string;
|
|
131
|
+
emailVerified: boolean | null;
|
|
132
|
+
} | null;
|
|
133
|
+
expect(row?.email).toBe("new@example.com");
|
|
134
|
+
expect(row?.emailVerified).toBe(false);
|
|
135
|
+
|
|
136
|
+
const oldLogin = await stack.http.raw("POST", "/api/auth/login", {
|
|
137
|
+
email: "old@example.com",
|
|
138
|
+
password: "secret-pw-1",
|
|
139
|
+
});
|
|
140
|
+
expect(oldLogin.status).toBe(401);
|
|
141
|
+
|
|
142
|
+
const newLogin = await stack.http.raw("POST", "/api/auth/login", {
|
|
143
|
+
email: "new@example.com",
|
|
144
|
+
password: "secret-pw-1",
|
|
145
|
+
});
|
|
146
|
+
expect(newLogin.status).toBe(200);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("change-email guards", () => {
|
|
151
|
+
test("falsches Passwort → invalid_credentials, Email unverändert", async () => {
|
|
152
|
+
const seed = await seedLoginUser({ email: "guard@example.com", password: "secret-pw-1" });
|
|
153
|
+
const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
|
|
154
|
+
|
|
155
|
+
const error = await stack.http.writeErr(
|
|
156
|
+
UserProfileHandlers.changeEmail,
|
|
157
|
+
{ currentPassword: "wrong", newEmail: "other@example.com" },
|
|
158
|
+
signedIn,
|
|
159
|
+
);
|
|
160
|
+
expectErrorIncludes(error, AuthErrors.invalidCredentials);
|
|
161
|
+
|
|
162
|
+
const row = (await fetchOne(stack.db, userTable, { id: seed.id })) as {
|
|
163
|
+
email: string;
|
|
164
|
+
} | null;
|
|
165
|
+
expect(row?.email).toBe("guard@example.com");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("Email bereits vergeben → email_already_exists", async () => {
|
|
169
|
+
await seedLoginUser({ email: "taken@example.com", password: "secret-pw-2" });
|
|
170
|
+
const seed = await seedLoginUser({ email: "me@example.com", password: "secret-pw-1" });
|
|
171
|
+
const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
|
|
172
|
+
|
|
173
|
+
const error = await stack.http.writeErr(
|
|
174
|
+
UserProfileHandlers.changeEmail,
|
|
175
|
+
{ currentPassword: "secret-pw-1", newEmail: "taken@example.com" },
|
|
176
|
+
signedIn,
|
|
177
|
+
);
|
|
178
|
+
expectErrorIncludes(error, UserErrors.emailAlreadyExists);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("gleiche Email → email_unchanged", async () => {
|
|
182
|
+
const seed = await seedLoginUser({ email: "same@example.com", password: "secret-pw-1" });
|
|
183
|
+
const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
|
|
184
|
+
|
|
185
|
+
const error = await stack.http.writeErr(
|
|
186
|
+
UserProfileHandlers.changeEmail,
|
|
187
|
+
{ currentPassword: "secret-pw-1", newEmail: "Same@example.com" },
|
|
188
|
+
signedIn,
|
|
189
|
+
);
|
|
190
|
+
expectErrorIncludes(error, UserProfileErrors.emailUnchanged);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("QN-Drift-Pins (client-Konstanten vs. Feature-Originale)", () => {
|
|
195
|
+
test("UserProfileQueries.me spiegelt UserQueries.me", () => {
|
|
196
|
+
// Der ProfileScreen darf das runtime-Barrel des user-Features nicht
|
|
197
|
+
// importieren (Runtime-Isolation) und pinnt die QN lokal — dieser
|
|
198
|
+
// Vergleich macht stillen Drift unmöglich.
|
|
199
|
+
expect(UserProfileQueries.me).toBe(UserQueries.me);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("danger-zone QN-Pin (ProfileScreen-Konstanten)", () => {
|
|
204
|
+
test("request-deletion + cancel-deletion sind unter den gepinnten QNs dispatchbar", async () => {
|
|
205
|
+
const seed = await seedLoginUser({ email: "danger@example.com", password: "secret-pw-1" });
|
|
206
|
+
const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
|
|
207
|
+
|
|
208
|
+
const requested = await stack.http.writeOk<{ status: string }>(
|
|
209
|
+
UserDataRightsHandlers.requestDeletion,
|
|
210
|
+
{},
|
|
211
|
+
signedIn,
|
|
212
|
+
);
|
|
213
|
+
expect(requested.status).toBe("deletionRequested");
|
|
214
|
+
|
|
215
|
+
const cancelled = await stack.http.writeOk<{ status: string }>(
|
|
216
|
+
UserDataRightsHandlers.cancelDeletion,
|
|
217
|
+
{},
|
|
218
|
+
signedIn,
|
|
219
|
+
);
|
|
220
|
+
expect(cancelled.status).toBe("active");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
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.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { createStore, type Dispatcher, type DispatcherStatus } from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import {
|
|
9
|
+
createStaticLocaleResolver,
|
|
10
|
+
DispatcherProvider,
|
|
11
|
+
kumikoDefaultTranslations,
|
|
12
|
+
type LiveEventSubscriber,
|
|
13
|
+
LiveEventsProvider,
|
|
14
|
+
LocaleProvider,
|
|
15
|
+
PrimitivesProvider,
|
|
16
|
+
TokensProvider,
|
|
17
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
18
|
+
import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-web";
|
|
19
|
+
import { render, waitFor } from "@testing-library/react";
|
|
20
|
+
import type { ReactNode } from "react";
|
|
21
|
+
import { defaultTranslations } from "../i18n";
|
|
22
|
+
import { ProfileScreen } from "../web/profile-screen";
|
|
23
|
+
|
|
24
|
+
const stubLiveEvents: LiveEventSubscriber = () => () => {};
|
|
25
|
+
const stubTokens = {
|
|
26
|
+
tokens: defaultTokens,
|
|
27
|
+
mode: "light" as const,
|
|
28
|
+
setMode: () => {},
|
|
29
|
+
toggleMode: () => {},
|
|
30
|
+
};
|
|
31
|
+
const stubResolver = createStaticLocaleResolver();
|
|
32
|
+
|
|
33
|
+
function makeDispatcher(me: Record<string, unknown>): Dispatcher {
|
|
34
|
+
const statusStore = createStore<DispatcherStatus>("online");
|
|
35
|
+
return {
|
|
36
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
37
|
+
query: (async () => ({ isSuccess: true, data: me })) as unknown as Dispatcher["query"],
|
|
38
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
39
|
+
statusStore,
|
|
40
|
+
pendingWrites: () => [],
|
|
41
|
+
pendingFiles: () => [],
|
|
42
|
+
} as unknown as Dispatcher; // @cast-boundary test-stub
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderProfile(me: Record<string, unknown>) {
|
|
46
|
+
const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
|
|
47
|
+
<TokensProvider value={stubTokens}>
|
|
48
|
+
<LocaleProvider
|
|
49
|
+
resolver={stubResolver}
|
|
50
|
+
fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
|
|
51
|
+
>
|
|
52
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
53
|
+
<LiveEventsProvider value={stubLiveEvents}>
|
|
54
|
+
<DispatcherProvider dispatcher={makeDispatcher(me)}>{children}</DispatcherProvider>
|
|
55
|
+
</LiveEventsProvider>
|
|
56
|
+
</PrimitivesProvider>
|
|
57
|
+
</LocaleProvider>
|
|
58
|
+
</TokensProvider>
|
|
59
|
+
);
|
|
60
|
+
return render(<ProfileScreen />, { wrapper });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const activeMe = {
|
|
64
|
+
id: "00000000-0000-4000-8000-000000000042",
|
|
65
|
+
email: "marc@example.com",
|
|
66
|
+
status: "active",
|
|
67
|
+
gracePeriodEnd: null,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe("ProfileScreen", () => {
|
|
71
|
+
test("aktiver User: alle drei Sektionen, Texte übersetzt (keine rohen Keys)", async () => {
|
|
72
|
+
const view = renderProfile(activeMe);
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
if (view.queryByTestId("profile-screen") === null) throw new Error("not mounted yet");
|
|
75
|
+
});
|
|
76
|
+
expect(view.getByTestId("profile-email")).toBeTruthy();
|
|
77
|
+
expect(view.getByTestId("profile-password")).toBeTruthy();
|
|
78
|
+
expect(view.getByTestId("profile-danger")).toBeTruthy();
|
|
79
|
+
expect(view.getByTestId("profile-email-current").textContent).toContain("marc@example.com");
|
|
80
|
+
expect(view.getByTestId("profile-danger-delete")).toBeTruthy();
|
|
81
|
+
// Echte i18n: kein einziger roher Key im sichtbaren Text.
|
|
82
|
+
expect(view.container.textContent).not.toContain("profile.");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("deletionRequested: Frist-Banner + Abbrechen statt Lösch-Button", async () => {
|
|
86
|
+
const view = renderProfile({
|
|
87
|
+
...activeMe,
|
|
88
|
+
status: "deletionRequested",
|
|
89
|
+
gracePeriodEnd: "2026-07-11T00:00:00Z",
|
|
90
|
+
});
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
if (view.queryByTestId("profile-screen") === null) throw new Error("not mounted yet");
|
|
93
|
+
});
|
|
94
|
+
const banner = view.getByTestId("profile-danger-requested");
|
|
95
|
+
expect(banner.textContent).toContain("2026-07-11");
|
|
96
|
+
expect(banner.textContent).not.toContain("{date}");
|
|
97
|
+
expect(view.queryByTestId("profile-danger-delete")).toBeNull();
|
|
98
|
+
expect(view.getByTestId("profile-danger-cancel")).toBeTruthy();
|
|
99
|
+
expect(view.container.textContent).not.toContain("profile.");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
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.
|
|
5
|
+
|
|
6
|
+
export const USER_PROFILE_FEATURE = "user-profile" as const;
|
|
7
|
+
|
|
8
|
+
export const UserProfileHandlers = {
|
|
9
|
+
changeEmail: "user-profile:write:change-email",
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
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.
|
|
16
|
+
export const UserProfileQueries = {
|
|
17
|
+
me: "user:query:user:me",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export const UserDataRightsHandlers = {
|
|
21
|
+
requestDeletion: "user-data-rights:write:request-deletion",
|
|
22
|
+
cancelDeletion: "user-data-rights:write:cancel-deletion",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export const UserProfileErrors = {
|
|
26
|
+
emailUnchanged: "email_unchanged",
|
|
27
|
+
} as const;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { changeEmailWrite } from "./handlers/change-email.write";
|
|
3
|
+
|
|
4
|
+
export function createUserProfileFeature(): FeatureDefinition {
|
|
5
|
+
return defineFeature("user-profile", (r) => {
|
|
6
|
+
r.describe(
|
|
7
|
+
"Self-service account page building blocks: a `change-email` write handler " +
|
|
8
|
+
"(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`, and `user-data-rights`.",
|
|
15
|
+
);
|
|
16
|
+
r.requires("user");
|
|
17
|
+
r.requires("auth-email-password");
|
|
18
|
+
r.requires("user-data-rights");
|
|
19
|
+
|
|
20
|
+
const handlers = {
|
|
21
|
+
changeEmail: r.writeHandler(changeEmailWrite),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return { handlers };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { AuthErrors, verifyPassword } from "../../auth-email-password";
|
|
6
|
+
import { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "../../user";
|
|
7
|
+
import { UserProfileErrors } from "../constants";
|
|
8
|
+
|
|
9
|
+
// Gleiche Failure-Shape wie auth-email-password (anti-enumeration):
|
|
10
|
+
// dessen errors.ts ist nicht Teil des Feature-Barrels, der Reason-Code
|
|
11
|
+
// + i18nKey sind aber stabile Public-API.
|
|
12
|
+
function invalidCredentials() {
|
|
13
|
+
return writeFailure(
|
|
14
|
+
new UnprocessableError(AuthErrors.invalidCredentials, {
|
|
15
|
+
i18nKey: "auth.errors.invalidCredentials",
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Change-email — authenticated, self-only. Der User bestätigt mit seinem
|
|
21
|
+
// aktuellen Passwort (Re-Auth wie change-password); die neue Adresse wird
|
|
22
|
+
// via ctx.writeAs(system) gegen den user-update-Handler geschrieben, weil
|
|
23
|
+
// `email` field-level privileged ist und bei Self-Updates sonst silent
|
|
24
|
+
// gestrippt würde. emailVerified flippt auf false — die App triggert
|
|
25
|
+
// anschließend den Verification-Flow (request-email-verification ist
|
|
26
|
+
// public; der ProfileScreen ruft ihn nach Erfolg auf).
|
|
27
|
+
export const changeEmailWrite = defineWriteHandler({
|
|
28
|
+
name: "change-email",
|
|
29
|
+
schema: z.object({
|
|
30
|
+
currentPassword: z.string().min(1),
|
|
31
|
+
newEmail: z.email(),
|
|
32
|
+
}),
|
|
33
|
+
access: { roles: access.authenticated },
|
|
34
|
+
handler: async (event, ctx) => {
|
|
35
|
+
const systemUser = createSystemUser(event.user.tenantId);
|
|
36
|
+
|
|
37
|
+
const me = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
|
|
38
|
+
id: event.user.id,
|
|
39
|
+
})) as { id: string; email: string; passwordHash: string | null; version: number } | null; // @cast-boundary db-runner
|
|
40
|
+
|
|
41
|
+
if (!me?.passwordHash) {
|
|
42
|
+
return invalidCredentials();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const passwordOk = await verifyPassword(me.passwordHash, event.payload.currentPassword);
|
|
46
|
+
if (!passwordOk) {
|
|
47
|
+
return invalidCredentials();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const newEmail = event.payload.newEmail.toLowerCase();
|
|
51
|
+
if (newEmail === me.email.toLowerCase()) {
|
|
52
|
+
return writeFailure(
|
|
53
|
+
new UnprocessableError(UserProfileErrors.emailUnchanged, {
|
|
54
|
+
i18nKey: "profile.errors.emailUnchanged",
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const existing = await ctx.queryAs(systemUser, UserQueries.findForAuth, { email: newEmail });
|
|
60
|
+
if (existing !== null) {
|
|
61
|
+
return writeFailure(
|
|
62
|
+
new UnprocessableError(UserErrors.emailAlreadyExists, {
|
|
63
|
+
i18nKey: "user.errors.emailAlreadyExists",
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Stream-Tenant-Auflösung wie in change-password: das user-Aggregate
|
|
69
|
+
// ist systemScope, sein Event-Stream kann in einem anderen Tenant
|
|
70
|
+
// liegen als die Session — optimistic locking braucht den echten.
|
|
71
|
+
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, event.user.id, USER_FEATURE);
|
|
72
|
+
const writer = createSystemUser(streamTenant ?? event.user.tenantId);
|
|
73
|
+
|
|
74
|
+
const writeRes = await ctx.writeAs(writer, UserHandlers.update, {
|
|
75
|
+
id: me.id,
|
|
76
|
+
version: me.version,
|
|
77
|
+
changes: { email: newEmail, emailVerified: false },
|
|
78
|
+
});
|
|
79
|
+
if (!writeRes.isSuccess) return writeRes;
|
|
80
|
+
|
|
81
|
+
return { isSuccess: true, data: { kind: "email-changed", email: newEmail } };
|
|
82
|
+
},
|
|
83
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
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: { de: { … } } })`.
|
|
5
|
+
// `auth.errors.invalidCredentials` + `user.errors.emailAlreadyExists`
|
|
6
|
+
// sind hier gedoppelt, damit der Screen auch ohne die jeweiligen
|
|
7
|
+
// Feature-Bundles vollständig übersetzt.
|
|
8
|
+
|
|
9
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
10
|
+
|
|
11
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
12
|
+
de: {
|
|
13
|
+
"profile.title": "Profil",
|
|
14
|
+
|
|
15
|
+
"profile.email.title": "E-Mail-Adresse",
|
|
16
|
+
"profile.email.current": "Aktuelle E-Mail",
|
|
17
|
+
"profile.email.new": "Neue E-Mail",
|
|
18
|
+
"profile.email.currentPassword": "Aktuelles Passwort",
|
|
19
|
+
"profile.email.submit": "E-Mail ändern",
|
|
20
|
+
"profile.email.success":
|
|
21
|
+
"E-Mail geändert. Wir haben einen Bestätigungslink an die neue Adresse geschickt.",
|
|
22
|
+
|
|
23
|
+
"profile.password.title": "Passwort",
|
|
24
|
+
"profile.password.old": "Aktuelles Passwort",
|
|
25
|
+
"profile.password.new": "Neues Passwort",
|
|
26
|
+
"profile.password.confirm": "Neues Passwort bestätigen",
|
|
27
|
+
"profile.password.submit": "Passwort ändern",
|
|
28
|
+
"profile.password.success": "Passwort geändert.",
|
|
29
|
+
"profile.password.mismatch": "Die Passwörter stimmen nicht überein.",
|
|
30
|
+
|
|
31
|
+
"profile.danger.title": "Konto löschen",
|
|
32
|
+
"profile.danger.explainer":
|
|
33
|
+
"Dein Konto wird nach einer Frist endgültig gelöscht. Bis dahin kannst du die Löschung jederzeit abbrechen.",
|
|
34
|
+
"profile.danger.delete": "Konto löschen",
|
|
35
|
+
"profile.danger.dialogTitle": "Konto wirklich löschen?",
|
|
36
|
+
"profile.danger.dialogDescription":
|
|
37
|
+
"Nach Ablauf der Frist werden deine Daten endgültig gelöscht. Bis dahin kannst du die Löschung abbrechen.",
|
|
38
|
+
"profile.danger.requested":
|
|
39
|
+
"Löschung beantragt — dein Konto wird am {date} endgültig gelöscht.",
|
|
40
|
+
"profile.danger.cancelDeletion": "Löschung abbrechen",
|
|
41
|
+
"profile.danger.cancelSuccess": "Löschung abgebrochen. Dein Konto bleibt bestehen.",
|
|
42
|
+
|
|
43
|
+
"profile.errors.generic": "Etwas ist schiefgegangen.",
|
|
44
|
+
"profile.errors.emailUnchanged": "Das ist bereits deine E-Mail-Adresse.",
|
|
45
|
+
"user.errors.emailAlreadyExists": "Diese E-Mail-Adresse wird bereits verwendet.",
|
|
46
|
+
"auth.errors.invalidCredentials": "E-Mail oder Passwort falsch.",
|
|
47
|
+
},
|
|
48
|
+
en: {
|
|
49
|
+
"profile.title": "Profile",
|
|
50
|
+
|
|
51
|
+
"profile.email.title": "Email address",
|
|
52
|
+
"profile.email.current": "Current email",
|
|
53
|
+
"profile.email.new": "New email",
|
|
54
|
+
"profile.email.currentPassword": "Current password",
|
|
55
|
+
"profile.email.submit": "Change email",
|
|
56
|
+
"profile.email.success": "Email changed. We sent a verification link to the new address.",
|
|
57
|
+
|
|
58
|
+
"profile.password.title": "Password",
|
|
59
|
+
"profile.password.old": "Current password",
|
|
60
|
+
"profile.password.new": "New password",
|
|
61
|
+
"profile.password.confirm": "Confirm new password",
|
|
62
|
+
"profile.password.submit": "Change password",
|
|
63
|
+
"profile.password.success": "Password changed.",
|
|
64
|
+
"profile.password.mismatch": "Passwords do not match.",
|
|
65
|
+
|
|
66
|
+
"profile.danger.title": "Delete account",
|
|
67
|
+
"profile.danger.explainer":
|
|
68
|
+
"Your account will be permanently deleted after a grace period. Until then you can cancel the deletion at any time.",
|
|
69
|
+
"profile.danger.delete": "Delete account",
|
|
70
|
+
"profile.danger.dialogTitle": "Really delete your account?",
|
|
71
|
+
"profile.danger.dialogDescription":
|
|
72
|
+
"After the grace period your data will be permanently deleted. Until then you can cancel.",
|
|
73
|
+
"profile.danger.requested":
|
|
74
|
+
"Deletion requested — your account will be permanently deleted on {date}.",
|
|
75
|
+
"profile.danger.cancelDeletion": "Cancel deletion",
|
|
76
|
+
"profile.danger.cancelSuccess": "Deletion cancelled. Your account stays.",
|
|
77
|
+
|
|
78
|
+
"profile.errors.generic": "Something went wrong.",
|
|
79
|
+
"profile.errors.emailUnchanged": "That is already your email address.",
|
|
80
|
+
"user.errors.emailAlreadyExists": "This email address is already in use.",
|
|
81
|
+
"auth.errors.invalidCredentials": "Email or password incorrect.",
|
|
82
|
+
},
|
|
83
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Bewusst OHNE i18n-Export: das Server-Barrel muss von Server-Samples
|
|
2
|
+
// ohne jsx-tsconfig kompilierbar bleiben (i18n zieht kumiko-renderer →
|
|
3
|
+
// .tsx). Translations kommen via ./web (userProfileClient).
|
|
4
|
+
export {
|
|
5
|
+
USER_PROFILE_FEATURE,
|
|
6
|
+
UserDataRightsHandlers,
|
|
7
|
+
UserProfileErrors,
|
|
8
|
+
UserProfileHandlers,
|
|
9
|
+
UserProfileQueries,
|
|
10
|
+
} from "./constants";
|
|
11
|
+
export { createUserProfileFeature } from "./feature";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import type { ComponentType, ReactNode } from "react";
|
|
4
|
+
import { defaultTranslations } from "../i18n";
|
|
5
|
+
|
|
6
|
+
export type UserProfileClientOptions = {
|
|
7
|
+
/** Key-weise Overrides über die Default-Bundles (de/en). */
|
|
8
|
+
readonly translations?: TranslationsByLocale;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type UserProfileClientFeature = {
|
|
12
|
+
readonly name: "user-profile";
|
|
13
|
+
readonly providers: readonly ComponentType<{ children: ReactNode }>[];
|
|
14
|
+
readonly gates: readonly ComponentType<{ children: ReactNode }>[];
|
|
15
|
+
readonly translations: TranslationsByLocale;
|
|
16
|
+
};
|
|
17
|
+
|
|
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.
|
|
21
|
+
export function userProfileClient(options?: UserProfileClientOptions): UserProfileClientFeature {
|
|
22
|
+
return {
|
|
23
|
+
name: "user-profile",
|
|
24
|
+
providers: [],
|
|
25
|
+
gates: [],
|
|
26
|
+
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -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
|
+
}
|