@micha.bigler/ui-core-micha 3.6.0 → 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.
Files changed (47) hide show
  1. package/DESIGN.md +1 -1
  2. package/README.md +2 -0
  3. package/WORK_ORDERS.md +7 -0
  4. package/dist/auth/UserMenu.js +23 -1
  5. package/dist/components/EmptyState.js +17 -0
  6. package/dist/components/UserListComponent.js +2 -1
  7. package/dist/i18n/authTranslations.js +196 -0
  8. package/dist/i18n/uiCoreTranslations.js +19 -0
  9. package/dist/index.js +2 -1
  10. package/dist/messaging/Composer.js +4 -2
  11. package/dist/messaging/MessageBubble.js +2 -1
  12. package/dist/messaging/ReadTicks.js +5 -3
  13. package/dist/messaging/linkifyText.js +39 -0
  14. package/dist/pages/PasswordInvitePage.js +23 -8
  15. package/dist/pages/SignUpPage.js +4 -4
  16. package/dist/theme/kitSxRegistry.js +5 -1
  17. package/package.json +1 -1
  18. package/src/auth/UserMenu.jsx +26 -1
  19. package/src/components/EmptyState.jsx +38 -0
  20. package/src/components/UserListComponent.jsx +2 -0
  21. package/src/i18n/authTranslations.ts +197 -0
  22. package/src/i18n/uiCoreTranslations.js +10 -0
  23. package/src/index.js +2 -1
  24. package/src/messaging/Composer.jsx +7 -3
  25. package/src/messaging/MessageBubble.jsx +2 -1
  26. package/src/messaging/ReadTicks.jsx +4 -3
  27. package/src/messaging/linkifyText.jsx +37 -0
  28. package/src/pages/PasswordInvitePage.jsx +36 -7
  29. package/src/pages/SignUpPage.jsx +79 -68
  30. package/src/theme/kitSxRegistry.js +9 -1
  31. package/tests/EmptyState.test.jsx +62 -0
  32. package/tests/PasswordInvitePage.test.jsx +135 -0
  33. package/tests/SignUpPage.test.jsx +15 -2
  34. package/tests/UserListComponent.test.jsx +85 -0
  35. package/tests/UserMenu.test.jsx +40 -4
  36. package/tests/i18nGermanVariant.test.js +65 -0
  37. package/tests/messagingComposer.test.jsx +25 -0
  38. package/tests/messagingLinkify.test.jsx +51 -0
  39. package/tests/messagingReadTicksTooltip.test.jsx +36 -0
  40. package/tests/userScreenTranslations.test.jsx +165 -0
  41. package/work-orders/UCM-AUTH-9.md +207 -0
  42. package/work-orders/UCM-I18N-4.md +226 -0
  43. package/work-orders/UCM-MSG-20.md +192 -0
  44. package/work-orders/UCM-PRIM-2.md +114 -0
  45. package/work-orders/UCM-SHELL-7.md +134 -0
  46. package/work-orders/UCM-THEME-13.md +69 -0
  47. package/work-orders/UCM-THEME-14.md +140 -0
@@ -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,207 @@
1
+ # UCM-AUTH-9 — Auth flows that dead-end: a rejected password removes the invite form, a closed signup looks open
2
+
3
+ # A. Envelope — authored by the Expertenchat
4
+
5
+ ## Goal & expected outcome
6
+
7
+ - Goal: every auth page leaves the person a way forward and says why something failed.
8
+ - Expected outcome:
9
+ - Invite/reset page: a server-side rejection of the password keeps the form, shows the reason
10
+ (e.g. "too common") at the field, and lets the person retry. After success the person sees a
11
+ confirmation, not a silent jump to the login page.
12
+ - Signup page: when self-registration is off (`signup: false`, `signup_modes: []`), the page says
13
+ so instead of rendering a form whose submit can never enable.
14
+ - User list: a role change confirms that it was saved, or shows why not.
15
+
16
+ ## Context — measured 2026-09-29 in jg-ferien's local browser test (ucm 3.5.0 installed there)
17
+
18
+ - `src/pages/PasswordInvitePage.jsx`: the form renders only when `!successKey && !errorKey`
19
+ (~l.142); any error — including the server's `Auth.RESET_PASSWORD_INVALID` with
20
+ `messages: ["This password is too common."]` — removes both password fields and the button;
21
+ only a reload recovers. The reason is not shown. Client-side validation keeps the form.
22
+ - After a successful set the page navigates to login (~l.102) with no confirmation.
23
+ - `src/pages/SignUpPage.jsx`: submit is `disabled: submitting || signupModes.length === 0 ||
24
+ (turnstileRequired && !turnstileToken)`; with no signup modes the page still shows e-mail and
25
+ access-code fields and a permanently grey button, no explanation.
26
+ - `src/components/UserListComponent.jsx`: role selects save without any feedback.
27
+
28
+ ## Scope + non-goals
29
+
30
+ - In scope: the three behaviours above.
31
+ - Explicit non-goals / do-not-touch:
32
+ - Password rules and the backend responses (dcm) stay as they are.
33
+ - Strings and the formal/informal choice are `UCM-I18N-4`.
34
+
35
+ ## Tier · gates
36
+
37
+ - **Tier 3** — shared-core auth screens.
38
+ - Reviewer: `reviewer` (all lenses) · `sec_reviewer` (auth flow) · `ui_reviewer`.
39
+ - **Done means released and jg-ferien's pin bumped.**
40
+
41
+ ## Risks
42
+
43
+ - An invalid/expired token must still end in the "link invalid" state without a form — only a
44
+ password rejection keeps the form. The two error classes must be told apart by code, not by text.
45
+
46
+ ## Required tests to WRITE (you write them and run YOUR OWN new ones; the orchestrator's run is the gate)
47
+
48
+ 1. Server rejects the password → form still present, reason shown, retry possible.
49
+ 2. Invalid token → invalid-link state, no form.
50
+ 3. Success → confirmation visible.
51
+ 4. Signup with no modes → explanatory message, no form.
52
+ 5. Role change → success or error feedback.
53
+
54
+ ---
55
+
56
+ # B. Implementation map — filled by the Orchestrator — ADDRESSED TO THE IMPLEMENTER
57
+
58
+ ## Context package — three independent fixes
59
+
60
+ ### 1. `src/pages/PasswordInvitePage.jsx` — the dead-end form + silent navigate
61
+
62
+ - **Root cause of both bugs: ONE shared `errorKey` state variable covers two structurally
63
+ different failures**, and the render condition `{!successKey && !errorKey && <PasswordSetForm
64
+ .../>}` (line 142) hides the form for EITHER. The link-validity check (`verifyResetToken`, the
65
+ `useEffect` at line 36) and the password submission (`setNewPassword`, `handleSubmit` at line 56)
66
+ are two separate API calls that already fail independently — the fix is to stop collapsing their
67
+ results into one state variable, not to inspect error codes at render time.
68
+ - **Fix — split the state:**
69
+ 1. Keep `errorKey` (rename mentally to "link check result") for the `useEffect` at line 36–54
70
+ only — this is the existing "invalid/expired link" path and must keep hiding the form (the
71
+ Risk note: "an invalid/expired token must still end in the 'link invalid' state without a
72
+ form"). No change needed to this effect's logic.
73
+ 2. Add a new `submitErrorKey` state, set ONLY inside `handleSubmit`'s `catch` block (line
74
+ 103–105) instead of reusing `errorKey`. Read the human-readable reason from the response body
75
+ the backend actually sends: `normaliseApiError` (see `src/utils/auth-errors.js`) puts the
76
+ whole response body on `err.raw`; a password-policy rejection carries
77
+ `err.raw?.messages` (an array of strings, e.g. `["This password is too common."]"`) alongside
78
+ `err.code`. Prefer joining `err.raw.messages` when it's a non-empty array; otherwise fall back
79
+ to `t(err.code || 'Auth.RESET_PASSWORD_FAILED')`, same as today.
80
+ 3. Change the render condition so the form shows whenever the LINK itself is valid, regardless
81
+ of `submitErrorKey`: `{!errorKey && !successKey && <PasswordSetForm .../>}` (drop the old
82
+ shared `errorKey` check that also hid it on submit errors — `errorKey` from here on only ever
83
+ means "link invalid", never "password rejected"). Display `submitErrorKey` in its own
84
+ `<Typography color="error">` above or below the form, distinct from the existing `errorKey`
85
+ alert.
86
+ 4. Pass `submitErrorKey` down or keep it page-local and render it directly — `PasswordSetForm`
87
+ itself does not need to change (it already renders its OWN `localErrorKey` for client-side
88
+ validation; the server-rejection message is a page-level concern rendered alongside the form,
89
+ not inside `PasswordSetForm`).
90
+ - **Fix — success confirmation instead of a silent jump:** in `handleSubmit`'s try block (line
91
+ 66–102), the code currently computes `target` and calls `navigate(target)` immediately after
92
+ `setSuccessKey(...)` — the navigation happens before the success message can ever be seen. Stop
93
+ calling `navigate` automatically. Instead:
94
+ 1. Keep the existing `successKey` set exactly as today (line 68–72) and the existing
95
+ `safeNextPath`/`target` computation (line 73–101) — that same-origin validation logic is
96
+ correct and must not change.
97
+ 2. Store the computed `target` in a new state variable (e.g. `continueTarget`) instead of
98
+ navigating with it immediately.
99
+ 3. Where `{successKey && <Typography color="primary" ...>}` already renders (line 136–140), add
100
+ a `<Button variant="contained" onClick={() => navigate(continueTarget)}>` reading
101
+ `t('Auth.SIGNUP_GO_TO_LOGIN')` (existing key, reused — this button always leads to the login
102
+ page, same destination the automatic navigate used to go to) right below the confirmation
103
+ text. The person now sees the confirmation and clicks through themselves.
104
+ - Test file: extend whatever test already covers `PasswordInvitePage` (grep `tests/` for
105
+ `PasswordInvitePage` — if none exists, create `tests/PasswordInvitePage.test.jsx` following
106
+ `tests/AccountPage.test.jsx`'s page-level render pattern: mock `../src/auth/authApi`'s
107
+ `verifyResetToken`/`setNewPassword` with `vi.mock`, wrap in a router since the page uses
108
+ `useNavigate`/`useParams`/`useLocation`).
109
+ 1. `verifyResetToken` resolves (valid link), `setNewPassword` rejects with
110
+ `{ response: { data: { code: 'Auth.RESET_PASSWORD_INVALID', messages: ['This password is too
111
+ common.'] } } }` (the shape `normaliseApiError` reads) → after submit, the `PasswordSetForm`
112
+ (its password fields) is still present, "This password is too common." is visible, and the
113
+ user can submit again.
114
+ 2. `verifyResetToken` rejects → the invalid-link message is shown and `PasswordSetForm` is
115
+ ABSENT (`screen.queryByLabelText`/`queryByRole` for a password field returns null).
116
+ 3. `verifyResetToken` resolves, `setNewPassword` resolves → after submit, the success message is
117
+ visible AND a "go to login" button is visible (and the test can assert `navigate` was NOT
118
+ called automatically — only after clicking the button, if you choose to test the click too).
119
+
120
+ ### 2. `src/pages/SignUpPage.jsx` — signup closed but form still renders
121
+
122
+ - `signupModes` (line 51–59) is already computed as an empty array when self-signup is off. The
123
+ page currently still renders the mode-picker (only if `signupModes.length > 1`, so with zero
124
+ modes that block is already skipped) AND the full email/access-code form regardless, with the
125
+ submit button just permanently disabled (line 234: `disabled={submitting || signupModes.length
126
+ === 0 || ...}`) and no explanation anywhere.
127
+ - **Fix:** immediately after the `NarrowPage`'s `<Helmet>` block (after line 165), add:
128
+ `{signupModes.length === 0 ? (<Alert severity="info" sx={SIGN_UP_PAGE_ALERT_SX}>{t('Auth.SIGNUP_CLOSED', 'Sign-up is currently closed for this project.')}</Alert>) : (<>...the existing success/error
129
+ alerts + mode-picker + form + "already have an account" block, unchanged...</>)}` — i.e. wrap
130
+ everything from the existing `successKey` alert (line 166) through the "already have an account"
131
+ block (line 254) in this conditional, so a zero-modes project shows ONLY the explanatory message,
132
+ no form at all. New translation key `Auth.SIGNUP_CLOSED` needs an entry in `authTranslations.ts`
133
+ (all locales the file already carries: de/fr/en/sw) — do not leave it English-only via the
134
+ `t(key, fallback)` default-only pattern, add the real bundle entry.
135
+ - Test file: extend whatever exists for `SignUpPage` (grep `tests/` for `SignUpPage`).
136
+ 1. `authMethods.signup_modes = []` and `authMethods.signup = false` → the explanatory message is
137
+ shown, no email/access-code `TextField` and no submit button are present.
138
+ 2. Existing signup-open behaviour (at least one mode) is unchanged — re-run/extend whatever
139
+ assertions already cover the working case so this fix can't be a silent regression.
140
+
141
+ ### 3. `src/components/UserListComponent.jsx` — role change has no feedback
142
+
143
+ - `handleChangeRole` (line 106–124) already has a `catch` that shows `alert(...)` on failure — that
144
+ half already satisfies "or shows why not". The missing half is SUCCESS feedback: today a
145
+ successful role change just silently re-fetches (`await loadUsers()`, line 120) with nothing
146
+ telling the person it worked.
147
+ - **Fix:** add a lightweight success signal after the `await loadUsers()` call inside the `try`
148
+ block (line 106–120) — the simplest, lowest-risk option consistent with this component's existing
149
+ style (it already uses the browser's own `alert()`/`window.confirm()` for feedback elsewhere in
150
+ this same file, e.g. line 93, 102, 122 — match that existing convention rather than introducing a
151
+ new Snackbar/Toast pattern this component doesn't otherwise use):
152
+ `alert(t('UserList.ROLE_UPDATE_SUCCESS', 'Role updated.'));` right after `await loadUsers();`.
153
+ New key `UserList.ROLE_UPDATE_SUCCESS` needs a real bundle entry (all locales) in
154
+ `authTranslations.ts` — this key is new to `I18N-4`'s hardcoded-English-string list too; if
155
+ `I18N-4` lands first in the register it may already exist, check before adding a duplicate.
156
+ - Test file: extend the existing `UserListComponent` test (grep `tests/` for
157
+ `UserListComponent`) — assert that a successful `onChangeRole`/`updateUserRole` call triggers the
158
+ new success signal (mock `window.alert` with `vi.spyOn(window, 'alert')` and assert it was called
159
+ with the translated success text), and that the existing failure path is unaffected (still calls
160
+ `alert` with the error path, exactly as today — do not weaken that assertion).
161
+
162
+ ## Do-not-touch / invariants
163
+
164
+ - Password rules and the backend responses (dcm) stay as they are — this WO only changes how the
165
+ frontend reads/displays what the backend already returns.
166
+ - Strings and the formal/informal address choice belong to `UCM-I18N-4`, not here — new keys added
167
+ here (`Auth.SIGNUP_CLOSED`, `UserList.ROLE_UPDATE_SUCCESS`) get plain, correct de/fr/en/sw values,
168
+ not the informal-German variant mechanism (that mechanism is `I18N-4`'s own scope).
169
+ - Do not touch `SignupConfirmPage.jsx` — out of scope (not named in the Envelope's Context), even
170
+ though it has a structurally similar immediate-navigate-after-success pattern; note it in the
171
+ register Notiz as a possible follow-up, do not fix it here.
172
+
173
+ ## Target repo working directory (absolute)
174
+
175
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`)
176
+
177
+ ## Preamble — a REQUIRED block IN this file, not something appended at invocation
178
+
179
+ > The text above is the COMPLETE spec — the committed WO file's content, not a plan to refine; there
180
+ > is no separate plan file. Read the nearest `AGENTS.md`, the relevant `.codex/skills/<role>/SKILL.md`, and the
181
+ > app `MEMORY.md` ONLY for conventions. Stay in scope; do not touch auth/permissions/deps/schema/CI
182
+ > unless the spec says so; do not update `MEMORY.md`. **Do NOT edit `WORK_ORDERS.md` — the register
183
+ > row and the review verdicts are the orchestrator's alone.** **Your tools are for editing source
184
+ > and test files and for running the tests you wrote — nothing else.** Do NOT install dependencies,
185
+ > touch a lockfile, run a package manager, or tidy up stray files; if something in the repo state
186
+ > blocks you, stop and report it as `RESULT: BLOCKED <reason>` instead of fixing it. Do NOT
187
+ > `git add`/`commit`/`push` — leave every
188
+ > change uncommitted in the working tree for the orchestrator's independent review. WRITE the tests
189
+ > the `Required tests` section calls for AND **RUN the tests you just wrote** to confirm they execute
190
+ > and pass — that is the ONLY test run you do (NOT the app's affected/full suite, NOT any review).
191
+ > The orchestrator re-runs the authoritative set + does the independent review after you finish —
192
+ > those are the gate; your own run does not count as the gate.
193
+ >
194
+ > Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
195
+ > `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (and `… done` on
196
+ > completion), spaced so no gap exceeds ~2 min, stdout unbuffered, plus exactly one final
197
+ > `RESULT: DONE|BLOCKED <reason>`.
198
+
199
+ ---
200
+
201
+ # C. Orchestrator only — NOT ADDRESSED TO THE IMPLEMENTER
202
+
203
+ > **If you are the implementer reading this work order as your own specification: STOP at this line.
204
+ > Everything below describes what the Orchestrator does AFTER you finish. You do none of it — no
205
+ > reviewers, no verification run, no register edit, no commit.**
206
+
207
+ *(to be filled by the Orchestrator: execution directive; `reviewer` (all lenses) + `sec_reviewer` + `ui_reviewer`; release; jg-ferien pin bump; register rows here and in jg (`JG-UX-19`).)*