@micha.bigler/ui-core-micha 3.5.1 → 3.7.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 +18 -0
- package/DESIGN.md +1 -1
- package/README.md +2 -0
- package/WORK_ORDERS.md +8 -1
- package/dist/auth/UserMenu.js +23 -1
- package/dist/components/EmptyState.js +17 -0
- package/dist/components/UserListComponent.js +2 -1
- package/dist/i18n/authTranslations.js +238 -0
- package/dist/i18n/uiCoreTranslations.js +19 -0
- package/dist/index.js +2 -1
- package/dist/messaging/Composer.js +4 -2
- package/dist/messaging/MessageBubble.js +2 -1
- package/dist/messaging/ReadTicks.js +5 -3
- package/dist/messaging/linkifyText.js +39 -0
- package/dist/pages/PasswordInvitePage.js +23 -8
- package/dist/pages/SignUpPage.js +4 -4
- package/dist/theme/kitSxRegistry.js +5 -1
- package/dist/utils/auth-errors.js +1 -1
- package/package.json +1 -1
- package/src/auth/UserMenu.jsx +26 -1
- package/src/components/EmptyState.jsx +38 -0
- package/src/components/UserListComponent.jsx +2 -0
- package/src/i18n/authTranslations.ts +243 -0
- package/src/i18n/uiCoreTranslations.js +10 -0
- package/src/index.js +2 -1
- package/src/messaging/Composer.jsx +7 -3
- package/src/messaging/MessageBubble.jsx +2 -1
- package/src/messaging/ReadTicks.jsx +4 -3
- package/src/messaging/linkifyText.jsx +37 -0
- package/src/pages/PasswordInvitePage.jsx +36 -7
- package/src/pages/SignUpPage.jsx +79 -68
- package/src/theme/kitSxRegistry.js +9 -1
- package/src/utils/auth-errors.js +2 -2
- package/tests/EmptyState.test.jsx +62 -0
- package/tests/PasswordInvitePage.test.jsx +135 -0
- package/tests/SignUpPage.test.jsx +15 -2
- package/tests/UserListComponent.test.jsx +85 -0
- package/tests/UserMenu.test.jsx +40 -4
- package/tests/authErrors.test.js +43 -0
- package/tests/i18nGermanVariant.test.js +65 -0
- package/tests/messagingComposer.test.jsx +25 -0
- package/tests/messagingLinkify.test.jsx +51 -0
- package/tests/messagingReadTicksTooltip.test.jsx +36 -0
- package/tests/userScreenTranslations.test.jsx +165 -0
- package/work-orders/AUTH-8.md +184 -0
- package/work-orders/UCM-AUTH-9.md +207 -0
- package/work-orders/UCM-I18N-4.md +226 -0
- package/work-orders/UCM-MSG-20.md +192 -0
- package/work-orders/UCM-PRIM-2.md +114 -0
- package/work-orders/UCM-SHELL-7.md +134 -0
- package/work-orders/UCM-THEME-13.md +69 -0
- package/work-orders/UCM-THEME-14.md +140 -0
package/tests/UserMenu.test.jsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
|
5
6
|
|
|
6
7
|
const translation = vi.hoisted(() => ({
|
|
7
8
|
t: vi.fn((key) => `translated:${key}`),
|
|
@@ -11,15 +12,26 @@ vi.mock('react-i18next', () => ({ useTranslation: () => translation }));
|
|
|
11
12
|
|
|
12
13
|
import { AuthContext } from '../src/auth/AuthContext';
|
|
13
14
|
import { UserMenu } from '../src/auth/UserMenu';
|
|
15
|
+
import { calculateContrastRatio, createAppTheme } from '../src/theme';
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
const defaultTheme = createAppTheme({ palette: { primary: { main: createTheme().palette.primary.main } } });
|
|
18
|
+
|
|
19
|
+
function renderUserMenu({ user, logout = vi.fn(), theme = defaultTheme, ...props } = {}) {
|
|
16
20
|
return render(
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
|
|
21
|
+
<ThemeProvider theme={theme}>
|
|
22
|
+
<AuthContext.Provider value={{ user, logout }}>
|
|
23
|
+
<UserMenu {...props} />
|
|
24
|
+
</AuthContext.Provider>
|
|
25
|
+
</ThemeProvider>,
|
|
20
26
|
);
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
function getAvatarContrast() {
|
|
30
|
+
const avatar = screen.getByText(/^[A-Z]+$/).closest('.MuiAvatar-root');
|
|
31
|
+
const styles = window.getComputedStyle(avatar);
|
|
32
|
+
return calculateContrastRatio(styles.color, styles.backgroundColor);
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
describe('UserMenu', () => {
|
|
24
36
|
afterEach(() => {
|
|
25
37
|
cleanup();
|
|
@@ -132,4 +144,28 @@ describe('UserMenu', () => {
|
|
|
132
144
|
renderUserMenu({ user: { username: 'grace' } });
|
|
133
145
|
expect(screen.getByText('G')).toBeTruthy();
|
|
134
146
|
});
|
|
147
|
+
|
|
148
|
+
it("uses a contrasting avatar pair with jg-ferien's primary colour", () => {
|
|
149
|
+
const theme = createAppTheme({ palette: { primary: { main: '#367964' } } });
|
|
150
|
+
renderUserMenu({ theme, user: { username: 'ab' } });
|
|
151
|
+
|
|
152
|
+
expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('uses a contrasting avatar pair with the library baseline primary colour', () => {
|
|
156
|
+
renderUserMenu({ user: { username: 'ab' } });
|
|
157
|
+
|
|
158
|
+
expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('falls back to the dark text pair for a medium-luminance primary colour', () => {
|
|
162
|
+
// A synthetic mid-grey (luminance ~0.216, no real brand meaning) picked to
|
|
163
|
+
// fail white-on-background at 4.5:1 (~3.95:1) so this test actually
|
|
164
|
+
// exercises the black-fallback branch, not just the white branch every
|
|
165
|
+
// other test here happens to land on.
|
|
166
|
+
const theme = createAppTheme({ palette: { primary: { main: '#808080' } } });
|
|
167
|
+
renderUserMenu({ theme, user: { username: 'ab' } });
|
|
168
|
+
|
|
169
|
+
expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
|
|
170
|
+
});
|
|
135
171
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { extractErrorInfo, normaliseApiError } from '../src/utils/auth-errors.js';
|
|
3
|
+
|
|
4
|
+
describe('auth error normalisation', () => {
|
|
5
|
+
it('uses the caller default for generic detail errors while preserving the detail message', () => {
|
|
6
|
+
const responseError = {
|
|
7
|
+
response: { status: 429, data: { detail: 'Die Anfrage wurde gedrosselt...' } },
|
|
8
|
+
};
|
|
9
|
+
const info = extractErrorInfo(responseError);
|
|
10
|
+
const error = normaliseApiError(responseError, 'Auth.LOGIN_FAILED');
|
|
11
|
+
|
|
12
|
+
expect(info.code).toBeNull();
|
|
13
|
+
expect(error.code).toBe('Auth.LOGIN_FAILED');
|
|
14
|
+
expect(error.message).toBe('Die Anfrage wurde gedrosselt...');
|
|
15
|
+
expect(error.status).toBe(429);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('uses its namespaced default when no caller default is provided', () => {
|
|
19
|
+
const error = normaliseApiError({
|
|
20
|
+
response: { status: 429, data: { detail: 'Die Anfrage wurde gedrosselt...' } },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(error.code).toBe('Auth.GENERIC_ERROR');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('preserves a backend code from the errors array', () => {
|
|
27
|
+
const error = normaliseApiError(
|
|
28
|
+
{ response: { data: { errors: [{ code: 'enter_current_password', message: 'x' }] } } },
|
|
29
|
+
'Auth.PROFILE_UPDATE_FAILED',
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(error.code).toBe('enter_current_password');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('preserves a backend code from the response data', () => {
|
|
36
|
+
const error = normaliseApiError(
|
|
37
|
+
{ response: { data: { code: 'invalid_credentials' } } },
|
|
38
|
+
'Auth.LOGIN_FAILED',
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(error.code).toBe('invalid_credentials');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { authTranslations } from '../src/i18n/authTranslations';
|
|
4
|
+
import {
|
|
5
|
+
createUiCoreTranslations,
|
|
6
|
+
uiCoreTranslations,
|
|
7
|
+
} from '../src/i18n/uiCoreTranslations';
|
|
8
|
+
|
|
9
|
+
const SIE_FORM = /\b(Sie|Ihr|Ihre|Ihrem|Ihren|Ihrer|Ihres)\b/;
|
|
10
|
+
const REQUIRED_LOCALES = ['de', 'en', 'fr', 'sw'];
|
|
11
|
+
|
|
12
|
+
describe('German translation variants', () => {
|
|
13
|
+
it('keeps formal German by default and switches representative auth copy to du', () => {
|
|
14
|
+
const key = 'Auth.PAGE_INVITE_SUBTITLE';
|
|
15
|
+
const formal = createUiCoreTranslations();
|
|
16
|
+
const informal = createUiCoreTranslations({ germanVariant: 'informal' });
|
|
17
|
+
|
|
18
|
+
expect(formal[key].de).toBe(uiCoreTranslations[key].de);
|
|
19
|
+
expect(formal[key]).not.toHaveProperty('de_informal');
|
|
20
|
+
expect(informal[key].de).toBe('Bitte wähle ein Passwort, um auf dein Konto zuzugreifen.');
|
|
21
|
+
expect(informal[key].de).not.toMatch(SIE_FORM);
|
|
22
|
+
expect(informal['Auth.EMAIL_LABEL'].de).toBe(formal['Auth.EMAIL_LABEL'].de);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('switches EVERY Sie-form key to its own rewritten de_informal, not one hardcoded example', () => {
|
|
26
|
+
// Not just one representative key -- a factory hardcoded to a single
|
|
27
|
+
// (key, value) pair would pass the test above but fail here the moment a
|
|
28
|
+
// second Sie-form key is checked against ITS OWN de_informal value.
|
|
29
|
+
const formalKeys = Object.entries(authTranslations)
|
|
30
|
+
.filter(([, value]) => SIE_FORM.test(value.de))
|
|
31
|
+
.map(([key]) => key);
|
|
32
|
+
expect(formalKeys.length).toBeGreaterThan(1);
|
|
33
|
+
|
|
34
|
+
const informal = createUiCoreTranslations({ germanVariant: 'informal' });
|
|
35
|
+
const formal = createUiCoreTranslations();
|
|
36
|
+
for (const key of formalKeys) {
|
|
37
|
+
expect(informal[key].de, `${key} should switch to its own de_informal`).toBe(authTranslations[key].de_informal);
|
|
38
|
+
expect(informal[key].de).not.toMatch(SIE_FORM);
|
|
39
|
+
expect(formal[key].de, `${key} default must stay formal`).toBe(authTranslations[key].de);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('requires every Sie-form auth string to have a rewritten informal variant', () => {
|
|
44
|
+
const formalKeys = Object.entries(authTranslations)
|
|
45
|
+
.filter(([, value]) => SIE_FORM.test(value.de))
|
|
46
|
+
.map(([key]) => key);
|
|
47
|
+
|
|
48
|
+
expect(formalKeys.length).toBeGreaterThan(0);
|
|
49
|
+
for (const key of formalKeys) {
|
|
50
|
+
const informal = authTranslations[key].de_informal;
|
|
51
|
+
expect(informal, `${key} needs de_informal`).toEqual(expect.any(String));
|
|
52
|
+
expect(informal.trim(), `${key}.de_informal must not be empty`).not.toBe('');
|
|
53
|
+
expect(informal, `${key}.de_informal must not retain Sie-form`).not.toMatch(SIE_FORM);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('preserves the four-locale shape for the aggregate after adding screen keys', () => {
|
|
58
|
+
for (const [key, value] of Object.entries(uiCoreTranslations)) {
|
|
59
|
+
for (const locale of REQUIRED_LOCALES) {
|
|
60
|
+
expect(typeof value[locale], `${key}.${locale} must be a string`).toBe('string');
|
|
61
|
+
expect(value[locale].trim(), `${key}.${locale} must not be empty`).not.toBe('');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -108,4 +108,29 @@ describe('Composer optimistic send and attachments', () => {
|
|
|
108
108
|
render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[{ id: 1, filename: 'note.pdf', content_type: 'application/pdf' }]} /></MessagingProvider>);
|
|
109
109
|
expect(screen.getByLabelText('MessagingAttachments.LABEL')).toBeTruthy();
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
it('keeps the message field and send button usable beside all enabled actions', () => {
|
|
113
|
+
render(<MessagingProvider api={makeApi()} active={false}><Composer conversationId={7} conversation={{ kind: 'group' }} allowAnnouncement /></MessagingProvider>);
|
|
114
|
+
const messageField = screen.getByLabelText('MessagingComposer.MESSAGE');
|
|
115
|
+
const messageFieldRoot = messageField.closest('.MuiFormControl-root');
|
|
116
|
+
expect(messageFieldRoot).toBeTruthy();
|
|
117
|
+
expect(window.getComputedStyle(messageFieldRoot).minWidth).toBe('120px');
|
|
118
|
+
const sendButton = screen.getByRole('button', { name: 'MessagingComposer.SEND' });
|
|
119
|
+
expect(window.getComputedStyle(sendButton).flexShrink).toBe('0');
|
|
120
|
+
expect(screen.getByRole('button', { name: 'MessagingComposer.ADD_ATTACHMENT' }).disabled).toBe(false);
|
|
121
|
+
expect(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }).disabled).toBe(false);
|
|
122
|
+
expect(screen.getByRole('button', { name: 'MessagingPoll.CREATE' }).disabled).toBe(false);
|
|
123
|
+
expect(screen.getByRole('button', { name: 'MessagingAnnouncement.CREATE' }).disabled).toBe(false);
|
|
124
|
+
|
|
125
|
+
// Structural proof, not just a CSS-value check: the action icons and the
|
|
126
|
+
// message field/send button must sit in DIFFERENT Stack rows, not merely
|
|
127
|
+
// carry different sx values while still being flex siblings in one row
|
|
128
|
+
// (a same-row layout with only the sx tweaks would still pass every
|
|
129
|
+
// assertion above without actually fixing the 375px overflow).
|
|
130
|
+
const attachButton = screen.getByRole('button', { name: 'MessagingComposer.ADD_ATTACHMENT' });
|
|
131
|
+
const actionsRow = attachButton.closest('.MuiStack-root');
|
|
132
|
+
expect(actionsRow).toBeTruthy();
|
|
133
|
+
expect(actionsRow.contains(messageField)).toBe(false);
|
|
134
|
+
expect(actionsRow.contains(sendButton)).toBe(false);
|
|
135
|
+
});
|
|
111
136
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key, i18n: { language: 'en' } }) }));
|
|
7
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
|
|
8
|
+
|
|
9
|
+
import { MessageBubble } from '../src/messaging/MessageBubble';
|
|
10
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
11
|
+
|
|
12
|
+
function makeApi() {
|
|
13
|
+
return {
|
|
14
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
|
|
15
|
+
getAttachment: vi.fn(), getAttachmentThumbnail: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(),
|
|
16
|
+
votePoll: vi.fn(), closePoll: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function renderMessage(body) {
|
|
20
|
+
return render(<MessagingProvider api={makeApi()} active={false}><MessageBubble message={{ id: 1, body, sender: { display_name: 'Ava' } }} conversation={{ kind: 'group' }} /></MessagingProvider>);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(cleanup);
|
|
24
|
+
|
|
25
|
+
describe('message URL linkification', () => {
|
|
26
|
+
it('renders http(s) URLs as safe external links', () => {
|
|
27
|
+
renderMessage('Open https://example.com/path for details.');
|
|
28
|
+
const link = screen.getByRole('link', { name: 'https://example.com/path' });
|
|
29
|
+
expect(link.getAttribute('href')).toBe('https://example.com/path');
|
|
30
|
+
expect(link.getAttribute('target')).toBe('_blank');
|
|
31
|
+
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not turn javascript URLs into links', () => {
|
|
35
|
+
renderMessage('javascript:alert(1)');
|
|
36
|
+
expect(screen.queryByRole('link')).toBeNull();
|
|
37
|
+
expect(screen.getByText('javascript:alert(1)')).toBeTruthy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('leaves surrounding prose outside the link and trims sentence punctuation', () => {
|
|
41
|
+
const { container } = renderMessage('See https://example.com/path, then continue reading.');
|
|
42
|
+
const link = screen.getByRole('link');
|
|
43
|
+
expect(link.textContent).toBe('https://example.com/path');
|
|
44
|
+
// MUI's `Link` itself carries a `MuiTypography-root` class (it is a
|
|
45
|
+
// Typography variant), so `.closest('.MuiTypography-root')` would match
|
|
46
|
+
// the link, not its containing paragraph -- `.closest('p')` skips past it
|
|
47
|
+
// to the actual message-body Typography (MUI's default root tag for the
|
|
48
|
+
// unvaried `body1` variant used here).
|
|
49
|
+
expect(link.closest('p').textContent).toBe('See https://example.com/path, then continue reading.');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.read != null ? `${key}:${options.read}/${options.total}` : key }) }));
|
|
7
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
|
|
8
|
+
|
|
9
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
10
|
+
import { ReadTicks } from '../src/messaging/ReadTicks';
|
|
11
|
+
|
|
12
|
+
function makeApi(status) {
|
|
13
|
+
return {
|
|
14
|
+
getReadStatus: vi.fn().mockResolvedValue(status),
|
|
15
|
+
getAttachment: vi.fn(), getAttachmentThumbnail: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(),
|
|
16
|
+
votePoll: vi.fn(), closePoll: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(cleanup);
|
|
21
|
+
|
|
22
|
+
describe('message read ratio marker', () => {
|
|
23
|
+
it('gives the visible ratio an explanatory tooltip', async () => {
|
|
24
|
+
render(<MessagingProvider api={makeApi({ read_count: 2, recipient_count: 5 })} active={false}><ReadTicks messageId={7} conversation={{ kind: 'group' }} /></MessagingProvider>);
|
|
25
|
+
expect(await screen.findByTitle('MessagingReadTicks.READ_RATIO:2/5')).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('hides a ratio when there are no recipients', async () => {
|
|
29
|
+
let resolveStatus;
|
|
30
|
+
const api = { ...makeApi({}), getReadStatus: vi.fn().mockReturnValue(new Promise((resolve) => { resolveStatus = resolve; })) };
|
|
31
|
+
const { container } = render(<MessagingProvider api={api} active={false}><ReadTicks messageId={7} conversation={{ kind: 'group' }} /></MessagingProvider>);
|
|
32
|
+
await waitFor(() => expect(api.getReadStatus).toHaveBeenCalled());
|
|
33
|
+
await act(async () => resolveStatus({ read_count: 0, recipient_count: 0 }));
|
|
34
|
+
expect(container.textContent).toBe('');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import { I18nextProvider } from 'react-i18next';
|
|
6
|
+
import i18next from 'i18next';
|
|
7
|
+
|
|
8
|
+
const authApi = vi.hoisted(() => ({
|
|
9
|
+
deleteUser: vi.fn(),
|
|
10
|
+
fetchUsersList: vi.fn(),
|
|
11
|
+
sendAdminInvite: vi.fn(),
|
|
12
|
+
updateUserRole: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('../src/auth/authApi', () => authApi);
|
|
16
|
+
|
|
17
|
+
import { authTranslations } from '../src/i18n/authTranslations';
|
|
18
|
+
import { BulkInviteCsvTab } from '../src/components/BulkInviteCsvTab';
|
|
19
|
+
import { UserInviteComponent } from '../src/components/UserInviteComponent';
|
|
20
|
+
import { UserListComponent } from '../src/components/UserListComponent';
|
|
21
|
+
|
|
22
|
+
const germanResources = Object.fromEntries(
|
|
23
|
+
Object.entries(authTranslations).map(([key, value]) => [key, value.de]),
|
|
24
|
+
);
|
|
25
|
+
const i18n = i18next.createInstance();
|
|
26
|
+
i18n.init({
|
|
27
|
+
lng: 'de',
|
|
28
|
+
fallbackLng: 'de',
|
|
29
|
+
resources: { de: { translation: germanResources } },
|
|
30
|
+
keySeparator: false,
|
|
31
|
+
nsSeparator: false,
|
|
32
|
+
interpolation: { escapeValue: false },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const ENGLISH_DEFAULTS = [
|
|
36
|
+
'All Users',
|
|
37
|
+
'Search',
|
|
38
|
+
'Search users...',
|
|
39
|
+
'Rows per page:',
|
|
40
|
+
'Successful Login',
|
|
41
|
+
'Are you sure you want to delete this user?',
|
|
42
|
+
'Invite a new user',
|
|
43
|
+
'Bulk Invite via CSV',
|
|
44
|
+
'Upload a CSV file containing email addresses. Header "email" is supported.',
|
|
45
|
+
'Upload CSV',
|
|
46
|
+
'Send Invites',
|
|
47
|
+
'Invitation sent.',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function withGermanI18n(children) {
|
|
51
|
+
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function expectNoEnglishDefaults() {
|
|
55
|
+
const text = document.body.textContent;
|
|
56
|
+
for (const english of ENGLISH_DEFAULTS) {
|
|
57
|
+
expect(text).not.toContain(english);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('German user and invite screen translations', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.clearAllMocks();
|
|
64
|
+
authApi.fetchUsersList.mockResolvedValue([
|
|
65
|
+
{
|
|
66
|
+
id: 1,
|
|
67
|
+
email: 'person@example.com',
|
|
68
|
+
first_name: 'Ada',
|
|
69
|
+
role: 'student',
|
|
70
|
+
is_new: true,
|
|
71
|
+
successful_login: true,
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
authApi.sendAdminInvite.mockResolvedValue({});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
cleanup();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('renders the empty-list and CSV-error strings in German too', async () => {
|
|
82
|
+
authApi.fetchUsersList.mockResolvedValue([]);
|
|
83
|
+
render(withGermanI18n(<UserListComponent currentUser={{ id: 99, is_superuser: true, role: 'admin' }} />));
|
|
84
|
+
await waitFor(() => expect(screen.getByText('Keine Benutzer gefunden.')).toBeTruthy());
|
|
85
|
+
|
|
86
|
+
cleanup();
|
|
87
|
+
|
|
88
|
+
render(withGermanI18n(<BulkInviteCsvTab inviteFn={vi.fn()} />));
|
|
89
|
+
const fileInput = document.querySelector('input[type="file"]');
|
|
90
|
+
fireEvent.change(fileInput, {
|
|
91
|
+
target: { files: [new File([''], 'empty.csv', { type: 'text/csv' })] },
|
|
92
|
+
});
|
|
93
|
+
await waitFor(() => expect(screen.getByText('Keine gültigen E-Mail-Adressen in der CSV-Datei gefunden.')).toBeTruthy());
|
|
94
|
+
expectNoEnglishDefaults();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('renders UserListComponent in German, including pagination and action feedback', async () => {
|
|
98
|
+
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
|
99
|
+
const alert = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
|
100
|
+
|
|
101
|
+
render(withGermanI18n(
|
|
102
|
+
<UserListComponent
|
|
103
|
+
currentUser={{ id: 99, is_superuser: true, role: 'admin' }}
|
|
104
|
+
extraRowActions={[{
|
|
105
|
+
key: 'failing-action',
|
|
106
|
+
label: 'Aktion',
|
|
107
|
+
onClick: () => Promise.reject({}),
|
|
108
|
+
}]}
|
|
109
|
+
/>,
|
|
110
|
+
));
|
|
111
|
+
|
|
112
|
+
await waitFor(() => expect(screen.getByText('Alle Benutzer')).toBeTruthy());
|
|
113
|
+
expect(screen.getByLabelText('Suche')).toBeTruthy();
|
|
114
|
+
expect(screen.getByPlaceholderText('Benutzer suchen...')).toBeTruthy();
|
|
115
|
+
for (const german of ['Neu', 'Erfolgreiche Anmeldung', 'Rolle', 'Aktionen', 'Zeilen pro Seite:', 'Löschen']) {
|
|
116
|
+
expect(screen.getByText(german)).toBeTruthy();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fireEvent.click(screen.getByRole('button', { name: 'Löschen' }));
|
|
120
|
+
expect(confirm).toHaveBeenCalledWith('Möchten Sie diesen Benutzer wirklich löschen?');
|
|
121
|
+
|
|
122
|
+
fireEvent.click(screen.getByRole('button', { name: 'Aktion' }));
|
|
123
|
+
await waitFor(() => expect(alert).toHaveBeenCalledWith('Vorgang fehlgeschlagen.'));
|
|
124
|
+
expectNoEnglishDefaults();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('renders UserInviteComponent in German and translates the success message', async () => {
|
|
128
|
+
render(withGermanI18n(<UserInviteComponent />));
|
|
129
|
+
|
|
130
|
+
expect(screen.getByText('Neuen Benutzer einladen')).toBeTruthy();
|
|
131
|
+
fireEvent.change(screen.getByLabelText('E-Mail-Adresse'), {
|
|
132
|
+
target: { value: 'new@example.com' },
|
|
133
|
+
});
|
|
134
|
+
fireEvent.click(screen.getByRole('button', { name: 'Einladen' }));
|
|
135
|
+
|
|
136
|
+
await waitFor(() => expect(screen.getByText('Einladung gesendet.')).toBeTruthy());
|
|
137
|
+
expectNoEnglishDefaults();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('renders BulkInviteCsvTab in German with translated progress and table copy', async () => {
|
|
141
|
+
render(withGermanI18n(
|
|
142
|
+
<BulkInviteCsvTab inviteFn={vi.fn().mockResolvedValue({})} />,
|
|
143
|
+
));
|
|
144
|
+
|
|
145
|
+
expect(screen.getByText('Masseneinladung per CSV')).toBeTruthy();
|
|
146
|
+
expect(screen.getByText('CSV-Datei mit E-Mail-Adressen hochladen. Header "email" wird unterstützt.')).toBeTruthy();
|
|
147
|
+
expect(screen.getByRole('button', { name: 'CSV hochladen' })).toBeTruthy();
|
|
148
|
+
expect(screen.getByRole('button', { name: 'Einladungen senden' })).toBeTruthy();
|
|
149
|
+
|
|
150
|
+
const fileInput = document.querySelector('input[type="file"]');
|
|
151
|
+
fireEvent.change(fileInput, {
|
|
152
|
+
target: { files: [new File(['email\nperson@example.com'], 'users.csv', { type: 'text/csv' })] },
|
|
153
|
+
});
|
|
154
|
+
await waitFor(() => expect(screen.getByText('1 E-Mail-Adressen geladen')).toBeTruthy());
|
|
155
|
+
|
|
156
|
+
fireEvent.click(screen.getByRole('button', { name: 'Einladungen senden' }));
|
|
157
|
+
await waitFor(() => expect(screen.getByText('1 / 1 Einladungen gesendet.')).toBeTruthy());
|
|
158
|
+
for (const german of ['Einladung gesendet.', 'Erfolgreich', 'Status', 'Details']) {
|
|
159
|
+
expect(screen.getByText(german)).toBeTruthy();
|
|
160
|
+
}
|
|
161
|
+
expect(document.body.textContent).toContain('1 / 1 verarbeitet');
|
|
162
|
+
expect(document.body.textContent).toContain('1 erfolgreich');
|
|
163
|
+
expectNoEnglishDefaults();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# AUTH-8 — `extractErrorInfo`'s hardcoded `'GENERIC'` code must not out-rank the caller's own default
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# A. Envelope — authored by the Expertenchat
|
|
6
|
+
|
|
7
|
+
## Goal & expected outcome
|
|
8
|
+
- Ziel: `extractErrorInfo` (`src/utils/auth-errors.js:2-27`) gibt für jeden Backend-Fehler der
|
|
9
|
+
Form `{"detail": "..."}` (Djangos generisches Fehlerformat — u.a. DRF `Throttled`, `Http404`,
|
|
10
|
+
`PermissionDenied`, jede unbehandelte `APIException`; exakt die Form des 429/409, das die
|
|
11
|
+
gesamte `KIRA-TRACK-3`/`AUTH-7`-Untersuchung ausgelöst hat) den hartcodierten Code `'GENERIC'`
|
|
12
|
+
zurück. `normaliseApiError` (Zeile 29-38) wählt dann `info.code || defaultCode` — da `'GENERIC'`
|
|
13
|
+
ein truthy String ist, GEWINNT er IMMER gegen den von JEDER der 39 bestehenden Aufrufstellen in
|
|
14
|
+
diesem Repo übergebenen, spezifischen `defaultCode` (z. B. `'Auth.LOGIN_FAILED'`,
|
|
15
|
+
`'Auth.PROFILE_UPDATE_FAILED'`, …). `'GENERIC'` ist an KEINER Stelle als i18n-Key registriert
|
|
16
|
+
(verifiziert: kein Treffer in `src/i18n/`, keine Erwähnung ausserhalb dieser einen Zeile) — die
|
|
17
|
+
UI zeigt also für JEDEN `{detail}`-förmigen Backend-Fehler den rohen String `"GENERIC"` statt
|
|
18
|
+
einer übersetzten Meldung, gleich welcher Aufrufer betroffen ist.
|
|
19
|
+
- Expected outcome: ein `{detail}`-förmiger Fehler liefert `code === <übergebener defaultCode>`
|
|
20
|
+
(übersetzt, wie jeder andere fehlende/unbekannte Code auch) — `data.detail`s Text bleibt weiterhin
|
|
21
|
+
in `.message` erhalten (nützlich für Logs/Debugging), beeinflusst aber nicht mehr `.code`.
|
|
22
|
+
|
|
23
|
+
## Scope + non-goals
|
|
24
|
+
- In scope: ausschliesslich `src/utils/auth-errors.js` — die EINE Zeile, die `code: 'GENERIC'`
|
|
25
|
+
zurückgibt, wird zu `code: null` (dann gewinnt in `normaliseApiError` korrekt der übergebene
|
|
26
|
+
`defaultCode`, exakt wie bei jedem anderen Fehler ohne erkennbaren Code).
|
|
27
|
+
- Explizite Non-Goals / nicht anfassen: die ANDEREN beiden Zweige von `extractErrorInfo`
|
|
28
|
+
(`data.errors[0].code`, `data.code`) liefern einen ECHTEN, vom Backend gesetzten Code — dieses
|
|
29
|
+
Verhalten ist korrekt und bleibt unverändert; nicht "verbessern" oder umbauen.
|
|
30
|
+
`normaliseApiError`s Signatur/Rückgabeform (`Error` mit `.code`/`.status`/`.raw`) bleibt gleich.
|
|
31
|
+
Keine neue i18n-Übersetzung für `'GENERIC'` selbst nötig — der Fix macht den String obsolet,
|
|
32
|
+
statt ihn übersetzbar zu machen. Keine Änderung an `LoginPage.jsx`/`SecurityComponent.jsx`/
|
|
33
|
+
anderen Konsumenten — die lesen bereits `err.code || '<eigener Default>'` und profitieren
|
|
34
|
+
automatisch, ohne selbst geändert zu werden.
|
|
35
|
+
|
|
36
|
+
## Tier · precondition / gate
|
|
37
|
+
- Tier: 3 — Shared-Core (`ui-core-micha`), betrifft laut Aufrufstellen-Scan ALLE 39 bestehenden
|
|
38
|
+
`normaliseApiError(...)`-Aufrufe in diesem Repo (jede Funktion in `src/auth/authApi.jsx` und
|
|
39
|
+
`src/utils/authService.js`). Kein Precondition/Gate.
|
|
40
|
+
- **Wichtig fürs Versionsschema:** anders als `AUTH-7` (rein additiv) ändert dieser Fix
|
|
41
|
+
BEOBACHTBARES Verhalten für jeden Aufrufer, der aktuell (fehlerhaft) `.code === 'GENERIC'`
|
|
42
|
+
bekommt — das ist der Bug selbst, aber jede konsumierende App, die diese Version zieht, sieht ab
|
|
43
|
+
sofort eine ANDERE (korrekte) übersetzte Meldung an Stellen, wo bisher der rohe String
|
|
44
|
+
`"GENERIC"` erschien. Deshalb MINOR-Bump (nicht PATCH) vorgesehen, siehe Teil C.
|
|
45
|
+
|
|
46
|
+
## Risks
|
|
47
|
+
- Wird der Fix zu breit gefasst (z. B. `'GENERIC'` komplett aus dem Vokabular entfernt statt nur
|
|
48
|
+
die eine Codepfad-Priorität zu korrigieren), könnte das `.message`-Feld (der rohe `data.detail`-
|
|
49
|
+
Text) verlorengehen — das wird von KEINEM bekannten Aufrufer als Anzeige-Text verwendet (alle
|
|
50
|
+
lesen `.code` fürs Rendering, s.o.), aber sicherheitshalber in den Tests absichern.
|
|
51
|
+
- Die beiden UNVERÄNDERTEN Zweige (`data.errors[].code`, `data.code`) müssen per Regressionstest
|
|
52
|
+
nachweislich unverändert bleiben.
|
|
53
|
+
|
|
54
|
+
## Required tests to WRITE
|
|
55
|
+
Neue Datei `tests/authErrors.test.js` (reiner Unit-Test, kein React/Testing-Library nötig — direkter
|
|
56
|
+
Import von `normaliseApiError`/`extractErrorInfo` aus `../src/utils/auth-errors.js`):
|
|
57
|
+
1. `{ response: { status: 429, data: { detail: 'Die Anfrage wurde gedrosselt...' } } }` mit
|
|
58
|
+
`normaliseApiError(err, 'Auth.LOGIN_FAILED')` aufgerufen → `.code === 'Auth.LOGIN_FAILED'` (NICHT
|
|
59
|
+
`'GENERIC'`), `.message === 'Die Anfrage wurde gedrosselt...'`, `.status === 429`.
|
|
60
|
+
2. Ohne übergebenen `defaultCode` (Default-Parameter) mit demselben `{detail}`-Fehler → `.code ===
|
|
61
|
+
'Auth.GENERIC_ERROR'` (der Funktions-Default, nicht `'GENERIC'`).
|
|
62
|
+
3. Regression: `{ response: { data: { errors: [{ code: 'enter_current_password', message: 'x' }] } } }`
|
|
63
|
+
→ `.code === 'enter_current_password'` (übergebener `defaultCode` NICHT verwendet — echter
|
|
64
|
+
Backend-Code gewinnt weiterhin, unverändert).
|
|
65
|
+
4. Regression: `{ response: { data: { code: 'invalid_credentials' } } }` → `.code ===
|
|
66
|
+
'invalid_credentials'` (unverändert).
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
# B. Implementation map — filled by the Orchestrator — ADDRESSED TO THE IMPLEMENTER
|
|
71
|
+
|
|
72
|
+
## Context package
|
|
73
|
+
|
|
74
|
+
**Named file to change:** `src/utils/auth-errors.js:21-24`:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
// Fallback for generic Django errors
|
|
78
|
+
if (typeof data.detail === 'string') {
|
|
79
|
+
return { status, code: 'GENERIC', message: data.detail, raw: data };
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Ändere die zurückgegebene `code`-Property von `'GENERIC'` zu `null`:
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
// Fallback for generic Django errors. `code: null` here (not a fake sentinel)
|
|
87
|
+
// lets normaliseApiError's `info.code || defaultCode` correctly prefer the
|
|
88
|
+
// caller's own specific default — `data.detail` is Django's generic
|
|
89
|
+
// human-readable message field, never a machine code, and no caller of
|
|
90
|
+
// normaliseApiError anywhere in this repo expects to see 'GENERIC' as a code
|
|
91
|
+
// (grepped: zero references outside this one line, zero i18n key for it).
|
|
92
|
+
if (typeof data.detail === 'string') {
|
|
93
|
+
return { status, code: null, message: data.detail, raw: data };
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Do NOT touch** the other two branches (lines 11-15 `data.errors[0]`, lines 17-19 `data.code`) or
|
|
98
|
+
`normaliseApiError` itself (lines 29-38) — its `info.code || defaultCode` logic is already correct;
|
|
99
|
+
the bug was entirely in what `extractErrorInfo` fed it.
|
|
100
|
+
|
|
101
|
+
**Invariants / Pitfalls:**
|
|
102
|
+
- `normaliseApiError`'s OWN default parameter is `defaultCode = 'Auth.GENERIC_ERROR'` (line 29) —
|
|
103
|
+
this is a DIFFERENT string (`'Auth.GENERIC_ERROR'`, already namespaced like every other code in
|
|
104
|
+
this file) from the bug's `'GENERIC'` sentinel. Do not confuse the two or "fix" the wrong one.
|
|
105
|
+
- `data.detail` being falsy/absent already falls through correctly to the final `return { status,
|
|
106
|
+
code: null, message: null, raw: data }` (line 26) — that path is already correct and untouched.
|
|
107
|
+
- No i18n file needs a new key — this fix does not introduce any new user-visible string; it makes
|
|
108
|
+
the ALREADY-TRANSLATED caller-specific keys (e.g. `Auth.LOGIN_FAILED`, confirmed translated in
|
|
109
|
+
de/fr/en/sw) reachable again.
|
|
110
|
+
|
|
111
|
+
Directive: Arbeite ausschliesslich aus diesem Kontext-Paket; öffne nur `src/utils/auth-errors.js`
|
|
112
|
+
und die neue Testdatei zur Verifikation. Kein breites Erkunden in `authApi.jsx`/`authService.js` —
|
|
113
|
+
deren 39 Aufrufstellen sind bereits korrekt (sie übergeben schon einen sinnvollen `defaultCode`);
|
|
114
|
+
sie müssen nicht einzeln angeschaut werden.
|
|
115
|
+
|
|
116
|
+
## Target repo working directory (absolute)
|
|
117
|
+
|
|
118
|
+
`C:\Users\Micha Bigler\Documents\webapps\ui-core-micha`
|
|
119
|
+
|
|
120
|
+
## Preamble — REQUIRED, part of this file
|
|
121
|
+
|
|
122
|
+
> The text above is the COMPLETE spec — the committed WO file's content, not a plan to refine;
|
|
123
|
+
> there is no separate plan file. Read the nearest `AGENTS.md`, the relevant
|
|
124
|
+
> `.codex/skills/<role>/SKILL.md`, and the app `MEMORY.md` ONLY for conventions. Stay in scope; do
|
|
125
|
+
> not touch auth/permissions/deps/schema/CI unless the spec says so; do not update `MEMORY.md`.
|
|
126
|
+
> **Do NOT edit `WORK_ORDERS.md`** — the register row and the review verdicts are the
|
|
127
|
+
> orchestrator's alone. **Your tools are for editing source and test files and for running the
|
|
128
|
+
> tests you wrote — nothing else.** Do NOT install dependencies, touch a lockfile, run a package
|
|
129
|
+
> manager, or tidy up stray files; if something in the repo state blocks you, stop and report it as
|
|
130
|
+
> `RESULT: BLOCKED <reason>` instead of fixing it. Do NOT `git add`/`commit`/`push` — leave every
|
|
131
|
+
> change uncommitted in the working tree for the orchestrator's independent review. WRITE the
|
|
132
|
+
> tests the `Required tests` section calls for AND **RUN the tests you just wrote** to confirm
|
|
133
|
+
> they execute and pass — that is the ONLY test run you do (NOT the app's affected/full suite, NOT
|
|
134
|
+
> any review). The orchestrator re-runs the authoritative set + does the independent review after
|
|
135
|
+
> you finish — those are the gate; your own run does not count as the gate.
|
|
136
|
+
>
|
|
137
|
+
> Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
|
|
138
|
+
> `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (and `… done` on
|
|
139
|
+
> completion), spaced so no gap exceeds ~2 min, stdout unbuffered, plus exactly one final
|
|
140
|
+
> `RESULT: DONE|BLOCKED <reason>`.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
# C. Orchestrator only — NOT ADDRESSED TO THE IMPLEMENTER
|
|
145
|
+
|
|
146
|
+
> **If you are the implementer reading this work order as your own specification: STOP at this
|
|
147
|
+
> line. Everything below describes what the Orchestrator does AFTER you finish. You do none of it
|
|
148
|
+
> — no reviewers, no verification run, no register edit, no commit.** You ARE the invocation
|
|
149
|
+
> described below; do NOT shell out to `codex exec`.
|
|
150
|
+
|
|
151
|
+
## Execution directive
|
|
152
|
+
|
|
153
|
+
Implement through `codex exec` in the background, invoked directly via Bash with both mandatory
|
|
154
|
+
flags. Fallback to direct Claude implementation only on Codex quota/rate-limit/non-zero exit
|
|
155
|
+
(flips authorship — independent reviewer becomes mandatory, already mandatory at Tier 3). Codex
|
|
156
|
+
confirmed available today (two successful dispatches already this session, `KIRA-TRACK-3`/
|
|
157
|
+
`AUTH-7`) — no fresh probe needed.
|
|
158
|
+
|
|
159
|
+
## Review routing
|
|
160
|
+
|
|
161
|
+
Tier 3, full set, all concurrent in one background batch: `reviewer` (all 4 lenses — `regression`
|
|
162
|
+
matters most here given 39 call sites; `envelope` needs Part A inline), `ui_reviewer` (this is
|
|
163
|
+
specifically an i18n/error-rendering correctness fix — narrow context: the diff + confirmation that
|
|
164
|
+
every `defaultCode` used across `authApi.jsx`/`authService.js` is actually a translated key in all
|
|
165
|
+
shipped locales), `sec_reviewer` (confirm `.message`/`.raw` still expose no more than before — this
|
|
166
|
+
diff should REDUCE not increase what reaches the UI, since `'GENERIC'` is replaced by `null`).
|
|
167
|
+
Orchestrator's own targeted pass runs alongside.
|
|
168
|
+
|
|
169
|
+
## Verification
|
|
170
|
+
|
|
171
|
+
Authoritative test run: `npx vitest run tests/authErrors.test.js tests/authErrorCodeRendering.test.jsx
|
|
172
|
+
tests/authApiLogin.test.js tests/authApiRegistration.test.js` (affected area — everything touching
|
|
173
|
+
`auth-errors.js` and its consumers). No prototype artifact, no rendered side-by-side (no markup
|
|
174
|
+
changed).
|
|
175
|
+
|
|
176
|
+
## Register + commit
|
|
177
|
+
|
|
178
|
+
Bump `package.json`'s `version` — **MINOR**, not PATCH, per the Envelope's tier note (observable
|
|
179
|
+
behavior change for existing `{detail}`-shaped-error callers, not purely additive) — from whatever
|
|
180
|
+
`AUTH-7` landed at, to the next `3.x.0`. `CHANGELOG.md` entry MUST explicitly warn: existing
|
|
181
|
+
consumers who upgrade will see a DIFFERENT (correct, translated) message wherever they previously
|
|
182
|
+
saw the raw string `"GENERIC"` for a `{detail}`-shaped backend error — re-check that UI surface
|
|
183
|
+
after bumping the pin, don't assume silent compatibility the way `AUTH-7`'s bump was. Commit on
|
|
184
|
+
`main`. `WORK_ORDERS.md` row for `AUTH-8` → `done`, named review verdict, landing SHA.
|