@cosmicdrift/kumiko-bundled-features 0.205.0 → 0.207.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 -7
- package/src/admin-shell/i18n.ts +36 -17
- package/src/admin-shell/web/i18n.ts +1 -1
- package/src/audit/i18n.ts +41 -25
- package/src/audit/web/i18n.ts +1 -1
- package/src/auth-email-password/i18n.ts +129 -0
- package/src/auth-mfa/i18n.ts +2 -1
- package/src/auth-mfa/web/i18n.ts +63 -0
- package/src/cap-counter/i18n.ts +10 -5
- package/src/compliance-profiles/i18n.ts +11 -3
- package/src/compliance-profiles/web/i18n.ts +10 -0
- package/src/config/i18n.ts +5 -5
- package/src/config/web/i18n.ts +9 -0
- package/src/custom-fields/web/i18n.ts +9 -0
- package/src/delivery/i18n.ts +7 -3
- package/src/delivery/web/i18n.ts +10 -0
- package/src/feature-toggles/i18n.ts +11 -3
- package/src/feature-toggles/web/i18n.ts +12 -0
- package/src/folders/web/i18n.ts +25 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +11 -10
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +21 -26
- package/src/jobs/i18n.ts +62 -40
- package/src/jobs/web/i18n.ts +1 -1
- package/src/managed-pages/i18n.ts +63 -23
- package/src/managed-pages/web/i18n.ts +1 -1
- package/src/notes-history/i18n.ts +1 -1
- package/src/notes-history/web/i18n.ts +8 -0
- package/src/personal-access-tokens/i18n.ts +6 -2
- package/src/personal-access-tokens/web/i18n.ts +35 -0
- package/src/sessions/i18n.ts +21 -17
- package/src/tags/i18n.ts +2 -2
- package/src/tags/web/i18n.ts +25 -0
- package/src/template-resolver/web/i18n.ts +14 -0
- package/src/tenant/i18n.ts +24 -13
- package/src/tenant/web/i18n.ts +19 -0
- package/src/tier-engine/i18n.ts +22 -2
- package/src/user/__tests__/email-unique-blind-index.integration.test.ts +140 -0
- package/src/user/__tests__/user.integration.test.ts +31 -0
- package/src/user/handlers/create.write.ts +54 -8
- package/src/user/i18n.ts +31 -14
- package/src/user/schema/user.ts +11 -0
- package/src/user-data-rights/i18n.ts +66 -18
- package/src/user-data-rights/web/i18n.ts +70 -1
- package/src/user-profile/i18n.ts +35 -0
package/src/tenant/i18n.ts
CHANGED
|
@@ -1,28 +1,39 @@
|
|
|
1
|
-
type LocalizedString = { readonly de: string; readonly en: string };
|
|
1
|
+
type LocalizedString = { readonly de: string; readonly en: string; readonly es: string };
|
|
2
2
|
|
|
3
3
|
export const TENANT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
-
"screen:tenant-list.title": { de: "Mandanten", en: "Tenants" },
|
|
5
|
-
"screen:tenant-edit.title": {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
"tenant
|
|
12
|
-
"tenant:entity:tenant:field:
|
|
4
|
+
"screen:tenant-list.title": { de: "Mandanten", en: "Tenants", es: "Organizaciones" },
|
|
5
|
+
"screen:tenant-edit.title": {
|
|
6
|
+
de: "Mandant bearbeiten",
|
|
7
|
+
en: "Edit tenant",
|
|
8
|
+
es: "Editar organización",
|
|
9
|
+
},
|
|
10
|
+
"screen:members.title": { de: "Team", en: "Team", es: "Equipo" },
|
|
11
|
+
"tenant.nav.members": { de: "Team", en: "Team", es: "Equipo" },
|
|
12
|
+
"tenant:entity:tenant:field:key": { de: "Schlüssel", en: "Key", es: "Clave" },
|
|
13
|
+
"tenant:entity:tenant:field:name": { de: "Name", en: "Name", es: "Nombre" },
|
|
14
|
+
"tenant:entity:tenant:field:isEnabled": { de: "Aktiv", en: "Enabled", es: "Activado" },
|
|
15
|
+
"tenant:entity:tenant:field:status": { de: "Status", en: "Status", es: "Estado" },
|
|
16
|
+
"tenant:entity:tenant:field:status:option:active": { de: "Aktiv", en: "Active", es: "Activo" },
|
|
13
17
|
"tenant:entity:tenant:field:status:option:destroyRequested": {
|
|
14
18
|
de: "Löschung angefordert",
|
|
15
19
|
en: "Destroy requested",
|
|
20
|
+
es: "Eliminación solicitada",
|
|
16
21
|
},
|
|
17
22
|
"tenant:entity:tenant:field:status:option:destroying": {
|
|
18
23
|
de: "Wird gelöscht",
|
|
19
24
|
en: "Destroying",
|
|
25
|
+
es: "Eliminándose",
|
|
20
26
|
},
|
|
21
27
|
"tenant:entity:tenant:field:status:option:destroyFailed": {
|
|
22
28
|
de: "Löschung fehlgeschlagen",
|
|
23
29
|
en: "Destroy failed",
|
|
30
|
+
es: "Eliminación fallida",
|
|
31
|
+
},
|
|
32
|
+
"tenant:entity:tenant:field:status:option:destroyed": {
|
|
33
|
+
de: "Gelöscht",
|
|
34
|
+
en: "Destroyed",
|
|
35
|
+
es: "Eliminado",
|
|
24
36
|
},
|
|
25
|
-
"tenant:entity:tenant:field:
|
|
26
|
-
"tenant:entity:tenant:field:isEnabled:option:
|
|
27
|
-
"tenant:entity:tenant:field:isEnabled:option:false": { de: "Nein", en: "No" },
|
|
37
|
+
"tenant:entity:tenant:field:isEnabled:option:true": { de: "Ja", en: "Yes", es: "Sí" },
|
|
38
|
+
"tenant:entity:tenant:field:isEnabled:option:false": { de: "Nein", en: "No", es: "No" },
|
|
28
39
|
};
|
package/src/tenant/web/i18n.ts
CHANGED
|
@@ -40,4 +40,23 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
40
40
|
"tenant.members.col.expires": "Valid until",
|
|
41
41
|
"tenant.nav.members": "Team",
|
|
42
42
|
},
|
|
43
|
+
es: {
|
|
44
|
+
"tenant.members.title": "Miembros",
|
|
45
|
+
"tenant.members.loading": "Cargando miembros…",
|
|
46
|
+
"tenant.members.active": "Miembros activos",
|
|
47
|
+
"tenant.members.invite.title": "Nueva invitación",
|
|
48
|
+
"tenant.members.invite.email": "Correo electrónico",
|
|
49
|
+
"tenant.members.invite.role": "Rol",
|
|
50
|
+
"tenant.members.invite.submit": "Enviar invitación",
|
|
51
|
+
"tenant.members.invite.submitting": "Enviando invitación…",
|
|
52
|
+
"tenant.members.invite.success": "Invitación enviada a {email}",
|
|
53
|
+
"tenant.members.pending": "Invitaciones pendientes",
|
|
54
|
+
"tenant.members.pending.empty": "No hay invitaciones pendientes.",
|
|
55
|
+
"tenant.members.cancel": "Cancelar",
|
|
56
|
+
"tenant.members.col.userId": "ID de usuario",
|
|
57
|
+
"tenant.members.col.roles": "Roles",
|
|
58
|
+
"tenant.members.col.email": "Correo electrónico",
|
|
59
|
+
"tenant.members.col.expires": "Válido hasta",
|
|
60
|
+
"tenant.nav.members": "Equipo",
|
|
61
|
+
},
|
|
43
62
|
};
|
package/src/tier-engine/i18n.ts
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
|
|
6
6
|
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
7
7
|
|
|
8
|
-
type LocalizedString = { readonly de: string; readonly en: string };
|
|
8
|
+
type LocalizedString = { readonly de: string; readonly en: string; readonly es: string };
|
|
9
9
|
|
|
10
10
|
export const TIER_ENGINE_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
11
|
-
"screen:tier-admin.title": {
|
|
11
|
+
"screen:tier-admin.title": {
|
|
12
|
+
de: "Tier manuell zuweisen",
|
|
13
|
+
en: "Assign tier manually",
|
|
14
|
+
es: "Asignar tier manualmente",
|
|
15
|
+
},
|
|
12
16
|
};
|
|
13
17
|
|
|
14
18
|
export const defaultTranslations: TranslationsByLocale = {
|
|
@@ -44,4 +48,20 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
44
48
|
"tier-admin.error.noTiers":
|
|
45
49
|
"This app has no TierMap configured — there are no assignable tiers.",
|
|
46
50
|
},
|
|
51
|
+
es: {
|
|
52
|
+
"screen:tier-admin.title": "Asignar tier manualmente",
|
|
53
|
+
"tier-admin.title": "Asignar tier manualmente",
|
|
54
|
+
"tier-admin.explainer":
|
|
55
|
+
"Asigna un tier a un tenant sin necesidad de compra. La asignación se marca como «manual» y no será sobrescrita por una sincronización de facturación posterior.",
|
|
56
|
+
"tier-admin.tenant.label": "Tenant",
|
|
57
|
+
"tier-admin.current.label": "Tier actual",
|
|
58
|
+
"tier-admin.current.none": "— aún ninguno —",
|
|
59
|
+
"tier-admin.tier.label": "Nuevo tier",
|
|
60
|
+
"tier-admin.submit": "Asignar tier",
|
|
61
|
+
"tier-admin.success": "Tier «{tier}» asignado.",
|
|
62
|
+
"tier-admin.error.generic": "No se pudo asignar el tier.",
|
|
63
|
+
"tier-admin.error.load": "No se pudieron cargar los tenants.",
|
|
64
|
+
"tier-admin.error.noTiers":
|
|
65
|
+
"Esta aplicación no tiene una TierMap configurada — no hay tiers asignables.",
|
|
66
|
+
},
|
|
47
67
|
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// fw#2134 — user:create's email-uniqueness check used to be a pure
|
|
2
|
+
// pre-flight fetchOne (TOCTOU: two concurrent creates can both pass the
|
|
3
|
+
// pre-flight and both insert, see handlers/create.write.ts). This proves
|
|
4
|
+
// the DB-level fix directly against the executor (bypassing the handler's
|
|
5
|
+
// pre-flight entirely) — a unique index over email's blind-index column
|
|
6
|
+
// rejects the loser of a genuine race, and the framework's F8 pg-23505
|
|
7
|
+
// mapping turns that into a clean 409 unique_violation, not a 500.
|
|
8
|
+
//
|
|
9
|
+
// createTestDb wires no blind-index key by default (see
|
|
10
|
+
// blind-index.integration.test.ts) — without configureBlindIndexKey the
|
|
11
|
+
// email_bidx column stays NULL for every row, the partial bidx unique
|
|
12
|
+
// index never applies, and a race "passing" here would prove nothing. So
|
|
13
|
+
// this test configures one explicitly and then proves the loser was
|
|
14
|
+
// actually rejected on the bidx constraint (not the plaintext-fallback
|
|
15
|
+
// index) by asserting the constraint name and reading email_bidx back off
|
|
16
|
+
// the surviving row.
|
|
17
|
+
|
|
18
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
|
+
import {
|
|
21
|
+
computeBlindIndex,
|
|
22
|
+
configureBlindIndexKey,
|
|
23
|
+
configurePiiSubjectKms,
|
|
24
|
+
decodeBlindIndexKey,
|
|
25
|
+
InMemoryKmsAdapter,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
27
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
28
|
+
import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
29
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
30
|
+
import {
|
|
31
|
+
createTestDb,
|
|
32
|
+
type TestDb,
|
|
33
|
+
unsafeCreateEntityTable,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
35
|
+
import {
|
|
36
|
+
resetBlindIndexKeyForTests,
|
|
37
|
+
resetPiiSubjectKmsForTests,
|
|
38
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
39
|
+
import { userEntity, userTable } from "../schema/user";
|
|
40
|
+
|
|
41
|
+
const TEST_KEY_B64 = Buffer.alloc(32, 9).toString("base64");
|
|
42
|
+
const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
|
|
43
|
+
|
|
44
|
+
let testDb: TestDb;
|
|
45
|
+
const executor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
testDb = await createTestDb();
|
|
49
|
+
await unsafeCreateEntityTable(testDb.db, userEntity, "user");
|
|
50
|
+
await createEventsTable(testDb.db);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(async () => {
|
|
54
|
+
await testDb.cleanup();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
await asRawClient(testDb.db).unsafe(
|
|
59
|
+
`TRUNCATE kumiko_events, read_users RESTART IDENTITY CASCADE`,
|
|
60
|
+
);
|
|
61
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
62
|
+
configureBlindIndexKey(TEST_KEY_B64);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
resetPiiSubjectKmsForTests();
|
|
67
|
+
resetBlindIndexKeyForTests();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("fw#2134 — email unique constraint on the blind-index column", () => {
|
|
71
|
+
test("two concurrent creates with the same email: exactly one wins, loser fails on the bidx constraint", async () => {
|
|
72
|
+
const email = "race@example.com";
|
|
73
|
+
const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
|
|
74
|
+
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
75
|
+
|
|
76
|
+
const [first, second] = await Promise.all([
|
|
77
|
+
executor.create({ email, displayName: "Racer One" }, systemUser, tdb),
|
|
78
|
+
executor.create({ email, displayName: "Racer Two" }, systemUser, tdb),
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const winner = first.isSuccess ? first : second;
|
|
82
|
+
const loser = first.isSuccess ? second : first;
|
|
83
|
+
if (loser.isSuccess || !winner.isSuccess) {
|
|
84
|
+
throw new Error("expected exactly one winner and one loser out of the two racing creates");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
expect(loser.error.code).toBe("unique_violation");
|
|
88
|
+
expect(loser.error.httpStatus).toBe(409);
|
|
89
|
+
const details = loser.error.details as { constraintName?: string };
|
|
90
|
+
expect(details.constraintName).toBe("read_users_email_unique_bidx");
|
|
91
|
+
|
|
92
|
+
// Proves the race was actually decided by the blind-index constraint,
|
|
93
|
+
// not the plaintext-fallback index: the surviving row's bidx column
|
|
94
|
+
// carries the deterministic HMAC of the email.
|
|
95
|
+
const rows = (await asRawClient(testDb.db).unsafe(
|
|
96
|
+
`SELECT "email_bidx" FROM "read_users" WHERE "id" = $1::uuid`,
|
|
97
|
+
[winner.data.id],
|
|
98
|
+
)) as ReadonlyArray<{ email_bidx: string | null }>;
|
|
99
|
+
expect(rows[0]?.email_bidx).toBe(computeBlindIndex(TEST_KEY, email));
|
|
100
|
+
|
|
101
|
+
// DB-proof: only one row actually survives under that blind index.
|
|
102
|
+
const survivors = (await asRawClient(testDb.db).unsafe(
|
|
103
|
+
`SELECT count(*)::int AS n FROM "read_users" WHERE "email_bidx" = $1`,
|
|
104
|
+
[computeBlindIndex(TEST_KEY, email)],
|
|
105
|
+
)) as ReadonlyArray<{ n: number }>;
|
|
106
|
+
expect(survivors[0]?.n).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a soft-deleted user still holds its email: constraint agrees with the pre-existing pre-flight behavior", async () => {
|
|
110
|
+
// Soft-delete only flips isDeleted/deletedAt (apply-entity-event.ts) —
|
|
111
|
+
// it never touches email/email_bidx, so the row still occupies the
|
|
112
|
+
// unique slot. This isn't new behavior from fw#2134: the handler's
|
|
113
|
+
// pre-flight fetchOne is a raw, unfiltered query (see create.write.ts)
|
|
114
|
+
// and already saw soft-deleted rows before this fix. This test pins
|
|
115
|
+
// that the new DB constraint doesn't diverge from that — only the
|
|
116
|
+
// GDPR "forgotten" hard-delete (see forget-cleanup) actually frees the
|
|
117
|
+
// email slot.
|
|
118
|
+
const email = "soft-deleted@example.com";
|
|
119
|
+
const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
|
|
120
|
+
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
121
|
+
|
|
122
|
+
const created = await executor.create(
|
|
123
|
+
{ email, displayName: "Departing User" },
|
|
124
|
+
systemUser,
|
|
125
|
+
tdb,
|
|
126
|
+
);
|
|
127
|
+
if (!created.isSuccess) throw new Error("expected create to succeed");
|
|
128
|
+
|
|
129
|
+
const deleted = await executor.delete({ id: created.data.id }, systemUser, tdb);
|
|
130
|
+
if (!deleted.isSuccess) throw new Error("expected soft-delete to succeed");
|
|
131
|
+
|
|
132
|
+
const reCreated = await executor.create({ email, displayName: "Squatter" }, systemUser, tdb);
|
|
133
|
+
if (reCreated.isSuccess) {
|
|
134
|
+
throw new Error("expected re-create with the same email to be rejected while soft-deleted");
|
|
135
|
+
}
|
|
136
|
+
expect(reCreated.error.code).toBe("unique_violation");
|
|
137
|
+
const details = reCreated.error.details as { constraintName?: string };
|
|
138
|
+
expect(details.constraintName).toBe("read_users_email_unique_bidx");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -107,6 +107,37 @@ describe("scenario 1: create + me", () => {
|
|
|
107
107
|
);
|
|
108
108
|
expectErrorIncludes(error, UserErrors.emailAlreadyExists);
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
// fw#2134 — the pre-flight fetchOne alone can't decide a genuine race
|
|
112
|
+
// (both requests can see "no duplicate" before either commits); this
|
|
113
|
+
// fires two real concurrent HTTP creates and checks the loser still
|
|
114
|
+
// gets a clean 4xx, never an unhandled 500. The DB-constraint-level
|
|
115
|
+
// proof (which layer actually catches the race, and that it's the
|
|
116
|
+
// blind-index column doing it) lives in
|
|
117
|
+
// email-unique-blind-index.integration.test.ts — this test's job is
|
|
118
|
+
// narrower: no request may 500 no matter who wins.
|
|
119
|
+
test("two concurrent creates with the same email: loser gets a clean 4xx, not a 500", async () => {
|
|
120
|
+
const email = "concurrent@example.com";
|
|
121
|
+
const [resA, resB] = await Promise.all([
|
|
122
|
+
stack.http.write(UserHandlers.create, { email, displayName: "Racer A" }, systemAdmin),
|
|
123
|
+
stack.http.write(UserHandlers.create, { email, displayName: "Racer B" }, systemAdmin),
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const bodyA = (await resA.json()) as { isSuccess: boolean };
|
|
127
|
+
const bodyB = (await resB.json()) as { isSuccess: boolean };
|
|
128
|
+
const successes = [bodyA, bodyB].filter((b) => b.isSuccess === true);
|
|
129
|
+
const failures = [
|
|
130
|
+
{ status: resA.status, body: bodyA },
|
|
131
|
+
{ status: resB.status, body: bodyB },
|
|
132
|
+
].filter((r) => r.body.isSuccess === false);
|
|
133
|
+
|
|
134
|
+
expect(successes).toHaveLength(1);
|
|
135
|
+
expect(failures).toHaveLength(1);
|
|
136
|
+
const loser = failures[0];
|
|
137
|
+
if (!loser) throw new Error("expected exactly one failing response");
|
|
138
|
+
expect(loser.status).toBeGreaterThanOrEqual(400);
|
|
139
|
+
expect(loser.status).toBeLessThan(500);
|
|
140
|
+
});
|
|
110
141
|
});
|
|
111
142
|
|
|
112
143
|
// --- Scenario 2: field-level read access hides passwordHash ---
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ConflictError,
|
|
6
|
+
InternalError,
|
|
7
|
+
type WriteErrorInfo,
|
|
8
|
+
writeFailure,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
5
10
|
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
6
11
|
import { z } from "zod";
|
|
7
12
|
import { UserErrors } from "../constants";
|
|
@@ -11,12 +16,16 @@ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user
|
|
|
11
16
|
|
|
12
17
|
// Only the Auth features (running as SYSTEM) or a SystemAdmin may create users.
|
|
13
18
|
//
|
|
14
|
-
// Email uniqueness
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
19
|
+
// Email uniqueness has two layers (fw#2134):
|
|
20
|
+
// 1. A pre-flight fetchOne — fast-fails with a friendly error on the
|
|
21
|
+
// common case, but is race-prone on its own: two concurrent requests
|
|
22
|
+
// can both see "no duplicate" and both proceed to crud.create.
|
|
23
|
+
// 2. The real guard is the unique index over email's blind-index column
|
|
24
|
+
// (schema/user.ts). A losing concurrent create hits that constraint
|
|
25
|
+
// and crud.create returns a UniqueViolationError (framework's F8
|
|
26
|
+
// pg-23505 mapping) instead of throwing — remapped below to the same
|
|
27
|
+
// emailAlreadyExists shape the pre-flight path returns, so callers see
|
|
28
|
+
// one consistent error regardless of which layer caught the race.
|
|
20
29
|
export const createWrite = defineWriteHandler({
|
|
21
30
|
name: "user:create",
|
|
22
31
|
schema: z.object({
|
|
@@ -56,6 +65,43 @@ export const createWrite = defineWriteHandler({
|
|
|
56
65
|
);
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
const result = await crud.create(event.payload, event.user, db);
|
|
69
|
+
if (!result.isSuccess && isEmailUniqueViolation(result.error)) {
|
|
70
|
+
return writeFailure(
|
|
71
|
+
new ConflictError({
|
|
72
|
+
message: "email already exists",
|
|
73
|
+
i18nKey: "user.errors.emailAlreadyExists",
|
|
74
|
+
details: {
|
|
75
|
+
reason: UserErrors.emailAlreadyExists,
|
|
76
|
+
field: "email",
|
|
77
|
+
constraintName: constraintNameOf(result.error),
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
60
83
|
},
|
|
61
84
|
});
|
|
85
|
+
|
|
86
|
+
// schema/user.ts's `read_users_email_unique` (+ its generated `_bidx`
|
|
87
|
+
// pendant) is the actual race-safety net behind the pre-flight check
|
|
88
|
+
// above — a losing concurrent create surfaces here as a generic
|
|
89
|
+
// UniqueViolationError (framework F8 pg-23505 mapping). Only the email
|
|
90
|
+
// constraint gets remapped; any other unique_violation on this entity
|
|
91
|
+
// passes through unchanged.
|
|
92
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
93
|
+
return typeof value === "object" && value !== null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function constraintNameOf(error: WriteErrorInfo): string | undefined {
|
|
97
|
+
if (!isRecord(error.details)) return undefined;
|
|
98
|
+
const constraintName = error.details["constraintName"];
|
|
99
|
+
return typeof constraintName === "string" ? constraintName : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isEmailUniqueViolation(error: WriteErrorInfo): boolean {
|
|
103
|
+
return (
|
|
104
|
+
error.code === "unique_violation" &&
|
|
105
|
+
(constraintNameOf(error)?.startsWith("read_users_email_unique") ?? false)
|
|
106
|
+
);
|
|
107
|
+
}
|
package/src/user/i18n.ts
CHANGED
|
@@ -1,21 +1,38 @@
|
|
|
1
|
-
type LocalizedString = { readonly de: string; readonly en: string };
|
|
1
|
+
type LocalizedString = { readonly de: string; readonly en: string; readonly es: string };
|
|
2
2
|
|
|
3
3
|
export const USER_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
-
"screen:user-list.title": { de: "Benutzer", en: "Users" },
|
|
5
|
-
"screen:user-edit.title": { de: "Benutzer bearbeiten", en: "Edit user" },
|
|
6
|
-
"user:entity:user:field:email": { de: "E-Mail", en: "Email" },
|
|
7
|
-
"user:entity:user:field:displayName": {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"user:entity:user:field:status
|
|
13
|
-
"user:entity:user:field:
|
|
4
|
+
"screen:user-list.title": { de: "Benutzer", en: "Users", es: "Usuarios" },
|
|
5
|
+
"screen:user-edit.title": { de: "Benutzer bearbeiten", en: "Edit user", es: "Editar usuario" },
|
|
6
|
+
"user:entity:user:field:email": { de: "E-Mail", en: "Email", es: "Correo electrónico" },
|
|
7
|
+
"user:entity:user:field:displayName": {
|
|
8
|
+
de: "Anzeigename",
|
|
9
|
+
en: "Display name",
|
|
10
|
+
es: "Nombre para mostrar",
|
|
11
|
+
},
|
|
12
|
+
"user:entity:user:field:status": { de: "Status", en: "Status", es: "Estado" },
|
|
13
|
+
"user:entity:user:field:emailVerified": {
|
|
14
|
+
de: "E-Mail bestätigt",
|
|
15
|
+
en: "Email verified",
|
|
16
|
+
es: "Correo verificado",
|
|
17
|
+
},
|
|
18
|
+
"user:entity:user:field:locale": { de: "Sprache", en: "Locale", es: "Idioma" },
|
|
19
|
+
"user:entity:user:field:timezone": { de: "Zeitzone", en: "Timezone", es: "Zona horaria" },
|
|
20
|
+
"user:entity:user:field:status:option:active": { de: "Aktiv", en: "Active", es: "Activo" },
|
|
21
|
+
"user:entity:user:field:status:option:restricted": {
|
|
22
|
+
de: "Eingeschränkt",
|
|
23
|
+
en: "Restricted",
|
|
24
|
+
es: "Restringido",
|
|
25
|
+
},
|
|
14
26
|
"user:entity:user:field:status:option:deletionRequested": {
|
|
15
27
|
de: "Löschung angefordert",
|
|
16
28
|
en: "Deletion requested",
|
|
29
|
+
es: "Eliminación solicitada",
|
|
30
|
+
},
|
|
31
|
+
"user:entity:user:field:status:option:deleted": {
|
|
32
|
+
de: "Gelöscht",
|
|
33
|
+
en: "Deleted",
|
|
34
|
+
es: "Eliminado",
|
|
17
35
|
},
|
|
18
|
-
"user:entity:user:field:
|
|
19
|
-
"user:entity:user:field:emailVerified:option:
|
|
20
|
-
"user:entity:user:field:emailVerified:option:false": { de: "Nein", en: "No" },
|
|
36
|
+
"user:entity:user:field:emailVerified:option:true": { de: "Ja", en: "Yes", es: "Sí" },
|
|
37
|
+
"user:entity:user:field:emailVerified:option:false": { de: "Nein", en: "No", es: "No" },
|
|
21
38
|
};
|
package/src/user/schema/user.ts
CHANGED
|
@@ -169,6 +169,17 @@ export const userEntity = createEntity({
|
|
|
169
169
|
access: { write: access.privileged },
|
|
170
170
|
}),
|
|
171
171
|
},
|
|
172
|
+
// fw#2134 — email is `pii: true, lookupable: true`, so the column holds
|
|
173
|
+
// per-row ciphertext: a plain unique index on it can't catch duplicates.
|
|
174
|
+
// `unique: true` over a lookupable column makes buildEntityTable /
|
|
175
|
+
// deriveEntityTableMeta emit a second, partial unique index over the
|
|
176
|
+
// deterministic `emailBidx` companion column (WHERE email_bidx IS NOT
|
|
177
|
+
// NULL — erased/key-less rows stay excluded), which is what actually
|
|
178
|
+
// closes the TOCTOU race between two concurrent user:create calls.
|
|
179
|
+
// Global (not tenant-scoped): user is a tenant-agnostic identity
|
|
180
|
+
// aggregate (systemStream: true) — a composite (tenantId, email) would
|
|
181
|
+
// leave the same email resolvable twice.
|
|
182
|
+
indexes: [{ columns: ["email"], unique: true, name: "read_users_email_unique" }],
|
|
172
183
|
});
|
|
173
184
|
|
|
174
185
|
export const userTable = buildEntityTable("user", userEntity);
|
|
@@ -1,40 +1,88 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
// Server + client i18n for user-data-rights (operator screens + nav). Pure
|
|
3
3
|
// data, importable from the web bundle (web/i18n.ts derives the client keys).
|
|
4
|
-
type LocalizedString = { readonly de: string; readonly en: string };
|
|
4
|
+
type LocalizedString = { readonly de: string; readonly en: string; readonly es: string };
|
|
5
5
|
|
|
6
6
|
export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
7
|
-
"user-data-rights:nav.exportJobs": {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
"user-data-rights:nav.exportJobs": {
|
|
8
|
+
de: "DSGVO-Exporte",
|
|
9
|
+
en: "GDPR exports",
|
|
10
|
+
es: "Exportaciones RGPD",
|
|
11
|
+
},
|
|
12
|
+
"screen:export-job-list.title": {
|
|
13
|
+
de: "DSGVO-Exporte",
|
|
14
|
+
en: "GDPR exports",
|
|
15
|
+
es: "Exportaciones RGPD",
|
|
16
|
+
},
|
|
17
|
+
"screen:export-job-detail.title": {
|
|
18
|
+
de: "Export-Job",
|
|
19
|
+
en: "Export job",
|
|
20
|
+
es: "Trabajo de exportación",
|
|
21
|
+
},
|
|
22
|
+
"screen:download-attempt-list.title": {
|
|
23
|
+
de: "Download-Versuche",
|
|
24
|
+
en: "Download attempts",
|
|
25
|
+
es: "Intentos de descarga",
|
|
26
|
+
},
|
|
27
|
+
"screen:privacy-center.title": { de: "Datenschutz", en: "Privacy", es: "Privacidad" },
|
|
28
|
+
"user-data-rights:entity:export-job:field:userId": { de: "Benutzer", en: "User", es: "Usuario" },
|
|
29
|
+
"user-data-rights:entity:export-job:field:status": { de: "Status", en: "Status", es: "Estado" },
|
|
30
|
+
"user-data-rights:entity:export-job:field:requestedAt": {
|
|
31
|
+
de: "Angefordert",
|
|
32
|
+
en: "Requested",
|
|
33
|
+
es: "Solicitado",
|
|
34
|
+
},
|
|
35
|
+
"user-data-rights:entity:export-job:field:completedAt": {
|
|
36
|
+
de: "Abgeschlossen",
|
|
37
|
+
en: "Completed",
|
|
38
|
+
es: "Completado",
|
|
39
|
+
},
|
|
40
|
+
"user-data-rights:entity:export-job:field:expiresAt": {
|
|
41
|
+
de: "Läuft ab",
|
|
42
|
+
en: "Expires",
|
|
43
|
+
es: "Caduca",
|
|
44
|
+
},
|
|
17
45
|
"user-data-rights:entity:export-job:field:requestedFromTenantId": {
|
|
18
46
|
de: "Mandant",
|
|
19
47
|
en: "Tenant",
|
|
48
|
+
es: "Organización",
|
|
49
|
+
},
|
|
50
|
+
"user-data-rights:entity:export-job:field:startedAt": {
|
|
51
|
+
de: "Gestartet",
|
|
52
|
+
en: "Started",
|
|
53
|
+
es: "Iniciado",
|
|
20
54
|
},
|
|
21
|
-
"user-data-rights:entity:export-job:field:startedAt": { de: "Gestartet", en: "Started" },
|
|
22
55
|
"user-data-rights:entity:export-job:field:downloadStorageKey": {
|
|
23
56
|
de: "Speicher-Schlüssel",
|
|
24
57
|
en: "Storage key",
|
|
58
|
+
es: "Clave de almacenamiento",
|
|
59
|
+
},
|
|
60
|
+
"user-data-rights:entity:export-job:field:bytesWritten": {
|
|
61
|
+
de: "Bytes",
|
|
62
|
+
en: "Bytes",
|
|
63
|
+
es: "Bytes",
|
|
64
|
+
},
|
|
65
|
+
"user-data-rights:entity:export-job:field:errorMessage": {
|
|
66
|
+
de: "Fehler",
|
|
67
|
+
en: "Error",
|
|
68
|
+
es: "Error",
|
|
25
69
|
},
|
|
26
|
-
"user-data-rights:entity:export-job:field:bytesWritten": { de: "Bytes", en: "Bytes" },
|
|
27
|
-
"user-data-rights:entity:export-job:field:errorMessage": { de: "Fehler", en: "Error" },
|
|
28
70
|
"user-data-rights:entity:download-attempt:field:attemptedAt": {
|
|
29
71
|
de: "Zeitpunkt",
|
|
30
72
|
en: "Attempted at",
|
|
73
|
+
es: "Fecha y hora",
|
|
74
|
+
},
|
|
75
|
+
"user-data-rights:entity:download-attempt:field:result": {
|
|
76
|
+
de: "Ergebnis",
|
|
77
|
+
en: "Result",
|
|
78
|
+
es: "Resultado",
|
|
31
79
|
},
|
|
32
|
-
"user-data-rights:entity:download-attempt:field:
|
|
33
|
-
"user-data-rights:entity:download-attempt:field:
|
|
34
|
-
"user-data-rights:entity:download-attempt:field:ip": { de: "IP", en: "IP" },
|
|
80
|
+
"user-data-rights:entity:download-attempt:field:via": { de: "Via", en: "Via", es: "Vía" },
|
|
81
|
+
"user-data-rights:entity:download-attempt:field:ip": { de: "IP", en: "IP", es: "IP" },
|
|
35
82
|
"user-data-rights:entity:download-attempt:field:attemptedByUserId": {
|
|
36
83
|
de: "Benutzer",
|
|
37
84
|
en: "User",
|
|
85
|
+
es: "Usuario",
|
|
38
86
|
},
|
|
39
|
-
"user-data-rights:entity:download-attempt:field:jobId": { de: "Job", en: "Job" },
|
|
87
|
+
"user-data-rights:entity:download-attempt:field:jobId": { de: "Job", en: "Job", es: "Trabajo" },
|
|
40
88
|
};
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
8
8
|
import { USER_DATA_RIGHTS_I18N } from "../i18n";
|
|
9
9
|
|
|
10
|
-
const LOCALES = ["de", "en"] as const;
|
|
10
|
+
const LOCALES = ["de", "en", "es"] as const;
|
|
11
11
|
|
|
12
12
|
// Operator keys (export-job list/detail titles + entity field labels + nav)
|
|
13
13
|
// live in the server i18n; the SystemAdmin entityList screens render them
|
|
@@ -167,9 +167,78 @@ const apexTranslations: TranslationsByLocale = {
|
|
|
167
167
|
"userDataRights.errors.download.signedUrlNotSupported":
|
|
168
168
|
"The download is currently unavailable due to a server configuration issue. The operator has been notified.",
|
|
169
169
|
},
|
|
170
|
+
es: {
|
|
171
|
+
"userDataRights.deletion.request.title": "Solicitar la eliminación de la cuenta",
|
|
172
|
+
"userDataRights.deletion.request.intro":
|
|
173
|
+
"Introduce la dirección de correo de tu cuenta. Si existe una cuenta, te enviaremos un enlace de confirmación para eliminarla.",
|
|
174
|
+
"userDataRights.deletion.request.email": "Correo electrónico",
|
|
175
|
+
"userDataRights.deletion.request.submit": "Solicitar enlace de confirmación",
|
|
176
|
+
"userDataRights.deletion.request.submitting": "…",
|
|
177
|
+
"userDataRights.deletion.request.successTitle": "Correo enviado",
|
|
178
|
+
"userDataRights.deletion.request.successBody":
|
|
179
|
+
"Si el correo existe en nuestro sistema, te hemos enviado un mensaje con un enlace de confirmación. Revisa tu bandeja de entrada.",
|
|
180
|
+
"userDataRights.deletion.request.error": "Algo salió mal. Inténtalo de nuevo.",
|
|
181
|
+
"userDataRights.deletion.confirm.title": "Confirmar la eliminación de la cuenta",
|
|
182
|
+
"userDataRights.deletion.confirm.intro":
|
|
183
|
+
"Al confirmar se inicia el plazo de eliminación. Hasta que termine, puedes cancelarla iniciando sesión en tu cuenta.",
|
|
184
|
+
"userDataRights.deletion.confirm.submit": "Confirmar eliminación",
|
|
185
|
+
"userDataRights.deletion.confirm.submitting": "…",
|
|
186
|
+
"userDataRights.deletion.confirm.successTitle": "Eliminación programada",
|
|
187
|
+
"userDataRights.deletion.confirm.successBody":
|
|
188
|
+
"Tu cuenta se eliminará cuando finalice el plazo. Hasta entonces, puedes cancelar la eliminación iniciando sesión en tu cuenta.",
|
|
189
|
+
"userDataRights.deletion.confirm.invalidToken":
|
|
190
|
+
"El enlace no es válido o ha caducado. Solicita uno nuevo.",
|
|
191
|
+
"userDataRights.deletion.confirm.missingToken":
|
|
192
|
+
"No se encontró ningún token en el enlace. Abre de nuevo el enlace del correo.",
|
|
193
|
+
"userDataRights.deletion.confirm.error": "Algo salió mal. Inténtalo de nuevo.",
|
|
194
|
+
|
|
195
|
+
"userDataRights.privacyCenter.title": "Privacidad",
|
|
196
|
+
"userDataRights.privacyCenter.intro":
|
|
197
|
+
"Gestiona tus derechos según el RGPD: acceso a tus datos, exportación, limitación y eliminación de tu cuenta.",
|
|
198
|
+
"userDataRights.privacyCenter.loading": "Cargando…",
|
|
199
|
+
"userDataRights.privacyCenter.loadError": "No se pudieron cargar tus datos.",
|
|
200
|
+
"userDataRights.privacyCenter.errors.generic": "Algo salió mal. Inténtalo de nuevo.",
|
|
201
|
+
|
|
202
|
+
"userDataRights.privacyCenter.export.title": "Exportar datos (art. 20)",
|
|
203
|
+
"userDataRights.privacyCenter.export.intro":
|
|
204
|
+
"Solicita una copia de tus datos. Se genera en segundo plano; en cuanto esté lista, podrás descargarla aquí.",
|
|
205
|
+
"userDataRights.privacyCenter.export.request": "Solicitar exportación de datos",
|
|
206
|
+
"userDataRights.privacyCenter.export.requesting": "Solicitando…",
|
|
207
|
+
"userDataRights.privacyCenter.export.pending":
|
|
208
|
+
"Tu exportación se está generando. Vuelve a comprobarlo más tarde.",
|
|
209
|
+
"userDataRights.privacyCenter.export.failed":
|
|
210
|
+
"La última exportación falló. Puedes intentarlo de nuevo.",
|
|
211
|
+
"userDataRights.privacyCenter.export.ready": "Tu exportación está lista.",
|
|
212
|
+
"userDataRights.privacyCenter.export.download": "Descargar exportación",
|
|
213
|
+
"userDataRights.privacyCenter.export.availableUntil": "Disponible hasta {date}",
|
|
214
|
+
"userDataRights.privacyCenter.export.requestNew": "Solicitar una nueva exportación",
|
|
215
|
+
|
|
216
|
+
"userDataRights.privacyCenter.restriction.title": "Limitar el tratamiento (art. 18)",
|
|
217
|
+
"userDataRights.privacyCenter.restriction.explainer":
|
|
218
|
+
"Congela tu cuenta: el tratamiento de tus datos se pausa y se cierra tu sesión. Después, solo el soporte puede levantar la limitación.",
|
|
219
|
+
"userDataRights.privacyCenter.restriction.restrict": "Limitar cuenta",
|
|
220
|
+
"userDataRights.privacyCenter.restriction.dialogTitle":
|
|
221
|
+
"¿Seguro que quieres limitar tu cuenta?",
|
|
222
|
+
"userDataRights.privacyCenter.restriction.dialogDescription":
|
|
223
|
+
"Se cerrará tu sesión de inmediato y no podrás volver a iniciarla hasta que el soporte levante la limitación.",
|
|
224
|
+
"userDataRights.privacyCenter.restriction.restricted":
|
|
225
|
+
"Tu cuenta está limitada. Ponte en contacto con el soporte para levantar la limitación.",
|
|
226
|
+
|
|
227
|
+
"userDataRights.privacyCenter.deletion.title": "Eliminar cuenta (art. 17)",
|
|
228
|
+
"userDataRights.privacyCenter.deletion.explainer":
|
|
229
|
+
"Solicita la eliminación de tu cuenta. Hasta que finalice el plazo, puedes cancelar la eliminación.",
|
|
230
|
+
"userDataRights.privacyCenter.deletion.delete": "Eliminar cuenta",
|
|
231
|
+
"userDataRights.privacyCenter.deletion.requested": "Tu cuenta se eliminará el {date}.",
|
|
232
|
+
"userDataRights.privacyCenter.deletion.cancel": "Cancelar eliminación",
|
|
233
|
+
"userDataRights.privacyCenter.deletion.cancelSuccess": "Se ha cancelado la eliminación.",
|
|
234
|
+
"userDataRights.privacyCenter.deletion.dialogTitle": "¿Seguro que quieres eliminar tu cuenta?",
|
|
235
|
+
"userDataRights.privacyCenter.deletion.dialogDescription":
|
|
236
|
+
"Al confirmar se inicia el plazo de eliminación. Puedes cancelarla hasta que finalice.",
|
|
237
|
+
},
|
|
170
238
|
};
|
|
171
239
|
|
|
172
240
|
export const defaultTranslations: TranslationsByLocale = {
|
|
173
241
|
de: { ...apexTranslations["de"], ...operatorTranslations["de"] },
|
|
174
242
|
en: { ...apexTranslations["en"], ...operatorTranslations["en"] },
|
|
243
|
+
es: { ...apexTranslations["es"], ...operatorTranslations["es"] },
|
|
175
244
|
};
|