@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.108.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 -6
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
- package/src/auth-email-password/web/auth-gate.tsx +3 -4
- package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
- package/src/auth-email-password/web/session.tsx +5 -1
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/config/__tests__/deserialize-value.test.ts +3 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
- package/src/custom-fields/constants.ts +1 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
- package/src/custom-fields/wire-for-entity.ts +104 -77
- package/src/data-retention/__tests__/parse-override.test.ts +11 -6
- package/src/data-retention/resolve-for-tenant.ts +16 -4
- package/src/data-retention/run-retention-cleanup.ts +72 -10
- package/src/delivery/attempt-log.ts +22 -10
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
- package/src/folders/__tests__/folders.integration.test.ts +26 -0
- package/src/folders/feature.ts +4 -2
- package/src/folders/handlers/delete-folder.write.ts +40 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
- package/src/folders/web/folder-manager.tsx +18 -13
- package/src/folders/web/i18n.ts +0 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
- package/src/folders-user-data/hooks.ts +31 -8
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/feature.ts +23 -21
- package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
- package/src/ledger/__tests__/reports.test.ts +20 -0
- package/src/ledger/entity.ts +1 -1
- package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
- package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
- package/src/ledger/recurring.ts +18 -9
- package/src/ledger/schemas.ts +4 -1
- package/src/ledger/web/index.ts +3 -2
- package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +204 -0
- package/src/personal-access-tokens/constants.ts +33 -0
- package/src/personal-access-tokens/feature.ts +78 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
- package/src/personal-access-tokens/handlers/create.write.ts +41 -0
- package/src/personal-access-tokens/handlers/list.query.ts +47 -0
- package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
- package/src/personal-access-tokens/hash.ts +17 -0
- package/src/personal-access-tokens/index.ts +45 -0
- package/src/personal-access-tokens/resolver.ts +58 -0
- package/src/personal-access-tokens/roles.ts +29 -0
- package/src/personal-access-tokens/schema/api-token.ts +56 -0
- package/src/personal-access-tokens/scopes.ts +22 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +45 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
- package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
- package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
- package/src/sessions/schema/user-session.ts +8 -2
- package/src/sessions/session-callbacks.ts +9 -3
- package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
- package/src/tags/__tests__/tags.integration.test.ts +15 -0
- package/src/tags/handlers/assign-tag.write.ts +13 -3
- package/src/tags/schemas.ts +10 -4
- package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/template-resolver/testing.ts +4 -3
- package/src/tier-engine/feature.ts +22 -10
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
- package/src/user/index.ts +4 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
- package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
- package/src/user-data-rights/run-forget-cleanup.ts +8 -1
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
- package/src/user-data-rights/web/i18n.ts +0 -10
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
- package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
- package/src/user-profile/handlers/change-email.write.ts +11 -8
- package/src/user-profile/web/profile-screen.tsx +10 -8
- package/src/auth-email-password/stream-tenant.ts +0 -31
- package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
|
@@ -1,11 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
createEventStoreExecutor,
|
|
4
|
+
createTenantDb,
|
|
5
|
+
type TenantDb,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import {
|
|
8
|
+
createSystemUser,
|
|
9
|
+
type SessionUser,
|
|
10
|
+
type UserDataDeleteHook,
|
|
11
|
+
type UserDataExportHook,
|
|
12
|
+
type UserDataHookCtx,
|
|
13
|
+
type UserDataStorageProvider,
|
|
7
14
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
-
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
15
|
+
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
16
|
+
|
|
17
|
+
// Forget writes go through the executor (events), not deleteMany/updateMany:
|
|
18
|
+
// a projection rebuild replays the events, so the erasure survives. Eventless
|
|
19
|
+
// writes are wiped/resurrected on rebuild — the Art.17 hole this fixes. Bounded:
|
|
20
|
+
// per-user forget flows are rare, so per-row events are acceptable.
|
|
21
|
+
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
9
22
|
|
|
10
23
|
// userData-Hook fuer fileRef-entity (S2.H2).
|
|
11
24
|
//
|
|
@@ -27,7 +40,7 @@ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
|
27
40
|
//
|
|
28
41
|
// **Zwei Fehlerklassen, bewusst verschieden behandelt:**
|
|
29
42
|
// 1. Resolution schlaegt fehl (kein Provider konfiguriert / configResolver
|
|
30
|
-
// fehlt) → NICHT fail-closed:
|
|
43
|
+
// fehlt) → NICHT fail-closed: Warn pro Aufruf + row-only-delete. Ein
|
|
31
44
|
// fehlkonfigurierter Store darf die Art.-17-Loeschung nicht DAUERHAFT
|
|
32
45
|
// blockieren (sonst haengt jeder User fuer immer in DeletionRequested);
|
|
33
46
|
// der Boot-Guard macht die Fehlkonfiguration sichtbar, Binaries werden
|
|
@@ -94,8 +107,6 @@ export const fileRefExportHook: UserDataExportHook = async (ctx) => {
|
|
|
94
107
|
};
|
|
95
108
|
};
|
|
96
109
|
|
|
97
|
-
let missingStorageWarned = false;
|
|
98
|
-
|
|
99
110
|
// Resolve the per-tenant provider the forget orchestrator injected. A
|
|
100
111
|
// resolution failure (no provider configured / configResolver absent) collapses
|
|
101
112
|
// to `undefined` so the hook degrades to a row-only delete instead of throwing —
|
|
@@ -107,58 +118,95 @@ async function resolveProvider(ctx: UserDataHookCtx): Promise<UserDataStoragePro
|
|
|
107
118
|
return await ctx.buildStorageProvider(ctx.tenantId);
|
|
108
119
|
} catch {
|
|
109
120
|
// skip: provider unresolvable (not configured) → fall through to row-only
|
|
110
|
-
// delete; warn
|
|
121
|
+
// delete; the warn below gives operator visibility, boot guard catches it.
|
|
111
122
|
return undefined;
|
|
112
123
|
}
|
|
113
124
|
}
|
|
114
125
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
131
|
-
console.warn(
|
|
132
|
-
`[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
|
|
133
|
-
);
|
|
134
|
-
failedKeys.push(key);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
// Fail-closed: abort before the row hard-delete so the sub-tx rolls back
|
|
138
|
-
// and the next forget run retries (delete is idempotent → converges).
|
|
139
|
-
if (failedKeys.length > 0) {
|
|
140
|
-
throw new Error(
|
|
141
|
-
`[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
} else if (!missingStorageWarned) {
|
|
145
|
-
missingStorageWarned = true;
|
|
146
|
-
// biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
|
|
126
|
+
// Delete every row's binary via the provider. Returns the keys whose delete
|
|
127
|
+
// threw — the caller fails closed on a non-empty list so the sub-tx rolls back
|
|
128
|
+
// and the next forget run retries (delete is idempotent → converges).
|
|
129
|
+
async function deleteBinaries(
|
|
130
|
+
rows: readonly Record<string, unknown>[],
|
|
131
|
+
provider: UserDataStorageProvider,
|
|
132
|
+
): Promise<readonly string[]> {
|
|
133
|
+
const failedKeys: string[] = [];
|
|
134
|
+
for (const row of rows) {
|
|
135
|
+
const key = row["storageKey"]; // @cast-boundary db-row
|
|
136
|
+
if (typeof key !== "string" || key.length === 0) continue;
|
|
137
|
+
try {
|
|
138
|
+
await provider.delete(key);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
147
141
|
console.warn(
|
|
148
|
-
|
|
142
|
+
`[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
|
|
143
|
+
);
|
|
144
|
+
failedKeys.push(key);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return failedKeys;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Null the person-link on every row via the executor (event → rebuild-safe).
|
|
151
|
+
// tdb: the executor needs a TenantDb (loadById → db.fetchOne), not the raw
|
|
152
|
+
// ctx.db runner.
|
|
153
|
+
async function severPersonLink(
|
|
154
|
+
tdb: TenantDb,
|
|
155
|
+
systemUser: SessionUser,
|
|
156
|
+
rows: readonly Record<string, unknown>[],
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
for (const row of rows) {
|
|
159
|
+
const id = row["id"]; // @cast-boundary db-row
|
|
160
|
+
if (typeof id !== "string") continue;
|
|
161
|
+
await crud.update({ id, changes: { insertedById: null } }, systemUser, tdb, {
|
|
162
|
+
skipOptimisticLock: true,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
168
|
+
const systemUser = createSystemUser(ctx.tenantId);
|
|
169
|
+
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
170
|
+
const rows = await selectMany<Record<string, unknown>>(ctx.db, fileRefsTable, {
|
|
171
|
+
tenantId: ctx.tenantId,
|
|
172
|
+
insertedById: ctx.userId,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (strategy !== "delete") {
|
|
176
|
+
// anonymize: insertedById=null, FileRef + binary bleiben. Use-case: shared
|
|
177
|
+
// chat-Attachment im Multi-User-Channel — Author-ID raus, Datei bleibt sichtbar.
|
|
178
|
+
await severPersonLink(tdb, systemUser, rows);
|
|
179
|
+
// skip: anonymize is complete — the hard-delete path below runs only for strategy "delete".
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const storageProvider = await resolveProvider(ctx);
|
|
184
|
+
if (storageProvider) {
|
|
185
|
+
const failedKeys = await deleteBinaries(rows, storageProvider);
|
|
186
|
+
if (failedKeys.length > 0) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
|
|
149
189
|
);
|
|
150
190
|
}
|
|
151
|
-
await deleteMany(ctx.db, fileRefsTable, { tenantId: ctx.tenantId, insertedById: ctx.userId });
|
|
152
191
|
} else {
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
{ tenantId: ctx.tenantId, insertedById: ctx.userId },
|
|
192
|
+
// No warn-once guard: a forget-cleanup cron runs rarely enough (not a hot
|
|
193
|
+
// path) that logging every occurrence is fine, and an operator who fixes the
|
|
194
|
+
// provider config mid-process sees the warning stop on the very next run —
|
|
195
|
+
// a module-level "warned once" flag would silence it for the rest of the
|
|
196
|
+
// process even after the misconfiguration is corrected.
|
|
197
|
+
// biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
|
|
198
|
+
console.warn(
|
|
199
|
+
"[user-data-rights-defaults:fileRef] no file provider resolvable from ctx.buildStorageProvider — file binaries are NOT deleted on forget (row-only delete). Mount file-foundation + a file-provider-* feature and set the provider config so erasure can reach the binaries.",
|
|
162
200
|
);
|
|
163
201
|
}
|
|
202
|
+
// Hard-purge each row via the executor forget-verb: emits fileRef.forgotten,
|
|
203
|
+
// which hard-deletes the row even though fileRef is softDelete — and, being an
|
|
204
|
+
// auto-verb, the erasure replays on rebuild (created → forgotten → row gone).
|
|
205
|
+
// The old hard deleteMany was resurrected on rebuild; this closes that Art.17
|
|
206
|
+
// hole without a direct write.
|
|
207
|
+
for (const row of rows) {
|
|
208
|
+
const id = row["id"]; // @cast-boundary db-row
|
|
209
|
+
if (typeof id !== "string") continue;
|
|
210
|
+
await crud.forget({ id }, systemUser, tdb);
|
|
211
|
+
}
|
|
164
212
|
};
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { fetchOne
|
|
2
|
-
import
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
createSystemUser,
|
|
5
|
+
type UserDataDeleteHook,
|
|
6
|
+
type UserDataExportHook,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
8
|
import {
|
|
4
9
|
USER_ANONYMIZED_DISPLAY_NAME,
|
|
5
10
|
USER_ANONYMIZED_EMAIL_DOMAIN,
|
|
@@ -7,9 +12,14 @@ import {
|
|
|
7
12
|
USER_DELETED_DISPLAY_NAME,
|
|
8
13
|
USER_DELETED_EMAIL_PREFIX,
|
|
9
14
|
USER_STATUS,
|
|
15
|
+
userEntity,
|
|
10
16
|
userTable,
|
|
11
17
|
} from "../../user";
|
|
12
|
-
|
|
18
|
+
|
|
19
|
+
// Forget writes go through the executor (events), not a raw UPDATE: a
|
|
20
|
+
// projection rebuild replays the events, so the anonymization survives it.
|
|
21
|
+
// A direct write would be wiped on rebuild — the Art.17 hole this fixes.
|
|
22
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
13
23
|
|
|
14
24
|
// userData-Hook fuer user-entity (S2.H1).
|
|
15
25
|
//
|
|
@@ -63,27 +73,55 @@ export const userDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
|
63
73
|
// korrelieren — user.id ist tenant-agnostic (User kann in mehreren
|
|
64
74
|
// Tenants Member sein), kein tenantId-Filter noetig.
|
|
65
75
|
|
|
76
|
+
// System actor + skipOptimisticLock: the forget pipeline has no read
|
|
77
|
+
// version and writes privileged identity columns ("system" is privileged).
|
|
78
|
+
// The executor needs a TenantDb (loadById → db.fetchOne); ctx.db is a raw
|
|
79
|
+
// runner, so wrap it. "system" mode = no tenant filter — user is a systemStream
|
|
80
|
+
// entity (tenant-agnostic) and forget must reach the row in any tenant.
|
|
81
|
+
const systemUser = createSystemUser(ctx.tenantId);
|
|
82
|
+
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
83
|
+
|
|
66
84
|
if (strategy === "delete") {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
85
|
+
// PII raus + status=deleted (Login geblockt) via Event → Rebuild spielt den
|
|
86
|
+
// Forget mit. KEIN softDelete: user ist systemStream (eine tenant-agnostische
|
|
87
|
+
// Row), der Forget laeuft pro Tenant — wuerde isDeleted gesetzt, faende der
|
|
88
|
+
// loadById (isDeleted:false) die Row beim zweiten Tenant nicht mehr
|
|
89
|
+
// (not_found). status=Deleted blockt Login; die Row bleibt findbar +
|
|
90
|
+
// idempotent ueber mehrere Tenant-Durchlaeufe (wie der alte raw UPDATE).
|
|
91
|
+
await crud.update(
|
|
92
|
+
{
|
|
93
|
+
id: ctx.userId,
|
|
94
|
+
changes: {
|
|
95
|
+
email: `${USER_DELETED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
|
|
96
|
+
displayName: USER_DELETED_DISPLAY_NAME,
|
|
97
|
+
passwordHash: null,
|
|
98
|
+
status: USER_STATUS.Deleted,
|
|
99
|
+
// deleted_at as an audit timestamp only — NOT isDeleted (see above).
|
|
100
|
+
// Frozen into the event payload, so a rebuild replays the same value.
|
|
101
|
+
deletedAt: Temporal.Now.instant(),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
systemUser,
|
|
105
|
+
tdb,
|
|
106
|
+
{ skipOptimisticLock: true },
|
|
107
|
+
);
|
|
73
108
|
} else {
|
|
74
109
|
// anonymize: PII raus, aber Row bleibt active (damit FK-References
|
|
75
110
|
// weiter aufloesbar sind). Account ist effektiv weiter nutzbar
|
|
76
111
|
// wenn der User sich neu authentifiziert — pragmatisch akzeptabel
|
|
77
112
|
// weil "anonymize" auf user-entity ein seltener Edge-Case ist
|
|
78
113
|
// (typisch hard-delete fuer User).
|
|
79
|
-
await
|
|
80
|
-
ctx.db,
|
|
81
|
-
userTable,
|
|
114
|
+
await crud.update(
|
|
82
115
|
{
|
|
83
|
-
|
|
84
|
-
|
|
116
|
+
id: ctx.userId,
|
|
117
|
+
changes: {
|
|
118
|
+
email: `${USER_ANONYMIZED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
|
|
119
|
+
displayName: USER_ANONYMIZED_DISPLAY_NAME,
|
|
120
|
+
},
|
|
85
121
|
},
|
|
86
|
-
|
|
122
|
+
systemUser,
|
|
123
|
+
tdb,
|
|
124
|
+
{ skipOptimisticLock: true },
|
|
87
125
|
);
|
|
88
126
|
}
|
|
89
127
|
};
|
|
@@ -88,6 +88,15 @@ describe("ProfileScreen", () => {
|
|
|
88
88
|
expect(cardCount(view.getByTestId("profile-password"))).toBe(1);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
test("Passwort- und Email-Section rendern auf einem echten <form> (Enter-to-submit, Password-Manager-Boundary) (555/1)", async () => {
|
|
92
|
+
const view = renderProfile(activeMe);
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
if (view.queryByTestId("profile-screen") === null) throw new Error("not mounted yet");
|
|
95
|
+
});
|
|
96
|
+
expect(view.getByTestId("profile-password").tagName).toBe("FORM");
|
|
97
|
+
expect(view.getByTestId("profile-email").tagName).toBe("FORM");
|
|
98
|
+
});
|
|
99
|
+
|
|
91
100
|
test("deletionRequested: Frist-Banner + Abbrechen statt Lösch-Button", async () => {
|
|
92
101
|
const view = renderProfile({
|
|
93
102
|
...activeMe,
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
createSystemUser,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
SYSTEM_TENANT_ID,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
2
7
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
-
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
8
|
import { z } from "zod";
|
|
5
9
|
import { AuthErrors, verifyPassword } from "../../auth-email-password";
|
|
6
|
-
import {
|
|
10
|
+
import { UserErrors, UserHandlers, UserQueries } from "../../user";
|
|
7
11
|
import { UserProfileErrors } from "../constants";
|
|
8
12
|
|
|
9
13
|
// Gleiche Failure-Shape wie auth-email-password (anti-enumeration):
|
|
@@ -66,11 +70,10 @@ export const changeEmailWrite = defineWriteHandler({
|
|
|
66
70
|
);
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
const
|
|
73
|
-
const writer = createSystemUser(streamTenant ?? event.user.tenantId);
|
|
73
|
+
// user ist systemStream (#497): der Event-Stream liegt deterministisch
|
|
74
|
+
// auf SYSTEM_TENANT_ID. Pre-#497-Streams brauchen einmalig
|
|
75
|
+
// backfillUserStreamTenants (#762).
|
|
76
|
+
const writer = createSystemUser(SYSTEM_TENANT_ID);
|
|
74
77
|
|
|
75
78
|
const writeRes = await ctx.writeAs(writer, UserHandlers.update, {
|
|
76
79
|
id: me.id,
|
|
@@ -59,7 +59,7 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
|
|
|
59
59
|
|
|
60
60
|
function ChangePasswordSection(): ReactNode {
|
|
61
61
|
const t = useTranslation();
|
|
62
|
-
const {
|
|
62
|
+
const { Form, Field, Input, Button } = usePrimitives();
|
|
63
63
|
const dispatcher = useDispatcher();
|
|
64
64
|
const [oldPassword, setOldPassword] = useState("");
|
|
65
65
|
const [newPassword, setNewPassword] = useState("");
|
|
@@ -90,11 +90,12 @@ function ChangePasswordSection(): ReactNode {
|
|
|
90
90
|
|
|
91
91
|
const submitting = status.kind === "submitting";
|
|
92
92
|
return (
|
|
93
|
-
<
|
|
93
|
+
<Form
|
|
94
94
|
testId="profile-password"
|
|
95
95
|
title={t("profile.password.title")}
|
|
96
|
+
onSubmit={onSubmit}
|
|
96
97
|
actions={
|
|
97
|
-
<Button
|
|
98
|
+
<Button type="submit" disabled={submitting} testId="profile-password-submit">
|
|
98
99
|
{t("profile.password.submit")}
|
|
99
100
|
</Button>
|
|
100
101
|
}
|
|
@@ -136,7 +137,7 @@ function ChangePasswordSection(): ReactNode {
|
|
|
136
137
|
/>
|
|
137
138
|
</Field>
|
|
138
139
|
<StatusBanner status={status} />
|
|
139
|
-
</
|
|
140
|
+
</Form>
|
|
140
141
|
);
|
|
141
142
|
}
|
|
142
143
|
|
|
@@ -148,7 +149,7 @@ function ChangeEmailSection({
|
|
|
148
149
|
readonly onChanged: () => void;
|
|
149
150
|
}): ReactNode {
|
|
150
151
|
const t = useTranslation();
|
|
151
|
-
const {
|
|
152
|
+
const { Form, Field, Input, Button } = usePrimitives();
|
|
152
153
|
const dispatcher = useDispatcher();
|
|
153
154
|
const [newEmail, setNewEmail] = useState("");
|
|
154
155
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
@@ -192,16 +193,17 @@ function ChangeEmailSection({
|
|
|
192
193
|
|
|
193
194
|
const submitting = status.kind === "submitting";
|
|
194
195
|
return (
|
|
195
|
-
<
|
|
196
|
+
<Form
|
|
196
197
|
testId="profile-email"
|
|
197
198
|
title={t("profile.email.title")}
|
|
199
|
+
onSubmit={onSubmit}
|
|
198
200
|
subtitle={
|
|
199
201
|
<span data-testid="profile-email-current">
|
|
200
202
|
{t("profile.email.current")}: {me.email}
|
|
201
203
|
</span>
|
|
202
204
|
}
|
|
203
205
|
actions={
|
|
204
|
-
<Button
|
|
206
|
+
<Button type="submit" disabled={submitting} testId="profile-email-submit">
|
|
205
207
|
{t("profile.email.submit")}
|
|
206
208
|
</Button>
|
|
207
209
|
}
|
|
@@ -231,7 +233,7 @@ function ChangeEmailSection({
|
|
|
231
233
|
/>
|
|
232
234
|
</Field>
|
|
233
235
|
<StatusBanner status={status} />
|
|
234
|
-
</
|
|
236
|
+
</Form>
|
|
235
237
|
);
|
|
236
238
|
}
|
|
237
239
|
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// user-feature runs with r.systemScope() but events land on a concrete
|
|
2
|
-
// tenant stream. The row.version tracks whichever stream the user's last
|
|
3
|
-
// modification wrote to — and that tenant is NOT discoverable from the
|
|
4
|
-
// row alone.
|
|
5
|
-
//
|
|
6
|
-
// Strategy: prioritize lastActiveTenantId (most likely holds the latest
|
|
7
|
-
// event), fall through to the remaining memberships in insertion order,
|
|
8
|
-
// and let the caller try each one in sequence. The first stream whose
|
|
9
|
-
// version matches row.version wins; the rest are bypassed.
|
|
10
|
-
//
|
|
11
|
-
// This is pragmatic — the real fix is to scope user events to
|
|
12
|
-
// SYSTEM_TENANT_ID when the feature is r.systemScope(), which is a
|
|
13
|
-
// framework-level change tracked separately. Until then, "try each
|
|
14
|
-
// tenant the user belongs to" is robust against non-deterministic
|
|
15
|
-
// memberships-query ordering (tenant:query:memberships has no ORDER BY).
|
|
16
|
-
|
|
17
|
-
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
-
|
|
19
|
-
export function orderTenantsByPreference(
|
|
20
|
-
memberships: readonly { readonly tenantId: TenantId }[],
|
|
21
|
-
lastActiveTenantId: string | null | undefined,
|
|
22
|
-
): TenantId[] {
|
|
23
|
-
if (memberships.length === 0) return [];
|
|
24
|
-
const ids = memberships.map((m) => m.tenantId);
|
|
25
|
-
if (!lastActiveTenantId) return ids;
|
|
26
|
-
// Move lastActiveTenantId to the front; preserve relative order of rest.
|
|
27
|
-
const preferred = ids.find((id) => id === lastActiveTenantId);
|
|
28
|
-
if (!preferred) return ids;
|
|
29
|
-
const rest = ids.filter((id) => id !== preferred);
|
|
30
|
-
return [preferred, ...rest];
|
|
31
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
|
|
4
|
-
export async function anonymizeDeletedUser(
|
|
5
|
-
db: DbRunner,
|
|
6
|
-
params: {
|
|
7
|
-
readonly email: string;
|
|
8
|
-
readonly displayName: string;
|
|
9
|
-
readonly status: string;
|
|
10
|
-
readonly userId: string;
|
|
11
|
-
},
|
|
12
|
-
): Promise<void> {
|
|
13
|
-
await asRawClient(db).unsafe(
|
|
14
|
-
'UPDATE "read_users" SET email = $1, display_name = $2, password_hash = $3, status = $4, deleted_at = now() WHERE id = $5',
|
|
15
|
-
[params.email, params.displayName, null, params.status, params.userId],
|
|
16
|
-
);
|
|
17
|
-
}
|