@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
@@ -7,8 +7,9 @@ import { useTranslation } from 'react-i18next';
7
7
  import { NarrowPage } from '../layout/PageLayout';
8
8
  import { requestPasswordReset } from '../auth/authApi';
9
9
  import { PasswordResetRequestForm } from '../components/PasswordResetRequestForm';
10
+ import { resolveErrorText } from '../utils/resolveErrorText';
10
11
  export function PasswordResetRequestPage() {
11
- const { t } = useTranslation();
12
+ const { t, i18n } = useTranslation();
12
13
  const [submitting, setSubmitting] = useState(false);
13
14
  const [successKey, setSuccessKey] = useState(null);
14
15
  const [errorKey, setErrorKey] = useState(null);
@@ -16,7 +17,7 @@ export function PasswordResetRequestPage() {
16
17
  setSuccessKey(null);
17
18
  setErrorKey(null);
18
19
  if (!email) {
19
- setErrorKey('Auth.EMAIL_REQUIRED');
20
+ setErrorKey(t('Auth.EMAIL_REQUIRED'));
20
21
  return;
21
22
  }
22
23
  setSubmitting(true);
@@ -26,11 +27,11 @@ export function PasswordResetRequestPage() {
26
27
  setSuccessKey('Auth.RESET_REQUEST_ACCEPTED');
27
28
  }
28
29
  catch (err) {
29
- setErrorKey(err.code || 'Auth.RESET_REQUEST_FAILED');
30
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.RESET_REQUEST_FAILED'));
30
31
  }
31
32
  finally {
32
33
  setSubmitting(false);
33
34
  }
34
35
  };
35
- return (_jsxs(NarrowPage, { title: t('Auth.PAGE_RESET_PASSWORD_TITLE'), subtitle: t('Auth.PAGE_RESET_REQUEST_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_RESET_PASSWORD_TITLE')] }) }), successKey && (_jsx(Typography, { color: "primary", gutterBottom: true, children: t(successKey) })), errorKey && (_jsx(Typography, { color: "error", gutterBottom: true, children: t(errorKey) })), _jsx(PasswordResetRequestForm, { onSubmit: handleSubmit, submitting: submitting })] }));
36
+ return (_jsxs(NarrowPage, { title: t('Auth.PAGE_RESET_PASSWORD_TITLE'), subtitle: t('Auth.PAGE_RESET_REQUEST_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_RESET_PASSWORD_TITLE')] }) }), successKey && (_jsx(Typography, { color: "primary", gutterBottom: true, children: t(successKey) })), errorKey && (_jsx(Typography, { color: "error", gutterBottom: true, children: errorKey })), _jsx(PasswordResetRequestForm, { onSubmit: handleSubmit, submitting: submitting })] }));
36
37
  }
@@ -8,6 +8,7 @@ import { NarrowPage } from '../layout/PageLayout';
8
8
  import { AuthContext } from '../auth/AuthContext';
9
9
  import { submitRegistrationRequest } from '../auth/authApi';
10
10
  import { TurnstileWidget } from '../components/TurnstileWidget';
11
+ import { resolveErrorText } from '../utils/resolveErrorText';
11
12
  export const SIGN_UP_PAGE_ALERT_SX = { mb: 2 };
12
13
  const TURNSTILE_SIGNUP_MODES = new Set([
13
14
  'self_signup_open',
@@ -28,7 +29,7 @@ const MODE_SUBTITLES = {
28
29
  export function SignUpPage() {
29
30
  const navigate = useNavigate();
30
31
  const location = useLocation();
31
- const { t } = useTranslation();
32
+ const { t, i18n } = useTranslation();
32
33
  const { authMethods } = useContext(AuthContext);
33
34
  const signupModes = useMemo(() => {
34
35
  const configured = Array.isArray(authMethods === null || authMethods === void 0 ? void 0 : authMethods.signup_modes)
@@ -75,15 +76,15 @@ export function SignUpPage() {
75
76
  setSuccessKey(null);
76
77
  setErrorKey(null);
77
78
  if (!email) {
78
- setErrorKey('Auth.EMAIL_REQUIRED');
79
+ setErrorKey(t('Auth.EMAIL_REQUIRED'));
79
80
  return;
80
81
  }
81
82
  if (mode === 'self_signup_access_code' && !accessCode) {
82
- setErrorKey('Auth.SIGNUP_ACCESS_CODE_REQUIRED');
83
+ setErrorKey(t('Auth.SIGNUP_ACCESS_CODE_REQUIRED'));
83
84
  return;
84
85
  }
85
86
  if (mode === 'self_signup_qr' && !tokenFromUrl) {
86
- setErrorKey('Auth.SIGNUP_QR_INVALID');
87
+ setErrorKey(t('Auth.SIGNUP_QR_INVALID'));
87
88
  return;
88
89
  }
89
90
  if (turnstileRequired && !turnstileToken)
@@ -104,7 +105,7 @@ export function SignUpPage() {
104
105
  setSuccessKey((res === null || res === void 0 ? void 0 : res.code) || 'Auth.INVITE_SENT');
105
106
  }
106
107
  catch (err) {
107
- setErrorKey(err.code || 'Auth.INVITE_FAILED');
108
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.INVITE_FAILED'));
108
109
  if (turnstileRequired) {
109
110
  setTurnstileToken('');
110
111
  (_a = turnstileWidgetRef.current) === null || _a === void 0 ? void 0 : _a.reset();
@@ -117,7 +118,7 @@ export function SignUpPage() {
117
118
  const handleGoToLogin = () => {
118
119
  navigate('/login');
119
120
  };
120
- return (_jsxs(NarrowPage, { title: t('Auth.LOGIN_SIGNUP_BUTTON'), subtitle: pageSubtitle, children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.LOGIN_SIGNUP_BUTTON')] }) }), signupModes.length === 0 ? (_jsx(Alert, { severity: "info", sx: SIGN_UP_PAGE_ALERT_SX, children: t('Auth.SELF_SIGNUP_DISABLED', 'Self sign-up is currently disabled.') })) : (_jsxs(_Fragment, { children: [successKey && (_jsx(Alert, { severity: "success", sx: SIGN_UP_PAGE_ALERT_SX, children: t(successKey, { email }) })), errorKey && (_jsx(Alert, { severity: "error", sx: SIGN_UP_PAGE_ALERT_SX, children: t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.')) })), signupModes.length > 1 && (_jsx(Stack, { spacing: 1, sx: { mb: 2 }, children: _jsx(Stack, { spacing: 1, children: signupModes.map((entry) => (_jsx(Button, { variant: mode === entry ? 'contained' : 'outlined', onClick: () => setMode(entry), disabled: submitting, fullWidth: true, children: t(MODE_LABELS[entry] || entry, entry) }, entry))) }) })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: email, onChange: (e) => setEmail(e.target.value), disabled: submitting }), mode === 'self_signup_access_code' && (_jsx(TextField, { label: t('Auth.ACCESS_CODE_LABEL'), type: "text", required: true, fullWidth: true, value: accessCode, onChange: (e) => setAccessCode(e.target.value), disabled: submitting })), turnstileRequired && (_jsx(TurnstileWidget, { ref: turnstileWidgetRef, siteKey: authMethods.turnstile_site_key, onToken: setTurnstileToken })), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting || (turnstileRequired && !turnstileToken), children: submitting
121
+ return (_jsxs(NarrowPage, { title: t('Auth.LOGIN_SIGNUP_BUTTON'), subtitle: pageSubtitle, children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.LOGIN_SIGNUP_BUTTON')] }) }), signupModes.length === 0 ? (_jsx(Alert, { severity: "info", sx: SIGN_UP_PAGE_ALERT_SX, children: t('Auth.SELF_SIGNUP_DISABLED', 'Self sign-up is currently disabled.') })) : (_jsxs(_Fragment, { children: [successKey && (_jsx(Alert, { severity: "success", sx: SIGN_UP_PAGE_ALERT_SX, children: t(successKey, { email }) })), errorKey && (_jsx(Alert, { severity: "error", sx: SIGN_UP_PAGE_ALERT_SX, children: errorKey })), signupModes.length > 1 && (_jsx(Stack, { spacing: 1, sx: { mb: 2 }, children: _jsx(Stack, { spacing: 1, children: signupModes.map((entry) => (_jsx(Button, { variant: mode === entry ? 'contained' : 'outlined', onClick: () => setMode(entry), disabled: submitting, fullWidth: true, children: t(MODE_LABELS[entry] || entry, entry) }, entry))) }) })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: email, onChange: (e) => setEmail(e.target.value), disabled: submitting }), mode === 'self_signup_access_code' && (_jsx(TextField, { label: t('Auth.ACCESS_CODE_LABEL'), type: "text", required: true, fullWidth: true, value: accessCode, onChange: (e) => setAccessCode(e.target.value), disabled: submitting })), turnstileRequired && (_jsx(TurnstileWidget, { ref: turnstileWidgetRef, siteKey: authMethods.turnstile_site_key, onToken: setTurnstileToken })), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting || (turnstileRequired && !turnstileToken), children: submitting
121
122
  ? t('Auth.SIGNUP_SUBMITTING')
122
123
  : t('Auth.SIGNUP_SUBMIT') })] })] })), _jsx(Box, { sx: { mt: 3 }, children: _jsxs(Typography, { variant: "body2", children: [t('Auth.SIGNUP_ALREADY_HAVE_ACCOUNT'), ' ', _jsx(Button, { onClick: handleGoToLogin, variant: "text", size: "small", children: t('Auth.SIGNUP_GO_TO_LOGIN') })] }) })] }));
123
124
  }
@@ -13,9 +13,10 @@ import { Helmet } from 'react-helmet';
13
13
  import { useTranslation } from 'react-i18next';
14
14
  import { NarrowPage } from '../layout/PageLayout';
15
15
  import { confirmRegistration } from '../auth/authApi';
16
+ import { resolveErrorText } from '../utils/resolveErrorText';
16
17
  export const SIGNUP_CONFIRM_PAGE_ALERT_SX = { mb: 2 };
17
18
  export function SignupConfirmPage() {
18
- const { t } = useTranslation();
19
+ const { t, i18n } = useTranslation();
19
20
  const location = useLocation();
20
21
  const navigate = useNavigate();
21
22
  const tokenFromUrl = useMemo(() => {
@@ -30,15 +31,15 @@ export function SignupConfirmPage() {
30
31
  event.preventDefault();
31
32
  setErrorKey(null);
32
33
  if (!tokenFromUrl) {
33
- setErrorKey('Auth.PENDING_TOKEN_INVALID');
34
+ setErrorKey(t('Auth.PENDING_TOKEN_INVALID'));
34
35
  return;
35
36
  }
36
37
  if (!password || password.length < 8) {
37
- setErrorKey('Auth.PASSWORD_TOO_SHORT');
38
+ setErrorKey(t('Auth.PASSWORD_TOO_SHORT'));
38
39
  return;
39
40
  }
40
41
  if (password !== confirmPassword) {
41
- setErrorKey('Auth.PASSWORD_MISMATCH');
42
+ setErrorKey(t('Auth.PASSWORD_MISMATCH'));
42
43
  return;
43
44
  }
44
45
  setSubmitting(true);
@@ -51,14 +52,14 @@ export function SignupConfirmPage() {
51
52
  return;
52
53
  }
53
54
  catch (err) {
54
- setErrorKey((err === null || err === void 0 ? void 0 : err.code) || 'Auth.PENDING_TOKEN_INVALID');
55
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.PENDING_TOKEN_INVALID'));
55
56
  }
56
57
  finally {
57
58
  setSubmitting(false);
58
59
  }
59
60
  };
60
61
  const tokenMissing = !tokenFromUrl;
61
- return (_jsxs(NarrowPage, { title: t('Auth.SIGNUP_CONFIRM_TITLE', 'Confirm your registration'), subtitle: t('Auth.SIGNUP_CONFIRM_SUBTITLE', 'Choose a password to finish creating your account.'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.SIGNUP_CONFIRM_TITLE', 'Confirm your registration')] }) }), tokenMissing ? (_jsxs(_Fragment, { children: [_jsx(Alert, { severity: "error", sx: SIGNUP_CONFIRM_PAGE_ALERT_SX, children: t('Auth.PENDING_TOKEN_INVALID', 'This confirmation link is invalid or expired.') }), _jsxs(Stack, { direction: "row", spacing: 1, sx: { mt: 1 }, children: [_jsx(Button, { onClick: () => navigate('/signup'), variant: "contained", children: t('Auth.SIGNUP_REQUEST_NEW', 'Request a new invitation') }), _jsx(Button, { onClick: () => navigate('/login'), variant: "text", children: t('Auth.SIGNUP_GO_TO_LOGIN', 'Go to login') })] })] })) : (_jsxs(_Fragment, { children: [errorKey && (_jsx(Alert, { severity: "error", sx: SIGNUP_CONFIRM_PAGE_ALERT_SX, children: t(errorKey, t('Auth.PENDING_TOKEN_INVALID', 'Could not confirm registration.')) })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.NEW_PASSWORD_LABEL', 'New password'), type: "password", required: true, fullWidth: true, value: password, onChange: (e) => setPassword(e.target.value), disabled: submitting, autoComplete: "new-password" }), _jsx(TextField, { label: t('Auth.PASSWORD_CONFIRM_LABEL', 'Confirm new password'), type: "password", required: true, fullWidth: true, value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), disabled: submitting, autoComplete: "new-password" }), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting, children: submitting
62
+ return (_jsxs(NarrowPage, { title: t('Auth.SIGNUP_CONFIRM_TITLE', 'Confirm your registration'), subtitle: t('Auth.SIGNUP_CONFIRM_SUBTITLE', 'Choose a password to finish creating your account.'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.SIGNUP_CONFIRM_TITLE', 'Confirm your registration')] }) }), tokenMissing ? (_jsxs(_Fragment, { children: [_jsx(Alert, { severity: "error", sx: SIGNUP_CONFIRM_PAGE_ALERT_SX, children: t('Auth.PENDING_TOKEN_INVALID', 'This confirmation link is invalid or expired.') }), _jsxs(Stack, { direction: "row", spacing: 1, sx: { mt: 1 }, children: [_jsx(Button, { onClick: () => navigate('/signup'), variant: "contained", children: t('Auth.SIGNUP_REQUEST_NEW', 'Request a new invitation') }), _jsx(Button, { onClick: () => navigate('/login'), variant: "text", children: t('Auth.SIGNUP_GO_TO_LOGIN', 'Go to login') })] })] })) : (_jsxs(_Fragment, { children: [errorKey && (_jsx(Alert, { severity: "error", sx: SIGNUP_CONFIRM_PAGE_ALERT_SX, children: errorKey })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.NEW_PASSWORD_LABEL', 'New password'), type: "password", required: true, fullWidth: true, value: password, onChange: (e) => setPassword(e.target.value), disabled: submitting, autoComplete: "new-password" }), _jsx(TextField, { label: t('Auth.PASSWORD_CONFIRM_LABEL', 'Confirm new password'), type: "password", required: true, fullWidth: true, value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), disabled: submitting, autoComplete: "new-password" }), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting, children: submitting
62
63
  ? t('Auth.SIGNUP_CONFIRM_SUBMITTING', 'Confirming…')
63
64
  : t('Auth.SIGNUP_CONFIRM_SUBMIT', 'Confirm registration') })] }), _jsxs(Stack, { direction: "row", spacing: 1, sx: { mt: 3 }, children: [_jsx(Typography, { variant: "body2", children: t('Auth.SIGNUP_ALREADY_HAVE_ACCOUNT', 'Already have an account?') }), _jsx(Button, { onClick: () => navigate('/login'), variant: "text", size: "small", children: t('Auth.SIGNUP_GO_TO_LOGIN', 'Go to login') })] })] }))] }));
64
65
  }
@@ -1,25 +1,59 @@
1
1
  // src/utils/auth-errors.js
2
2
  export function extractErrorInfo(error) {
3
- var _a, _b, _c, _d;
3
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
4
4
  const status = (_b = (_a = error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : null;
5
5
  const data = (_d = (_c = error.response) === null || _c === void 0 ? void 0 : _c.data) !== null && _d !== void 0 ? _d : null;
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
  // Allauth Headless structure often nests errors in "errors" array or "status" key
10
17
  if (Array.isArray(data.errors) && data.errors.length > 0) {
11
- // Pick the first error code
12
- const first = data.errors[0];
13
- return { status, code: first.code, message: first.message, raw: data };
18
+ const first = data.errors[0] || {};
19
+ return {
20
+ status,
21
+ code: (_e = first.code) !== null && _e !== void 0 ? _e : null,
22
+ field: (_g = (_f = first.field) !== null && _f !== void 0 ? _f : first.param) !== null && _g !== void 0 ? _g : null,
23
+ i18nKey: (_h = first.i18nKey) !== null && _h !== void 0 ? _h : null,
24
+ message: first.message,
25
+ raw: data,
26
+ };
14
27
  }
15
28
  if (typeof data.code === 'string') {
16
- return { status, code: data.code, message: null, raw: data };
29
+ return {
30
+ status,
31
+ code: data.code,
32
+ field: (_j = data.field) !== null && _j !== void 0 ? _j : null,
33
+ i18nKey: (_k = data.i18nKey) !== null && _k !== void 0 ? _k : null,
34
+ message: (_l = data.message) !== null && _l !== void 0 ? _l : data.detail,
35
+ raw: data,
36
+ };
17
37
  }
18
38
  // Fallback for generic Django errors
19
39
  if (typeof data.detail === 'string') {
20
- return { status, code: null, message: data.detail, raw: data };
40
+ return {
41
+ status,
42
+ code: null,
43
+ field: (_m = data.field) !== null && _m !== void 0 ? _m : null,
44
+ i18nKey: (_o = data.i18nKey) !== null && _o !== void 0 ? _o : null,
45
+ message: data.detail,
46
+ raw: data,
47
+ };
21
48
  }
22
- return { status, code: null, message: null, raw: data };
49
+ return {
50
+ status,
51
+ code: null,
52
+ field: (_p = data.field) !== null && _p !== void 0 ? _p : null,
53
+ i18nKey: (_q = data.i18nKey) !== null && _q !== void 0 ? _q : null,
54
+ message: data.message,
55
+ raw: data,
56
+ };
23
57
  }
24
58
  export function normaliseApiError(error, defaultCode = 'Auth.GENERIC_ERROR') {
25
59
  const info = extractErrorInfo(error);
@@ -27,6 +61,9 @@ export function normaliseApiError(error, defaultCode = 'Auth.GENERIC_ERROR') {
27
61
  const message = info.message || code || defaultCode;
28
62
  const err = new Error(message);
29
63
  err.code = code;
64
+ err.field = info.field;
65
+ err.i18nKey = info.i18nKey;
66
+ err.backendMessage = info.message;
30
67
  err.status = info.status;
31
68
  err.raw = info.raw;
32
69
  return err;
@@ -0,0 +1,24 @@
1
+ import { extractErrorInfo } from './auth-errors';
2
+ export function resolveErrorText(i18n, err, defaultKey) {
3
+ // API helpers normally throw normalized errors. Keeping this small boundary
4
+ // adapter also makes the resolver safe for callers that pass an Axios error
5
+ // directly (for example, an app-level wrapper around a kit API call).
6
+ const info = (err === null || err === void 0 ? void 0 : err.response) ? extractErrorInfo(err) : null;
7
+ const resolvedErr = info
8
+ ? Object.assign(Object.assign({}, err), { code: info.code || err.code, field: info.field, i18nKey: info.i18nKey, backendMessage: info.message }) : err;
9
+ const exists = (key) => Boolean(key) && i18n.exists(key);
10
+ if (!resolvedErr)
11
+ return i18n.t(defaultKey);
12
+ if (exists(resolvedErr.i18nKey))
13
+ return i18n.t(resolvedErr.i18nKey);
14
+ if (resolvedErr.field && resolvedErr.code) {
15
+ const fieldKey = `Auth.field.${resolvedErr.field}.${resolvedErr.code}`;
16
+ if (exists(fieldKey))
17
+ return i18n.t(fieldKey);
18
+ }
19
+ if (exists(resolvedErr.code))
20
+ return i18n.t(resolvedErr.code);
21
+ if (resolvedErr.backendMessage)
22
+ return resolvedErr.backendMessage;
23
+ return i18n.t(defaultKey);
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "3.8.0",
3
+ "version": "3.9.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "sideEffects": [
@@ -1,4 +1,5 @@
1
1
  import axios from "axios";
2
+ import i18next from "i18next";
2
3
  import { CSRF_URL } from "./authConfig";
3
4
  import { requestReauth } from "./reauth";
4
5
 
@@ -8,6 +9,14 @@ const apiClient = axios.create({
8
9
  xsrfHeaderName: "X-CSRFToken",
9
10
  });
10
11
 
12
+ apiClient.interceptors.request.use((config) => {
13
+ const language = i18next?.isInitialized ? i18next.language : null;
14
+ if (language) {
15
+ config.headers = { ...config.headers, 'Accept-Language': language };
16
+ }
17
+ return config;
18
+ });
19
+
11
20
  let redirectingToLogin = false;
12
21
 
13
22
  function isBrowser() {
@@ -13,6 +13,7 @@ import {
13
13
  import ContentCopyIcon from '@mui/icons-material/ContentCopy';
14
14
  import DeleteIcon from '@mui/icons-material/Delete';
15
15
  import { useTranslation } from 'react-i18next';
16
+ import { resolveErrorText } from '../utils/resolveErrorText';
16
17
 
17
18
  // Pfad ggf. anpassen je nach Struktur: von src/auth/components zu src/auth/authApi
18
19
  import {
@@ -36,7 +37,7 @@ export const ACCESS_CODE_VALUE_FIELD_SX = {
36
37
  };
37
38
 
38
39
  export function AccessCodeManager() {
39
- const { t } = useTranslation();
40
+ const { t, i18n } = useTranslation();
40
41
  const [codes, setCodes] = useState([]);
41
42
  const [loading, setLoading] = useState(true);
42
43
  const [submitting, setSubmitting] = useState(false);
@@ -50,8 +51,7 @@ export function AccessCodeManager() {
50
51
 
51
52
  // Helper that prefers backend error code if available
52
53
  const setErrorFromErrorObject = (err, fallbackCode) => {
53
- const backendCode = err?.code;
54
- setErrorKey(backendCode || fallbackCode);
54
+ setErrorKey(resolveErrorText(i18n, err, fallbackCode));
55
55
  };
56
56
 
57
57
  // Load all access codes from backend
@@ -104,7 +104,7 @@ export function AccessCodeManager() {
104
104
 
105
105
  const handleAddManual = async () => {
106
106
  if (!manualCode.trim()) {
107
- setErrorKey('Auth.SIGNUP_ACCESS_CODE_REQUIRED');
107
+ setErrorKey(t('Auth.SIGNUP_ACCESS_CODE_REQUIRED'));
108
108
  return;
109
109
  }
110
110
  await handleCreateCode(manualCode.trim());
@@ -150,7 +150,7 @@ export function AccessCodeManager() {
150
150
  <Box>
151
151
  {errorKey && (
152
152
  <Alert severity="error" sx={ACCESS_CODE_ALERT_SX}>
153
- {t(errorKey)}
153
+ {errorKey}
154
154
  </Alert>
155
155
  )}
156
156
  {successKey && (
@@ -8,11 +8,12 @@ import {
8
8
  } from '@mui/material';
9
9
  import { useTranslation } from 'react-i18next';
10
10
  import { fetchAuthPolicy, updateAuthPolicy } from '../auth/authApi';
11
+ import { resolveErrorText } from '../utils/resolveErrorText';
11
12
 
12
13
  export const ACCESS_CODE_SINGLE_USE_ALERT_SX = { mb: 2 };
13
14
 
14
15
  export function AccessCodeSingleUseToggle({ canEdit = true, policy = null, onPolicyChange = null }) {
15
- const { t } = useTranslation();
16
+ const { t, i18n } = useTranslation();
16
17
  const [value, setValue] = useState(false);
17
18
  const [busy, setBusy] = useState(false);
18
19
  const [error, setError] = useState('');
@@ -57,7 +58,7 @@ export function AccessCodeSingleUseToggle({ canEdit = true, policy = null, onPol
57
58
  setSuccess(t('Auth.ACCESS_CODE_SINGLE_USE_SAVE_SUCCESS', 'Access code policy saved.'));
58
59
  } catch (err) {
59
60
  setValue(previous);
60
- setError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save access code policy.'));
61
+ setError(resolveErrorText(i18n, err, 'Auth.AUTH_POLICY_UPDATE_FAILED'));
61
62
  } finally {
62
63
  setBusy(false);
63
64
  }
@@ -8,6 +8,7 @@ import {
8
8
  } from '@mui/material';
9
9
  import { useTranslation } from 'react-i18next';
10
10
  import { updateAuthPolicy } from '../auth/authApi';
11
+ import { resolveErrorText } from '../utils/resolveErrorText';
11
12
 
12
13
  export const ALLOWED_EMAIL_DOMAINS_ALERT_SX = { mb: 2 };
13
14
  export const ALLOWED_EMAIL_DOMAINS_BUTTON_SX = { mt: 2 };
@@ -18,7 +19,7 @@ export function AllowedEmailDomainsManager({
18
19
  onPolicyChange,
19
20
  canEdit = true,
20
21
  }) {
21
- const { t } = useTranslation();
22
+ const { t, i18n } = useTranslation();
22
23
  const [domainsText, setDomainsText] = useState('');
23
24
  const [busy, setBusy] = useState(false);
24
25
  const [error, setError] = useState('');
@@ -44,7 +45,7 @@ export function AllowedEmailDomainsManager({
44
45
  setSuccess(t('Auth.AUTH_POLICY_SAVE_SUCCESS', 'Authentication settings saved.'));
45
46
  if (onPolicyChange) onPolicyChange(next);
46
47
  } catch (err) {
47
- setError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save authentication settings.'));
48
+ setError(resolveErrorText(i18n, err, 'Auth.AUTH_POLICY_UPDATE_FAILED'));
48
49
  } finally {
49
50
  setBusy(false);
50
51
  }
@@ -10,11 +10,12 @@ import {
10
10
  } from '@mui/material';
11
11
  import { useTranslation } from 'react-i18next';
12
12
  import { fetchAuthPolicy, updateAuthPolicy } from '../auth/authApi';
13
+ import { resolveErrorText } from '../utils/resolveErrorText';
13
14
 
14
15
  export const AUTH_FACTOR_REQUIREMENT_ALERT_SX = { mb: 2 };
15
16
 
16
17
  export function AuthFactorRequirementCard({ canEdit = true, policy = null }) {
17
- const { t } = useTranslation();
18
+ const { t, i18n } = useTranslation();
18
19
  const [value, setValue] = useState('1');
19
20
  const [busy, setBusy] = useState(false);
20
21
  const [error, setError] = useState('');
@@ -56,7 +57,7 @@ export function AuthFactorRequirementCard({ canEdit = true, policy = null }) {
56
57
  setSuccess(t('Auth.AUTH_FACTOR_SAVE_SUCCESS', 'Factor requirement saved.'));
57
58
  } catch (err) {
58
59
  setValue(previous);
59
- setError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save factor requirement.'));
60
+ setError(resolveErrorText(i18n, err, 'Auth.AUTH_POLICY_UPDATE_FAILED'));
60
61
  } finally {
61
62
  setBusy(false);
62
63
  }
@@ -15,6 +15,7 @@ import {
15
15
  } from '@mui/material';
16
16
  import { useTranslation } from 'react-i18next';
17
17
  import { sendAdminInvite } from '../auth/authApi';
18
+ import { resolveErrorText } from '../utils/resolveErrorText';
18
19
 
19
20
  export const BULK_INVITE_ACTION_SX = {
20
21
  minWidth: 120,
@@ -51,7 +52,7 @@ export function BulkInviteCsvTab({
51
52
  inviteFn = (email) => sendAdminInvite(email),
52
53
  onCompleted,
53
54
  }) {
54
- const { t } = useTranslation();
55
+ const { t, i18n } = useTranslation();
55
56
  const [emails, setEmails] = useState([]);
56
57
  const [results, setResults] = useState({});
57
58
  const [busy, setBusy] = useState(false);
@@ -106,7 +107,7 @@ export function BulkInviteCsvTab({
106
107
  } catch (err) {
107
108
  nextResults[email] = {
108
109
  ok: false,
109
- message: t(err?.code || 'Auth.INVITE_FAILED'),
110
+ message: resolveErrorText(i18n, err, 'Auth.INVITE_FAILED'),
110
111
  };
111
112
  }
112
113
  setResults({ ...nextResults });
@@ -19,6 +19,7 @@ import DeleteIcon from '@mui/icons-material/Delete';
19
19
  import ContentCopyIcon from '@mui/icons-material/ContentCopy';
20
20
  import { useTranslation } from 'react-i18next';
21
21
  import { fetchAuthenticators, requestTotpKey, activateTotp, deactivateTotp, fetchRecoveryCodes, generateRecoveryCodes } from '../auth/authApi';
22
+ import { resolveErrorText } from '../utils/resolveErrorText';
22
23
 
23
24
  export const MFA_ALERT_SX = { mb: 2 };
24
25
  export const MFA_CARD_SX = { mb: 3 };
@@ -28,7 +29,7 @@ export const MFA_DIVIDER_SX = { my: 3 };
28
29
  export const MFA_RECOVERY_BUTTON_SX = { mt: 2 };
29
30
 
30
31
  export function MFAComponent() {
31
- const { t } = useTranslation();
32
+ const { t, i18n } = useTranslation();
32
33
 
33
34
  const [authenticators, setAuthenticators] = useState([]);
34
35
  const [loading, setLoading] = useState(true);
@@ -75,7 +76,7 @@ export function MFAComponent() {
75
76
 
76
77
  if (result.exists) {
77
78
  setIsSettingUp(false);
78
- setErrorKey('Auth.MFA_TOTP_ALREADY_ACTIVE');
79
+ setErrorKey(t('Auth.MFA_TOTP_ALREADY_ACTIVE'));
79
80
  return;
80
81
  }
81
82
 
@@ -86,7 +87,7 @@ export function MFAComponent() {
86
87
  } catch (err) {
87
88
  // eslint-disable-next-line no-console
88
89
  console.error(err);
89
- setErrorKey(err.code || 'Auth.TOTP_REQUEST_FAILED');
90
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.TOTP_REQUEST_FAILED'));
90
91
  setIsSettingUp(false);
91
92
  }
92
93
  };
@@ -104,7 +105,7 @@ export function MFAComponent() {
104
105
  await loadData();
105
106
  await handleShowRecoveryCodes();
106
107
  } catch (err) {
107
- setErrorKey(err.code || 'Auth.MFA_AUTHENTICATE_FAILED');
108
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.MFA_AUTHENTICATE_FAILED'));
108
109
  } finally {
109
110
  setSubmitting(false);
110
111
  }
@@ -124,7 +125,7 @@ export function MFAComponent() {
124
125
  } catch (err) {
125
126
  // eslint-disable-next-line no-console
126
127
  console.error(err);
127
- setErrorKey(err.code || 'Auth.TOTP_DEACTIVATE_FAILED');
128
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.TOTP_DEACTIVATE_FAILED'));
128
129
  }
129
130
  };
130
131
 
@@ -139,7 +140,7 @@ export function MFAComponent() {
139
140
  } catch (err) {
140
141
  // eslint-disable-next-line no-console
141
142
  console.error(err);
142
- setErrorKey(err.code || 'Auth.RECOVERY_CODES_FETCH_FAILED');
143
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.RECOVERY_CODES_FETCH_FAILED'));
143
144
  }
144
145
  };
145
146
 
@@ -152,7 +153,7 @@ export function MFAComponent() {
152
153
  } catch (err) {
153
154
  // eslint-disable-next-line no-console
154
155
  console.error(err);
155
- setErrorKey(err.code || 'Auth.RECOVERY_CODES_GENERATE_FAILED');
156
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.RECOVERY_CODES_GENERATE_FAILED'));
156
157
  }
157
158
  };
158
159
 
@@ -173,7 +174,7 @@ export function MFAComponent() {
173
174
 
174
175
  {errorKey && (
175
176
  <Alert severity="error" sx={MFA_ALERT_SX}>
176
- {t(errorKey)}
177
+ {errorKey}
177
178
  </Alert>
178
179
  )}
179
180
 
@@ -15,13 +15,14 @@ import {
15
15
  import { useTranslation } from 'react-i18next';
16
16
  import { authenticateWithMFA, fetchCurrentUser, requestMfaSupportHelp } from '../auth/authApi';
17
17
  import { loginWithPasskey } from '../utils/authService';
18
+ import { resolveErrorText } from '../utils/resolveErrorText';
18
19
 
19
20
  export const MFA_LOGIN_ALERT_SX = { mb: 2 };
20
21
  export const MFA_LOGIN_DIVIDER_SX = { my: 2 };
21
22
  export const MFA_LOGIN_TEXT_FIELD_SX = { mb: 2 };
22
23
 
23
24
  export function MfaLoginComponent({ availableTypes, identifier, onSuccess, onCancel }) {
24
- const { t } = useTranslation();
25
+ const { t, i18n } = useTranslation();
25
26
  const [code, setCode] = useState('');
26
27
  const [submitting, setSubmitting] = useState(false);
27
28
  const [errorKey, setErrorKey] = useState(null);
@@ -54,7 +55,7 @@ export function MfaLoginComponent({ availableTypes, identifier, onSuccess, onCan
54
55
  method: isRecovery ? 'recovery_code' : 'totp',
55
56
  });
56
57
  } catch (err) {
57
- setErrorKey(err.code || 'Auth.MFA_AUTHENTICATE_FAILED');
58
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.MFA_AUTHENTICATE_FAILED'));
58
59
  } finally {
59
60
  setSubmitting(false);
60
61
  }
@@ -73,11 +74,11 @@ export function MfaLoginComponent({ availableTypes, identifier, onSuccess, onCan
73
74
  // Detailed error handling
74
75
  if (err.code === 'Auth.PASSKEY_CANCELLED') {
75
76
  // User cancelled - show specific key or just ignore
76
- setErrorKey('Auth.PASSKEY_CANCELLED');
77
+ setErrorKey(t('Auth.PASSKEY_CANCELLED'));
77
78
  } else {
78
79
  // eslint-disable-next-line no-console
79
80
  console.error(err);
80
- setErrorKey('Auth.PASSKEY_FAILED');
81
+ setErrorKey(t('Auth.PASSKEY_FAILED'));
81
82
  }
82
83
  } finally {
83
84
  setSubmitting(false);
@@ -101,7 +102,7 @@ export function MfaLoginComponent({ availableTypes, identifier, onSuccess, onCan
101
102
  setHelpDialogOpen(false);
102
103
  setHelpMessage('');
103
104
  } catch (err) {
104
- setErrorKey(err.code || 'Auth.MFA_HELP_REQUEST_FAILED');
105
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.MFA_HELP_REQUEST_FAILED'));
105
106
  } finally {
106
107
  setSubmitting(false);
107
108
  }
@@ -118,7 +119,7 @@ export function MfaLoginComponent({ availableTypes, identifier, onSuccess, onCan
118
119
 
119
120
  {errorKey && (
120
121
  <Alert severity="error" sx={MFA_LOGIN_ALERT_SX}>
121
- {t(errorKey)}
122
+ {errorKey}
122
123
  </Alert>
123
124
  )}
124
125
 
@@ -21,12 +21,13 @@ import { useTranslation } from 'react-i18next';
21
21
  import { fetchPasskeys, deletePasskey } from '../auth/authApi';
22
22
  import { registerPasskey } from '../utils/authService';
23
23
  import { FEATURES } from '../auth/authConfig';
24
+ import { resolveErrorText } from '../utils/resolveErrorText';
24
25
 
25
26
  export const PASSKEYS_ALERT_SX = { mb: 2 };
26
27
  export const PASSKEYS_DIVIDER_SX = { mb: 2 };
27
28
 
28
29
  export function PasskeysComponent() {
29
- const { t } = useTranslation();
30
+ const { t, i18n } = useTranslation();
30
31
 
31
32
  const [passkeys, setPasskeys] = useState([]);
32
33
  const [loading, setLoading] = useState(true);
@@ -48,7 +49,7 @@ export function PasskeysComponent() {
48
49
  const data = await fetchPasskeys();
49
50
  setPasskeys(Array.isArray(data) ? data : []);
50
51
  } catch (err) {
51
- setErrorKey(err.code || 'Auth.PASSKEY_LIST_FAILED');
52
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.PASSKEY_LIST_FAILED'));
52
53
  } finally {
53
54
  setLoading(false);
54
55
  setReloading(false);
@@ -68,7 +69,7 @@ export function PasskeysComponent() {
68
69
  setErrorKey(null);
69
70
 
70
71
  if (!FEATURES.passkeysEnabled || !passkeysSupported) {
71
- setErrorKey('Auth.PASSKEY_NOT_AVAILABLE_ENV');
72
+ setErrorKey(t('Auth.PASSKEY_NOT_AVAILABLE_ENV'));
72
73
  return;
73
74
  }
74
75
 
@@ -85,7 +86,7 @@ export function PasskeysComponent() {
85
86
  await loadPasskeys();
86
87
  } catch (err) {
87
88
  // registerPasskey wirft normalisierte Errors mit .code
88
- setErrorKey(err.code || 'Auth.PASSKEY_CREATE_FAILED');
89
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.PASSKEY_CREATE_FAILED'));
89
90
  } finally {
90
91
  setCreating(false);
91
92
  }
@@ -101,7 +102,7 @@ export function PasskeysComponent() {
101
102
  setPasskeys((prev) => prev.filter((pk) => pk.id !== id));
102
103
  setMessageKey('Auth.PASSKEY_DELETE_SUCCESS');
103
104
  } catch (err) {
104
- setErrorKey(err.code || 'Auth.PASSKEY_DELETE_FAILED');
105
+ setErrorKey(resolveErrorText(i18n, err, 'Auth.PASSKEY_DELETE_FAILED'));
105
106
  } finally {
106
107
  setDeletingIds((prev) => {
107
108
  const next = new Set(prev);
@@ -146,7 +147,7 @@ export function PasskeysComponent() {
146
147
  )}
147
148
  {errorKey && (
148
149
  <Alert severity="error" sx={PASSKEYS_ALERT_SX}>
149
- {t(errorKey)}
150
+ {errorKey}
150
151
  </Alert>
151
152
  )}
152
153