@micha.bigler/ui-core-micha 3.7.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.
- package/CHANGELOG.md +25 -0
- package/DESIGN.md +8 -0
- package/WORK_ORDERS.md +2 -0
- package/dist/auth/apiClient.js +8 -0
- package/dist/components/AccessCodeManager.js +5 -5
- package/dist/components/AccessCodeSingleUseToggle.js +3 -2
- package/dist/components/AllowedEmailDomainsManager.js +3 -2
- package/dist/components/AuthFactorRequirementCard.js +3 -2
- package/dist/components/BulkInviteCsvTab.js +3 -2
- package/dist/components/MFAComponent.js +9 -8
- package/dist/components/MfaLoginComponent.js +7 -6
- package/dist/components/PasskeysComponent.js +7 -6
- package/dist/components/ProfileComponent.js +4 -3
- package/dist/components/QrSignupManager.js +4 -3
- package/dist/components/QrSignupValidityManager.js +3 -2
- package/dist/components/RegistrationMethodsManager.js +3 -2
- package/dist/components/SecurityComponent.js +5 -4
- package/dist/components/SupportRecoveryRequestsTab.js +8 -7
- package/dist/components/UserInviteComponent.js +3 -2
- package/dist/components/UserListComponent.js +7 -6
- package/dist/i18n/authTranslations.js +420 -0
- package/dist/pages/AccountPage.js +3 -2
- package/dist/pages/LoginPage.js +10 -9
- package/dist/pages/PasswordChangePage.js +4 -3
- package/dist/pages/PasswordInvitePage.js +7 -6
- package/dist/pages/PasswordResetRequestPage.js +5 -4
- package/dist/pages/SignUpPage.js +7 -6
- package/dist/pages/SignupConfirmPage.js +7 -6
- package/dist/theme/createAppTheme.js +62 -9
- package/dist/theme/tokens.js +1 -1
- package/dist/utils/auth-errors.js +45 -8
- package/dist/utils/resolveErrorText.js +24 -0
- package/package.json +1 -1
- package/src/auth/apiClient.jsx +9 -0
- package/src/components/AccessCodeManager.jsx +5 -5
- package/src/components/AccessCodeSingleUseToggle.jsx +3 -2
- package/src/components/AllowedEmailDomainsManager.jsx +3 -2
- package/src/components/AuthFactorRequirementCard.jsx +3 -2
- package/src/components/BulkInviteCsvTab.jsx +3 -2
- package/src/components/MFAComponent.jsx +9 -8
- package/src/components/MfaLoginComponent.jsx +7 -6
- package/src/components/PasskeysComponent.jsx +7 -6
- package/src/components/ProfileComponent.jsx +4 -3
- package/src/components/QrSignupManager.jsx +4 -3
- package/src/components/QrSignupValidityManager.jsx +3 -2
- package/src/components/RegistrationMethodsManager.jsx +3 -2
- package/src/components/SecurityComponent.jsx +5 -4
- package/src/components/SupportRecoveryRequestsTab.jsx +9 -8
- package/src/components/UserInviteComponent.jsx +3 -2
- package/src/components/UserListComponent.jsx +7 -6
- package/src/i18n/authTranslations.ts +421 -0
- package/src/pages/AccountPage.jsx +3 -4
- package/src/pages/LoginPage.jsx +10 -9
- package/src/pages/PasswordChangePage.jsx +4 -3
- package/src/pages/PasswordInvitePage.jsx +7 -6
- package/src/pages/PasswordResetRequestPage.jsx +5 -4
- package/src/pages/SignUpPage.jsx +7 -6
- package/src/pages/SignupConfirmPage.jsx +7 -6
- package/src/theme/createAppTheme.js +68 -4
- package/src/theme/tokens.js +1 -1
- package/src/utils/auth-errors.js +44 -7
- package/src/utils/resolveErrorText.js +28 -0
- package/tests/AccountPage.test.jsx +27 -20
- package/tests/PasswordInvitePage.test.jsx +28 -21
- package/tests/QrSignupManager.registrationContext.test.jsx +5 -1
- package/tests/SignUpPage.test.jsx +14 -3
- package/tests/UserListComponent.test.jsx +22 -15
- package/tests/apiClientLanguage.test.js +33 -0
- package/tests/authErrors.test.js +31 -0
- package/tests/createAppThemePageContrast.test.js +108 -0
- package/tests/errorCallSiteGuard.test.js +32 -0
- package/tests/loginPageErrorResolution.test.jsx +102 -0
- package/tests/resolveErrorText.test.js +72 -0
- package/work-orders/UCM-AUTH-10.md +490 -0
- package/work-orders/UCM-THEME-14.md +3 -1
- package/work-orders/UCM-THEME-15.md +356 -0
|
@@ -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.`
|
|
@@ -60,7 +60,9 @@ Non-goals:
|
|
|
60
60
|
|
|
61
61
|
- **Tier 3 · tests: light-output regression guard, dark completeness, focus contrast in both modes,
|
|
62
62
|
and the kit's existing theme tests.** A change inside shared-core (`AGENTS.md` -> Tiering).
|
|
63
|
-
- Precondition: **`UCM-THEME-13` frozen
|
|
63
|
+
- Precondition: **`UCM-THEME-13` frozen**, and **`UCM-THEME-15` landed**. 15 generalises the focus
|
|
64
|
+
derivation into page-derived status and border colours on the same surface pair; build the dark
|
|
65
|
+
branch on top of it, not beside it.
|
|
64
66
|
- Downstream: `webshop-guenter`/`WSG-THEME-1` waits for this release and for its own pin bump.
|
|
65
67
|
|
|
66
68
|
## Risks
|