@micha.bigler/ui-core-micha 2.26.3 → 2.28.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.
- package/DESIGN.md +2 -1
- package/WORK_ORDERS.md +5 -1
- package/dist/components/PasswordChangeForm.js +14 -4
- package/dist/components/PasswordSetForm.js +10 -2
- package/dist/components/charts/ChartFrame.js +14 -3
- package/dist/components/charts/TimeSeriesChart.js +27 -3
- package/dist/i18n/authTranslations.js +93 -0
- package/dist/i18n/notificationsTranslations.js +8 -0
- package/dist/notifications/NotificationSettings.js +48 -3
- package/dist/notifications/api.js +5 -0
- package/package.json +1 -1
- package/src/components/PasswordChangeForm.jsx +26 -1
- package/src/components/PasswordSetForm.jsx +18 -1
- package/src/components/charts/ChartFrame.jsx +44 -1
- package/src/components/charts/TimeSeriesChart.jsx +28 -5
- package/src/i18n/authTranslations.ts +94 -0
- package/src/i18n/notificationsTranslations.ts +8 -0
- package/src/notifications/NotificationSettings.jsx +95 -0
- package/src/notifications/api.js +6 -0
- package/tests/ChartFrame.test.jsx +45 -0
- package/tests/NotificationSettings.test.jsx +105 -0
- package/tests/PasswordChangeForm.test.jsx +70 -0
- package/tests/PasswordSetForm.test.jsx +70 -0
- package/tests/TimeSeriesChart.test.jsx +64 -9
- package/tests/authErrorCodeRendering.test.jsx +77 -0
- package/tests/authTranslations.test.js +52 -0
- package/work-orders/AUTH-1.md +74 -0
- package/work-orders/AUTH-2.md +74 -0
- package/work-orders/CHART-3.md +163 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({
|
|
7
|
+
useTranslation: () => ({ t: (key, fallback) => (typeof fallback === 'string' ? fallback : key) }),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { PasswordSetForm } from '../src/components/PasswordSetForm';
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
cleanup();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function fillAndSubmit(password1, password2) {
|
|
17
|
+
fireEvent.change(screen.getByLabelText('Auth.NEW_PASSWORD_LABEL'), { target: { value: password1 } });
|
|
18
|
+
fireEvent.change(screen.getByLabelText('Auth.PASSWORD_CONFIRM_LABEL'), { target: { value: password2 } });
|
|
19
|
+
fireEvent.click(screen.getByRole('button', { name: 'Auth.PASSWORD_SET_BUTTON' }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('PasswordSetForm', () => {
|
|
23
|
+
it('renders the default password-rules helperText', () => {
|
|
24
|
+
render(<PasswordSetForm onSubmit={vi.fn()} />);
|
|
25
|
+
expect(screen.getAllByText('Auth.PASSWORD_RULES_HINT').length).toBeGreaterThan(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('lets a caller override the helperText via passwordRulesHint', () => {
|
|
29
|
+
render(<PasswordSetForm onSubmit={vi.fn()} passwordRulesHint="Custom rule text" />);
|
|
30
|
+
expect(screen.getAllByText('Custom rule text').length).toBeGreaterThan(0);
|
|
31
|
+
expect(screen.queryByText('Auth.PASSWORD_RULES_HINT')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('blocks submit and shows the local error for a password shorter than 8 characters', () => {
|
|
35
|
+
const onSubmit = vi.fn();
|
|
36
|
+
render(<PasswordSetForm onSubmit={onSubmit} />);
|
|
37
|
+
fillAndSubmit('short1', 'short1');
|
|
38
|
+
expect(screen.getByText('Auth.PASSWORD_TOO_SHORT_LOCAL')).toBeTruthy();
|
|
39
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('blocks submit and shows the local error for an entirely numeric password', () => {
|
|
43
|
+
const onSubmit = vi.fn();
|
|
44
|
+
render(<PasswordSetForm onSubmit={onSubmit} />);
|
|
45
|
+
fillAndSubmit('12345678', '12345678');
|
|
46
|
+
expect(screen.getByText('Auth.PASSWORD_NUMERIC_LOCAL')).toBeTruthy();
|
|
47
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('calls onSubmit for a valid, non-numeric password of sufficient length', () => {
|
|
51
|
+
const onSubmit = vi.fn();
|
|
52
|
+
render(<PasswordSetForm onSubmit={onSubmit} />);
|
|
53
|
+
fillAndSubmit('correct-horse-1', 'correct-horse-1');
|
|
54
|
+
expect(onSubmit).toHaveBeenCalledWith('correct-horse-1');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('honours a custom minPasswordLength for an app with a looser backend policy', () => {
|
|
58
|
+
const onSubmit = vi.fn();
|
|
59
|
+
render(<PasswordSetForm onSubmit={onSubmit} minPasswordLength={4} />);
|
|
60
|
+
fillAndSubmit('ab12', 'ab12');
|
|
61
|
+
expect(onSubmit).toHaveBeenCalledWith('ab12');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('allows an entirely numeric password when allowNumericPassword is set', () => {
|
|
65
|
+
const onSubmit = vi.fn();
|
|
66
|
+
render(<PasswordSetForm onSubmit={onSubmit} allowNumericPassword />);
|
|
67
|
+
fillAndSubmit('12345678', '12345678');
|
|
68
|
+
expect(onSubmit).toHaveBeenCalledWith('12345678');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -38,19 +38,15 @@ const SAMPLE_DATA = {
|
|
|
38
38
|
function renderChart(props = {}) {
|
|
39
39
|
const i18n = i18next.createInstance();
|
|
40
40
|
i18n.init({ lng: 'en', resources, interpolation: { escapeValue: false } });
|
|
41
|
-
|
|
41
|
+
const wrap = (chartProps) => (
|
|
42
42
|
<ThemeProvider theme={createTheme()}>
|
|
43
43
|
<I18nextProvider i18n={i18n}>
|
|
44
|
-
<TimeSeriesChart
|
|
45
|
-
title="Activity"
|
|
46
|
-
xAxisLabel="Day"
|
|
47
|
-
yAxisLabel="Count"
|
|
48
|
-
data={SAMPLE_DATA}
|
|
49
|
-
{...props}
|
|
50
|
-
/>
|
|
44
|
+
<TimeSeriesChart title="Activity" xAxisLabel="Day" yAxisLabel="Count" data={SAMPLE_DATA} {...chartProps} />
|
|
51
45
|
</I18nextProvider>
|
|
52
|
-
</ThemeProvider
|
|
46
|
+
</ThemeProvider>
|
|
53
47
|
);
|
|
48
|
+
const view = render(wrap(props));
|
|
49
|
+
return { ...view, rerenderWith: (nextProps) => view.rerender(wrap(nextProps)) };
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
describe('TimeSeriesChart', () => {
|
|
@@ -128,4 +124,63 @@ describe('TimeSeriesChart', () => {
|
|
|
128
124
|
renderChart({ loading: true });
|
|
129
125
|
expect(screen.getByLabelText('Loading chart.')).toBeTruthy();
|
|
130
126
|
});
|
|
127
|
+
|
|
128
|
+
// CHART-3 regression: visibleKeys must not be captured only at first mount.
|
|
129
|
+
// Reproduces the live bug — host mounts with empty data, fetches
|
|
130
|
+
// asynchronously, then rerenders with real data. Before the fix this stayed
|
|
131
|
+
// on ChartFrame's empty state forever even with real series present.
|
|
132
|
+
it('CHART-3: renders the chart once data arrives after an empty-data mount', () => {
|
|
133
|
+
const { rerenderWith } = renderChart({ data: { xLabels: [], series: [] } });
|
|
134
|
+
expect(screen.getByText('No data available.')).toBeTruthy();
|
|
135
|
+
expect(screen.queryByTestId('mui-bar-chart')).toBeNull();
|
|
136
|
+
|
|
137
|
+
rerenderWith({ data: SAMPLE_DATA });
|
|
138
|
+
|
|
139
|
+
expect(screen.queryByText('No data available.')).toBeNull();
|
|
140
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
141
|
+
expect(props.series).toHaveLength(2);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('CHART-3: preserves a manual toggle-off across a data update that keeps the same key', () => {
|
|
145
|
+
const { rerenderWith } = renderChart({ data: SAMPLE_DATA });
|
|
146
|
+
|
|
147
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Users' }));
|
|
148
|
+
let props = chartSpy.mock.calls.at(-1)[0];
|
|
149
|
+
expect(props.series.map((series) => series.label)).toEqual(['Presence hours']);
|
|
150
|
+
|
|
151
|
+
rerenderWith({
|
|
152
|
+
data: {
|
|
153
|
+
xLabels: ['Mon', 'Tue', 'Wed', 'Thu'],
|
|
154
|
+
series: [
|
|
155
|
+
{ key: 'users', label: 'Users', data: [1, 2, 3, 4] },
|
|
156
|
+
{ key: 'presence', label: 'Presence hours', data: [4, 5, 6, 7] },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
props = chartSpy.mock.calls.at(-1)[0];
|
|
162
|
+
expect(props.series.map((series) => series.label)).toEqual(['Presence hours']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('CHART-3: a series key appearing later defaults to visible while an earlier toggle-off survives', () => {
|
|
166
|
+
const { rerenderWith } = renderChart({
|
|
167
|
+
data: { xLabels: ['Mon'], series: [{ key: 'users', label: 'Users', data: [1] }] },
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Users' }));
|
|
171
|
+
expect(screen.getByText('No data available.')).toBeTruthy();
|
|
172
|
+
|
|
173
|
+
rerenderWith({
|
|
174
|
+
data: {
|
|
175
|
+
xLabels: ['Mon'],
|
|
176
|
+
series: [
|
|
177
|
+
{ key: 'users', label: 'Users', data: [1] },
|
|
178
|
+
{ key: 'presence', label: 'Presence hours', data: [2] },
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
184
|
+
expect(props.series.map((series) => series.label)).toEqual(['Presence hours']);
|
|
185
|
+
});
|
|
131
186
|
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { I18nextProvider } from 'react-i18next';
|
|
6
|
+
import i18next from 'i18next';
|
|
7
|
+
|
|
8
|
+
const authApi = vi.hoisted(() => ({
|
|
9
|
+
changePassword: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('../src/auth/authApi', () => authApi);
|
|
13
|
+
vi.mock('../src/utils/authService', () => ({ startSocialLogin: vi.fn() }));
|
|
14
|
+
|
|
15
|
+
import { authTranslations } from '../src/i18n/authTranslations';
|
|
16
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
17
|
+
import { SecurityComponent } from '../src/components/SecurityComponent';
|
|
18
|
+
|
|
19
|
+
const translations = Object.fromEntries(
|
|
20
|
+
Object.entries(authTranslations).map(([key, value]) => [key, value.en]),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const i18n = i18next.createInstance();
|
|
24
|
+
i18n.init({
|
|
25
|
+
lng: 'en',
|
|
26
|
+
fallbackLng: 'en',
|
|
27
|
+
resources: { en: { translation: translations } },
|
|
28
|
+
keySeparator: false,
|
|
29
|
+
nsSeparator: false,
|
|
30
|
+
interpolation: { escapeValue: false },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function renderSecurity() {
|
|
34
|
+
return render(
|
|
35
|
+
<I18nextProvider i18n={i18n}>
|
|
36
|
+
<AuthContext.Provider value={{ authMethods: { password_change: true } }}>
|
|
37
|
+
<SecurityComponent />
|
|
38
|
+
</AuthContext.Provider>
|
|
39
|
+
</I18nextProvider>,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function submitPasswordChange() {
|
|
44
|
+
fireEvent.change(screen.getByLabelText(/Current password/), { target: { value: 'currentpw' } });
|
|
45
|
+
fireEvent.change(screen.getByLabelText(/New password/), { target: { value: 'a-fine-new-password' } });
|
|
46
|
+
fireEvent.click(screen.getByRole('button', { name: 'Change password' }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
cleanup();
|
|
51
|
+
authApi.changePassword.mockReset();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('SecurityComponent — backend auth error code rendering', () => {
|
|
55
|
+
it('shows translated human text, not the raw code, for a mapped error', async () => {
|
|
56
|
+
authApi.changePassword.mockRejectedValue({ code: 'enter_current_password' });
|
|
57
|
+
renderSecurity();
|
|
58
|
+
|
|
59
|
+
submitPasswordChange();
|
|
60
|
+
|
|
61
|
+
await waitFor(() => {
|
|
62
|
+
expect(screen.getByText('The current password is incorrect.')).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
expect(screen.queryByText('enter_current_password')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('falls back to the generic failure message when the backend sends no code at all', async () => {
|
|
68
|
+
authApi.changePassword.mockRejectedValue(new Error('network failure'));
|
|
69
|
+
renderSecurity();
|
|
70
|
+
|
|
71
|
+
submitPasswordChange();
|
|
72
|
+
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(screen.getByText('The password could not be changed.')).toBeTruthy();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { authTranslations } from '../src/i18n/authTranslations';
|
|
4
|
+
|
|
5
|
+
const REQUIRED_LOCALES = ['de', 'fr', 'en'];
|
|
6
|
+
|
|
7
|
+
const AUTH1_KEYS = [
|
|
8
|
+
'Auth.PASSWORD_RULES_HINT',
|
|
9
|
+
'Auth.PASSWORD_TOO_SHORT_LOCAL',
|
|
10
|
+
'Auth.PASSWORD_NUMERIC_LOCAL',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
// Raw backend error codes (allauth-headless + Django password validators), keyed
|
|
14
|
+
// verbatim so the existing `t(err.code)` call sites resolve as a drop-in. See AUTH-2.
|
|
15
|
+
const AUTH2_CODE_KEYS = [
|
|
16
|
+
'email_password_mismatch',
|
|
17
|
+
'username_password_mismatch',
|
|
18
|
+
'phone_password_mismatch',
|
|
19
|
+
'too_many_login_attempts',
|
|
20
|
+
'invalid_login',
|
|
21
|
+
'account_inactive',
|
|
22
|
+
'enter_current_password',
|
|
23
|
+
'password_too_short',
|
|
24
|
+
'password_too_common',
|
|
25
|
+
'password_entirely_numeric',
|
|
26
|
+
'password_too_similar',
|
|
27
|
+
'incorrect_code',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
describe('authTranslations — AUTH-1 password-rules keys', () => {
|
|
31
|
+
it.each(AUTH1_KEYS)('%s has de/fr/en entries', (key) => {
|
|
32
|
+
expect(authTranslations[key]).toBeDefined();
|
|
33
|
+
for (const locale of REQUIRED_LOCALES) {
|
|
34
|
+
expect(authTranslations[key][locale]).toBeTruthy();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('authTranslations — AUTH-2 raw backend error codes', () => {
|
|
40
|
+
it.each(AUTH2_CODE_KEYS)('%s has de/fr/en entries', (code) => {
|
|
41
|
+
expect(authTranslations[code]).toBeDefined();
|
|
42
|
+
for (const locale of REQUIRED_LOCALES) {
|
|
43
|
+
expect(authTranslations[code][locale]).toBeTruthy();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does not shadow the existing generic Auth.*_FAILED fallbacks', () => {
|
|
48
|
+
expect(authTranslations['Auth.PASSWORD_CHANGE_FAILED']).toBeDefined();
|
|
49
|
+
expect(authTranslations['Auth.LOGIN_FAILED']).toBeDefined();
|
|
50
|
+
expect(authTranslations['Auth.MFA_AUTHENTICATE_FAILED']).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# WORK ORDER AUTH-1 — Password forms: state the rules (proactive helperText + inline validator)
|
|
2
|
+
|
|
3
|
+
Tier 2 (touches `ui-core-micha` = shared-core). ucm-only, **zero per-app work** (consumers get it
|
|
4
|
+
on their next ucm pin-bump). Origin: Spesix field report 2026-08-06 — a first-time admin setting a
|
|
5
|
+
password from an invitation link could not tell what the password had to satisfy; the only way to
|
|
6
|
+
learn the rules was to submit and read the rejection.
|
|
7
|
+
|
|
8
|
+
## A. Envelope (authoritative WHAT / WHY)
|
|
9
|
+
|
|
10
|
+
### Goal
|
|
11
|
+
Tell the user the password rules **before** they submit, and give **instant frontend feedback** on
|
|
12
|
+
the two most common failures — so the invitation/first-contact screen no longer rejects people
|
|
13
|
+
without ever saying what it wants.
|
|
14
|
+
|
|
15
|
+
### Scope — exactly the two forms that take a NEW password
|
|
16
|
+
- `src/components/PasswordSetForm.jsx` (invitation set + reset-confirm).
|
|
17
|
+
- `src/components/PasswordChangeForm.jsx`.
|
|
18
|
+
- **NOT** `PasswordResetRequestForm.jsx` — verified it takes **email only** (`type="email"`,
|
|
19
|
+
`onSubmit(email)`), no password field. The Spesix report listed it by mistake.
|
|
20
|
+
|
|
21
|
+
### Deliverable 1 — proactive helperText (the rules, stated)
|
|
22
|
+
- Render `helperText` on the **new-password** `TextField` in both forms, default =
|
|
23
|
+
`t('Auth.PASSWORD_RULES_HINT')`.
|
|
24
|
+
- Add `Auth.PASSWORD_RULES_HINT` to `src/i18n/authTranslations.ts` in **de/fr/en**, stating the
|
|
25
|
+
baseline the template ships with. The baseline is authoritative from dcm
|
|
26
|
+
`settings_base.py:493-498` (the 4 Django defaults): **min 8 chars · not entirely numeric · not a
|
|
27
|
+
common password · not too similar to personal info.** Suggested de: „Mindestens 8 Zeichen, nicht
|
|
28
|
+
nur Zahlen, kein gängiges Passwort." (keep it concise; the similarity rule is niche — translator
|
|
29
|
+
finalises).
|
|
30
|
+
- Add an **optional prop** `passwordRulesHint` (string) to both forms — overrides the default so an
|
|
31
|
+
app that configures custom validators can pass matching text. This is the "Better" variant: cheap
|
|
32
|
+
effort, removes the only real objection (divergence from a customised backend).
|
|
33
|
+
|
|
34
|
+
### Deliverable 2 — inline validator (signal before the backend rejects)
|
|
35
|
+
Before calling `onSubmit(...)`, validate the two rules that are cheap and faithful to replicate,
|
|
36
|
+
reusing the existing local-error mechanism (`localErrorKey` — the forms already do this for
|
|
37
|
+
empty/mismatch):
|
|
38
|
+
- **min length 8** → `Auth.PASSWORD_TOO_SHORT_LOCAL`,
|
|
39
|
+
- **not entirely numeric** → `Auth.PASSWORD_NUMERIC_LOCAL`.
|
|
40
|
+
Add both keys (de/fr/en). **Deliberately NOT** replicating CommonPassword (needs the ~20k list in
|
|
41
|
+
the browser) or UserAttributeSimilarity (needs user attributes the set-from-invite form doesn't
|
|
42
|
+
have) — those stay backend-enforced; the helperText already warns about them, and AUTH-2 will
|
|
43
|
+
translate their backend rejection. This is a **best-effort UX layer, not a security boundary** —
|
|
44
|
+
the backend stays authoritative (frontend permissive, backend decides).
|
|
45
|
+
|
|
46
|
+
### Non-goals
|
|
47
|
+
- Don't change the backend or the validators. Don't touch `PasswordResetRequestForm`. Don't touch
|
|
48
|
+
the `onSubmit` contract or add API-error rendering (that's the deferred reactive error-prop, out
|
|
49
|
+
of scope). No new screen → **no prototype** (trivial addition per `frontend-design`).
|
|
50
|
+
|
|
51
|
+
### Tier / rollout
|
|
52
|
+
Tier 2 (shared-core). ucm has **no staging** → publish-from-main: land on `main` → **minor version
|
|
53
|
+
bump** (2.26.4 → 2.27.0) → consumers adopt on their own pin-bump. **Spesix is pinned at 2.5.0**
|
|
54
|
+
(far behind) — it won't receive this until it does a separate, larger ucm pin-bump; that's an
|
|
55
|
+
app-side decision, not part of this WO.
|
|
56
|
+
|
|
57
|
+
### Required tests
|
|
58
|
+
ucm component tests: helperText renders on both forms (and the `passwordRulesHint` prop overrides);
|
|
59
|
+
the inline validator blocks submit + shows the right message for a <8-char and an all-numeric
|
|
60
|
+
password; de/fr/en keys present for all 3 new strings.
|
|
61
|
+
|
|
62
|
+
## B. Implementation map (seed)
|
|
63
|
+
- `src/components/PasswordSetForm.jsx` (new-password `TextField` ~L48) + `PasswordChangeForm.jsx` —
|
|
64
|
+
add `helperText` + the pre-`onSubmit` length/numeric check via `localErrorKey`; add the
|
|
65
|
+
`passwordRulesHint` prop.
|
|
66
|
+
- `src/i18n/authTranslations.ts` — 3 new keys × de/fr/en: `PASSWORD_RULES_HINT`,
|
|
67
|
+
`PASSWORD_TOO_SHORT_LOCAL`, `PASSWORD_NUMERIC_LOCAL`.
|
|
68
|
+
- `package.json` — minor bump at finalize (Orchestrator, on green).
|
|
69
|
+
|
|
70
|
+
**Review:** Tier 2 → independent `reviewer` + **`ui_reviewer`** (frontend diff, i18n coverage).
|
|
71
|
+
**Directive:** implementer leaves the diff; Orchestrator reviews + bumps the version + commits on
|
|
72
|
+
green. Codex out until 2026-08-06 → direct Claude.
|
|
73
|
+
|
|
74
|
+
**Companion:** AUTH-2 (translate the backend auth error codes — the *reactive* half, broader).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# WORK ORDER AUTH-2 — Translate backend auth error codes (stop leaking raw codes to users)
|
|
2
|
+
|
|
3
|
+
Tier 2 (touches `ui-core-micha` = shared-core). ucm-only, **zero per-app work** for the core (the
|
|
4
|
+
components already call `t(err.code)` — they just lack the translations). Found alongside the
|
|
5
|
+
AUTH-1 password report: ucm surfaces raw backend error **codes**, not human text.
|
|
6
|
+
|
|
7
|
+
## A. Envelope (authoritative WHAT / WHY)
|
|
8
|
+
|
|
9
|
+
### Problem (verified)
|
|
10
|
+
`src/auth/apiClient.jsx:107-124` extracts `err.code` (a machine code) from the backend/allauth
|
|
11
|
+
response. Components then render it as an i18n key: `setErrorKey(err.code || 'Auth.X_FAILED')` →
|
|
12
|
+
`t(errorKey)` (e.g. `SecurityComponent.jsx:60/71`, `ProfileComponent.jsx:143`,
|
|
13
|
+
`MfaLoginComponent.jsx:53`), or `t(err?.code || 'Auth.X_FAILED', 'fallback')`. **The i18n
|
|
14
|
+
catalogue has no entries for these codes** (grep for `email_password_mismatch` / `password_too_*`
|
|
15
|
+
= empty), so i18next returns the key unchanged and the user sees the literal
|
|
16
|
+
**`email_password_mismatch`** (or a generic fallback that hides the specific reason). This affects
|
|
17
|
+
**all** rendering auth screens — login, change-password, MFA, profile — not just passwords.
|
|
18
|
+
|
|
19
|
+
### Goal
|
|
20
|
+
Render translated, human text for the known backend auth error codes, so the specific reason
|
|
21
|
+
(including the password rules AUTH-1's frontend validator can't replicate — common-password,
|
|
22
|
+
similarity) shows wherever a ucm component already renders an auth error. Because the components
|
|
23
|
+
already `t(err.code)`, this is achieved by **adding the translations** — no component change, no
|
|
24
|
+
app change.
|
|
25
|
+
|
|
26
|
+
### Deliverable
|
|
27
|
+
1. **Enumerate the backend auth error codes** actually emitted — from allauth-headless + Django
|
|
28
|
+
password-validator codes, confirmed against real responses (don't guess the full set). Known
|
|
29
|
+
starters: `email_password_mismatch`, `password_too_short`, `password_too_common`,
|
|
30
|
+
`password_entirely_numeric` (verify exact spelling), `password_too_similar`, and the current-
|
|
31
|
+
password / incorrect-password codes surfaced by the change-password flow.
|
|
32
|
+
2. Add i18n entries for each, **de/fr/en**, in `src/i18n/authTranslations.ts`, **keyed by the raw
|
|
33
|
+
code string** (so the existing `t(err.code)` resolves as a drop-in — no component edit).
|
|
34
|
+
3. Keep the generic `Auth.X_FAILED` fallback for unknown/unmapped codes (no regression).
|
|
35
|
+
|
|
36
|
+
### Design note — keying (decision to confirm in planning)
|
|
37
|
+
Raw-code keys (`"password_too_short": {...}`) are the **drop-in** fix matching the existing
|
|
38
|
+
`t(err.code)` calls — pure ucm, no backend change. Cleaner but two-repo alternative: have dcm's
|
|
39
|
+
exception handler set a namespaced `i18nKey` per error (apiClient already reads `data.i18nKey`,
|
|
40
|
+
`apiClient.jsx:107`) → ucm keys become `Auth.ERROR.*`. Prefer the drop-in unless the operator wants
|
|
41
|
+
the namespaced contract.
|
|
42
|
+
|
|
43
|
+
### Scope boundary (honest)
|
|
44
|
+
This fixes screens that **render** the error (login, change-password via `SecurityComponent`, MFA,
|
|
45
|
+
profile). It does **NOT** reach the invitation **`PasswordSetForm`** — that form **delegates**
|
|
46
|
+
`onSubmit` and renders no API error at all; its proactive coverage is AUTH-1 (helperText +
|
|
47
|
+
validator), and its full reactive display would need the deferred per-app error-prop.
|
|
48
|
+
|
|
49
|
+
### Non-goals
|
|
50
|
+
No component/contract change (translations only, unless the namespaced-`i18nKey` option is chosen →
|
|
51
|
+
then a small dcm touch). No new error surfaces. Don't remove the generic fallbacks.
|
|
52
|
+
|
|
53
|
+
### Tier / rollout
|
|
54
|
+
Tier 2 (shared-core). Publish-from-main → minor bump → consumers adopt on pin-bump. Zero per-app
|
|
55
|
+
for the core.
|
|
56
|
+
|
|
57
|
+
### Required tests
|
|
58
|
+
`t(<code>)` resolves to translated text for each mapped code (de/fr/en); an unmapped code still
|
|
59
|
+
falls back to the generic `Auth.*_FAILED`; a representative render path (e.g. `SecurityComponent`
|
|
60
|
+
change-password error) shows human text, not the raw code.
|
|
61
|
+
|
|
62
|
+
## B. Implementation map (seed)
|
|
63
|
+
- `src/i18n/authTranslations.ts` — add the enumerated code→text entries (de/fr/en).
|
|
64
|
+
- (verification only) `src/auth/apiClient.jsx:107-124` + the `setErrorKey(err.code…)` /
|
|
65
|
+
`t(err.code…)` call sites — confirm they resolve against the new entries; no edit expected.
|
|
66
|
+
- `package.json` — minor bump at finalize.
|
|
67
|
+
- (optional, if namespaced-`i18nKey` chosen) `django-core-micha` auth exception handler — set
|
|
68
|
+
`i18nKey` per error.
|
|
69
|
+
|
|
70
|
+
**Review:** Tier 2 → `reviewer` + **`ui_reviewer`** (i18n/translation coverage — the whole point).
|
|
71
|
+
**Directive:** implementer leaves the diff; Orchestrator reviews + bumps version + commits on
|
|
72
|
+
green. Codex out until 2026-08-06 → direct Claude.
|
|
73
|
+
|
|
74
|
+
**Companion:** AUTH-1 (the proactive helperText + inline validator — the other half).
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# WORK ORDER CHART-3 (ui-core-micha) — fix TimeSeriesChart rendering nothing on async data load
|
|
2
|
+
|
|
3
|
+
**EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is not
|
|
4
|
+
addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not shell
|
|
5
|
+
out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked **directly
|
|
6
|
+
via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
|
|
7
|
+
and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
|
|
8
|
+
file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
9
|
+
|
|
10
|
+
Bug-fix WO on the landed CHART-2. Found live in jg-ferien's "Aktivität" screen (ACT-3 consumer): the
|
|
11
|
+
toolbar (range picker + series checkboxes) renders correctly, but the chart body never appears —
|
|
12
|
+
`ChartFrame`'s empty-state `Alert` shows permanently, even once real data has loaded.
|
|
13
|
+
|
|
14
|
+
## TIER
|
|
15
|
+
Tier 2 — shared-core UI consumed by every app that adopts `TimeSeriesChart`.
|
|
16
|
+
Independent `reviewer` + `ui_reviewer` mandatory.
|
|
17
|
+
|
|
18
|
+
## WHY
|
|
19
|
+
|
|
20
|
+
`TimeSeriesChart` initializes `visibleKeys` via a **lazy `useState` initializer** that reads
|
|
21
|
+
`seriesConfig` (derived from the `data` prop) only once, at first mount:
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
const [visibleKeys, setVisibleKeys] = useState(
|
|
25
|
+
() => new Set(seriesConfig.map((series) => series.key)),
|
|
26
|
+
);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
jg-ferien's `ActivitySection.jsx` mounts `TimeSeriesChart` with `data = { xLabels: [], series: [] }`
|
|
30
|
+
(its own initial `useState`), then fetches asynchronously in a `useEffect` and updates `data` only
|
|
31
|
+
once the API call resolves — the standard "mount empty, fill later" pattern. Because the lazy
|
|
32
|
+
initializer never re-runs on prop updates, `visibleKeys` stays a permanently empty `Set` even after
|
|
33
|
+
real series arrive. Downstream:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
const visibleSeries = seriesConfig.filter((series) => visibleKeys.has(series.key)); // always []
|
|
37
|
+
const isDataEmpty = seriesConfig.length === 0 || visibleSeries.length === 0; // always true
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`isDataEmpty` is permanently `true`, so `ChartFrame` renders its empty-state `Alert` forever, no
|
|
41
|
+
matter how much data loads. The toolbar renders fine because the range picker and the series
|
|
42
|
+
checkboxes are driven directly off the live `seriesConfig`, not off `visibleKeys` — only the chart
|
|
43
|
+
body is gated on the stale set. This reproduces on every host that fetches asynchronously after
|
|
44
|
+
mount (the overwhelmingly common case), not just jg-ferien.
|
|
45
|
+
|
|
46
|
+
## SCOPE
|
|
47
|
+
|
|
48
|
+
**Fix `visibleKeys` to track newly-appeared series without discarding a user's manual toggle.**
|
|
49
|
+
|
|
50
|
+
Required behaviour:
|
|
51
|
+
1. When `data`/`seriesConfig` changes and introduces a series key not seen before, that key must
|
|
52
|
+
become visible by default (matches current first-mount behaviour, just not mount-only).
|
|
53
|
+
2. A series key the user has manually toggled off must **stay off** across a range change that
|
|
54
|
+
still includes that key (e.g. don't reset all toggles to "all visible" on every `data` update —
|
|
55
|
+
that would silently undo the user's own toggle, a second bug of the opposite kind).
|
|
56
|
+
3. A key that disappears from `seriesConfig` (e.g. a range change that drops a series) and later
|
|
57
|
+
reappears is treated as new for step 1 (simplest correct behaviour; do not attempt to remember
|
|
58
|
+
toggle state for absent keys unless it falls out naturally from the fix).
|
|
59
|
+
|
|
60
|
+
This is a "seen this key before, default it visible; otherwise respect the user's own toggle" sync,
|
|
61
|
+
not a full rewrite of the toggle model — `toggleSeries`, the checkbox rendering, and the empty-state
|
|
62
|
+
logic in SCOPE stay as they are; only the initialization/sync of `visibleKeys` changes.
|
|
63
|
+
|
|
64
|
+
## NON-GOALS / DO NOT TOUCH
|
|
65
|
+
- Do not change `ChartFrame`, `BarChart`, `LineChart`, or the palette.
|
|
66
|
+
- Do not change the range-picker or the range-to-granularity mapping (`RANGE_OPTIONS`).
|
|
67
|
+
- Do not add an `onSeriesToggle` callback or any other new prop/API surface — this is a bug fix
|
|
68
|
+
within the existing component contract, not a scope extension.
|
|
69
|
+
- Do not touch `chartsTranslations.ts` — this WO is unrelated to i18n (a separate, already-diagnosed
|
|
70
|
+
issue in a different repo is being fixed independently).
|
|
71
|
+
|
|
72
|
+
## RISKS
|
|
73
|
+
- The fix must not reintroduce the mount-time bug in a different form (e.g. resetting on every
|
|
74
|
+
render, or resetting on every `data` change including in-place mutations of the same series set —
|
|
75
|
+
that would silently discard the user's manual toggles on every range change, which is the
|
|
76
|
+
behaviour test 2 below exists to catch).
|
|
77
|
+
- Every host that has adopted `TimeSeriesChart` since CHART-2 shipped is affected by this fix
|
|
78
|
+
(currently only jg-ferien via ACT-3); this changes rendering behaviour for all of them, in the
|
|
79
|
+
correct direction (chart now actually renders).
|
|
80
|
+
|
|
81
|
+
## REQUIRED TESTS TO WRITE
|
|
82
|
+
Narrow and behavioural, extending `tests/TimeSeriesChart.test.jsx`. Do NOT run the full suite.
|
|
83
|
+
|
|
84
|
+
1. **Regression test for this exact bug**: mount `TimeSeriesChart` with empty `data`
|
|
85
|
+
(`{ xLabels: [], series: [] }`), then rerender with populated `data` (real `xLabels`/`series`) —
|
|
86
|
+
assert the chart body renders (not `ChartFrame`'s empty state), i.e. `isEmpty` becomes `false`
|
|
87
|
+
and `BarChart` receives the new series. This must fail against the current code and pass after
|
|
88
|
+
the fix — it is the non-vacuity check for this WO.
|
|
89
|
+
2. **Toggle persistence across a data update**: toggle one series off, then rerender with updated
|
|
90
|
+
`data` that still contains that series key (e.g. a value change, same keys) — assert that series
|
|
91
|
+
is still filtered out (the user's manual toggle was not reset by the data update).
|
|
92
|
+
3. **New series key appearing later defaults to visible**: mount with one series, rerender with a
|
|
93
|
+
second series key added — assert the new key is visible by default while any prior manual toggle
|
|
94
|
+
on the first key is preserved.
|
|
95
|
+
|
|
96
|
+
**Non-vacuity:** test 1 must fail against the current `visibleKeys` lazy-initializer code (verify
|
|
97
|
+
this before considering the fix complete) — it is reproducing the exact live bug from jg-ferien.
|
|
98
|
+
|
|
99
|
+
## CONTEXT PACKAGE — verified current state (Orchestrator, Implementation map)
|
|
100
|
+
|
|
101
|
+
Work from this package; do not explore broadly from scratch — open only the named files to verify.
|
|
102
|
+
|
|
103
|
+
**File to change: `src/components/charts/TimeSeriesChart.jsx`.**
|
|
104
|
+
|
|
105
|
+
Current state (verified, full relevant slice):
|
|
106
|
+
```js
|
|
107
|
+
const seriesConfig = data?.series || [];
|
|
108
|
+
|
|
109
|
+
const [range, setRange] = useState(defaultRange);
|
|
110
|
+
// Series identity (keys) is assumed stable for a given chart instance — the
|
|
111
|
+
// toggle set is initialized once from the first data the chart receives.
|
|
112
|
+
const [visibleKeys, setVisibleKeys] = useState(
|
|
113
|
+
() => new Set(seriesConfig.map((series) => series.key)),
|
|
114
|
+
);
|
|
115
|
+
```
|
|
116
|
+
(`TimeSeriesChart.jsx:52-59`) — the comment above `visibleKeys` states the now-disproven assumption
|
|
117
|
+
("series identity is stable for a given chart instance") that this WO corrects; update or remove it
|
|
118
|
+
to match the new behaviour.
|
|
119
|
+
|
|
120
|
+
`toggleSeries` (`:68-75`), `visibleSeries`/`visiblePalette`/`isDataEmpty` derivation (`:77-84`), and
|
|
121
|
+
the checkbox rendering (`:103-123`) are all downstream of `visibleKeys` and should not need changes
|
|
122
|
+
— only how `visibleKeys` is initialized/kept in sync with `seriesConfig` changes.
|
|
123
|
+
|
|
124
|
+
**Suggested shape (not prescriptive — implement whatever satisfies SCOPE correctly):** replace the
|
|
125
|
+
lazy `useState` with a `useEffect` keyed on the set of series keys in `seriesConfig`, that adds any
|
|
126
|
+
newly-seen key to `visibleKeys` (defaulting it visible) without touching keys already present in the
|
|
127
|
+
set — e.g. track previously-seen keys in a ref to detect "new" vs "toggled off", since a key
|
|
128
|
+
present in both old and new `seriesConfig` but absent from `visibleKeys` must stay absent (it was a
|
|
129
|
+
deliberate user toggle-off, not a fresh key). Keep `visibleKeys`'s existing `Set`-of-keys shape,
|
|
130
|
+
`toggleSeries`'s update pattern, and the checkbox/empty-state code untouched.
|
|
131
|
+
|
|
132
|
+
**Test file to extend: `tests/TimeSeriesChart.test.jsx`** — matches `tests/ChartFrame.test.jsx`/
|
|
133
|
+
`tests/BarChart.test.jsx` conventions: `// @vitest-environment jsdom` header, vitest +
|
|
134
|
+
`@testing-library/react`, `afterEach(cleanup)`, a minimal per-file `i18next.createInstance()` +
|
|
135
|
+
`I18nextProvider`, wrapped in `ThemeProvider theme={createTheme()}`. Use `rerender()` from
|
|
136
|
+
`@testing-library/react`'s `render()` return value to simulate the host updating `data` after mount
|
|
137
|
+
(this is the exact scenario CHART-2's own tests never covered — they only mounted once with
|
|
138
|
+
synchronous populated data).
|
|
139
|
+
|
|
140
|
+
## TARGET REPO
|
|
141
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main`. Publish per the repo's release
|
|
142
|
+
flow (patch bump — bug fix, no interface change); consumer pin bumps are separate, out of scope.
|
|
143
|
+
|
|
144
|
+
## TEST SCOPE FOR THE GATE (orchestrator)
|
|
145
|
+
`tests/TimeSeriesChart.test.jsx` only. Not the full suite.
|
|
146
|
+
|
|
147
|
+
## MINI-HANDOVER (pastable)
|
|
148
|
+
|
|
149
|
+
> Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`).
|
|
150
|
+
> Work order: `work-orders/CHART-3.md` — read it fully, then follow the `orchestrate-codex` skill.
|
|
151
|
+
>
|
|
152
|
+
> Bug fix on CHART-2's `TimeSeriesChart`: `visibleKeys` is initialized once via a lazy `useState`
|
|
153
|
+
> from whatever `data` is at first mount, so any host that fetches asynchronously after mount (the
|
|
154
|
+
> normal case) ends up with a permanently-empty `visibleKeys` and the chart body never renders —
|
|
155
|
+
> `ChartFrame`'s empty state shows forever even once real data arrives. Fix the sync so newly-seen
|
|
156
|
+
> series keys default to visible while a user's manual toggle-off on an already-seen key survives a
|
|
157
|
+
> data update. Regression test 1 is the non-vacuity check — verify it fails against current code
|
|
158
|
+
> first.
|
|
159
|
+
|
|
160
|
+
## PROGRESS CONTRACT
|
|
161
|
+
Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
|
|
162
|
+
relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
|
|
163
|
+
stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
|