@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/DESIGN.md +8 -0
  3. package/WORK_ORDERS.md +2 -0
  4. package/dist/auth/apiClient.js +8 -0
  5. package/dist/components/AccessCodeManager.js +5 -5
  6. package/dist/components/AccessCodeSingleUseToggle.js +3 -2
  7. package/dist/components/AllowedEmailDomainsManager.js +3 -2
  8. package/dist/components/AuthFactorRequirementCard.js +3 -2
  9. package/dist/components/BulkInviteCsvTab.js +3 -2
  10. package/dist/components/MFAComponent.js +9 -8
  11. package/dist/components/MfaLoginComponent.js +7 -6
  12. package/dist/components/PasskeysComponent.js +7 -6
  13. package/dist/components/ProfileComponent.js +4 -3
  14. package/dist/components/QrSignupManager.js +4 -3
  15. package/dist/components/QrSignupValidityManager.js +3 -2
  16. package/dist/components/RegistrationMethodsManager.js +3 -2
  17. package/dist/components/SecurityComponent.js +5 -4
  18. package/dist/components/SupportRecoveryRequestsTab.js +8 -7
  19. package/dist/components/UserInviteComponent.js +3 -2
  20. package/dist/components/UserListComponent.js +7 -6
  21. package/dist/i18n/authTranslations.js +420 -0
  22. package/dist/pages/AccountPage.js +3 -2
  23. package/dist/pages/LoginPage.js +10 -9
  24. package/dist/pages/PasswordChangePage.js +4 -3
  25. package/dist/pages/PasswordInvitePage.js +7 -6
  26. package/dist/pages/PasswordResetRequestPage.js +5 -4
  27. package/dist/pages/SignUpPage.js +7 -6
  28. package/dist/pages/SignupConfirmPage.js +7 -6
  29. package/dist/theme/createAppTheme.js +62 -9
  30. package/dist/theme/tokens.js +1 -1
  31. package/dist/utils/auth-errors.js +45 -8
  32. package/dist/utils/resolveErrorText.js +24 -0
  33. package/package.json +1 -1
  34. package/src/auth/apiClient.jsx +9 -0
  35. package/src/components/AccessCodeManager.jsx +5 -5
  36. package/src/components/AccessCodeSingleUseToggle.jsx +3 -2
  37. package/src/components/AllowedEmailDomainsManager.jsx +3 -2
  38. package/src/components/AuthFactorRequirementCard.jsx +3 -2
  39. package/src/components/BulkInviteCsvTab.jsx +3 -2
  40. package/src/components/MFAComponent.jsx +9 -8
  41. package/src/components/MfaLoginComponent.jsx +7 -6
  42. package/src/components/PasskeysComponent.jsx +7 -6
  43. package/src/components/ProfileComponent.jsx +4 -3
  44. package/src/components/QrSignupManager.jsx +4 -3
  45. package/src/components/QrSignupValidityManager.jsx +3 -2
  46. package/src/components/RegistrationMethodsManager.jsx +3 -2
  47. package/src/components/SecurityComponent.jsx +5 -4
  48. package/src/components/SupportRecoveryRequestsTab.jsx +9 -8
  49. package/src/components/UserInviteComponent.jsx +3 -2
  50. package/src/components/UserListComponent.jsx +7 -6
  51. package/src/i18n/authTranslations.ts +421 -0
  52. package/src/pages/AccountPage.jsx +3 -4
  53. package/src/pages/LoginPage.jsx +10 -9
  54. package/src/pages/PasswordChangePage.jsx +4 -3
  55. package/src/pages/PasswordInvitePage.jsx +7 -6
  56. package/src/pages/PasswordResetRequestPage.jsx +5 -4
  57. package/src/pages/SignUpPage.jsx +7 -6
  58. package/src/pages/SignupConfirmPage.jsx +7 -6
  59. package/src/theme/createAppTheme.js +68 -4
  60. package/src/theme/tokens.js +1 -1
  61. package/src/utils/auth-errors.js +44 -7
  62. package/src/utils/resolveErrorText.js +28 -0
  63. package/tests/AccountPage.test.jsx +27 -20
  64. package/tests/PasswordInvitePage.test.jsx +28 -21
  65. package/tests/QrSignupManager.registrationContext.test.jsx +5 -1
  66. package/tests/SignUpPage.test.jsx +14 -3
  67. package/tests/UserListComponent.test.jsx +22 -15
  68. package/tests/apiClientLanguage.test.js +33 -0
  69. package/tests/authErrors.test.js +31 -0
  70. package/tests/createAppThemePageContrast.test.js +108 -0
  71. package/tests/errorCallSiteGuard.test.js +32 -0
  72. package/tests/loginPageErrorResolution.test.jsx +102 -0
  73. package/tests/resolveErrorText.test.js +72 -0
  74. package/work-orders/UCM-AUTH-10.md +490 -0
  75. package/work-orders/UCM-THEME-14.md +3 -1
  76. package/work-orders/UCM-THEME-15.md +356 -0
@@ -20,6 +20,7 @@ import { NarrowPage } from '../layout/PageLayout';
20
20
  import { AuthContext } from '../auth/AuthContext';
21
21
  import { submitRegistrationRequest } from '../auth/authApi';
22
22
  import { TurnstileWidget } from '../components/TurnstileWidget';
23
+ import { resolveErrorText } from '../utils/resolveErrorText';
23
24
 
24
25
  export const SIGN_UP_PAGE_ALERT_SX = { mb: 2 };
25
26
 
@@ -45,7 +46,7 @@ const MODE_SUBTITLES = {
45
46
  export function SignUpPage() {
46
47
  const navigate = useNavigate();
47
48
  const location = useLocation();
48
- const { t } = useTranslation();
49
+ const { t, i18n } = useTranslation();
49
50
  const { authMethods } = useContext(AuthContext);
50
51
 
51
52
  const signupModes = useMemo(() => {
@@ -107,17 +108,17 @@ export function SignUpPage() {
107
108
  setErrorKey(null);
108
109
 
109
110
  if (!email) {
110
- setErrorKey('Auth.EMAIL_REQUIRED');
111
+ setErrorKey(t('Auth.EMAIL_REQUIRED'));
111
112
  return;
112
113
  }
113
114
 
114
115
  if (mode === 'self_signup_access_code' && !accessCode) {
115
- setErrorKey('Auth.SIGNUP_ACCESS_CODE_REQUIRED');
116
+ setErrorKey(t('Auth.SIGNUP_ACCESS_CODE_REQUIRED'));
116
117
  return;
117
118
  }
118
119
 
119
120
  if (mode === 'self_signup_qr' && !tokenFromUrl) {
120
- setErrorKey('Auth.SIGNUP_QR_INVALID');
121
+ setErrorKey(t('Auth.SIGNUP_QR_INVALID'));
121
122
  return;
122
123
  }
123
124
 
@@ -138,7 +139,7 @@ export function SignUpPage() {
138
139
  // signup, which have no code whose validity needs hiding.
139
140
  setSuccessKey(res?.code || 'Auth.INVITE_SENT');
140
141
  } catch (err) {
141
- setErrorKey(err.code || 'Auth.INVITE_FAILED');
142
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.INVITE_FAILED'));
142
143
  if (turnstileRequired) {
143
144
  setTurnstileToken('');
144
145
  turnstileWidgetRef.current?.reset();
@@ -177,7 +178,7 @@ export function SignUpPage() {
177
178
 
178
179
  {errorKey && (
179
180
  <Alert severity="error" sx={SIGN_UP_PAGE_ALERT_SX}>
180
- {t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.'))}
181
+ {errorKey}
181
182
  </Alert>
182
183
  )}
183
184
 
@@ -19,11 +19,12 @@ import { Helmet } from 'react-helmet';
19
19
  import { useTranslation } from 'react-i18next';
20
20
  import { NarrowPage } from '../layout/PageLayout';
21
21
  import { confirmRegistration } from '../auth/authApi';
22
+ import { resolveErrorText } from '../utils/resolveErrorText';
22
23
 
23
24
  export const SIGNUP_CONFIRM_PAGE_ALERT_SX = { mb: 2 };
24
25
 
25
26
  export function SignupConfirmPage() {
26
- const { t } = useTranslation();
27
+ const { t, i18n } = useTranslation();
27
28
  const location = useLocation();
28
29
  const navigate = useNavigate();
29
30
 
@@ -42,15 +43,15 @@ export function SignupConfirmPage() {
42
43
  setErrorKey(null);
43
44
 
44
45
  if (!tokenFromUrl) {
45
- setErrorKey('Auth.PENDING_TOKEN_INVALID');
46
+ setErrorKey(t('Auth.PENDING_TOKEN_INVALID'));
46
47
  return;
47
48
  }
48
49
  if (!password || password.length < 8) {
49
- setErrorKey('Auth.PASSWORD_TOO_SHORT');
50
+ setErrorKey(t('Auth.PASSWORD_TOO_SHORT'));
50
51
  return;
51
52
  }
52
53
  if (password !== confirmPassword) {
53
- setErrorKey('Auth.PASSWORD_MISMATCH');
54
+ setErrorKey(t('Auth.PASSWORD_MISMATCH'));
54
55
  return;
55
56
  }
56
57
 
@@ -63,7 +64,7 @@ export function SignupConfirmPage() {
63
64
  navigate('/login', { replace: true });
64
65
  return;
65
66
  } catch (err) {
66
- setErrorKey(err?.code || 'Auth.PENDING_TOKEN_INVALID');
67
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.PENDING_TOKEN_INVALID'));
67
68
  } finally {
68
69
  setSubmitting(false);
69
70
  }
@@ -103,7 +104,7 @@ export function SignupConfirmPage() {
103
104
  <>
104
105
  {errorKey && (
105
106
  <Alert severity="error" sx={SIGNUP_CONFIRM_PAGE_ALERT_SX}>
106
- {t(errorKey, t('Auth.PENDING_TOKEN_INVALID', 'Could not confirm registration.'))}
107
+ {errorKey}
107
108
  </Alert>
108
109
  )}
109
110
 
@@ -7,7 +7,9 @@ import {
7
7
  BASELINE_PALETTE,
8
8
  BASELINE_STATIC,
9
9
  MOTION,
10
+ withMainShades,
10
11
  } from './tokens';
12
+ import { calculateContrastRatio } from './themeCompleteness';
11
13
 
12
14
  function findFunction(value, path) {
13
15
  if (typeof value === 'function') return path;
@@ -53,6 +55,43 @@ function deriveFocusColour(primary, surfaces) {
53
55
  return focus;
54
56
  }
55
57
 
58
+ function clearsPageContrast(colour, surfaces, threshold) {
59
+ return surfaces.every((surface) => {
60
+ const ratio = calculateContrastRatio(colour, surface);
61
+ return Number.isFinite(ratio) && ratio >= threshold;
62
+ });
63
+ }
64
+
65
+ function deriveDarkenedPageColour(original, surfaces, threshold) {
66
+ if (clearsPageContrast(original, surfaces, threshold)) return original;
67
+
68
+ let colour = original;
69
+ for (let coefficient = 0.05; !clearsPageContrast(colour, surfaces, threshold); coefficient += 0.05) {
70
+ colour = darken(original, Math.min(coefficient, 0.9));
71
+ if (coefficient >= 0.9) break;
72
+ }
73
+ return colour;
74
+ }
75
+
76
+ const RGBA_PATTERN = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)$/;
77
+
78
+ function deriveAlphaPageColour(original, surfaces, threshold) {
79
+ if (clearsPageContrast(original, surfaces, threshold)) return original;
80
+
81
+ const match = original.match(RGBA_PATTERN);
82
+ if (!match) return original;
83
+
84
+ const [, red, green, blue, alphaText] = match;
85
+ let alpha = Number(alphaText);
86
+ let colour = original;
87
+ while (alpha < 1) {
88
+ alpha = Math.min(1, Number((alpha + 0.05).toFixed(2)));
89
+ colour = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
90
+ if (clearsPageContrast(colour, surfaces, threshold)) return colour;
91
+ }
92
+ return colour;
93
+ }
94
+
56
95
  function createPaletteAwareComponents(palette) {
57
96
  const stateTransition = `border-color ${MOTION.duration.fast}ms ${MOTION.easing.state}, color ${MOTION.duration.fast}ms ${MOTION.easing.state}, background-color ${MOTION.duration.fast}ms ${MOTION.easing.state}`;
58
97
  const autofill = {
@@ -179,13 +218,38 @@ export function createAppTheme(appConfig = {}) {
179
218
  { palette: appConfig.palette },
180
219
  );
181
220
  const finalPalette = paletteTheme.palette;
221
+ const contrastSurfaces = ['#FFFFFF', finalPalette.background.default];
222
+ const derivedStatusEntries = {};
223
+ for (const status of ['success', 'warning', 'error', 'info']) {
224
+ if (appConfig.palette?.[status]?.main !== undefined) continue;
225
+
226
+ const current = finalPalette[status];
227
+ const main = deriveDarkenedPageColour(current.main, contrastSurfaces, 4.5);
228
+ if (main !== current.main) {
229
+ derivedStatusEntries[status] = {
230
+ ...current,
231
+ ...withMainShades(main, current.contrastText),
232
+ };
233
+ }
234
+ }
235
+
236
+ const derivedControlBorderEntries = {};
237
+ for (const state of ['main', 'hover', 'error']) {
238
+ if (appConfig.palette?.controlBorder?.[state] !== undefined) continue;
239
+
240
+ const current = finalPalette.controlBorder[state];
241
+ const derived = state === 'error'
242
+ ? deriveDarkenedPageColour(current, contrastSurfaces, 3)
243
+ : deriveAlphaPageColour(current, contrastSurfaces, 3);
244
+ if (derived !== current) derivedControlBorderEntries[state] = derived;
245
+ }
246
+
182
247
  const computedPalette = {
248
+ ...derivedStatusEntries,
183
249
  controlBorder: {
184
250
  ...finalPalette.controlBorder,
185
- focus: deriveFocusColour(finalPalette.primary.main, [
186
- '#FFFFFF',
187
- finalPalette.background.default,
188
- ]),
251
+ ...derivedControlBorderEntries,
252
+ focus: deriveFocusColour(finalPalette.primary.main, contrastSurfaces),
189
253
  },
190
254
  };
191
255
  const resolvedPalette = { ...finalPalette, ...computedPalette };
@@ -8,7 +8,7 @@ import { darken, lighten } from '@mui/material/styles';
8
8
  // though `text`/`fill`/`bg` are set correctly, so every status channel MUI
9
9
  // recognises (success/warning/error/info) must carry its own explicit
10
10
  // main/light/dark/contrastText, computed the same way augmentColor would.
11
- const withMainShades = (main, contrastText) => ({
11
+ export const withMainShades = (main, contrastText) => ({
12
12
  main,
13
13
  light: lighten(main, 0.2),
14
14
  dark: darken(main, 0.3),
@@ -4,26 +4,60 @@ export function extractErrorInfo(error) {
4
4
  const data = error.response?.data ?? null;
5
5
 
6
6
  if (!data) {
7
- return { status, code: null, message: error.message || null, raw: null };
7
+ return {
8
+ status,
9
+ code: null,
10
+ field: null,
11
+ i18nKey: null,
12
+ message: null,
13
+ raw: null,
14
+ };
8
15
  }
9
16
 
10
17
  // Allauth Headless structure often nests errors in "errors" array or "status" key
11
18
  if (Array.isArray(data.errors) && data.errors.length > 0) {
12
- // Pick the first error code
13
- const first = data.errors[0];
14
- return { status, code: first.code, message: first.message, raw: data };
19
+ const first = data.errors[0] || {};
20
+ return {
21
+ status,
22
+ code: first.code ?? null,
23
+ field: first.field ?? first.param ?? null,
24
+ i18nKey: first.i18nKey ?? null,
25
+ message: first.message,
26
+ raw: data,
27
+ };
15
28
  }
16
29
 
17
30
  if (typeof data.code === 'string') {
18
- return { status, code: data.code, message: null, raw: data };
31
+ return {
32
+ status,
33
+ code: data.code,
34
+ field: data.field ?? null,
35
+ i18nKey: data.i18nKey ?? null,
36
+ message: data.message ?? data.detail,
37
+ raw: data,
38
+ };
19
39
  }
20
40
 
21
41
  // Fallback for generic Django errors
22
42
  if (typeof data.detail === 'string') {
23
- return { status, code: null, message: data.detail, raw: data };
43
+ return {
44
+ status,
45
+ code: null,
46
+ field: data.field ?? null,
47
+ i18nKey: data.i18nKey ?? null,
48
+ message: data.detail,
49
+ raw: data,
50
+ };
24
51
  }
25
52
 
26
- return { status, code: null, message: null, raw: data };
53
+ return {
54
+ status,
55
+ code: null,
56
+ field: data.field ?? null,
57
+ i18nKey: data.i18nKey ?? null,
58
+ message: data.message,
59
+ raw: data,
60
+ };
27
61
  }
28
62
 
29
63
  export function normaliseApiError(error, defaultCode = 'Auth.GENERIC_ERROR') {
@@ -33,6 +67,9 @@ export function normaliseApiError(error, defaultCode = 'Auth.GENERIC_ERROR') {
33
67
 
34
68
  const err = new Error(message);
35
69
  err.code = code;
70
+ err.field = info.field;
71
+ err.i18nKey = info.i18nKey;
72
+ err.backendMessage = info.message;
36
73
  err.status = info.status;
37
74
  err.raw = info.raw;
38
75
  return err;
@@ -0,0 +1,28 @@
1
+ import { extractErrorInfo } from './auth-errors';
2
+
3
+ export function resolveErrorText(i18n, err, defaultKey) {
4
+ // API helpers normally throw normalized errors. Keeping this small boundary
5
+ // adapter also makes the resolver safe for callers that pass an Axios error
6
+ // directly (for example, an app-level wrapper around a kit API call).
7
+ const info = err?.response ? extractErrorInfo(err) : null;
8
+ const resolvedErr = info
9
+ ? {
10
+ ...err,
11
+ code: info.code || err.code,
12
+ field: info.field,
13
+ i18nKey: info.i18nKey,
14
+ backendMessage: info.message,
15
+ }
16
+ : err;
17
+ const exists = (key) => Boolean(key) && i18n.exists(key);
18
+
19
+ if (!resolvedErr) return i18n.t(defaultKey);
20
+ if (exists(resolvedErr.i18nKey)) return i18n.t(resolvedErr.i18nKey);
21
+ if (resolvedErr.field && resolvedErr.code) {
22
+ const fieldKey = `Auth.field.${resolvedErr.field}.${resolvedErr.code}`;
23
+ if (exists(fieldKey)) return i18n.t(fieldKey);
24
+ }
25
+ if (exists(resolvedErr.code)) return i18n.t(resolvedErr.code);
26
+ if (resolvedErr.backendMessage) return resolvedErr.backendMessage;
27
+ return i18n.t(defaultKey);
28
+ }
@@ -5,26 +5,33 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
5
5
  import { ThemeProvider, createTheme } from '@mui/material/styles';
6
6
  import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
7
7
 
8
- vi.mock('react-i18next', () => ({
9
- useTranslation: () => ({
10
- t: (key, fallback) => ({
11
- 'Account.TITLE': 'Account & Administration',
12
- 'Account.PAGE_TITLE': 'Account',
13
- 'Account.TAB_PROFILE': 'Profile',
14
- 'Account.TAB_SECURITY': 'Security',
15
- 'Account.TAB_USERS': 'Users',
16
- 'Account.TAB_INVITE': 'Invite',
17
- 'Account.TAB_SUPPORT': 'Support',
18
- 'Account.GROUP_MY_ACCOUNT': 'My account',
19
- 'Account.GROUP_MANAGEMENT': 'Management',
20
- 'Account.GROUP_HELP': 'Help',
21
- 'Account.GROUP_MORE': 'More',
22
- 'SectionNav.TITLE': 'Switch section',
23
- 'SectionNav.TRIGGER_EYEBROW': 'Section',
24
- 'SectionNav.LAST_OPENED': 'Last opened',
25
- }[key] ?? fallback ?? key),
26
- }),
27
- }));
8
+ vi.mock('react-i18next', () => {
9
+ const KEYS = {
10
+ 'Account.TITLE': 'Account & Administration',
11
+ 'Account.PAGE_TITLE': 'Account',
12
+ 'Account.TAB_PROFILE': 'Profile',
13
+ 'Account.TAB_SECURITY': 'Security',
14
+ 'Account.TAB_USERS': 'Users',
15
+ 'Account.TAB_INVITE': 'Invite',
16
+ 'Account.TAB_SUPPORT': 'Support',
17
+ 'Account.GROUP_MY_ACCOUNT': 'My account',
18
+ 'Account.GROUP_MANAGEMENT': 'Management',
19
+ 'Account.GROUP_HELP': 'Help',
20
+ 'Account.GROUP_MORE': 'More',
21
+ 'SectionNav.TITLE': 'Switch section',
22
+ 'SectionNav.TRIGGER_EYEBROW': 'Section',
23
+ 'SectionNav.LAST_OPENED': 'Last opened',
24
+ };
25
+ const t = (key, fallback) => KEYS[key] ?? fallback ?? key;
26
+ return {
27
+ useTranslation: () => ({
28
+ t,
29
+ // resolveErrorText (AUTH-10) reads i18n.exists/i18n.t off the object
30
+ // useTranslation() returns — this mock must carry both, not just `t`.
31
+ i18n: { exists: (key) => Object.hasOwn(KEYS, key), t },
32
+ }),
33
+ };
34
+ });
28
35
 
29
36
  vi.mock('../src/auth/authApi', () => ({
30
37
  fetchAuthPolicy: vi.fn(() => new Promise(() => {})),
@@ -11,27 +11,34 @@ const authApi = vi.hoisted(() => ({
11
11
 
12
12
  vi.mock('../src/auth/authApi', () => authApi);
13
13
  vi.mock('react-helmet', () => ({ Helmet: ({ children }) => <>{children}</> }));
14
- vi.mock('react-i18next', () => ({
15
- useTranslation: () => ({
16
- t: (key, fallback) => ({
17
- 'Auth.PAGE_INVITE_TITLE': 'Set password',
18
- 'Auth.PAGE_INVITE_SUBTITLE': 'Choose a password.',
19
- 'Auth.PAGE_RESET_PASSWORD_TITLE': 'Reset password',
20
- 'Auth.PAGE_RESET_PASSWORD_SUBTITLE': 'Choose a new password.',
21
- 'Auth.PAGE_CHECKING_LINK_TITLE': 'Checking link',
22
- 'Auth.PAGE_CHECKING_LINK_TEXT': 'Checking…',
23
- 'Auth.RESET_LINK_INVALID': 'The link is invalid or expired.',
24
- 'Auth.RESET_PASSWORD_SUCCESS_INVITE': 'Password set successfully.',
25
- 'Auth.RESET_PASSWORD_SUCCESS_RESET': 'Password changed successfully.',
26
- 'Auth.SIGNUP_GO_TO_LOGIN': 'Go to login',
27
- 'Auth.NEW_PASSWORD_LABEL': 'New password',
28
- 'Auth.PASSWORD_CONFIRM_LABEL': 'Confirm password',
29
- 'Auth.PASSWORD_RULES_HINT': 'Password rules',
30
- 'Auth.PASSWORD_SET_BUTTON': 'Set password',
31
- 'Auth.PASSWORD_SET_BUTTON_LOADING': 'Setting password…',
32
- }[key] ?? (typeof fallback === 'string' ? fallback : key)),
33
- }),
34
- }));
14
+ vi.mock('react-i18next', () => {
15
+ const KEYS = {
16
+ 'Auth.PAGE_INVITE_TITLE': 'Set password',
17
+ 'Auth.PAGE_INVITE_SUBTITLE': 'Choose a password.',
18
+ 'Auth.PAGE_RESET_PASSWORD_TITLE': 'Reset password',
19
+ 'Auth.PAGE_RESET_PASSWORD_SUBTITLE': 'Choose a new password.',
20
+ 'Auth.PAGE_CHECKING_LINK_TITLE': 'Checking link',
21
+ 'Auth.PAGE_CHECKING_LINK_TEXT': 'Checking…',
22
+ 'Auth.RESET_LINK_INVALID': 'The link is invalid or expired.',
23
+ 'Auth.RESET_PASSWORD_SUCCESS_INVITE': 'Password set successfully.',
24
+ 'Auth.RESET_PASSWORD_SUCCESS_RESET': 'Password changed successfully.',
25
+ 'Auth.SIGNUP_GO_TO_LOGIN': 'Go to login',
26
+ 'Auth.NEW_PASSWORD_LABEL': 'New password',
27
+ 'Auth.PASSWORD_CONFIRM_LABEL': 'Confirm password',
28
+ 'Auth.PASSWORD_RULES_HINT': 'Password rules',
29
+ 'Auth.PASSWORD_SET_BUTTON': 'Set password',
30
+ 'Auth.PASSWORD_SET_BUTTON_LOADING': 'Setting password…',
31
+ };
32
+ const t = (key, fallback) => KEYS[key] ?? (typeof fallback === 'string' ? fallback : key);
33
+ return {
34
+ useTranslation: () => ({
35
+ t,
36
+ // resolveErrorText (AUTH-10) reads i18n.exists/i18n.t off the object
37
+ // useTranslation() returns — this mock must carry both, not just `t`.
38
+ i18n: { exists: (key) => Object.hasOwn(KEYS, key), t },
39
+ }),
40
+ };
41
+ });
35
42
 
36
43
  function LocationProbe() {
37
44
  const location = useLocation();
@@ -25,8 +25,12 @@ vi.mock('qrcode.react', () => ({
25
25
  // re-render (e.g. the setBusy(true) inside it), which is a mock artifact this test file must
26
26
  // not reintroduce given it asserts exact createSignupQr call counts.
27
27
  const stableT = (_key, fallback) => fallback;
28
+ // resolveErrorText (AUTH-10) reads i18n.exists/i18n.t off the object
29
+ // useTranslation() returns — kept module-scoped/stable for the same reason
30
+ // `stableT` is, above.
31
+ const stableI18n = { exists: () => false, t: stableT };
28
32
  vi.mock('react-i18next', () => ({
29
- useTranslation: () => ({ t: stableT }),
33
+ useTranslation: () => ({ t: stableT, i18n: stableI18n }),
30
34
  }));
31
35
 
32
36
  import { QrSignupManager } from '../src/components/QrSignupManager';
@@ -13,9 +13,20 @@ vi.mock('react-router-dom', () => ({
13
13
  useLocation: () => ({ search: '' }),
14
14
  }));
15
15
  vi.mock('react-helmet', () => ({ Helmet: ({ children }) => <>{children}</> }));
16
- vi.mock('react-i18next', () => ({
17
- useTranslation: () => ({ t: (key, fallback) => (typeof fallback === 'string' ? fallback : key) }),
18
- }));
16
+ vi.mock('react-i18next', () => {
17
+ const t = (key, fallback) => (typeof fallback === 'string' ? fallback : key);
18
+ return {
19
+ useTranslation: () => ({
20
+ t,
21
+ // resolveErrorText (AUTH-10) reads i18n.exists/i18n.t off the object
22
+ // useTranslation() returns — this mock must carry both, not just `t`.
23
+ // No real catalogue here, so nothing "exists"; resolveErrorText falls
24
+ // through to the backend message or the caller's default key, exactly
25
+ // as it did before this mock had an i18n object at all.
26
+ i18n: { exists: () => false, t },
27
+ }),
28
+ };
29
+ });
19
30
 
20
31
  import { AuthContext } from '../src/auth/AuthContext';
21
32
  import { SignUpPage } from '../src/pages/SignUpPage';
@@ -10,21 +10,28 @@ const authApi = vi.hoisted(() => ({
10
10
  }));
11
11
 
12
12
  vi.mock('../src/auth/authApi', () => authApi);
13
- vi.mock('react-i18next', () => ({
14
- useTranslation: () => ({
15
- t: (key, fallback) => ({
16
- 'UserList.TITLE': 'All Users',
17
- 'Common.SEARCH': 'Search',
18
- 'UserList.SEARCH_PLACEHOLDER': 'Search users...',
19
- 'Auth.EMAIL_LABEL': 'Email',
20
- 'Profile.NAME_LABEL': 'Name',
21
- 'UserList.ROLE': 'Role',
22
- 'UserList.ROLE_UPDATE_SUCCESS': 'Role updated.',
23
- 'Auth.USER_ROLE_UPDATE_FAILED': 'Role update failed.',
24
- 'UserList.NO_USERS': 'No users found.',
25
- }[key] ?? (typeof fallback === 'string' ? fallback : key)),
26
- }),
27
- }));
13
+ vi.mock('react-i18next', () => {
14
+ const KEYS = {
15
+ 'UserList.TITLE': 'All Users',
16
+ 'Common.SEARCH': 'Search',
17
+ 'UserList.SEARCH_PLACEHOLDER': 'Search users...',
18
+ 'Auth.EMAIL_LABEL': 'Email',
19
+ 'Profile.NAME_LABEL': 'Name',
20
+ 'UserList.ROLE': 'Role',
21
+ 'UserList.ROLE_UPDATE_SUCCESS': 'Role updated.',
22
+ 'Auth.USER_ROLE_UPDATE_FAILED': 'Role update failed.',
23
+ 'UserList.NO_USERS': 'No users found.',
24
+ };
25
+ const t = (key, fallback) => KEYS[key] ?? (typeof fallback === 'string' ? fallback : key);
26
+ return {
27
+ useTranslation: () => ({
28
+ t,
29
+ // resolveErrorText (AUTH-10) reads i18n.exists/i18n.t off the object
30
+ // useTranslation() returns — this mock must carry both, not just `t`.
31
+ i18n: { exists: (key) => Object.hasOwn(KEYS, key), t },
32
+ }),
33
+ };
34
+ });
28
35
 
29
36
  import { UserListComponent } from '../src/components/UserListComponent';
30
37
 
@@ -0,0 +1,33 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import i18next from 'i18next';
3
+
4
+ import apiClient from '../src/auth/apiClient';
5
+
6
+ function requestConfig() {
7
+ const interceptor = apiClient.interceptors.request.handlers.find(Boolean);
8
+ return interceptor.fulfilled({ headers: { Existing: 'value' } });
9
+ }
10
+
11
+ describe('apiClient Accept-Language interceptor', () => {
12
+ let wasInitialized;
13
+
14
+ beforeEach(() => {
15
+ wasInitialized = i18next.isInitialized;
16
+ });
17
+
18
+ it('uses the active default i18next language and follows language changes', async () => {
19
+ await i18next.init({ lng: 'de', fallbackLng: 'en', resources: {} });
20
+ expect(requestConfig().headers['Accept-Language']).toBe('de');
21
+
22
+ await i18next.changeLanguage('en');
23
+ expect(requestConfig().headers['Accept-Language']).toBe('en');
24
+ });
25
+
26
+ it('sends no header when the default i18next instance is not initialized', () => {
27
+ i18next.isInitialized = false;
28
+ const config = requestConfig();
29
+ expect(config.headers).not.toHaveProperty('Accept-Language');
30
+ expect(config.headers.Existing).toBe('value');
31
+ i18next.isInitialized = wasInitialized;
32
+ });
33
+ });
@@ -40,4 +40,35 @@ describe('auth error normalisation', () => {
40
40
 
41
41
  expect(error.code).toBe('invalid_credentials');
42
42
  });
43
+
44
+ it('keeps field, explicit i18n key, and backend message separate', () => {
45
+ const error = normaliseApiError({
46
+ response: {
47
+ status: 400,
48
+ data: {
49
+ errors: [{
50
+ code: 'invalid',
51
+ param: 'email',
52
+ i18nKey: 'Auth.EMAIL_INVALID',
53
+ message: 'Backend message',
54
+ }],
55
+ },
56
+ },
57
+ });
58
+
59
+ expect(error.code).toBe('invalid');
60
+ expect(error.field).toBe('email');
61
+ expect(error.i18nKey).toBe('Auth.EMAIL_INVALID');
62
+ expect(error.backendMessage).toBe('Backend message');
63
+ expect(error.message).toBe('Backend message');
64
+ });
65
+
66
+ it('does not turn a missing backend message into a code message', () => {
67
+ const error = normaliseApiError({
68
+ response: { data: { code: 'unknown_backend_code' } },
69
+ });
70
+
71
+ expect(error.backendMessage).toBeUndefined();
72
+ expect(error.message).toBe('unknown_backend_code');
73
+ });
43
74
  });