@micha.bigler/ui-core-micha 3.7.0 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/DESIGN.md +8 -0
- package/WORK_ORDERS.md +2 -0
- package/dist/auth/apiClient.js +8 -0
- package/dist/components/AccessCodeManager.js +5 -5
- package/dist/components/AccessCodeSingleUseToggle.js +3 -2
- package/dist/components/AllowedEmailDomainsManager.js +3 -2
- package/dist/components/AuthFactorRequirementCard.js +3 -2
- package/dist/components/BulkInviteCsvTab.js +3 -2
- package/dist/components/MFAComponent.js +9 -8
- package/dist/components/MfaLoginComponent.js +7 -6
- package/dist/components/PasskeysComponent.js +7 -6
- package/dist/components/ProfileComponent.js +4 -3
- package/dist/components/QrSignupManager.js +4 -3
- package/dist/components/QrSignupValidityManager.js +3 -2
- package/dist/components/RegistrationMethodsManager.js +3 -2
- package/dist/components/SecurityComponent.js +5 -4
- package/dist/components/SupportRecoveryRequestsTab.js +8 -7
- package/dist/components/UserInviteComponent.js +3 -2
- package/dist/components/UserListComponent.js +7 -6
- package/dist/i18n/authTranslations.js +420 -0
- package/dist/pages/AccountPage.js +3 -2
- package/dist/pages/LoginPage.js +10 -9
- package/dist/pages/PasswordChangePage.js +4 -3
- package/dist/pages/PasswordInvitePage.js +7 -6
- package/dist/pages/PasswordResetRequestPage.js +5 -4
- package/dist/pages/SignUpPage.js +7 -6
- package/dist/pages/SignupConfirmPage.js +7 -6
- package/dist/theme/createAppTheme.js +62 -9
- package/dist/theme/tokens.js +1 -1
- package/dist/utils/auth-errors.js +45 -8
- package/dist/utils/resolveErrorText.js +24 -0
- package/package.json +1 -1
- package/src/auth/apiClient.jsx +9 -0
- package/src/components/AccessCodeManager.jsx +5 -5
- package/src/components/AccessCodeSingleUseToggle.jsx +3 -2
- package/src/components/AllowedEmailDomainsManager.jsx +3 -2
- package/src/components/AuthFactorRequirementCard.jsx +3 -2
- package/src/components/BulkInviteCsvTab.jsx +3 -2
- package/src/components/MFAComponent.jsx +9 -8
- package/src/components/MfaLoginComponent.jsx +7 -6
- package/src/components/PasskeysComponent.jsx +7 -6
- package/src/components/ProfileComponent.jsx +4 -3
- package/src/components/QrSignupManager.jsx +4 -3
- package/src/components/QrSignupValidityManager.jsx +3 -2
- package/src/components/RegistrationMethodsManager.jsx +3 -2
- package/src/components/SecurityComponent.jsx +5 -4
- package/src/components/SupportRecoveryRequestsTab.jsx +9 -8
- package/src/components/UserInviteComponent.jsx +3 -2
- package/src/components/UserListComponent.jsx +7 -6
- package/src/i18n/authTranslations.ts +421 -0
- package/src/pages/AccountPage.jsx +3 -4
- package/src/pages/LoginPage.jsx +10 -9
- package/src/pages/PasswordChangePage.jsx +4 -3
- package/src/pages/PasswordInvitePage.jsx +7 -6
- package/src/pages/PasswordResetRequestPage.jsx +5 -4
- package/src/pages/SignUpPage.jsx +7 -6
- package/src/pages/SignupConfirmPage.jsx +7 -6
- package/src/theme/createAppTheme.js +68 -4
- package/src/theme/tokens.js +1 -1
- package/src/utils/auth-errors.js +44 -7
- package/src/utils/resolveErrorText.js +28 -0
- package/tests/AccountPage.test.jsx +27 -20
- package/tests/PasswordInvitePage.test.jsx +28 -21
- package/tests/QrSignupManager.registrationContext.test.jsx +5 -1
- package/tests/SignUpPage.test.jsx +14 -3
- package/tests/UserListComponent.test.jsx +22 -15
- package/tests/apiClientLanguage.test.js +33 -0
- package/tests/authErrors.test.js +31 -0
- package/tests/createAppThemePageContrast.test.js +108 -0
- package/tests/errorCallSiteGuard.test.js +32 -0
- package/tests/loginPageErrorResolution.test.jsx +102 -0
- package/tests/resolveErrorText.test.js +72 -0
- package/work-orders/UCM-AUTH-10.md +490 -0
- package/work-orders/UCM-THEME-14.md +3 -1
- package/work-orders/UCM-THEME-15.md +356 -0
package/src/pages/SignUpPage.jsx
CHANGED
|
@@ -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
|
|
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
|
-
{
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
finalPalette.background.default,
|
|
188
|
-
]),
|
|
251
|
+
...derivedControlBorderEntries,
|
|
252
|
+
focus: deriveFocusColour(finalPalette.primary.main, contrastSurfaces),
|
|
189
253
|
},
|
|
190
254
|
};
|
|
191
255
|
const resolvedPalette = { ...finalPalette, ...computedPalette };
|
package/src/theme/tokens.js
CHANGED
|
@@ -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),
|
package/src/utils/auth-errors.js
CHANGED
|
@@ -4,26 +4,60 @@ export function extractErrorInfo(error) {
|
|
|
4
4
|
const data = error.response?.data ?? null;
|
|
5
5
|
|
|
6
6
|
if (!data) {
|
|
7
|
-
return {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+
});
|
package/tests/authErrors.test.js
CHANGED
|
@@ -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
|
});
|