@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,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,6 @@
1
+ export {
2
+ type UserProfileClientFeature,
3
+ type UserProfileClientOptions,
4
+ userProfileClient,
5
+ } from "./client-plugin";
6
+ export { ProfileScreen } from "./profile-screen";