@micha.bigler/ui-core-micha 3.7.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/DESIGN.md +8 -0
  3. package/WORK_ORDERS.md +2 -0
  4. package/dist/auth/apiClient.js +8 -0
  5. package/dist/components/AccessCodeManager.js +5 -5
  6. package/dist/components/AccessCodeSingleUseToggle.js +3 -2
  7. package/dist/components/AllowedEmailDomainsManager.js +3 -2
  8. package/dist/components/AuthFactorRequirementCard.js +3 -2
  9. package/dist/components/BulkInviteCsvTab.js +3 -2
  10. package/dist/components/MFAComponent.js +9 -8
  11. package/dist/components/MfaLoginComponent.js +7 -6
  12. package/dist/components/PasskeysComponent.js +7 -6
  13. package/dist/components/ProfileComponent.js +4 -3
  14. package/dist/components/QrSignupManager.js +4 -3
  15. package/dist/components/QrSignupValidityManager.js +3 -2
  16. package/dist/components/RegistrationMethodsManager.js +3 -2
  17. package/dist/components/SecurityComponent.js +5 -4
  18. package/dist/components/SupportRecoveryRequestsTab.js +8 -7
  19. package/dist/components/UserInviteComponent.js +3 -2
  20. package/dist/components/UserListComponent.js +7 -6
  21. package/dist/i18n/authTranslations.js +420 -0
  22. package/dist/pages/AccountPage.js +3 -2
  23. package/dist/pages/LoginPage.js +10 -9
  24. package/dist/pages/PasswordChangePage.js +4 -3
  25. package/dist/pages/PasswordInvitePage.js +7 -6
  26. package/dist/pages/PasswordResetRequestPage.js +5 -4
  27. package/dist/pages/SignUpPage.js +7 -6
  28. package/dist/pages/SignupConfirmPage.js +7 -6
  29. package/dist/theme/createAppTheme.js +62 -9
  30. package/dist/theme/tokens.js +1 -1
  31. package/dist/utils/auth-errors.js +45 -8
  32. package/dist/utils/resolveErrorText.js +24 -0
  33. package/package.json +1 -1
  34. package/src/auth/apiClient.jsx +9 -0
  35. package/src/components/AccessCodeManager.jsx +5 -5
  36. package/src/components/AccessCodeSingleUseToggle.jsx +3 -2
  37. package/src/components/AllowedEmailDomainsManager.jsx +3 -2
  38. package/src/components/AuthFactorRequirementCard.jsx +3 -2
  39. package/src/components/BulkInviteCsvTab.jsx +3 -2
  40. package/src/components/MFAComponent.jsx +9 -8
  41. package/src/components/MfaLoginComponent.jsx +7 -6
  42. package/src/components/PasskeysComponent.jsx +7 -6
  43. package/src/components/ProfileComponent.jsx +4 -3
  44. package/src/components/QrSignupManager.jsx +4 -3
  45. package/src/components/QrSignupValidityManager.jsx +3 -2
  46. package/src/components/RegistrationMethodsManager.jsx +3 -2
  47. package/src/components/SecurityComponent.jsx +5 -4
  48. package/src/components/SupportRecoveryRequestsTab.jsx +9 -8
  49. package/src/components/UserInviteComponent.jsx +3 -2
  50. package/src/components/UserListComponent.jsx +7 -6
  51. package/src/i18n/authTranslations.ts +421 -0
  52. package/src/pages/AccountPage.jsx +3 -4
  53. package/src/pages/LoginPage.jsx +10 -9
  54. package/src/pages/PasswordChangePage.jsx +4 -3
  55. package/src/pages/PasswordInvitePage.jsx +7 -6
  56. package/src/pages/PasswordResetRequestPage.jsx +5 -4
  57. package/src/pages/SignUpPage.jsx +7 -6
  58. package/src/pages/SignupConfirmPage.jsx +7 -6
  59. package/src/theme/createAppTheme.js +68 -4
  60. package/src/theme/tokens.js +1 -1
  61. package/src/utils/auth-errors.js +44 -7
  62. package/src/utils/resolveErrorText.js +28 -0
  63. package/tests/AccountPage.test.jsx +27 -20
  64. package/tests/PasswordInvitePage.test.jsx +28 -21
  65. package/tests/QrSignupManager.registrationContext.test.jsx +5 -1
  66. package/tests/SignUpPage.test.jsx +14 -3
  67. package/tests/UserListComponent.test.jsx +22 -15
  68. package/tests/apiClientLanguage.test.js +33 -0
  69. package/tests/authErrors.test.js +31 -0
  70. package/tests/createAppThemePageContrast.test.js +108 -0
  71. package/tests/errorCallSiteGuard.test.js +32 -0
  72. package/tests/loginPageErrorResolution.test.jsx +102 -0
  73. package/tests/resolveErrorText.test.js +72 -0
  74. package/work-orders/UCM-AUTH-10.md +490 -0
  75. package/work-orders/UCM-THEME-14.md +3 -1
  76. package/work-orders/UCM-THEME-15.md +356 -0
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { assertThemeComplete, createAppTheme } from '../src/theme';
4
+ import { BASELINE_PALETTE } from '../src/theme/tokens';
5
+
6
+ const primary = { main: '#0F62FE' };
7
+ const pageBackgrounds = [
8
+ '#FAFAFA',
9
+ '#D8E4E1',
10
+ '#eceae2',
11
+ '#E8EAF6',
12
+ '#E6EAF0',
13
+ '#E398C7',
14
+ ];
15
+
16
+ function pageContrastFindings(theme) {
17
+ return assertThemeComplete(theme).findings.filter(({ surface }) => (
18
+ /^contrast\.(success|warning|error|info)\.main-on-(white|page)$/.test(surface)
19
+ || surface.startsWith('contrast.controlBorder.')
20
+ ));
21
+ }
22
+
23
+ describe('createAppTheme page-sensitive baseline contrast', () => {
24
+ it.each(pageBackgrounds)('derives baseline colours for page %s', (background) => {
25
+ const theme = createAppTheme({ palette: { primary, background: { default: background } } });
26
+
27
+ expect(pageContrastFindings(theme)).toEqual([]);
28
+ });
29
+
30
+ it('keeps all baseline values byte-identical when they already pass', () => {
31
+ const theme = createAppTheme({ palette: { primary } });
32
+
33
+ for (const status of ['success', 'warning', 'error', 'info']) {
34
+ expect(theme.palette[status]).toMatchObject({
35
+ main: BASELINE_PALETTE[status].main,
36
+ light: BASELINE_PALETTE[status].light,
37
+ dark: BASELINE_PALETTE[status].dark,
38
+ contrastText: BASELINE_PALETTE[status].contrastText,
39
+ });
40
+ }
41
+ for (const state of ['main', 'hover', 'error']) {
42
+ expect(theme.palette.controlBorder[state]).toBe(BASELINE_PALETTE.controlBorder[state]);
43
+ }
44
+ });
45
+
46
+ it('leaves app-supplied failing status and control-border values untouched', () => {
47
+ const appConfig = {
48
+ palette: {
49
+ primary,
50
+ background: { default: '#E398C7' },
51
+ success: { main: '#A5D6A7' },
52
+ warning: { main: '#FFE082' },
53
+ controlBorder: { main: '#FFFFFF', hover: '#FAFAFA', error: '#FFCDD2' },
54
+ },
55
+ };
56
+ const theme = createAppTheme(appConfig);
57
+ const findings = assertThemeComplete(theme).findings.map(({ surface }) => surface);
58
+
59
+ expect(theme.palette.success.main).toBe(appConfig.palette.success.main);
60
+ expect(theme.palette.warning.main).toBe(appConfig.palette.warning.main);
61
+ expect(theme.palette.controlBorder.main).toBe(appConfig.palette.controlBorder.main);
62
+ expect(theme.palette.controlBorder.hover).toBe(appConfig.palette.controlBorder.hover);
63
+ expect(theme.palette.controlBorder.error).toBe(appConfig.palette.controlBorder.error);
64
+ for (const surface of [
65
+ 'contrast.success.main-on-white',
66
+ 'contrast.success.main-on-page',
67
+ 'contrast.warning.main-on-white',
68
+ 'contrast.warning.main-on-page',
69
+ 'contrast.controlBorder.main-on-white',
70
+ 'contrast.controlBorder.main-on-page',
71
+ 'contrast.controlBorder.hover-on-white',
72
+ 'contrast.controlBorder.hover-on-page',
73
+ 'contrast.controlBorder.error-on-white',
74
+ 'contrast.controlBorder.error-on-page',
75
+ ]) {
76
+ expect(findings).toContain(surface);
77
+ }
78
+
79
+ // The two untouched status keys (error/info) -- both fail on ygbs's pink
80
+ // per the Envelope's measured table -- must still derive normally, proving
81
+ // rule 3 is checked per status key, not per whole-status-object: this
82
+ // config only overrides success/warning, so error/info stay baseline-owned
83
+ // and should move off their baseline hex and stop failing.
84
+ expect(theme.palette.error.main).not.toBe(BASELINE_PALETTE.error.main);
85
+ expect(theme.palette.info.main).not.toBe(BASELINE_PALETTE.info.main);
86
+ expect(findings).not.toContain('contrast.error.main-on-page');
87
+ expect(findings).not.toContain('contrast.info.main-on-page');
88
+ });
89
+
90
+ it('terminates and leaves findings when the darkening cap cannot pass', () => {
91
+ const create = () => createAppTheme({
92
+ palette: { primary, background: { default: '#666666' } },
93
+ });
94
+
95
+ expect(create).not.toThrow();
96
+ const theme = create();
97
+ expect(theme.palette.success.main).toBeDefined();
98
+ expect(theme.palette.warning.main).toBeDefined();
99
+ expect(theme.palette.error.main).toBeDefined();
100
+ expect(theme.palette.info.main).toBeDefined();
101
+ expect(theme.palette.controlBorder.main).toBeDefined();
102
+ expect(theme.palette.controlBorder.hover).toBeDefined();
103
+ expect(theme.palette.controlBorder.error).toBeDefined();
104
+ expect(assertThemeComplete(theme).findings.map(({ surface }) => surface)).toContain(
105
+ 'contrast.success.main-on-page',
106
+ );
107
+ });
108
+ });
@@ -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
+ });
@@ -0,0 +1,102 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { I18nextProvider } from 'react-i18next';
6
+ import i18next from 'i18next';
7
+ import { MemoryRouter } from 'react-router-dom';
8
+
9
+ const authApi = vi.hoisted(() => ({
10
+ fetchRecoverySessionToken: vi.fn(),
11
+ loginWithPassword: vi.fn(),
12
+ loginWithRecoveryPassword: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('../src/auth/authApi', () => authApi);
16
+ vi.mock('../src/utils/authService', () => ({
17
+ loginWithPasskey: vi.fn(),
18
+ startSocialLogin: vi.fn(),
19
+ }));
20
+ vi.mock('react-helmet', () => ({ Helmet: ({ children }) => <>{children}</> }));
21
+
22
+ import { authTranslations } from '../src/i18n/authTranslations';
23
+ import { AuthContext } from '../src/auth/AuthContext';
24
+ import { LoginPage } from '../src/pages/LoginPage';
25
+
26
+ const translations = Object.fromEntries(
27
+ Object.entries(authTranslations).map(([key, value]) => [key, value.en]),
28
+ );
29
+ const i18n = i18next.createInstance();
30
+
31
+ beforeAll(async () => {
32
+ await i18n.init({
33
+ lng: 'en',
34
+ fallbackLng: 'en',
35
+ resources: { en: { translation: translations } },
36
+ keySeparator: false,
37
+ nsSeparator: false,
38
+ interpolation: { escapeValue: false },
39
+ });
40
+ });
41
+
42
+ beforeEach(() => {
43
+ authApi.loginWithPassword.mockRejectedValue({
44
+ response: {
45
+ data: {
46
+ errors: [{
47
+ message: 'Gib eine gültige E-Mail Adresse an.',
48
+ code: 'invalid',
49
+ param: 'email',
50
+ }],
51
+ },
52
+ },
53
+ });
54
+ });
55
+
56
+ afterEach(() => {
57
+ cleanup();
58
+ vi.clearAllMocks();
59
+ });
60
+
61
+ function renderLoginPage() {
62
+ return render(
63
+ <I18nextProvider i18n={i18n}>
64
+ <MemoryRouter initialEntries={['/login']}>
65
+ <AuthContext.Provider value={{
66
+ user: null,
67
+ loading: false,
68
+ login: vi.fn(),
69
+ authMethods: {
70
+ password_login: true,
71
+ password_reset: false,
72
+ signup: false,
73
+ signup_modes: [],
74
+ social_login: false,
75
+ passkey_login: false,
76
+ },
77
+ }}>
78
+ <LoginPage />
79
+ </AuthContext.Provider>
80
+ </MemoryRouter>
81
+ </I18nextProvider>,
82
+ );
83
+ }
84
+
85
+ describe('LoginPage error resolution', () => {
86
+ it('shows the email field translation for the exact allauth invalid-email response', async () => {
87
+ renderLoginPage();
88
+
89
+ fireEvent.change(screen.getByRole('textbox', { name: new RegExp(authTranslations['Auth.EMAIL_LABEL'].en) }), {
90
+ target: { value: 'not-an-email@example.com' },
91
+ });
92
+ fireEvent.change(screen.getByLabelText(new RegExp(authTranslations['Auth.LOGIN_PASSWORD_LABEL'].en)), {
93
+ target: { value: 'password' },
94
+ });
95
+ fireEvent.click(screen.getByRole('button', { name: authTranslations['Auth.PAGE_LOGIN_TITLE'].en }));
96
+
97
+ await waitFor(() => {
98
+ expect(screen.getByText(authTranslations['Auth.field.email.invalid'].en)).toBeTruthy();
99
+ });
100
+ expect(screen.queryByText('invalid')).toBeNull();
101
+ });
102
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveErrorText } from '../src/utils/resolveErrorText';
3
+
4
+ function makeI18n(values) {
5
+ return {
6
+ exists: (key) => Object.hasOwn(values, key),
7
+ t: (key) => values[key] ?? `missing:${key}`,
8
+ };
9
+ }
10
+
11
+ describe('resolveErrorText', () => {
12
+ it('prefers an explicit backend i18nKey over every lower-priority source', () => {
13
+ const i18n = makeI18n({
14
+ 'Auth.explicit': 'Explicit text',
15
+ 'Auth.field.email.invalid': 'Field text',
16
+ invalid: 'Code text',
17
+ 'Auth.DEFAULT': 'Default text',
18
+ });
19
+
20
+ expect(resolveErrorText(i18n, {
21
+ i18nKey: 'Auth.explicit',
22
+ field: 'email',
23
+ code: 'invalid',
24
+ backendMessage: 'Backend text',
25
+ }, 'Auth.DEFAULT')).toBe('Explicit text');
26
+ });
27
+
28
+ it('uses the allauth param as a field-specific key before the code', () => {
29
+ const i18n = makeI18n({
30
+ 'Auth.field.email.invalid': 'Field text',
31
+ invalid: 'Code text',
32
+ 'Auth.DEFAULT': 'Default text',
33
+ });
34
+
35
+ expect(resolveErrorText(i18n, {
36
+ field: 'email',
37
+ code: 'invalid',
38
+ backendMessage: 'Backend text',
39
+ }, 'Auth.DEFAULT')).toBe('Field text');
40
+ });
41
+
42
+ it('uses the code catalogue entry when no field entry exists', () => {
43
+ const i18n = makeI18n({
44
+ email_password_mismatch: 'Code text',
45
+ 'Auth.DEFAULT': 'Default text',
46
+ });
47
+
48
+ expect(resolveErrorText(i18n, {
49
+ field: 'email',
50
+ code: 'email_password_mismatch',
51
+ backendMessage: 'Backend text',
52
+ }, 'Auth.DEFAULT')).toBe('Code text');
53
+ });
54
+
55
+ it('uses the backend message before the caller default', () => {
56
+ const i18n = makeI18n({ 'Auth.DEFAULT': 'Default text' });
57
+
58
+ expect(resolveErrorText(i18n, {
59
+ code: 'unknown_backend_code',
60
+ backendMessage: 'Backend text',
61
+ }, 'Auth.DEFAULT')).toBe('Backend text');
62
+ });
63
+
64
+ it('uses the caller default instead of ever returning an unknown raw code', () => {
65
+ const i18n = makeI18n({ 'Auth.DEFAULT': 'Default text' });
66
+
67
+ expect(resolveErrorText(i18n, {
68
+ code: 'unknown_backend_code',
69
+ }, 'Auth.DEFAULT')).toBe('Default text');
70
+ expect(resolveErrorText(i18n, null, 'Auth.DEFAULT')).toBe('Default text');
71
+ });
72
+ });