@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
@@ -2,7 +2,7 @@
2
2
  import React, { useState, useEffect } from 'react';
3
3
  import { useNavigate, useParams, useLocation } from 'react-router-dom';
4
4
  import { Helmet } from 'react-helmet';
5
- import { Typography } from '@mui/material';
5
+ import { Button, Typography } from '@mui/material';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import { NarrowPage } from '../layout/PageLayout';
8
8
  import { PasswordSetForm } from '../components/PasswordSetForm';
@@ -18,7 +18,9 @@ export function PasswordInvitePage() {
18
18
 
19
19
  const [submitting, setSubmitting] = useState(false);
20
20
  const [errorKey, setErrorKey] = useState(null);
21
+ const [submitErrorKey, setSubmitErrorKey] = useState(null);
21
22
  const [successKey, setSuccessKey] = useState(null);
23
+ const [continueTarget, setContinueTarget] = useState(null);
22
24
  const [checked, setChecked] = useState(false);
23
25
 
24
26
  // Unterscheidung Invite-Link vs klassischer Reset
@@ -55,12 +57,11 @@ export function PasswordInvitePage() {
55
57
 
56
58
  const handleSubmit = async (newPassword) => {
57
59
  if (!uid || !token) {
58
- setErrorKey('Auth.RESET_LINK_INVALID');
59
60
  return;
60
61
  }
61
62
 
62
63
  setSubmitting(true);
63
- setErrorKey(null);
64
+ setSubmitErrorKey(null);
64
65
  setSuccessKey(null);
65
66
 
66
67
  try {
@@ -99,9 +100,23 @@ export function PasswordInvitePage() {
99
100
  const target = safeNextPath
100
101
  ? `/login?next=${encodeURIComponent(safeNextPath)}`
101
102
  : '/login';
102
- navigate(target);
103
+ setContinueTarget(target);
103
104
  } catch (err) {
104
- setErrorKey(err.code || 'Auth.RESET_PASSWORD_FAILED');
105
+ // Told apart by CODE, not by the presence of `messages` (a submission
106
+ // can fail with a link problem too, e.g. the token expires in the gap
107
+ // between the page's initial verifyResetToken check and this submit) —
108
+ // only a password rejection keeps the form; a link problem must still
109
+ // end in the same invalid-link state the initial check produces.
110
+ if (err.code === 'Auth.RESET_LINK_INVALID') {
111
+ setErrorKey(err.code);
112
+ } else {
113
+ const responseMessages = err.raw?.messages || err.response?.data?.messages;
114
+ setSubmitErrorKey(
115
+ Array.isArray(responseMessages) && responseMessages.length > 0
116
+ ? responseMessages.join(' ')
117
+ : t(err.code || 'Auth.RESET_PASSWORD_FAILED'),
118
+ );
119
+ }
105
120
  } finally {
106
121
  setSubmitting(false);
107
122
  }
@@ -134,8 +149,22 @@ export function PasswordInvitePage() {
134
149
  )}
135
150
 
136
151
  {successKey && (
137
- <Typography color="primary" gutterBottom>
138
- {t(successKey)}
152
+ <>
153
+ <Typography color="primary" gutterBottom>
154
+ {t(successKey)}
155
+ </Typography>
156
+ <Button
157
+ variant="contained"
158
+ onClick={() => navigate(continueTarget)}
159
+ >
160
+ {t('Auth.SIGNUP_GO_TO_LOGIN')}
161
+ </Button>
162
+ </>
163
+ )}
164
+
165
+ {submitErrorKey && !successKey && (
166
+ <Typography color="error" gutterBottom>
167
+ {submitErrorKey}
139
168
  </Typography>
140
169
  )}
141
170
 
@@ -163,82 +163,93 @@ export function SignUpPage() {
163
163
  </title>
164
164
  </Helmet>
165
165
 
166
- {successKey && (
167
- <Alert severity="success" sx={SIGN_UP_PAGE_ALERT_SX}>
168
- {t(successKey, { email })}
166
+ {signupModes.length === 0 ? (
167
+ <Alert severity="info" sx={SIGN_UP_PAGE_ALERT_SX}>
168
+ {t('Auth.SELF_SIGNUP_DISABLED', 'Self sign-up is currently disabled.')}
169
169
  </Alert>
170
- )}
170
+ ) : (
171
+ <>
172
+ {successKey && (
173
+ <Alert severity="success" sx={SIGN_UP_PAGE_ALERT_SX}>
174
+ {t(successKey, { email })}
175
+ </Alert>
176
+ )}
171
177
 
172
- {errorKey && (
173
- <Alert severity="error" sx={SIGN_UP_PAGE_ALERT_SX}>
174
- {t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.'))}
175
- </Alert>
176
- )}
178
+ {errorKey && (
179
+ <Alert severity="error" sx={SIGN_UP_PAGE_ALERT_SX}>
180
+ {t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.'))}
181
+ </Alert>
182
+ )}
177
183
 
178
- {signupModes.length > 1 && (
179
- <Stack spacing={1} sx={{ mb: 2 }}>
180
- <Stack spacing={1}>
181
- {signupModes.map((entry) => (
182
- <Button
183
- key={entry}
184
- variant={mode === entry ? 'contained' : 'outlined'}
185
- onClick={() => setMode(entry)}
186
- disabled={submitting}
187
- fullWidth
188
- >
189
- {t(MODE_LABELS[entry] || entry, entry)}
190
- </Button>
191
- ))}
192
- </Stack>
193
- </Stack>
194
- )}
184
+ {signupModes.length > 1 && (
185
+ <Stack spacing={1} sx={{ mb: 2 }}>
186
+ <Stack spacing={1}>
187
+ {signupModes.map((entry) => (
188
+ <Button
189
+ key={entry}
190
+ variant={mode === entry ? 'contained' : 'outlined'}
191
+ onClick={() => setMode(entry)}
192
+ disabled={submitting}
193
+ fullWidth
194
+ >
195
+ {t(MODE_LABELS[entry] || entry, entry)}
196
+ </Button>
197
+ ))}
198
+ </Stack>
199
+ </Stack>
200
+ )}
195
201
 
196
- <Box
197
- component="form"
198
- onSubmit={handleSubmit}
199
- sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
200
- >
201
- <TextField
202
- label={t('Auth.EMAIL_LABEL')}
203
- type="email"
204
- required
205
- fullWidth
206
- value={email}
207
- onChange={(e) => setEmail(e.target.value)}
208
- disabled={submitting}
209
- />
202
+ <Box
203
+ component="form"
204
+ onSubmit={handleSubmit}
205
+ sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
206
+ >
207
+ <TextField
208
+ label={t('Auth.EMAIL_LABEL')}
209
+ type="email"
210
+ required
211
+ fullWidth
212
+ value={email}
213
+ onChange={(e) => setEmail(e.target.value)}
214
+ disabled={submitting}
215
+ />
210
216
 
211
- {mode === 'self_signup_access_code' && (
212
- <TextField
213
- label={t('Auth.ACCESS_CODE_LABEL')}
214
- type="text"
215
- required
216
- fullWidth
217
- value={accessCode}
218
- onChange={(e) => setAccessCode(e.target.value)}
219
- disabled={submitting}
220
- />
221
- )}
217
+ {mode === 'self_signup_access_code' && (
218
+ <TextField
219
+ label={t('Auth.ACCESS_CODE_LABEL')}
220
+ type="text"
221
+ required
222
+ fullWidth
223
+ value={accessCode}
224
+ onChange={(e) => setAccessCode(e.target.value)}
225
+ disabled={submitting}
226
+ />
227
+ )}
222
228
 
223
- {turnstileRequired && (
224
- <TurnstileWidget
225
- ref={turnstileWidgetRef}
226
- siteKey={authMethods.turnstile_site_key}
227
- onToken={setTurnstileToken}
228
- />
229
- )}
229
+ {turnstileRequired && (
230
+ <TurnstileWidget
231
+ ref={turnstileWidgetRef}
232
+ siteKey={authMethods.turnstile_site_key}
233
+ onToken={setTurnstileToken}
234
+ />
235
+ )}
230
236
 
231
- <Button
232
- type="submit"
233
- variant="contained"
234
- disabled={submitting || signupModes.length === 0 || (turnstileRequired && !turnstileToken)}
235
- >
236
- {submitting
237
- ? t('Auth.SIGNUP_SUBMITTING')
238
- : t('Auth.SIGNUP_SUBMIT')}
239
- </Button>
240
- </Box>
237
+ <Button
238
+ type="submit"
239
+ variant="contained"
240
+ disabled={submitting || (turnstileRequired && !turnstileToken)}
241
+ >
242
+ {submitting
243
+ ? t('Auth.SIGNUP_SUBMITTING')
244
+ : t('Auth.SIGNUP_SUBMIT')}
245
+ </Button>
246
+ </Box>
247
+ </>
248
+ )}
241
249
 
250
+ {/* Always rendered, closed or open — a closed signup page must still
251
+ leave the person a way forward (the Envelope's own Goal), not just
252
+ say why the form is missing. */}
242
253
  <Box sx={{ mt: 3 }}>
243
254
  <Typography variant="body2">
244
255
  {t('Auth.SIGNUP_ALREADY_HAVE_ACCOUNT')}{' '}
@@ -69,7 +69,11 @@ import {
69
69
  ATTACHMENT_LIST_ALERT_SX,
70
70
  ATTACHMENT_LIST_LIGHTBOX_ACTION_SX,
71
71
  } from '../messaging/AttachmentList';
72
- import { COMPOSER_EMOJI_BUTTON_SX } from '../messaging/Composer';
72
+ import {
73
+ COMPOSER_EMOJI_BUTTON_SX,
74
+ COMPOSER_MESSAGE_FIELD_SX,
75
+ COMPOSER_SEND_BUTTON_SX,
76
+ } from '../messaging/Composer';
73
77
  import { DIRECT_MESSAGE_LAUNCHER_ALERT_SX } from '../messaging/DirectMessageLauncher';
74
78
  import { MESSAGE_BUBBLE_ACTION_SX, MESSAGE_BUBBLE_ROOT_SX } from '../messaging/MessageBubble';
75
79
  import {
@@ -112,6 +116,8 @@ const SX_EXPORTS = {
112
116
  CHART_FRAME_ALERT_SX,
113
117
  CHART_FRAME_ROOT_SX,
114
118
  COMPOSER_EMOJI_BUTTON_SX,
119
+ COMPOSER_MESSAGE_FIELD_SX,
120
+ COMPOSER_SEND_BUTTON_SX,
115
121
  DIRECT_MESSAGE_LAUNCHER_ALERT_SX,
116
122
  LOGIN_FORM_DIVIDER_SX,
117
123
  LOGIN_PAGE_ALERT_SX,
@@ -243,6 +249,8 @@ export const KIT_COMPONENT_SX_REGISTRY = [
243
249
  entry('AttachmentList.lightboxAction', 'MuiIconButton', ATTACHMENT_LIST_LIGHTBOX_ACTION_SX),
244
250
  entry('AttachmentList.alert', 'MuiAlert', ATTACHMENT_LIST_ALERT_SX),
245
251
  entry('Composer.emojiAction', 'MuiIconButton', COMPOSER_EMOJI_BUTTON_SX),
252
+ entry('Composer.messageField', 'MuiTextField', COMPOSER_MESSAGE_FIELD_SX),
253
+ entry('Composer.sendAction', 'MuiButton', COMPOSER_SEND_BUTTON_SX),
246
254
  entry('DirectMessageLauncher.alert', 'MuiAlert', DIRECT_MESSAGE_LAUNCHER_ALERT_SX),
247
255
  entry('MessageBubble.root', 'MuiPaper', MESSAGE_BUBBLE_ROOT_SX),
248
256
  entry('MessageBubble.action', 'MuiIconButton', MESSAGE_BUBBLE_ACTION_SX),
@@ -0,0 +1,62 @@
1
+ // @vitest-environment jsdom
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
6
+ import { ThemeProvider } from '@mui/material/styles';
7
+
8
+ import EmptyState from '../src/components/EmptyState';
9
+ import { createAppTheme, reportOffPaletteColours } from '../src/theme';
10
+
11
+ const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
12
+
13
+ function renderEmptyState(props) {
14
+ return render(<ThemeProvider theme={theme}><EmptyState {...props} /></ThemeProvider>);
15
+ }
16
+
17
+ afterEach(() => cleanup());
18
+
19
+ describe('EmptyState', () => {
20
+ it('renders its title as text content, driven by the prop', () => {
21
+ renderEmptyState({ title: 'No events yet' });
22
+
23
+ expect(screen.getByText('No events yet')).toBeTruthy();
24
+ cleanup();
25
+
26
+ renderEmptyState({ title: 'No songs yet' });
27
+
28
+ expect(screen.getByText('No songs yet')).toBeTruthy();
29
+ expect(screen.queryByText('No events yet')).toBeNull();
30
+ });
31
+
32
+ it('renders an optional description, driven by the prop', () => {
33
+ renderEmptyState({ title: 'No events yet', description: 'Create an event to see it here.' });
34
+
35
+ expect(screen.getByText('Create an event to see it here.')).toBeTruthy();
36
+ cleanup();
37
+
38
+ renderEmptyState({ title: 'No events yet' });
39
+
40
+ expect(screen.queryByText('Create an event to see it here.')).toBeNull();
41
+ });
42
+
43
+ it('renders an accessible real button and calls its action', () => {
44
+ const onClick = vi.fn();
45
+ renderEmptyState({
46
+ title: 'No events yet',
47
+ action: { label: 'Create event', onClick },
48
+ });
49
+
50
+ const button = screen.getByRole('button', { name: 'Create event' });
51
+ expect(button.tagName).toBe('BUTTON');
52
+ fireEvent.click(button);
53
+ expect(onClick).toHaveBeenCalledOnce();
54
+ });
55
+
56
+ it('keeps the component source free of off-palette colour literals', () => {
57
+ const source = fs.readFileSync(path.resolve('src/components/EmptyState.jsx'), 'utf8');
58
+ const result = reportOffPaletteColours([{ path: 'src/components/EmptyState.jsx', source }]);
59
+
60
+ expect(result.findings).toEqual([]);
61
+ });
62
+ });
@@ -0,0 +1,135 @@
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 { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
6
+
7
+ const authApi = vi.hoisted(() => ({
8
+ verifyResetToken: vi.fn(),
9
+ setNewPassword: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../src/auth/authApi', () => authApi);
13
+ vi.mock('react-helmet', () => ({ Helmet: ({ children }) => <>{children}</> }));
14
+ vi.mock('react-i18next', () => ({
15
+ useTranslation: () => ({
16
+ t: (key, fallback) => ({
17
+ 'Auth.PAGE_INVITE_TITLE': 'Set password',
18
+ 'Auth.PAGE_INVITE_SUBTITLE': 'Choose a password.',
19
+ 'Auth.PAGE_RESET_PASSWORD_TITLE': 'Reset password',
20
+ 'Auth.PAGE_RESET_PASSWORD_SUBTITLE': 'Choose a new password.',
21
+ 'Auth.PAGE_CHECKING_LINK_TITLE': 'Checking link',
22
+ 'Auth.PAGE_CHECKING_LINK_TEXT': 'Checking…',
23
+ 'Auth.RESET_LINK_INVALID': 'The link is invalid or expired.',
24
+ 'Auth.RESET_PASSWORD_SUCCESS_INVITE': 'Password set successfully.',
25
+ 'Auth.RESET_PASSWORD_SUCCESS_RESET': 'Password changed successfully.',
26
+ 'Auth.SIGNUP_GO_TO_LOGIN': 'Go to login',
27
+ 'Auth.NEW_PASSWORD_LABEL': 'New password',
28
+ 'Auth.PASSWORD_CONFIRM_LABEL': 'Confirm password',
29
+ 'Auth.PASSWORD_RULES_HINT': 'Password rules',
30
+ 'Auth.PASSWORD_SET_BUTTON': 'Set password',
31
+ 'Auth.PASSWORD_SET_BUTTON_LOADING': 'Setting password…',
32
+ }[key] ?? (typeof fallback === 'string' ? fallback : key)),
33
+ }),
34
+ }));
35
+
36
+ function LocationProbe() {
37
+ const location = useLocation();
38
+ return <output data-testid="pathname">{location.pathname}</output>;
39
+ }
40
+
41
+ function renderPage(route = '/invite/uid/token') {
42
+ return render(
43
+ <MemoryRouter initialEntries={[route]}>
44
+ <LocationProbe />
45
+ <Routes>
46
+ <Route path="/invite/:uid/:token" element={<PasswordInvitePage />} />
47
+ <Route path="/reset/:uid/:token" element={<PasswordInvitePage />} />
48
+ </Routes>
49
+ </MemoryRouter>,
50
+ );
51
+ }
52
+
53
+ import { PasswordInvitePage } from '../src/pages/PasswordInvitePage';
54
+
55
+ describe('PasswordInvitePage auth flow', () => {
56
+ beforeEach(() => {
57
+ vi.clearAllMocks();
58
+ authApi.verifyResetToken.mockResolvedValue({});
59
+ });
60
+
61
+ afterEach(() => cleanup());
62
+
63
+ it('keeps the form and shows the server password reason so the person can retry', async () => {
64
+ authApi.setNewPassword
65
+ .mockRejectedValueOnce({
66
+ response: {
67
+ data: {
68
+ code: 'Auth.RESET_PASSWORD_INVALID',
69
+ messages: ['This password is too common.'],
70
+ },
71
+ },
72
+ })
73
+ .mockResolvedValueOnce({});
74
+
75
+ renderPage();
76
+ await waitFor(() => expect(screen.getByLabelText('New password')).toBeTruthy());
77
+
78
+ fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'password123' } });
79
+ fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'password123' } });
80
+ fireEvent.click(screen.getByRole('button', { name: 'Set password' }));
81
+
82
+ await waitFor(() => expect(screen.getByText('This password is too common.')).toBeTruthy());
83
+ expect(screen.getByLabelText('New password')).toBeTruthy();
84
+
85
+ fireEvent.click(screen.getByRole('button', { name: 'Set password' }));
86
+ await waitFor(() => expect(screen.getByText('Password set successfully.')).toBeTruthy());
87
+ expect(authApi.setNewPassword).toHaveBeenCalledTimes(2);
88
+ });
89
+
90
+ it('shows the invalid-link state, not a retryable form, when the link expires during submit', async () => {
91
+ // The initial verifyResetToken check can pass and the token can still
92
+ // expire before setNewPassword is called (a real race, not hypothetical)
93
+ // -- told apart by the server's CODE, not by treating every submit
94
+ // error as a password rejection.
95
+ authApi.setNewPassword.mockRejectedValueOnce({ code: 'Auth.RESET_LINK_INVALID' });
96
+
97
+ renderPage();
98
+ await waitFor(() => expect(screen.getByLabelText('New password')).toBeTruthy());
99
+
100
+ fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'password123' } });
101
+ fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'password123' } });
102
+ fireEvent.click(screen.getByRole('button', { name: 'Set password' }));
103
+
104
+ await waitFor(() => expect(screen.getByText('The link is invalid or expired.')).toBeTruthy());
105
+ expect(screen.queryByLabelText('New password')).toBeNull();
106
+ });
107
+
108
+ it('shows the invalid-link state without a password form when token verification fails', async () => {
109
+ authApi.verifyResetToken.mockRejectedValue({ code: 'Auth.RESET_LINK_INVALID' });
110
+
111
+ renderPage();
112
+
113
+ await waitFor(() => expect(screen.getByText('The link is invalid or expired.')).toBeTruthy());
114
+ expect(screen.queryByLabelText('New password')).toBeNull();
115
+ expect(screen.queryByLabelText('Confirm password')).toBeNull();
116
+ });
117
+
118
+ it('shows confirmation and waits for an explicit login action after success', async () => {
119
+ authApi.setNewPassword.mockResolvedValue({});
120
+
121
+ renderPage('/reset/uid/token?next=/account');
122
+ await waitFor(() => expect(screen.getByLabelText('New password')).toBeTruthy());
123
+
124
+ fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'new-password' } });
125
+ fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'new-password' } });
126
+ fireEvent.click(screen.getByRole('button', { name: 'Set password' }));
127
+
128
+ await waitFor(() => expect(screen.getByText('Password changed successfully.')).toBeTruthy());
129
+ expect(screen.getByRole('button', { name: 'Go to login' })).toBeTruthy();
130
+ expect(screen.getByTestId('pathname').textContent).toBe('/reset/uid/token');
131
+
132
+ fireEvent.click(screen.getByRole('button', { name: 'Go to login' }));
133
+ await waitFor(() => expect(screen.getByTestId('pathname').textContent).toBe('/login'));
134
+ });
135
+ });
@@ -20,10 +20,10 @@ vi.mock('react-i18next', () => ({
20
20
  import { AuthContext } from '../src/auth/AuthContext';
21
21
  import { SignUpPage } from '../src/pages/SignUpPage';
22
22
 
23
- function renderSignUp({ signupModes, siteKey }) {
23
+ function renderSignUp({ signup = true, signupModes, siteKey }) {
24
24
  return render(
25
25
  <AuthContext.Provider value={{ authMethods: {
26
- signup: true,
26
+ signup,
27
27
  signup_modes: signupModes,
28
28
  turnstile_site_key: siteKey,
29
29
  } }}>
@@ -62,6 +62,19 @@ describe('SignUpPage Turnstile integration', () => {
62
62
  await waitFor(() => expect(screen.getByTestId('turnstile-widget')).toBeTruthy());
63
63
  });
64
64
 
65
+ it('explains that signup is closed and omits the form when no signup modes are available', () => {
66
+ renderSignUp({ signup: false, signupModes: [] });
67
+
68
+ expect(screen.getByText('Self sign-up is currently disabled.')).toBeTruthy();
69
+ expect(screen.queryByRole('textbox', { name: /Auth\.EMAIL_LABEL/ })).toBeNull();
70
+ expect(screen.queryByRole('textbox', { name: /Auth\.ACCESS_CODE_LABEL/ })).toBeNull();
71
+ expect(screen.queryByRole('button', { name: 'Auth.SIGNUP_SUBMIT' })).toBeNull();
72
+ // A closed page must still leave a way forward, not just say why the
73
+ // form is missing (caught by review: the first implementation hid this
74
+ // too).
75
+ expect(screen.getByRole('button', { name: 'Auth.SIGNUP_GO_TO_LOGIN' })).toBeTruthy();
76
+ });
77
+
65
78
  it('gates submission until verified and passes the Turnstile token to the registration API', async () => {
66
79
  renderSignUp({ signupModes: ['self_signup_open'], siteKey: 'site-key' });
67
80
 
@@ -0,0 +1,85 @@
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
+
6
+ const authApi = vi.hoisted(() => ({
7
+ fetchUsersList: vi.fn(),
8
+ updateUserRole: vi.fn(),
9
+ deleteUser: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../src/auth/authApi', () => authApi);
13
+ vi.mock('react-i18next', () => ({
14
+ useTranslation: () => ({
15
+ t: (key, fallback) => ({
16
+ 'UserList.TITLE': 'All Users',
17
+ 'Common.SEARCH': 'Search',
18
+ 'UserList.SEARCH_PLACEHOLDER': 'Search users...',
19
+ 'Auth.EMAIL_LABEL': 'Email',
20
+ 'Profile.NAME_LABEL': 'Name',
21
+ 'UserList.ROLE': 'Role',
22
+ 'UserList.ROLE_UPDATE_SUCCESS': 'Role updated.',
23
+ 'Auth.USER_ROLE_UPDATE_FAILED': 'Role update failed.',
24
+ 'UserList.NO_USERS': 'No users found.',
25
+ }[key] ?? (typeof fallback === 'string' ? fallback : key)),
26
+ }),
27
+ }));
28
+
29
+ import { UserListComponent } from '../src/components/UserListComponent';
30
+
31
+ const currentUser = { id: 99, is_superuser: true, role: 'admin' };
32
+ const user = { id: 1, email: 'person@example.com', role: 'student' };
33
+
34
+ function renderUserList() {
35
+ return render(
36
+ <UserListComponent
37
+ currentUser={currentUser}
38
+ showNewColumn={false}
39
+ showSuccessfulLoginColumn={false}
40
+ showDeleteAction={false}
41
+ />,
42
+ );
43
+ }
44
+
45
+ async function chooseRole(role) {
46
+ fireEvent.mouseDown(screen.getAllByRole('combobox')[0]);
47
+ await waitFor(() => expect(screen.getByRole('listbox')).toBeTruthy());
48
+ fireEvent.click(screen.getByRole('option', { name: role }));
49
+ }
50
+
51
+ describe('UserListComponent role updates', () => {
52
+ beforeEach(() => {
53
+ vi.clearAllMocks();
54
+ authApi.fetchUsersList.mockResolvedValue([user]);
55
+ authApi.updateUserRole.mockResolvedValue({});
56
+ });
57
+
58
+ afterEach(() => {
59
+ cleanup();
60
+ vi.restoreAllMocks();
61
+ });
62
+
63
+ it('confirms a successful role update', async () => {
64
+ const alert = vi.spyOn(window, 'alert').mockImplementation(() => {});
65
+ renderUserList();
66
+
67
+ await waitFor(() => expect(screen.getAllByRole('combobox')[0]).toBeTruthy());
68
+ await chooseRole('teacher');
69
+
70
+ await waitFor(() => expect(authApi.updateUserRole).toHaveBeenCalledWith(1, 'teacher'));
71
+ expect(alert).toHaveBeenCalledWith('Role updated.');
72
+ });
73
+
74
+ it('keeps the failure feedback when a role update fails', async () => {
75
+ authApi.updateUserRole.mockRejectedValue({ code: 'Auth.USER_ROLE_UPDATE_FAILED' });
76
+ const alert = vi.spyOn(window, 'alert').mockImplementation(() => {});
77
+ renderUserList();
78
+
79
+ await waitFor(() => expect(screen.getAllByRole('combobox')[0]).toBeTruthy());
80
+ await chooseRole('teacher');
81
+
82
+ await waitFor(() => expect(alert).toHaveBeenCalledWith('Role update failed.'));
83
+ expect(authApi.fetchUsersList).toHaveBeenCalledTimes(1);
84
+ });
85
+ });
@@ -2,6 +2,7 @@
2
2
  import React from 'react';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+ import { createTheme, ThemeProvider } from '@mui/material/styles';
5
6
 
6
7
  const translation = vi.hoisted(() => ({
7
8
  t: vi.fn((key) => `translated:${key}`),
@@ -11,15 +12,26 @@ vi.mock('react-i18next', () => ({ useTranslation: () => translation }));
11
12
 
12
13
  import { AuthContext } from '../src/auth/AuthContext';
13
14
  import { UserMenu } from '../src/auth/UserMenu';
15
+ import { calculateContrastRatio, createAppTheme } from '../src/theme';
14
16
 
15
- function renderUserMenu({ user, logout = vi.fn(), ...props } = {}) {
17
+ const defaultTheme = createAppTheme({ palette: { primary: { main: createTheme().palette.primary.main } } });
18
+
19
+ function renderUserMenu({ user, logout = vi.fn(), theme = defaultTheme, ...props } = {}) {
16
20
  return render(
17
- <AuthContext.Provider value={{ user, logout }}>
18
- <UserMenu {...props} />
19
- </AuthContext.Provider>,
21
+ <ThemeProvider theme={theme}>
22
+ <AuthContext.Provider value={{ user, logout }}>
23
+ <UserMenu {...props} />
24
+ </AuthContext.Provider>
25
+ </ThemeProvider>,
20
26
  );
21
27
  }
22
28
 
29
+ function getAvatarContrast() {
30
+ const avatar = screen.getByText(/^[A-Z]+$/).closest('.MuiAvatar-root');
31
+ const styles = window.getComputedStyle(avatar);
32
+ return calculateContrastRatio(styles.color, styles.backgroundColor);
33
+ }
34
+
23
35
  describe('UserMenu', () => {
24
36
  afterEach(() => {
25
37
  cleanup();
@@ -132,4 +144,28 @@ describe('UserMenu', () => {
132
144
  renderUserMenu({ user: { username: 'grace' } });
133
145
  expect(screen.getByText('G')).toBeTruthy();
134
146
  });
147
+
148
+ it("uses a contrasting avatar pair with jg-ferien's primary colour", () => {
149
+ const theme = createAppTheme({ palette: { primary: { main: '#367964' } } });
150
+ renderUserMenu({ theme, user: { username: 'ab' } });
151
+
152
+ expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
153
+ });
154
+
155
+ it('uses a contrasting avatar pair with the library baseline primary colour', () => {
156
+ renderUserMenu({ user: { username: 'ab' } });
157
+
158
+ expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
159
+ });
160
+
161
+ it('falls back to the dark text pair for a medium-luminance primary colour', () => {
162
+ // A synthetic mid-grey (luminance ~0.216, no real brand meaning) picked to
163
+ // fail white-on-background at 4.5:1 (~3.95:1) so this test actually
164
+ // exercises the black-fallback branch, not just the white branch every
165
+ // other test here happens to land on.
166
+ const theme = createAppTheme({ palette: { primary: { main: '#808080' } } });
167
+ renderUserMenu({ theme, user: { username: 'ab' } });
168
+
169
+ expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
170
+ });
135
171
  });