@micha.bigler/ui-core-micha 3.8.0 → 3.9.1

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 (69) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/WORK_ORDERS.md +2 -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 +14 -7
  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 +14 -7
  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 +100 -16
  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
  69. package/work-orders/UCM-AUTH-11.md +185 -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
 
@@ -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';
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import React from 'react';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
5
5
 
6
6
  const authApi = vi.hoisted(() => ({
7
7
  fetchUsersList: vi.fn(),
@@ -10,26 +10,49 @@ 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
 
31
38
  const currentUser = { id: 99, is_superuser: true, role: 'admin' };
32
39
  const user = { id: 1, email: 'person@example.com', role: 'student' };
40
+ const ownUser = { id: 99, email: 'admin@example.com', role: 'admin' };
41
+
42
+ function renderDeleteList(props = {}) {
43
+ return render(
44
+ <UserListComponent
45
+ currentUser={currentUser}
46
+ showNewColumn={false}
47
+ showSuccessfulLoginColumn={false}
48
+ {...props}
49
+ />,
50
+ );
51
+ }
52
+
53
+ function getRowByEmail(email) {
54
+ return screen.getByRole('cell', { name: email }).closest('tr');
55
+ }
33
56
 
34
57
  function renderUserList() {
35
58
  return render(
@@ -83,3 +106,64 @@ describe('UserListComponent role updates', () => {
83
106
  expect(authApi.fetchUsersList).toHaveBeenCalledTimes(1);
84
107
  });
85
108
  });
109
+
110
+ describe('UserListComponent delete actions', () => {
111
+ beforeEach(() => {
112
+ vi.clearAllMocks();
113
+ authApi.fetchUsersList.mockResolvedValue([ownUser, user]);
114
+ });
115
+
116
+ afterEach(() => {
117
+ cleanup();
118
+ vi.restoreAllMocks();
119
+ });
120
+
121
+ it('does not offer a superuser deletion of their own row', async () => {
122
+ renderDeleteList();
123
+
124
+ await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
125
+
126
+ expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
127
+ expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
128
+ });
129
+
130
+ it('does not offer an admin deletion of their own row', async () => {
131
+ renderDeleteList({ currentUser: { id: 99, role: 'admin' } });
132
+
133
+ await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
134
+
135
+ expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
136
+ expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
137
+ });
138
+
139
+ it('does not restore the own-row delete action when canDeleteUser returns true', async () => {
140
+ renderDeleteList({ canDeleteUser: () => true });
141
+
142
+ await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
143
+
144
+ expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
145
+ expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
146
+ });
147
+
148
+ it('keeps unrelated rows and disabled delete actions when currentUser is not loaded', async () => {
149
+ authApi.fetchUsersList.mockResolvedValue([user]);
150
+ renderDeleteList({ currentUser: null });
151
+
152
+ await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
153
+
154
+ expect(screen.getByRole('button', { name: 'Delete' }).disabled).toBe(true);
155
+ });
156
+
157
+ it('matches the own row even when the row id and current-user id have different types', async () => {
158
+ // The backend row's id can come back as a string while currentUser.id is a
159
+ // number (or vice versa) -- isOwnRow must compare by value, not by strict
160
+ // type, or this exact mismatch would silently show the button again.
161
+ authApi.fetchUsersList.mockResolvedValue([{ ...ownUser, id: String(ownUser.id) }, user]);
162
+ renderDeleteList();
163
+
164
+ await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
165
+
166
+ expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
167
+ expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
168
+ });
169
+ });
@@ -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
  });
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const SOURCE_DIRECTORY = path.resolve('src');
6
+ const OLD_DISPLAY_PATTERN = /(setErrorKey|setError|setAgentErrorKey|alert)\(.*\.code|t\(\w+\??\.code/;
7
+
8
+ function jsxFiles(directory) {
9
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
10
+ const entryPath = path.join(directory, entry.name);
11
+ if (entry.isDirectory()) return jsxFiles(entryPath);
12
+ return entry.isFile() && entry.name.endsWith('.jsx') ? [entryPath] : [];
13
+ });
14
+ }
15
+
16
+ describe('auth error display call-site guard', () => {
17
+ it('has no old raw-code display pattern in JSX source', () => {
18
+ const matches = [];
19
+ for (const file of jsxFiles(SOURCE_DIRECTORY)) {
20
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
21
+ lines.forEach((line, index) => {
22
+ if (OLD_DISPLAY_PATTERN.test(line)) matches.push(`${file}:${index + 1}: ${line}`);
23
+ });
24
+ }
25
+ expect(matches).toEqual([]);
26
+ });
27
+
28
+ it('would catch a restored old-style call site', () => {
29
+ expect(OLD_DISPLAY_PATTERN.test("setErrorKey(err.code || 'Auth.X');")).toBe(true);
30
+ expect(OLD_DISPLAY_PATTERN.test("alert(t(err?.code || 'Auth.X'));" )).toBe(true);
31
+ });
32
+ });