@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.
- package/DESIGN.md +1 -1
- package/README.md +2 -0
- package/WORK_ORDERS.md +7 -0
- 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 +196 -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/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 +197 -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/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/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/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
|
@@ -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`).)*
|