@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.
Files changed (44) hide show
  1. package/package.json +7 -7
  2. package/src/admin-shell/i18n.ts +36 -17
  3. package/src/admin-shell/web/i18n.ts +1 -1
  4. package/src/audit/i18n.ts +41 -25
  5. package/src/audit/web/i18n.ts +1 -1
  6. package/src/auth-email-password/i18n.ts +129 -0
  7. package/src/auth-mfa/i18n.ts +2 -1
  8. package/src/auth-mfa/web/i18n.ts +63 -0
  9. package/src/cap-counter/i18n.ts +10 -5
  10. package/src/compliance-profiles/i18n.ts +11 -3
  11. package/src/compliance-profiles/web/i18n.ts +10 -0
  12. package/src/config/i18n.ts +5 -5
  13. package/src/config/web/i18n.ts +9 -0
  14. package/src/custom-fields/web/i18n.ts +9 -0
  15. package/src/delivery/i18n.ts +7 -3
  16. package/src/delivery/web/i18n.ts +10 -0
  17. package/src/feature-toggles/i18n.ts +11 -3
  18. package/src/feature-toggles/web/i18n.ts +12 -0
  19. package/src/folders/web/i18n.ts +25 -0
  20. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +11 -10
  21. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +21 -26
  22. package/src/jobs/i18n.ts +62 -40
  23. package/src/jobs/web/i18n.ts +1 -1
  24. package/src/managed-pages/i18n.ts +63 -23
  25. package/src/managed-pages/web/i18n.ts +1 -1
  26. package/src/notes-history/i18n.ts +1 -1
  27. package/src/notes-history/web/i18n.ts +8 -0
  28. package/src/personal-access-tokens/i18n.ts +6 -2
  29. package/src/personal-access-tokens/web/i18n.ts +35 -0
  30. package/src/sessions/i18n.ts +21 -17
  31. package/src/tags/i18n.ts +2 -2
  32. package/src/tags/web/i18n.ts +25 -0
  33. package/src/template-resolver/web/i18n.ts +14 -0
  34. package/src/tenant/i18n.ts +24 -13
  35. package/src/tenant/web/i18n.ts +19 -0
  36. package/src/tier-engine/i18n.ts +22 -2
  37. package/src/user/__tests__/email-unique-blind-index.integration.test.ts +140 -0
  38. package/src/user/__tests__/user.integration.test.ts +31 -0
  39. package/src/user/handlers/create.write.ts +54 -8
  40. package/src/user/i18n.ts +31 -14
  41. package/src/user/schema/user.ts +11 -0
  42. package/src/user-data-rights/i18n.ts +66 -18
  43. package/src/user-data-rights/web/i18n.ts +70 -1
  44. package/src/user-profile/i18n.ts +35 -0
@@ -323,11 +323,11 @@ describe("scenario 2b: ingest-message — Thread-Rollup selbstkorrigierend statt
323
323
  });
324
324
  });
325
325
 
326
- describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #1229)", () => {
326
+ describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #1229, closed by #2155)", () => {
327
327
  // Probabilistic — looped per house convention (mehrere Durchläufe, z.B.
328
- // 20x): the version-conflict race between two step-5 thread-rollup
329
- // appends only fires on genuine transaction-timing interleaving, which
330
- // a single run can't force deterministically.
328
+ // 20x): whether a concurrent thread-rollup commit lands inside the
329
+ // step-5 critical section depends on real transaction-timing
330
+ // interleaving, which a single run can't force deterministically.
331
331
  const ITERATIONS = 20;
332
332
  const CONCURRENCY = 3;
333
333
 
@@ -352,10 +352,10 @@ describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #
352
352
  ),
353
353
  );
354
354
 
355
- // No writer's transaction fails with a 500 from the thread-rollup
356
- // version conflict — each of the CONCURRENCY distinct messages is
357
- // its own aggregate, so step 4 never races; only step 5 (shared
358
- // threadAggId) does, and the bounded retry loop must absorb it.
355
+ // No writer's transaction fails with a 500 — each of the CONCURRENCY
356
+ // distinct messages is its own aggregate, so step 4 never races; step
357
+ // 5 (shared threadAggId) is serialized by the pg_advisory_xact_lock,
358
+ // so there's nothing here for the bounded retry loop to absorb.
359
359
  for (const res of responses) {
360
360
  expect(res.status).toBe(200);
361
361
  const body = (await res.json()) as { isSuccess: boolean; data: { duplicate: boolean } };
@@ -366,8 +366,9 @@ describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #
366
366
  const threadAggId = mailThreadAggregateId(admin.tenantId, `mid:${rootMessageIdHeader}`);
367
367
  const threadRows = await selectMany(db, mailThreadsProjectionTable, { id: threadAggId });
368
368
  expect(threadRows).toHaveLength(1);
369
- // Every message's contribution landed — no drift from a silently
370
- // stale-read-and-succeed append (the bug the retry loop replaces).
369
+ // Every message's contribution landed — the advisory lock closes the
370
+ // TOCTOU gap between countWhere and append that used to let a stale
371
+ // messageCount succeed without ever surfacing as a version conflict.
371
372
  expect(threadRows[0]?.["messageCount"]).toBe(CONCURRENCY);
372
373
  }
373
374
  });
@@ -25,6 +25,7 @@ import {
25
25
  configuredPiiSubjectKms,
26
26
  encryptPiiFieldValues,
27
27
  } from "@cosmicdrift/kumiko-framework/crypto";
28
+ import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
28
29
  import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
29
30
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
30
31
  import { Temporal } from "temporal-polyfill";
@@ -101,6 +102,10 @@ function buildThreadKey(p: IngestMessagePayload, effectiveMessageIdHeader: strin
101
102
  }
102
103
 
103
104
  const THREAD_ROLLUP_MAX_ATTEMPTS = 5;
105
+ // pg_advisory_xact_lock namespace (int4) for the step-5 thread-rollup lock
106
+ // — 'inbm' as ASCII, keeps it disjoint from the framework's other fixed
107
+ // single-int advisory-lock keys (schema bootstrap, es-ops boot).
108
+ const THREAD_ROLLUP_LOCK_NAMESPACE = 0x696e626d;
104
109
  export const ingestMessageHandler: WriteHandlerDef = {
105
110
  name: "ingest-message",
106
111
  schema: ingestMessageSchema,
@@ -230,35 +235,23 @@ export const ingestMessageHandler: WriteHandlerDef = {
230
235
  // encrypted once up front (constant across retries, doesn't
231
236
  // depend on the thread's prior state).
232
237
  //
233
- // messageCount deliberately does NOT come from a previously loaded
234
- // threadEvents snapshot (previousCount+1): two concurrent ingests
235
- // on the same thread (Watch-Push vs. Poll-Reconciliation overlap,
236
- // or two different messages of the same thread arriving at once)
237
- // can both read the same stream version before either commits.
238
- // tryAppendEvent's fresh version-read at append time makes the
239
- // loser's append fail with a VersionConflictError ONLY when the
240
- // other TX's commit lands between this reload and the append's own
241
- // version check — so we retry: reload the thread stream, recompute
242
- // the live row-count and lastMessageAt, and append again. If the
243
- // other TX instead commits in the gap between the countWhere below
244
- // and the append's version-read, the version check sees the
245
- // already-bumped stream and appends without conflict — but with the
246
- // stale row-count this countWhere captured before the concurrent
247
- // insert. That write still lands (messageCount can undercount by
248
- // one round), it is not eliminated by this retry loop; the next
249
- // ingest on the thread re-counts and self-corrects. Unlike step 4,
250
- // a lost version-conflict race here must NOT be treated as a no-op
238
+ // messageCount is a live COUNT, not previousCount+1, so two
239
+ // concurrent ingests on the same thread (Watch-Push vs.
240
+ // Poll-Reconciliation overlap, or two different messages of the
241
+ // same thread arriving at once) converge on the true count.
242
+ // acquireNamespacedAdvisoryLock below (issue #2155) serializes this whole
243
+ // countWhere+append section per threadAggId — transaction-scoped,
244
+ // so it survives tryAppendEvent's inner SAVEPOINT and releases at
245
+ // the enclosing TX's commit/rollback. Without it, a concurrent
246
+ // commit landing between countWhere and tryAppendEvent's own fresh
247
+ // version-read makes the append succeed with a stale count instead
248
+ // of a VersionConflictError, so the retry loop below never sees it
249
+ // as a conflict to retry (#1229's original gap). Unlike step 4, a
250
+ // lost version-conflict race here must NOT be treated as a no-op
251
251
  // duplicate — skipping would leave this message's contribution to
252
252
  // messageCount/lastMessageAt out of the thread forever. Exhausting
253
253
  // all attempts throws, rolling back the whole TX (including the
254
- // step-4 message append) for a clean re-ingest on the next poll —
255
- // never return success with a stale snapshot.
256
- //
257
- // A true race-free read+append (e.g. SELECT ... FOR UPDATE or an
258
- // advisory lock on threadAggId) would close the countWhere→append
259
- // gap above and is deliberately not part of this fix — two writers
260
- // (watch + poll) make self-correcting drift an acceptable trade for
261
- // now.
254
+ // step-4 message append) for a clean re-ingest.
262
255
  // ---------------------------------------------------------------
263
256
  const threadPlainPii = { tenantId, subject: payload.subject };
264
257
  const encryptedThreadFields = piiKms
@@ -274,6 +267,8 @@ export const ingestMessageHandler: WriteHandlerDef = {
274
267
  )
275
268
  : threadPlainPii;
276
269
 
270
+ await acquireNamespacedAdvisoryLock(ctx.db.raw, THREAD_ROLLUP_LOCK_NAMESPACE, threadAggId);
271
+
277
272
  let threadAppendOk = false;
278
273
  for (let attempt = 1; attempt <= THREAD_ROLLUP_MAX_ATTEMPTS && !threadAppendOk; attempt++) {
279
274
  const threadEvents = await ctx.loadAggregate(threadAggId);
package/src/jobs/i18n.ts CHANGED
@@ -1,70 +1,92 @@
1
1
  // @runtime client
2
2
  // Server + client i18n for jobs operator screens.
3
3
 
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 JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
7
- "screen:job-runs.title": { de: "Job-Läufe", en: "Job runs" },
8
- "screen:job-run-detail.title": { de: "Job-Lauf", en: "Job run" },
9
- "jobs:nav.jobRuns": { de: "Jobs", en: "Jobs" },
10
- "jobs.runs.title": { de: "Job-Läufe", en: "Job runs" },
11
- "jobs.runs.loading": { de: "Lade Läufe…", en: "Loading runs…" },
12
- "jobs.runs.empty": { de: "Keine Job-Läufe.", en: "No job runs." },
13
- "jobs.runs.open": { de: "Details", en: "Details" },
14
- "jobs.runs.filter.status": { de: "Status", en: "Status" },
15
- "jobs.runs.filter.all": { de: "Alle", en: "All" },
16
- "jobs.runs.filter.completed": { de: "Abgeschlossen", en: "Completed" },
17
- "jobs.runs.filter.failed": { de: "Fehlgeschlagen", en: "Failed" },
18
- "jobs.runs.filter.running": { de: "Läuft", en: "Running" },
19
- "jobs.runs.filter.queued": { de: "Wartend", en: "Queued" },
20
- "jobs.runs.col.job": { de: "Job", en: "Job" },
21
- "jobs.runs.col.status": { de: "Status", en: "Status" },
22
- "jobs.runs.col.started": { de: "Gestartet", en: "Started" },
23
- "jobs.runs.col.duration": { de: "Dauer (ms)", en: "Duration (ms)" },
24
- "jobs.detail.loading": { de: "Lade Details…", en: "Loading details…" },
25
- "jobs.detail.missing": { de: "Lauf nicht gefunden.", en: "Run not found." },
26
- "jobs.detail.field.job": { de: "Job", en: "Job" },
27
- "jobs.detail.field.status": { de: "Status", en: "Status" },
28
- "jobs.detail.field.id": { de: "Run-ID", en: "Run ID" },
29
- "jobs.detail.field.started": { de: "Gestartet", en: "Started" },
30
- "jobs.detail.field.finished": { de: "Beendet", en: "Finished" },
31
- "jobs.detail.field.duration": { de: "Dauer (ms)", en: "Duration (ms)" },
32
- "jobs.detail.field.error": { de: "Fehler", en: "Error" },
33
- "jobs.detail.logs": { de: "Logs", en: "Logs" },
34
- "jobs.detail.logs.empty": { de: "Keine Log-Zeilen.", en: "No log lines." },
35
- "jobs.detail.retry": { de: "Erneut ausführen", en: "Retry" },
36
- "jobs.detail.retrying": { de: "Wird erneut gestartet…", en: "Retrying…" },
37
- "jobs.trigger.title": { de: "Job starten", en: "Run a job" },
7
+ "screen:job-runs.title": { de: "Job-Läufe", en: "Job runs", es: "Ejecuciones de trabajos" },
8
+ "screen:job-run-detail.title": { de: "Job-Lauf", en: "Job run", es: "Ejecución de trabajo" },
9
+ "jobs:nav.jobRuns": { de: "Jobs", en: "Jobs", es: "Trabajos" },
10
+ "jobs.runs.title": { de: "Job-Läufe", en: "Job runs", es: "Ejecuciones de trabajos" },
11
+ "jobs.runs.loading": { de: "Lade Läufe…", en: "Loading runs…", es: "Cargando ejecuciones…" },
12
+ "jobs.runs.empty": {
13
+ de: "Keine Job-Läufe.",
14
+ en: "No job runs.",
15
+ es: "No hay ejecuciones de trabajos.",
16
+ },
17
+ "jobs.runs.open": { de: "Details", en: "Details", es: "Detalles" },
18
+ "jobs.runs.filter.status": { de: "Status", en: "Status", es: "Estado" },
19
+ "jobs.runs.filter.all": { de: "Alle", en: "All", es: "Todos" },
20
+ "jobs.runs.filter.completed": { de: "Abgeschlossen", en: "Completed", es: "Completado" },
21
+ "jobs.runs.filter.failed": { de: "Fehlgeschlagen", en: "Failed", es: "Fallido" },
22
+ "jobs.runs.filter.running": { de: "Läuft", en: "Running", es: "En curso" },
23
+ "jobs.runs.filter.queued": { de: "Wartend", en: "Queued", es: "En cola" },
24
+ "jobs.runs.col.job": { de: "Job", en: "Job", es: "Trabajo" },
25
+ "jobs.runs.col.status": { de: "Status", en: "Status", es: "Estado" },
26
+ "jobs.runs.col.started": { de: "Gestartet", en: "Started", es: "Iniciado" },
27
+ "jobs.runs.col.duration": { de: "Dauer (ms)", en: "Duration (ms)", es: "Duración (ms)" },
28
+ "jobs.detail.loading": { de: "Lade Details…", en: "Loading details…", es: "Cargando detalles…" },
29
+ "jobs.detail.missing": {
30
+ de: "Lauf nicht gefunden.",
31
+ en: "Run not found.",
32
+ es: "Ejecución no encontrada.",
33
+ },
34
+ "jobs.detail.field.job": { de: "Job", en: "Job", es: "Trabajo" },
35
+ "jobs.detail.field.status": { de: "Status", en: "Status", es: "Estado" },
36
+ "jobs.detail.field.id": { de: "Run-ID", en: "Run ID", es: "ID de ejecución" },
37
+ "jobs.detail.field.started": { de: "Gestartet", en: "Started", es: "Iniciado" },
38
+ "jobs.detail.field.finished": { de: "Beendet", en: "Finished", es: "Finalizado" },
39
+ "jobs.detail.field.duration": { de: "Dauer (ms)", en: "Duration (ms)", es: "Duración (ms)" },
40
+ "jobs.detail.field.error": { de: "Fehler", en: "Error", es: "Error" },
41
+ "jobs.detail.logs": { de: "Logs", en: "Logs", es: "Registros" },
42
+ "jobs.detail.logs.empty": {
43
+ de: "Keine Log-Zeilen.",
44
+ en: "No log lines.",
45
+ es: "No hay líneas de registro.",
46
+ },
47
+ "jobs.detail.retry": { de: "Erneut ausführen", en: "Retry", es: "Reintentar" },
48
+ "jobs.detail.retrying": { de: "Wird erneut gestartet…", en: "Retrying…", es: "Reintentando…" },
49
+ "jobs.trigger.title": { de: "Job starten", en: "Run a job", es: "Ejecutar un trabajo" },
38
50
  "jobs.trigger.explainer": {
39
51
  de: "Nur manuell startbare Jobs. Nach dem Start erscheint der Lauf in der Historie unten.",
40
52
  en: "Only manually triggerable jobs. After starting, the run appears in the history below.",
53
+ es: "Solo trabajos que se pueden iniciar manualmente. Tras iniciarlo, la ejecución aparece en el historial de abajo.",
41
54
  },
42
- "jobs.trigger.job": { de: "Job", en: "Job" },
43
- "jobs.trigger.payload": { de: "Payload (JSON)", en: "Payload (JSON)" },
44
- "jobs.trigger.payload.hint": { de: "Schema-Hinweis", en: "Schema hint" },
55
+ "jobs.trigger.job": { de: "Job", en: "Job", es: "Trabajo" },
56
+ "jobs.trigger.payload": { de: "Payload (JSON)", en: "Payload (JSON)", es: "Payload (JSON)" },
57
+ "jobs.trigger.payload.hint": { de: "Schema-Hinweis", en: "Schema hint", es: "Pista de esquema" },
45
58
  "jobs.trigger.payload.invalidJson": {
46
59
  de: "Payload ist kein gültiges JSON-Objekt.",
47
60
  en: "Payload is not a valid JSON object.",
61
+ es: "El payload no es un objeto JSON válido.",
48
62
  },
49
- "jobs.trigger.submit": { de: "Starten", en: "Run" },
50
- "jobs.trigger.submitting": { de: "Wird gestartet…", en: "Starting…" },
51
- "jobs.trigger.success": { de: "Job gestartet.", en: "Job started." },
63
+ "jobs.trigger.submit": { de: "Starten", en: "Run", es: "Ejecutar" },
64
+ "jobs.trigger.submitting": { de: "Wird gestartet…", en: "Starting…", es: "Iniciando…" },
65
+ "jobs.trigger.success": { de: "Job gestartet.", en: "Job started.", es: "Trabajo iniciado." },
52
66
  "jobs.trigger.empty": {
53
67
  de: "Keine manuell startbaren Jobs registriert.",
54
68
  en: "No manually triggerable jobs are registered.",
69
+ es: "No hay trabajos registrados que se puedan iniciar manualmente.",
55
70
  },
56
71
  "jobs.trigger.perTenant": {
57
72
  de: "Läuft einmal pro aktivem Tenant.",
58
73
  en: "Runs once per active tenant.",
74
+ es: "Se ejecuta una vez por cada organización activa.",
75
+ },
76
+ "jobs.errors.unknownJob": {
77
+ de: "Unbekannter Job.",
78
+ en: "Unknown job.",
79
+ es: "Trabajo desconocido.",
59
80
  },
60
- "jobs.errors.unknownJob": { de: "Unbekannter Job.", en: "Unknown job." },
61
81
  "jobs.errors.notManual": {
62
82
  de: "Dieser Job ist nicht manuell startbar.",
63
83
  en: "This job cannot be triggered manually.",
84
+ es: "Este trabajo no se puede iniciar manualmente.",
64
85
  },
65
- "jobs.errors.notFound": { de: "Nicht gefunden.", en: "Not found." },
86
+ "jobs.errors.notFound": { de: "Nicht gefunden.", en: "Not found.", es: "No encontrado." },
66
87
  "jobs.errors.onlyFailedCanRetry": {
67
88
  de: "Nur fehlgeschlagene Läufe können erneut gestartet werden.",
68
89
  en: "Only failed runs can be retried.",
90
+ es: "Solo se pueden reintentar las ejecuciones fallidas.",
69
91
  },
70
92
  };
@@ -2,7 +2,7 @@
2
2
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
3
  import { JOBS_I18N } from "../i18n";
4
4
 
5
- const LOCALES = ["de", "en"] as const;
5
+ const LOCALES = ["de", "en", "es"] as const;
6
6
 
7
7
  export const defaultTranslations: TranslationsByLocale = Object.fromEntries(
8
8
  LOCALES.map((locale) => [
@@ -11,56 +11,96 @@
11
11
  // registriert. Die zwei customCss-Keys werden unconditional geliefert (schaden
12
12
  // bei allowCustomCss:false nicht, sind aber Pflicht für Consumer die es aktivieren).
13
13
 
14
- type LocalizedString = { readonly de: string; readonly en: string };
14
+ type LocalizedString = { readonly de: string; readonly en: string; readonly es: string };
15
15
 
16
16
  export const MANAGED_PAGES_I18N: Readonly<Record<string, LocalizedString>> = {
17
17
  // Screen-Titel (global/unpräfixiert — screenTitleKey = screen:<id>.title)
18
- "screen:page-list.title": { de: "Seiten", en: "Pages" },
19
- "screen:page-edit.title": { de: "Seite bearbeiten", en: "Edit page" },
20
- "screen:branding-settings.title": { de: "Branding", en: "Branding" },
18
+ "screen:page-list.title": { de: "Seiten", en: "Pages", es: "Páginas" },
19
+ "screen:page-edit.title": { de: "Seite bearbeiten", en: "Edit page", es: "Editar página" },
20
+ "screen:branding-settings.title": { de: "Branding", en: "Branding", es: "Marca" },
21
21
 
22
22
  // Page-Entity Feld-Labels (managed-pages:entity:page:field:*)
23
- "managed-pages:entity:page:field:slug": { de: "Slug", en: "Slug" },
24
- "managed-pages:entity:page:field:lang": { de: "Sprache", en: "Language" },
25
- "managed-pages:entity:page:field:title": { de: "Titel", en: "Title" },
26
- "managed-pages:entity:page:field:published": { de: "Veröffentlicht", en: "Published" },
23
+ "managed-pages:entity:page:field:slug": { de: "Slug", en: "Slug", es: "Slug" },
24
+ "managed-pages:entity:page:field:lang": { de: "Sprache", en: "Language", es: "Idioma" },
25
+ "managed-pages:entity:page:field:title": { de: "Titel", en: "Title", es: "Título" },
26
+ "managed-pages:entity:page:field:published": {
27
+ de: "Veröffentlicht",
28
+ en: "Published",
29
+ es: "Publicado",
30
+ },
27
31
  "managed-pages:entity:page:field:published:option:true": {
28
32
  de: "Veröffentlicht",
29
33
  en: "Published",
34
+ es: "Publicado",
35
+ },
36
+ "managed-pages:entity:page:field:published:option:false": {
37
+ de: "Entwurf",
38
+ en: "Draft",
39
+ es: "Borrador",
40
+ },
41
+ "managed-pages:entity:page:field:description": {
42
+ de: "Beschreibung",
43
+ en: "Description",
44
+ es: "Descripción",
30
45
  },
31
- "managed-pages:entity:page:field:published:option:false": { de: "Entwurf", en: "Draft" },
32
- "managed-pages:entity:page:field:description": { de: "Beschreibung", en: "Description" },
33
- "managed-pages:entity:page:field:ogImage": { de: "OG-Bild", en: "OG image" },
34
- "managed-pages:entity:page:field:body": { de: "Inhalt", en: "Content" },
46
+ "managed-pages:entity:page:field:ogImage": { de: "OG-Bild", en: "OG image", es: "Imagen OG" },
47
+ "managed-pages:entity:page:field:body": { de: "Inhalt", en: "Content", es: "Contenido" },
35
48
 
36
49
  // Config-Edit Feld-Labels (managed-pages:entity:__config-edit__:field:*, camelCase Form-Keys)
37
- "managed-pages:entity:__config-edit__:field:title": { de: "Website-Titel", en: "Site title" },
50
+ "managed-pages:entity:__config-edit__:field:title": {
51
+ de: "Website-Titel",
52
+ en: "Site title",
53
+ es: "Título del sitio",
54
+ },
38
55
  "managed-pages:entity:__config-edit__:field:description": {
39
56
  de: "Beschreibung",
40
57
  en: "Description",
58
+ es: "Descripción",
59
+ },
60
+ "managed-pages:entity:__config-edit__:field:siteUrl": {
61
+ de: "Website-URL",
62
+ en: "Site URL",
63
+ es: "URL del sitio",
41
64
  },
42
- "managed-pages:entity:__config-edit__:field:siteUrl": { de: "Website-URL", en: "Site URL" },
43
65
  "managed-pages:entity:__config-edit__:field:accentColor": {
44
66
  de: "Akzentfarbe",
45
67
  en: "Accent color",
68
+ es: "Color de acento",
69
+ },
70
+ "managed-pages:entity:__config-edit__:field:logoUrl": {
71
+ de: "Logo-URL",
72
+ en: "Logo URL",
73
+ es: "URL del logo",
74
+ },
75
+ "managed-pages:entity:__config-edit__:field:layoutPreset": {
76
+ de: "Layout",
77
+ en: "Layout",
78
+ es: "Diseño",
46
79
  },
47
- "managed-pages:entity:__config-edit__:field:logoUrl": { de: "Logo-URL", en: "Logo URL" },
48
- "managed-pages:entity:__config-edit__:field:layoutPreset": { de: "Layout", en: "Layout" },
49
80
 
50
81
  // Section-Header
51
- "managed-pages:section.meta": { de: "Metadaten", en: "Metadata" },
52
- "managed-pages:section.body": { de: "Inhalt", en: "Content" },
53
- "managed-pages:branding.section.identity": { de: "Identität", en: "Identity" },
82
+ "managed-pages:section.meta": { de: "Metadaten", en: "Metadata", es: "Metadatos" },
83
+ "managed-pages:section.body": { de: "Inhalt", en: "Content", es: "Contenido" },
84
+ "managed-pages:branding.section.identity": { de: "Identität", en: "Identity", es: "Identidad" },
54
85
 
55
86
  // Row-Actions + Confirm (pageListScreen)
56
- "managed-pages:actions.edit": { de: "Bearbeiten", en: "Edit" },
57
- "managed-pages:actions.delete": { de: "Löschen", en: "Delete" },
87
+ "managed-pages:actions.edit": { de: "Bearbeiten", en: "Edit", es: "Editar" },
88
+ "managed-pages:actions.delete": { de: "Löschen", en: "Delete", es: "Eliminar" },
58
89
  "managed-pages:confirms.page-delete": {
59
90
  de: "Diese Seite wirklich löschen?",
60
91
  en: "Delete this page?",
92
+ es: "¿Seguro que quieres eliminar esta página?",
61
93
  },
62
94
 
63
95
  // Nur bei allowCustomCss:true im Screen — unconditional geliefert
64
- "managed-pages:entity:__config-edit__:field:customCss": { de: "Eigenes CSS", en: "Custom CSS" },
65
- "managed-pages:branding.section.custom-css": { de: "Eigenes CSS", en: "Custom CSS" },
96
+ "managed-pages:entity:__config-edit__:field:customCss": {
97
+ de: "Eigenes CSS",
98
+ en: "Custom CSS",
99
+ es: "CSS personalizado",
100
+ },
101
+ "managed-pages:branding.section.custom-css": {
102
+ de: "Eigenes CSS",
103
+ en: "Custom CSS",
104
+ es: "CSS personalizado",
105
+ },
66
106
  };
@@ -12,7 +12,7 @@
12
12
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
13
13
  import { MANAGED_PAGES_I18N } from "../i18n";
14
14
 
15
- const LOCALES = ["de", "en"] as const;
15
+ const LOCALES = ["de", "en", "es"] as const;
16
16
 
17
17
  export const defaultTranslations: TranslationsByLocale = Object.fromEntries(
18
18
  LOCALES.map((locale) => [
@@ -1,3 +1,3 @@
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 NOTES_HISTORY_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {};
@@ -23,4 +23,12 @@ export const defaultTranslations: TranslationsByLocale = {
23
23
  "notesHistory.section.add": "Add note",
24
24
  "notesHistory.section.meta": "{author} · {date}",
25
25
  },
26
+ es: {
27
+ "notesHistory.section.createMode": "Guarda primero la entrada para poder añadir notas.",
28
+ "notesHistory.section.loading": "Cargando…",
29
+ "notesHistory.section.empty": "Aún no hay notas.",
30
+ "notesHistory.section.working": "Guardando…",
31
+ "notesHistory.section.add": "Añadir nota",
32
+ "notesHistory.section.meta": "{author} · {date}",
33
+ },
26
34
  };
@@ -1,5 +1,9 @@
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 PAT_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
4
- "screen:api-tokens.title": { de: "Personal Access Tokens", en: "Personal Access Tokens" },
4
+ "screen:api-tokens.title": {
5
+ de: "Personal Access Tokens",
6
+ en: "Personal Access Tokens",
7
+ es: "Tokens de acceso personal",
8
+ },
5
9
  };
@@ -74,4 +74,39 @@ export const defaultTranslations: TranslationsByLocale = {
74
74
  "pat.list.created": "Created {date}",
75
75
  "pat.error.generic": "Action failed.",
76
76
  },
77
+ es: {
78
+ "pat.title": "Tokens de acceso personal",
79
+ "pat.create.title": "Crear un token nuevo",
80
+ "pat.create.subtitle": "Elige los permisos y una caducidad. El token solo se muestra una vez.",
81
+ "pat.create.name": "Nombre",
82
+ "pat.create.namePlaceholder": "p. ej. automatización de CLI",
83
+ "pat.create.scopes": "Acceso por ámbito",
84
+ "pat.level.none": "Sin acceso",
85
+ "pat.level.read": "Lectura",
86
+ "pat.level.write": "Lectura y escritura",
87
+ "pat.create.expiry": "Válido hasta",
88
+ "pat.create.submit": "Crear token",
89
+ "pat.create.needName": "Indica un nombre.",
90
+ "pat.create.needScope": "Selecciona al menos un permiso.",
91
+ "pat.create.needPassword": "Introduce tu contraseña para confirmar.",
92
+ "pat.create.currentPassword": "Contraseña (para confirmar)",
93
+ "pat.create.mfaCode": "Código 2FA (si está activado)",
94
+ "pat.create.mfaCodePlaceholder": "Código de 6 dígitos",
95
+ "pat.expiry.30d": "30 días",
96
+ "pat.expiry.90d": "90 días",
97
+ "pat.expiry.1y": "1 año",
98
+ "pat.expiry.never": "Sin caducidad",
99
+ "pat.created.hint": "Copia este token ahora — por seguridad solo se muestra esta vez.",
100
+ "pat.created.copy": "Copiar",
101
+ "pat.created.copied": "Copiado ✓",
102
+ "pat.created.dismiss": "Listo",
103
+ "pat.list.title": "Tus tokens",
104
+ "pat.list.empty": "Todavía no hay tokens.",
105
+ "pat.list.revoke": "Revocar",
106
+ "pat.list.revoked": "Revocado",
107
+ "pat.list.validUntil": "Válido hasta {date}",
108
+ "pat.list.neverExpires": "Sin caducidad",
109
+ "pat.list.created": "Creado el {date}",
110
+ "pat.error.generic": "La acción ha fallado.",
111
+ },
77
112
  };
@@ -1,23 +1,27 @@
1
1
  // @runtime client
2
2
  // Server + client i18n for the sessions operator screens.
3
3
 
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 SESSIONS_I18N: Readonly<Record<string, LocalizedString>> = {
7
- "screen:session-list.title": { de: "Sitzungen", en: "Sessions" },
8
- "screen:session-detail.title": { de: "Sitzung", en: "Session" },
9
- "sessions:nav.sessionList": { de: "Sitzungen", en: "Sessions" },
10
- "sessions.list.col.id": { de: "ID", en: "ID" },
11
- "sessions.list.col.userId": { de: "User-ID", en: "User ID" },
12
- "sessions.list.col.createdAt": { de: "Erstellt", en: "Created" },
13
- "sessions.list.col.expiresAt": { de: "Läuft ab", en: "Expires" },
14
- "sessions.list.col.revokedAt": { de: "Widerrufen", en: "Revoked" },
15
- "sessions.list.action.open": { de: "Details", en: "Details" },
16
- "sessions.detail.field.id": { de: "ID", en: "ID" },
17
- "sessions.detail.field.userId": { de: "User-ID", en: "User ID" },
18
- "sessions.detail.field.createdAt": { de: "Erstellt", en: "Created" },
19
- "sessions.detail.field.expiresAt": { de: "Läuft ab", en: "Expires" },
20
- "sessions.detail.field.revokedAt": { de: "Widerrufen", en: "Revoked" },
21
- "sessions.detail.field.ip": { de: "IP-Adresse", en: "IP address" },
22
- "sessions.detail.field.userAgent": { de: "User-Agent", en: "User agent" },
7
+ "screen:session-list.title": { de: "Sitzungen", en: "Sessions", es: "Sesiones" },
8
+ "screen:session-detail.title": { de: "Sitzung", en: "Session", es: "Sesión" },
9
+ "sessions:nav.sessionList": { de: "Sitzungen", en: "Sessions", es: "Sesiones" },
10
+ "sessions.list.col.id": { de: "ID", en: "ID", es: "ID" },
11
+ "sessions.list.col.userId": { de: "User-ID", en: "User ID", es: "ID de usuario" },
12
+ "sessions.list.col.createdAt": { de: "Erstellt", en: "Created", es: "Creado" },
13
+ "sessions.list.col.expiresAt": { de: "Läuft ab", en: "Expires", es: "Caduca" },
14
+ "sessions.list.col.revokedAt": { de: "Widerrufen", en: "Revoked", es: "Revocado" },
15
+ "sessions.list.action.open": { de: "Details", en: "Details", es: "Detalles" },
16
+ "sessions.detail.field.id": { de: "ID", en: "ID", es: "ID" },
17
+ "sessions.detail.field.userId": { de: "User-ID", en: "User ID", es: "ID de usuario" },
18
+ "sessions.detail.field.createdAt": { de: "Erstellt", en: "Created", es: "Creado" },
19
+ "sessions.detail.field.expiresAt": { de: "Läuft ab", en: "Expires", es: "Caduca" },
20
+ "sessions.detail.field.revokedAt": { de: "Widerrufen", en: "Revoked", es: "Revocado" },
21
+ "sessions.detail.field.ip": { de: "IP-Adresse", en: "IP address", es: "Dirección IP" },
22
+ "sessions.detail.field.userAgent": {
23
+ de: "User-Agent",
24
+ en: "User agent",
25
+ es: "Agente de usuario",
26
+ },
23
27
  };
package/src/tags/i18n.ts CHANGED
@@ -1,5 +1,5 @@
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 TAGS_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
4
- "screen:tag-list.title": { de: "Tags", en: "Tags" },
4
+ "screen:tag-list.title": { de: "Tags", en: "Tags", es: "Etiquetas" },
5
5
  };
@@ -57,4 +57,29 @@ export const defaultTranslations: TranslationsByLocale = {
57
57
  "tags.filter.label": "Filter by tag",
58
58
  "tags.filter.clear": "Clear",
59
59
  },
60
+ es: {
61
+ "tags.section.createMode": "Guarda primero la entrada para añadir etiquetas.",
62
+ "tags.section.loading": "Cargando…",
63
+ "tags.section.empty": "No se encontraron etiquetas.",
64
+ "tags.section.working": "Guardando…",
65
+ "tags.section.none": "Sin etiquetas",
66
+ "tags.section.edit": "Editar etiquetas",
67
+ "tags.manage.newLabel": "Nueva etiqueta",
68
+ "tags.manage.namePlaceholder": "Nombre de la etiqueta",
69
+ "tags.manage.scopeLabel": "Ámbito (tipo de entidad, vacío = global)",
70
+ "tags.manage.scopePlaceholder": "p. ej. nota (vacío = en todas partes)",
71
+ "tags.manage.create": "Crear etiqueta",
72
+ "tags.manage.edit": "Editar",
73
+ "tags.manage.save": "Guardar",
74
+ "tags.manage.cancel": "Cancelar",
75
+ "tags.manage.delete": "Eliminar",
76
+ "tags.manage.toggle": "Alternar etiqueta",
77
+ "tags.manage.usage": "{count}×",
78
+ "tags.manage.deleteConfirmTitle": "¿Eliminar la etiqueta «{name}»?",
79
+ "tags.manage.deleteConfirmDesc": "Se quitará de {count} objetos. Esto no se puede deshacer.",
80
+ "tags.picker.title": "Etiquetas",
81
+ "tags.picker.done": "Listo",
82
+ "tags.filter.label": "Filtrar por etiqueta",
83
+ "tags.filter.clear": "Quitar filtro",
84
+ },
60
85
  };
@@ -36,4 +36,18 @@ export const defaultTranslations: TranslationsByLocale = {
36
36
  "template-resolver.editor.readOnly":
37
37
  "Read-only — TenantAdmin or SystemAdmin role required to make changes.",
38
38
  },
39
+ es: {
40
+ "template-resolver.editor.titleLabel": "Título",
41
+ "template-resolver.editor.contentLabel": "Contenido",
42
+ "template-resolver.editor.save": "Guardar",
43
+ "template-resolver.editor.saving": "Guardando…",
44
+ "template-resolver.editor.created": "Creado.",
45
+ "template-resolver.editor.saved": "Guardado.",
46
+ "template-resolver.editor.saveFailed": "Error al guardar.",
47
+ "template-resolver.editor.networkError": "Error de red al guardar.",
48
+ "template-resolver.editor.loading": "Cargando versión actual…",
49
+ "template-resolver.editor.loadFailed": "No se pudo cargar el bloque",
50
+ "template-resolver.editor.readOnly":
51
+ "Solo lectura — se requiere el rol TenantAdmin o SystemAdmin para hacer cambios.",
52
+ },
39
53
  };