@micha.bigler/ui-core-micha 3.8.0 → 3.9.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 (68) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/WORK_ORDERS.md +1 -0
  3. package/dist/auth/apiClient.js +8 -0
  4. package/dist/components/AccessCodeManager.js +5 -5
  5. package/dist/components/AccessCodeSingleUseToggle.js +3 -2
  6. package/dist/components/AllowedEmailDomainsManager.js +3 -2
  7. package/dist/components/AuthFactorRequirementCard.js +3 -2
  8. package/dist/components/BulkInviteCsvTab.js +3 -2
  9. package/dist/components/MFAComponent.js +9 -8
  10. package/dist/components/MfaLoginComponent.js +7 -6
  11. package/dist/components/PasskeysComponent.js +7 -6
  12. package/dist/components/ProfileComponent.js +4 -3
  13. package/dist/components/QrSignupManager.js +4 -3
  14. package/dist/components/QrSignupValidityManager.js +3 -2
  15. package/dist/components/RegistrationMethodsManager.js +3 -2
  16. package/dist/components/SecurityComponent.js +5 -4
  17. package/dist/components/SupportRecoveryRequestsTab.js +8 -7
  18. package/dist/components/UserInviteComponent.js +3 -2
  19. package/dist/components/UserListComponent.js +7 -6
  20. package/dist/i18n/authTranslations.js +420 -0
  21. package/dist/pages/AccountPage.js +3 -2
  22. package/dist/pages/LoginPage.js +10 -9
  23. package/dist/pages/PasswordChangePage.js +4 -3
  24. package/dist/pages/PasswordInvitePage.js +7 -6
  25. package/dist/pages/PasswordResetRequestPage.js +5 -4
  26. package/dist/pages/SignUpPage.js +7 -6
  27. package/dist/pages/SignupConfirmPage.js +7 -6
  28. package/dist/utils/auth-errors.js +45 -8
  29. package/dist/utils/resolveErrorText.js +24 -0
  30. package/package.json +1 -1
  31. package/src/auth/apiClient.jsx +9 -0
  32. package/src/components/AccessCodeManager.jsx +5 -5
  33. package/src/components/AccessCodeSingleUseToggle.jsx +3 -2
  34. package/src/components/AllowedEmailDomainsManager.jsx +3 -2
  35. package/src/components/AuthFactorRequirementCard.jsx +3 -2
  36. package/src/components/BulkInviteCsvTab.jsx +3 -2
  37. package/src/components/MFAComponent.jsx +9 -8
  38. package/src/components/MfaLoginComponent.jsx +7 -6
  39. package/src/components/PasskeysComponent.jsx +7 -6
  40. package/src/components/ProfileComponent.jsx +4 -3
  41. package/src/components/QrSignupManager.jsx +4 -3
  42. package/src/components/QrSignupValidityManager.jsx +3 -2
  43. package/src/components/RegistrationMethodsManager.jsx +3 -2
  44. package/src/components/SecurityComponent.jsx +5 -4
  45. package/src/components/SupportRecoveryRequestsTab.jsx +9 -8
  46. package/src/components/UserInviteComponent.jsx +3 -2
  47. package/src/components/UserListComponent.jsx +7 -6
  48. package/src/i18n/authTranslations.ts +421 -0
  49. package/src/pages/AccountPage.jsx +3 -4
  50. package/src/pages/LoginPage.jsx +10 -9
  51. package/src/pages/PasswordChangePage.jsx +4 -3
  52. package/src/pages/PasswordInvitePage.jsx +7 -6
  53. package/src/pages/PasswordResetRequestPage.jsx +5 -4
  54. package/src/pages/SignUpPage.jsx +7 -6
  55. package/src/pages/SignupConfirmPage.jsx +7 -6
  56. package/src/utils/auth-errors.js +44 -7
  57. package/src/utils/resolveErrorText.js +28 -0
  58. package/tests/AccountPage.test.jsx +27 -20
  59. package/tests/PasswordInvitePage.test.jsx +28 -21
  60. package/tests/QrSignupManager.registrationContext.test.jsx +5 -1
  61. package/tests/SignUpPage.test.jsx +14 -3
  62. package/tests/UserListComponent.test.jsx +22 -15
  63. package/tests/apiClientLanguage.test.js +33 -0
  64. package/tests/authErrors.test.js +31 -0
  65. package/tests/errorCallSiteGuard.test.js +32 -0
  66. package/tests/loginPageErrorResolution.test.jsx +102 -0
  67. package/tests/resolveErrorText.test.js +72 -0
  68. package/work-orders/UCM-AUTH-10.md +490 -0
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveErrorText } from '../src/utils/resolveErrorText';
3
+
4
+ function makeI18n(values) {
5
+ return {
6
+ exists: (key) => Object.hasOwn(values, key),
7
+ t: (key) => values[key] ?? `missing:${key}`,
8
+ };
9
+ }
10
+
11
+ describe('resolveErrorText', () => {
12
+ it('prefers an explicit backend i18nKey over every lower-priority source', () => {
13
+ const i18n = makeI18n({
14
+ 'Auth.explicit': 'Explicit text',
15
+ 'Auth.field.email.invalid': 'Field text',
16
+ invalid: 'Code text',
17
+ 'Auth.DEFAULT': 'Default text',
18
+ });
19
+
20
+ expect(resolveErrorText(i18n, {
21
+ i18nKey: 'Auth.explicit',
22
+ field: 'email',
23
+ code: 'invalid',
24
+ backendMessage: 'Backend text',
25
+ }, 'Auth.DEFAULT')).toBe('Explicit text');
26
+ });
27
+
28
+ it('uses the allauth param as a field-specific key before the code', () => {
29
+ const i18n = makeI18n({
30
+ 'Auth.field.email.invalid': 'Field text',
31
+ invalid: 'Code text',
32
+ 'Auth.DEFAULT': 'Default text',
33
+ });
34
+
35
+ expect(resolveErrorText(i18n, {
36
+ field: 'email',
37
+ code: 'invalid',
38
+ backendMessage: 'Backend text',
39
+ }, 'Auth.DEFAULT')).toBe('Field text');
40
+ });
41
+
42
+ it('uses the code catalogue entry when no field entry exists', () => {
43
+ const i18n = makeI18n({
44
+ email_password_mismatch: 'Code text',
45
+ 'Auth.DEFAULT': 'Default text',
46
+ });
47
+
48
+ expect(resolveErrorText(i18n, {
49
+ field: 'email',
50
+ code: 'email_password_mismatch',
51
+ backendMessage: 'Backend text',
52
+ }, 'Auth.DEFAULT')).toBe('Code text');
53
+ });
54
+
55
+ it('uses the backend message before the caller default', () => {
56
+ const i18n = makeI18n({ 'Auth.DEFAULT': 'Default text' });
57
+
58
+ expect(resolveErrorText(i18n, {
59
+ code: 'unknown_backend_code',
60
+ backendMessage: 'Backend text',
61
+ }, 'Auth.DEFAULT')).toBe('Backend text');
62
+ });
63
+
64
+ it('uses the caller default instead of ever returning an unknown raw code', () => {
65
+ const i18n = makeI18n({ 'Auth.DEFAULT': 'Default text' });
66
+
67
+ expect(resolveErrorText(i18n, {
68
+ code: 'unknown_backend_code',
69
+ }, 'Auth.DEFAULT')).toBe('Default text');
70
+ expect(resolveErrorText(i18n, null, 'Auth.DEFAULT')).toBe('Default text');
71
+ });
72
+ });
@@ -0,0 +1,490 @@
1
+ # UCM-AUTH-10: Error texts never show a raw backend code
2
+
3
+ # A. Envelope, authored by the Expertenchat
4
+
5
+ ## Goal & expected outcome
6
+
7
+ A person who makes a mistake on a kit screen sees a sentence in the language the UI is set to. **Never
8
+ a raw backend code.** Today a login with a malformed e-mail shows the literal word `invalid`
9
+ (operator, `survey_app` staging, 2026-09-30), although the backend sent a translated sentence.
10
+
11
+ After this order every kit error display resolves its text through one shared chain, and the kit's API
12
+ client tells the backend which language the UI is in.
13
+
14
+ ## Context the operator established
15
+
16
+ - **What the backend sends.** allauth headless: `{"status": 400, "errors": [{"message": "Gib eine
17
+ gültige E-Mail Adresse an.", "code": "invalid", "param": "email"}]}`. django-core-micha's own
18
+ endpoints, through `custom_exception_handler` / `_flatten` (`django_core_micha/auth/exception_handler.py:31-50,
19
+ 119`): `{"errors": [{"field": ..., "code": ..., "message"?: ..., "i18nKey"?: ..., "params"?: ...}]}`,
20
+ and a few plain `{"code": "Auth.RECOVERY_TOKEN_INVALID"}` / `{"detail": ...}` responses. **The field is
21
+ called `param` in allauth and `field` in dcm**, and dcm can already name an explicit `i18nKey`.
22
+ - **What the kit does with it.** `extractErrorInfo` (`src/utils/auth-errors.js:2-27`) keeps
23
+ `errors[0].code` and `.message` and drops the field name. `normaliseApiError` (`:29-39`) sets
24
+ `err.code`, and `err.message` to the backend message **or the code itself**, so the message alone
25
+ cannot be told apart from a code. Then about 40 call sites in some 25 files render
26
+ `t(err.code || '<default key>')` or `setErrorKey(err.code || ...)` followed by `t(errorKey)` (e.g.
27
+ `src/pages/LoginPage.jsx:220` and `:290`). An unknown code has no catalogue entry, so i18next shows
28
+ the key itself.
29
+ - **Why `AUTH-2` did not close this.** `AUTH-2` (done) keyed translations by the raw code and enumerated
30
+ the login and password codes (`email_password_mismatch`, `password_too_short`, ...). The generic
31
+ Django field codes (`invalid`, `required`, `max_length`, ...) were not in its list. **They cannot be
32
+ translated by code alone**, because `invalid` means something different on each field.
33
+ - **Where texts live today** (measured 2026-09-30): kit texts in `src/i18n/*Translations.ts`, four
34
+ languages (`de`/`en`/`fr`/`sw`), formal German, plus optional `de_informal` entries since
35
+ `UCM-I18N-4`. Apps merge them into their own i18next instance at start-up, **kit keys last**, so kit
36
+ texts win over an app's. Backend messages come from allauth's own gettext catalogue (dcm ships no
37
+ `.po` files), in the language Django's `LocaleMiddleware` picks from `Accept-Language`, i.e. **the
38
+ browser's language, not the UI's**. `apiClient` (`src/auth/apiClient.jsx:5-9`) sends no language
39
+ header. allauth's German catalogue addresses the user informally ("Gib ...").
40
+ - `i18next` and `react-i18next` are **peer** dependencies. The kit does not import an `i18next`
41
+ instance anywhere today. The apps initialise the default instance (`import i18n from "i18next"`).
42
+
43
+ ## The chain (decided)
44
+
45
+ **Operator decision (2026-09-30): code-based in the UI, the backend message as the last specific
46
+ fallback, and the UI language sent to the backend.**
47
+
48
+ For each error, the text is the first of these that exists:
49
+
50
+ 0. **An explicit `i18nKey` from the backend** (dcm already sends one on some errors).
51
+ 1. **A field-specific key** `Auth.field.<field>.<code>`, e.g. `Auth.field.email.invalid`, where
52
+ `<field>` is `param` (allauth) or `field` (dcm).
53
+ 2. **The code as a key**, exactly as `AUTH-2` did, e.g. `email_password_mismatch`.
54
+ 3. **The backend's own message**, which with rule 5 below arrives in the UI language.
55
+ 4. **The caller's default key** (`Auth.LOGIN_FAILED`, ...), as today.
56
+
57
+ A raw code is never a result. "Exists" means that the key has an entry in the active language; i18next
58
+ returning the key unchanged does not count.
59
+
60
+ 5. **`apiClient` sends `Accept-Language`** set to the current language of the default `i18next`
61
+ instance. If no instance is initialised, it sends nothing (today's behaviour). This applies to every
62
+ request through `apiClient`, not only errors, which is intended: backend texts then match the UI.
63
+
64
+ 6. **Field keys to add, in every kit language** (`de`/`en`/`fr`/`sw`, formal German, plus `de_informal`
65
+ where German differs): `invalid` and `required` for every field that a kit screen submits.
66
+ Enumerate the fields from `src/auth/authApi.jsx` (measured so far: `email`, `password`,
67
+ `new_password`, `current_password`, `key`, `code`, and the signup payload). No bare `invalid` or
68
+ `required` key at step 2: it would shadow step 3 with a vaguer text.
69
+
70
+ ## Scope + non-goals
71
+
72
+ In scope:
73
+ - `extractErrorInfo` / `normaliseApiError` carry the field name, the `i18nKey` and the backend message
74
+ separately (the message field must not fall back to the code).
75
+ - One shared resolver implementing steps 0-4, used by **every** call site that renders
76
+ `err.code` today, all ~40 of them (`grep -rnE "(setErrorKey|setError|setAgentErrorKey|alert)\(.*\.code|t\(\w+\??\.code"
77
+ src`). A call site left on the old pattern is a finding.
78
+ - The `Accept-Language` header in `apiClient`.
79
+ - The new field keys.
80
+ - `CHANGELOG.md` with an entry for this release, and the missing `3.7.0` entry reconstructed from its
81
+ commits (`UCM-AUTH-9`, `UCM-I18N-4`, `UCM-SHELL-7`, `UCM-MSG-20`, `UCM-PRIM-2`). A **minor** release.
82
+
83
+ Non-goals:
84
+ - No backend change (allauth, dcm). dcm's `_flatten` shape is taken as it is.
85
+ - No change to which errors the kit shows or where. Only the text changes.
86
+ - No new languages (`it`/`es` stay unsupported in the kit).
87
+ - No field-level display of errors. The text stays where each screen shows it today.
88
+ - No consumer bump. Apps pick this up through their own pins.
89
+
90
+ ## Tier · precondition / gate
91
+
92
+ - **Tier 3 · tests: the resolver chain, the ~40 call sites, the header, the screenshot case end to end;
93
+ the kit's existing auth tests.** A change inside shared-core, on auth screens, plus a header on every
94
+ API request.
95
+ - Precondition: none.
96
+
97
+ ## Risks
98
+
99
+ - **The header changes the language of every backend text** for a user whose browser language differs
100
+ from the UI language. That is the point, but it also reaches non-error texts that the backend
101
+ renders (e.g. option labels). `Accept-Language` is a CORS-safelisted header for these values, so no
102
+ new preflight appears.
103
+ - **A duplicated `i18next` copy** in an app's bundle would give the kit a different instance than the
104
+ app, and the header would then be missing or wrong. Read the language defensively and fall back to no
105
+ header, never to a guess.
106
+ - **German step 3 can still address the user informally**, for codes without a key. Step 6 exists to
107
+ keep that rare. Name any code that the kit screens are known to receive and that still lands on
108
+ step 3.
109
+ - **Changing ~40 call sites touches every auth and admin screen.** The resolver must be a drop-in for
110
+ what those sites render today, including their current default keys and the English fallbacks some
111
+ pass as the second `t()` argument.
112
+
113
+ ## Required tests to WRITE (you write them and run YOUR OWN new ones; the Orchestrator's run is the gate)
114
+
115
+ Kit test suite:
116
+
117
+ 1. **Resolver order.** One case per step 0-4, each showing that the step above it wins when present:
118
+ explicit `i18nKey` > field key > code key > backend message > default key. Include the allauth
119
+ (`param`) and the dcm (`field`) shape.
120
+ 2. **Never a raw code.** An unknown code with no message resolves to the default key's text, not to
121
+ the code.
122
+ 3. **The screenshot case, end to end:** `LoginPage` receiving exactly the operator's 400 response shows
123
+ the text of `Auth.field.email.invalid`, not `invalid`.
124
+ 4. **Call-site guard.** A test that scans `src/` for the old pattern (`t(err.code ...)`,
125
+ `setErrorKey(err.code ...)` and siblings) and fails if one remains. Show that it fails on a copy with
126
+ one site restored.
127
+ 5. **Header.** A request through `apiClient` carries `Accept-Language` equal to the active i18next
128
+ language, follows a language change, and carries none when no instance is initialised.
129
+ 6. **Catalogue completeness** for the new field keys: every field × `invalid`/`required` has all four
130
+ languages.
131
+
132
+ Run, do not write: the kit's existing auth, `LoginPage`, `SignUpPage` and `PasswordInvitePage` tests.
133
+
134
+ ## Parity guardrail
135
+
136
+ No change to control flow, redirects, what triggers an error, or which request is sent. Only the
137
+ displayed text and the new request header change.
138
+
139
+ ---
140
+
141
+ # B. Implementation map, filled by the Orchestrator and ADDRESSED TO THE IMPLEMENTER
142
+
143
+ ## Context package
144
+
145
+ ### The two backend shapes, quoted directly (the sibling `django-core-micha` repo — read this, do
146
+ ### not try to open that repo yourself, a sandboxed process cannot see it)
147
+
148
+ allauth headless (400, malformed email, the operator's exact screenshot case):
149
+ ```json
150
+ {"status": 400, "errors": [{"message": "Gib eine gültige E-Mail Adresse an.", "code": "invalid", "param": "email"}]}
151
+ ```
152
+
153
+ dcm's `django_core_micha/auth/exception_handler.py` (`_flatten`, quoted verbatim, lines 31-64):
154
+ ```python
155
+ def _flatten(detail, field=None):
156
+ out = []
157
+ if isinstance(detail, dict):
158
+ if _is_error_object(detail): # any of code/i18nKey/message/params present
159
+ out.append({
160
+ "field": field,
161
+ "code": str(detail.get("code", "error")),
162
+ **({"message": str(detail.get("message"))} if detail.get("message") is not None else {}),
163
+ **({"i18nKey": str(detail.get("i18nKey"))} if detail.get("i18nKey") is not None else {}),
164
+ **({"params": detail.get("params")} if isinstance(detail.get("params"), dict) else {}),
165
+ })
166
+ return out
167
+ for k, v in detail.items():
168
+ out.extend(_flatten(v, f"{field}.{k}" if field else k))
169
+ return out
170
+ if isinstance(detail, list):
171
+ for item in detail:
172
+ out.extend(_flatten(item, field))
173
+ return out
174
+ code = getattr(detail, "code", "error")
175
+ out.append({"field": field, "code": str(code), "message": str(detail)})
176
+ return out
177
+ ```
178
+ Wired as `Response({"errors": _flatten(resp.data)}, status=resp.status_code)` for every
179
+ `ValidationError` (400s). Separately, 401/403 responses get `resp.data.setdefault("code", ...)` /
180
+ `.setdefault("i18nKey", AUTH_CODE_MAP.get(...))` merged onto whatever `resp.data` already is — so a
181
+ plain `{"detail": "..."}` becomes `{"detail": "...", "code": "...", "i18nKey": "..." | None}`.
182
+
183
+ **Net shape your code must read, from `data.errors[0]` (validation) or top-level `data`
184
+ (401/403/plain)**: `code` (always present, `"error"` if truly unknown), `field` OR `param` (dcm vs
185
+ allauth — normalise to one name), optional `message`, optional `i18nKey`, optional `params` (unused
186
+ here — no interpolation into these texts is in scope).
187
+
188
+ ### `src/utils/auth-errors.js` — carry field/i18nKey/message separately, no code-as-message fallback
189
+
190
+ Current (`extractErrorInfo` lines 2-27, `normaliseApiError` lines 29-39) already read almost this
191
+ shape but (a) drop the field name entirely, (b) fold `i18nKey` into nothing (never read), (c)
192
+ `normaliseApiError`'s `message` **falls back to the code** (`info.message || code || defaultCode`) —
193
+ this is the literal bug: after that line, nothing downstream can tell a real backend message from a
194
+ copy of the code.
195
+
196
+ Rewrite `extractErrorInfo` to also return `field` (read `first.field ?? first.param ?? null` for the
197
+ `errors[]` branch, `data.field ?? null` for the plain-code branch) and `i18nKey` (`first.i18nKey ??
198
+ null` / `data.i18nKey ?? null`), and to leave `message` as `null`/`undefined` whenever the backend
199
+ sent none — **never** substitute the code.
200
+
201
+ Rewrite `normaliseApiError` to set, on the thrown `Error`: `err.code` (unchanged), `err.field`,
202
+ `err.i18nKey`, **`err.backendMessage`** (exactly `info.message`, possibly `undefined` — this is the
203
+ new, unambiguous field the resolver reads; do not confuse it with the native `err.message`, which you
204
+ may leave with its existing fallback-to-code behaviour untouched — changing it is not required by
205
+ this WO and other incidental consumers of `.message` for logging should not regress), `err.status`,
206
+ `err.raw` (all unchanged in spirit).
207
+
208
+ ### New resolver — `src/utils/resolveErrorText.js` (new file)
209
+
210
+ A plain function, not a hook (it is called imperatively inside `catch` blocks, where hooks cannot
211
+ run):
212
+ ```js
213
+ export function resolveErrorText(i18n, err, defaultKey) {
214
+ const exists = (key) => Boolean(key) && i18n.exists(key);
215
+
216
+ if (!err) return i18n.t(defaultKey);
217
+ if (exists(err.i18nKey)) return i18n.t(err.i18nKey);
218
+ if (err.field && err.code) {
219
+ const fieldKey = `Auth.field.${err.field}.${err.code}`;
220
+ if (exists(fieldKey)) return i18n.t(fieldKey);
221
+ }
222
+ if (exists(err.code)) return i18n.t(err.code);
223
+ if (err.backendMessage) return err.backendMessage;
224
+ return i18n.t(defaultKey);
225
+ }
226
+ ```
227
+ This implements steps 0-4 exactly, in order, each an early return. `i18n.exists(key)` (not
228
+ `t(key) !== key`) is what makes "exists" mean "has a real catalogue entry in the active language" —
229
+ the Envelope is explicit that a `t()` echo of the key does not count, and `i18n.exists` is the
230
+ correct, direct way to ask that question (`t()` alone cannot distinguish a real translation from its
231
+ own missing-key echo). Takes `i18n` (not just `t`) because `.exists` lives on `i18n`, not on `t`.
232
+
233
+ ### Call sites — the exact mechanical transformation, worked on the named file
234
+
235
+ **Every call site now stores/returns FINAL DISPLAY TEXT, never a translation key** — this is the one
236
+ structural decision that makes the ~40-site sweep uniform: today some state variables hold a key
237
+ (rendered later via `t(errorKey)`), which is exactly the bug (an unresolved code has no catalogue
238
+ entry, so `t()` echoes it raw). After this WO, the *same* state variables hold resolved text, and
239
+ the render side drops its `t()` wrapper.
240
+
241
+ Per file:
242
+ 1. Add `i18n` to the existing `const { t } = useTranslation();` destructure →
243
+ `const { t, i18n } = useTranslation();` (skip if the file already has `i18n`).
244
+ 2. Import `{ resolveErrorText }` from `'../utils/resolveErrorText'` (adjust relative path per file
245
+ depth).
246
+ 3. Every occurrence of the pattern `<expr>.code || 'Auth.SOME_DEFAULT'` (with or without an outer
247
+ `t(...)` and/or optional-chaining `?.`) — where `<expr>` is the caught error variable — becomes
248
+ `resolveErrorText(i18n, <expr>, 'Auth.SOME_DEFAULT')`, with any outer `t(...)` that directly
249
+ wrapped it REMOVED (the resolver already returns final text; wrapping it in `t()` again would
250
+ only be harmless by accident of i18next's missing-key echo — do not rely on that, remove the
251
+ wrapper so the code says what it means).
252
+ 4. Every OTHER existing setter of that same state variable that passes a **plain literal key** with
253
+ no error object at all (a client-side/synthetic condition — a URL param, a null token, a resolved
254
+ promise carrying no server error) — e.g. `setErrorKey('Auth.SOCIAL_LOGIN_FAILED')` — becomes
255
+ `setErrorKey(t('Auth.SOCIAL_LOGIN_FAILED'))` (resolve immediately, so the state variable is
256
+ *always* final text, uniformly, regardless of which branch set it — never leave some code paths
257
+ storing keys and others storing text on the same variable).
258
+ 5. The render site — `{t(errorKey)}` (or equivalent) — becomes `{errorKey}` (drop the wrapper; the
259
+ variable is text now).
260
+
261
+ **Worked example — `src/pages/LoginPage.jsx`** (do this file first, verify it, then repeat the
262
+ identical pattern across the rest; this file alone exercises every shape you will meet elsewhere):
263
+ - Line 29: `const { t } = useTranslation();` → `const { t, i18n } = useTranslation();`.
264
+ - Line 99: `setErrorKey('Auth.SOCIAL_LOGIN_FAILED');` → `setErrorKey(t('Auth.SOCIAL_LOGIN_FAILED'));`
265
+ (plain literal, no `err` — case 4 above).
266
+ - Lines 135, 142, 153: same treatment (`'Auth.RECOVERY_TOKEN_EXPIRED'`, `'Auth.RECOVERY_TOKEN_INVALID'`
267
+ ×2) — plain literals, wrap in `t()` at the call site.
268
+ - Line 161: `setErrorKey(err?.code || 'Auth.RECOVERY_TOKEN_INVALID');` →
269
+ `setErrorKey(resolveErrorText(i18n, err, 'Auth.RECOVERY_TOKEN_INVALID'));`
270
+ - Line 220: `setErrorKey(err.code || 'Auth.LOGIN_FAILED');` →
271
+ `setErrorKey(resolveErrorText(i18n, err, 'Auth.LOGIN_FAILED'));`
272
+ - Line 236: `setErrorKey(err.code || 'Auth.PASSKEY_FAILED');` →
273
+ `setErrorKey(resolveErrorText(i18n, err, 'Auth.PASSKEY_FAILED'));`
274
+ - Line 290: `{t(errorKey)}` → `{errorKey}`.
275
+ - Everything else in the file (the redirect logic, the recovery-token handoff, the MFA flow) is
276
+ untouched — this WO changes displayed text only (Parity guardrail).
277
+
278
+ **The remaining ~21 files** (`src/components/AccessCodeSingleUseToggle.jsx`,
279
+ `AllowedEmailDomainsManager.jsx`, `AuthFactorRequirementCard.jsx`, `BulkInviteCsvTab.jsx`,
280
+ `MFAComponent.jsx`, `MfaLoginComponent.jsx`, `PasskeysComponent.jsx`, `ProfileComponent.jsx`,
281
+ `QrSignupManager.jsx`, `QrSignupValidityManager.jsx`, `RegistrationMethodsManager.jsx`,
282
+ `SecurityComponent.jsx`, `SupportRecoveryRequestsTab.jsx`, `UserInviteComponent.jsx`,
283
+ `UserListComponent.jsx`; `src/pages/AccountPage.jsx`, `PasswordChangePage.jsx`,
284
+ `PasswordInvitePage.jsx`, `PasswordResetRequestPage.jsx`, `SignupConfirmPage.jsx`,
285
+ `SignUpPage.jsx`) — apply the identical five-step transformation. Re-run the grep below after every
286
+ few files to track remaining count; it must reach zero:
287
+ ```
288
+ grep -rnE "(setErrorKey|setError|setAgentErrorKey|alert)\(.*\.code|t\(\w+\??\.code" src
289
+ ```
290
+ (one false-positive hit is a comment in `src/i18n/authTranslations.ts` mentioning the old pattern —
291
+ not a call site; leave that comment as-is, or update its wording if you touch that file for the new
292
+ keys, but it is not counted by the call-site-guard test, which is scoped to `.jsx` files only, see
293
+ below.)
294
+
295
+ `alert(...)`/`window.alert(...)` sites (several of the components above) follow the same rule:
296
+ `alert(t(err.code || 'Auth.X'))` → `alert(resolveErrorText(i18n, err, 'Auth.X'))`.
297
+
298
+ **A file that references `err.code` for a NON-display reason** (e.g. `LoginPage.jsx:235`'s
299
+ `if (err.code !== 'Auth.PASSKEY_CANCELLED')` control-flow check, not a display) is NOT in scope —
300
+ the Parity guardrail says only displayed text changes; a code comparison controlling behaviour stays
301
+ exactly as it is.
302
+
303
+ ### `src/auth/apiClient.jsx` — `Accept-Language` request interceptor
304
+
305
+ Add a **request** interceptor (the file currently only has a response interceptor, `:161-201`) —
306
+ place it near the top-level `apiClient` setup:
307
+ ```js
308
+ import i18next from 'i18next';
309
+ // ...
310
+ apiClient.interceptors.request.use((config) => {
311
+ const language = i18next?.isInitialized ? i18next.language : null;
312
+ if (language) {
313
+ config.headers = { ...config.headers, 'Accept-Language': language };
314
+ }
315
+ return config;
316
+ });
317
+ ```
318
+ `i18next` is already a peer dependency (never bundled) — importing it directly here, the same way
319
+ `resolveErrorText` reads `i18n.exists`/`i18n.t` off the instance handed to it by each component's own
320
+ `useTranslation()`, is how rule 5 is meant to work: **the same default i18next instance the app
321
+ itself initialised.** The defensive guard (Risk note) is exactly `i18next?.isInitialized` — if false
322
+ (no instance, or a duplicated/separate copy that never got `.init()` called on it), send nothing, do
323
+ not guess a language. Do not import `i18next` in a way that could throw if the peer dependency is
324
+ somehow entirely absent from a consumer's `node_modules` — a bare `import i18next from 'i18next'` is
325
+ fine (it's declared as a peer dep in `package.json`, so it is always resolvable), just do not add
326
+ extra defensiveness beyond the `isInitialized` check the Risk note asks for.
327
+
328
+ ### New field keys — rule 6
329
+
330
+ Enumerate every distinct field name sent as a request body key from `src/auth/authApi.jsx`'s
331
+ `apiClient.post/patch/put` calls (grep `apiClient\.(post|patch|put)\(` in that file and read each
332
+ call's payload) — the operator's own partial list (`email`, `password`, `new_password`,
333
+ `current_password`, `key`, `code`) plus the signup payload's fields
334
+ (`mode`, `access_code`, `registration_context_token`, `registration_context`, `turnstile_token`) and
335
+ `register-confirm`'s `token` are the floor, not the ceiling; if you find another plausibly-user-facing
336
+ field a kit SCREEN submits (not an admin/internal endpoint like `update-role`), add it too and name
337
+ it in `PROGRESS`. For each field, two keys: `Auth.field.<field>.invalid` and
338
+ `Auth.field.<field>.required`, in `de` (formal — plus `de_informal` only where the wording actually
339
+ differs, same Sie/Ihr-form scan `UCM-I18N-4` used), `en`, `fr`, `sw`, added to
340
+ `src/i18n/authTranslations.ts` next to the existing `Auth.field.*`-shaped or nearest-related keys (if
341
+ none exist yet, add a new clearly-labelled section). Reasonable generic wording, e.g.
342
+ `Auth.field.email.invalid` → "Enter a valid email address." / `Auth.field.email.required` → "Enter an
343
+ email address."; adapt per field name. **No bare `invalid`/`required` key at step 2** (rule 6's own
344
+ explicit warning — those two codes are too generic to mean anything without a field, and adding them
345
+ as top-level keys would let step 2 win over step 3's more specific backend message for a field this
346
+ WO didn't enumerate).
347
+
348
+ ### Test files
349
+
350
+ New, next to the existing auth tests:
351
+ 1. `tests/resolveErrorText.test.js` — one case per step 0-4 (Required Test #1): construct a
352
+ `{ i18n }` fixture (a real minimal i18next instance via `i18next.createInstance().init(...)`, or a
353
+ hand-built stub exposing `.exists`/`.t` — check `tests/i18nGermanVariant.test.js`/
354
+ `tests/authTranslations.test.js` for the repo's existing i18next-in-tests convention before
355
+ inventing a new one) with a few real keys loaded (`Auth.field.email.invalid`, a code key like
356
+ `Auth.LOGIN_FAILED`, and NOT `some_unknown_code`), and assert each step wins over the ones below it
357
+ when present — include one case shaped like allauth's `param` and one shaped like dcm's `field`.
358
+ Required Test #2 (never a raw code): an `err` with an unknown `code` and no `backendMessage` UB
359
+ resolves to `i18n.t(defaultKey)`, not the code string.
360
+ 2. `tests/loginPageErrorResolution.test.jsx` (or extend an existing `LoginPage` test if one covers
361
+ error rendering) — Required Test #3, the screenshot case end to end: mock `loginWithPassword` to
362
+ reject with exactly `{ response: { data: { errors: [{ message: "Gib eine gültige E-Mail Adresse
363
+ an.", code: "invalid", param: "email" }] } } }` (the operator's literal shape), render `LoginPage`,
364
+ submit, assert the visible text equals the resolved `Auth.field.email.invalid` translation (not
365
+ the literal word `"invalid"`).
366
+ 3. `tests/errorCallSiteGuard.test.js` — Required Test #4: recursively scan every `.jsx` file under
367
+ `src/` for the pattern `/(setErrorKey|setError|setAgentErrorKey|alert)\(.*\.code|t\(\w+\??\.code/`
368
+ per line, assert zero matches across the whole tree. Prove the scanner itself would catch a
369
+ regression (not a permanent toggle in the committed suite): a second test asserts the SAME regex
370
+ matches a hand-written fixture string shaped like the old pattern
371
+ (e.g. `"setErrorKey(err.code || 'Auth.X');"`) — this is the "fails on a copy with one site
372
+ restored" proof the Required Tests ask for, without mutating real source files in the suite.
373
+ 4. Extend `src/auth/apiClient.jsx`'s existing test (grep `tests/` for `apiClient`) or add a focused
374
+ new one — Required Test #5: a request through `apiClient` carries `Accept-Language` equal to
375
+ `i18next.language` after `i18next.init(...)`/`.changeLanguage(...)`, and carries no such header
376
+ when no instance is initialised (you likely need to exercise this against a fresh `i18next`
377
+ instance per test case — check how the existing apiClient tests mock axios/interceptors first).
378
+ 5. Required Test #6 (catalogue completeness): a test iterating the new `Auth.field.*` keys added in
379
+ this WO, asserting each has all four locales non-empty — likely most naturally added as new
380
+ assertions in `tests/i18nAggregate.test.js` (the existing four-locale shape test already does this
381
+ generically for the WHOLE aggregate, so verify your new keys are automatically covered by it
382
+ before writing a redundant second test — if they already are, say so in `PROGRESS` instead of
383
+ duplicating).
384
+
385
+ Run, do not write: `tests/*` covering existing auth flows, `LoginPage`, `SignUpPage` and
386
+ `PasswordInvitePage` (the Envelope names these explicitly) — do not modify their existing assertions
387
+ beyond what the render-side `{errorKey}` vs `{t(errorKey)}` change mechanically requires (a test
388
+ asserting rendered text should see the SAME final text as before — if any existing test breaks for a
389
+ reason OTHER than this file's own mechanical transformation, stop and report it, do not paper over a
390
+ real regression).
391
+
392
+ ### `CHANGELOG.md` — the missing `3.7.0` entry, reconstructed
393
+
394
+ `3.7.0` shipped, in this order, from commits `059e9f0`..`a70580a` (five WOs, one combined release):
395
+ - `UCM-PRIM-2`: new `EmptyState` primitive (icon/title/description/action, theme tokens).
396
+ - `UCM-SHELL-7`: `UserMenu` avatar initials now reach ≥4.5:1 contrast against `primary.main` on any
397
+ theme (dynamic white/black pick via `calculateContrastRatio`), replacing MUI's default grey.
398
+ - `UCM-MSG-20`: `Composer` no longer collapses at 375px (actions/input split into two rows); message
399
+ URLs (`http`/`https` only) render as safe links; the read-ratio marker gets a tooltip and hides
400
+ when there are no recipients.
401
+ - `UCM-AUTH-9`: `PasswordInvitePage` keeps the form and shows the reason after a server-side password
402
+ rejection (was: silently hid the form); success now waits for an explicit "go to login" click
403
+ (was: navigated away instantly with no confirmation); `SignUpPage` explains when self-signup is off
404
+ instead of rendering a permanently-disabled form; `UserListComponent` role changes now confirm
405
+ success, not just failure.
406
+ - `UCM-I18N-4`: new `createUiCoreTranslations({ germanVariant })` export — an app can opt into
407
+ informal ("du") German for the auth bundle, default stays formal; hard-coded English in
408
+ `UserListComponent`/`UserInviteComponent`/`BulkInviteCsvTab` now has real translation keys in all
409
+ four locales.
410
+
411
+ Add this as its own `## 3.7.0` section (matching the existing `CHANGELOG.md` heading style — read
412
+ the file first), placed correctly relative to the `3.6.0`/`3.8.0` entries already there, THEN add
413
+ this WO's own `## 3.9.0 — UCM-AUTH-10` entry above it: the resolver chain (never a raw backend code),
414
+ the `Accept-Language` header sent with every request, and the new field-level translation keys.
415
+
416
+ ## Do-not-touch / invariants
417
+
418
+ - No backend change anywhere (this repo has no access to `django-core-micha`'s source to modify it,
419
+ and the WO forbids it regardless).
420
+ - No change to which errors are shown, where, or what triggers one — only the text and the new
421
+ request header.
422
+ - No new supported language.
423
+ - No field-level (inline, per-input) error display — the text stays wherever each screen already
424
+ puts it.
425
+ - Do not touch `err.message` itself (the native `Error.message`) — only add the new
426
+ `err.field`/`err.i18nKey`/`err.backendMessage` fields.
427
+
428
+ ## Target repo working directory (absolute)
429
+
430
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`)
431
+
432
+ ## Preamble — a REQUIRED block IN this file, not something appended at invocation
433
+
434
+ > The text above is the COMPLETE spec — the committed WO file's content, not a plan to refine; there
435
+ > is no separate plan file. Read the nearest `AGENTS.md`, the relevant `.codex/skills/<role>/SKILL.md`, and the
436
+ > app `MEMORY.md` ONLY for conventions. Stay in scope; do not touch auth/permissions/deps/schema/CI
437
+ > unless the spec says so; do not update `MEMORY.md`. **Do NOT edit `WORK_ORDERS.md` — the register
438
+ > row and the review verdicts are the orchestrator's alone.** **Your tools are for editing source
439
+ > and test files (and `CHANGELOG.md` as named above) and for running the tests you wrote — nothing
440
+ > else.** Do NOT install dependencies, touch a lockfile, run a package manager, or tidy up stray
441
+ > files; if something in the repo state blocks you, stop and report it as `RESULT: BLOCKED <reason>`
442
+ > instead of fixing it. Do NOT `git add`/`commit`/`push` — leave every change uncommitted in the
443
+ > working tree for the orchestrator's independent review. WRITE the tests the `Required tests`
444
+ > section calls for AND **RUN the tests you just wrote** to confirm they execute and pass — that is
445
+ > the ONLY test run you do (NOT the app's affected/full suite, NOT any review). The orchestrator
446
+ > re-runs the authoritative set + does the independent review after you finish — those are the gate;
447
+ > your own run does not count as the gate.
448
+ >
449
+ > Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
450
+ > `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (and `… done` on
451
+ > completion), spaced so no gap exceeds ~2 min, stdout unbuffered, plus exactly one final
452
+ > `RESULT: DONE|BLOCKED <reason>`.
453
+
454
+ ---
455
+
456
+ # C. Orchestrator only, NOT ADDRESSED TO THE IMPLEMENTER
457
+
458
+ > **If you are the implementer reading this work order as your own specification: STOP at this line.**
459
+ > Everything below describes what the Orchestrator does AFTER you finish. You do none of it: no
460
+ > reviewers, no verification run, no register edit, no `git add`/`commit`/`push`.
461
+
462
+ ### Execution directive
463
+
464
+ Check `.claude/codex-status.md` first (newest-first; read it with `head`). No line for today means use
465
+ Codex. Before `git push`, run `git log origin/main..HEAD` and push only if every listed commit is this
466
+ order's (parallel sessions share this checkout).
467
+
468
+ ### Review routing
469
+
470
+ Tier 3: independent `reviewer` (all configured lenses), `ui_reviewer` and **`sec_reviewer`**,
471
+ concurrent, one batch, before the commit. Ask `sec_reviewer` specifically whether step 3 can put
472
+ attacker-controlled or PII-bearing backend text on screen that the old code-only path hid. dcm
473
+ deliberately stores only codes in its audit log for that reason (`exception_handler.py:84`, S4). Quote
474
+ dcm's `_flatten` and the allauth response into the brief: Codex reviewers cannot see the sibling repo.
475
+
476
+ ### Verification
477
+
478
+ Render `LoginPage` with the operator's exact 400 response, in `de` and in `en`, and name the two texts
479
+ in the register Notiz. No prototype is in scope.
480
+
481
+ ### Register + commit + release
482
+
483
+ Row -> `done` with the review Notiz in the `AGENTS.md` shape and the published version. The publish
484
+ runs on push to `main` when `package.json`/`src/**` change. Consumer bumps are separate rows in each
485
+ app, classified by the dependency-bump test in `AGENTS.md` -> Tiering.
486
+
487
+ ### Mini-handover
488
+
489
+ `Orchestrator: implement work-orders/UCM-AUTH-10.md in ui-core-micha (main). git pull first, read the
490
+ WO, then follow orchestrate-codex.`