@micha.bigler/ui-core-micha 3.6.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/DESIGN.md +9 -1
- package/README.md +2 -0
- package/WORK_ORDERS.md +8 -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/createAppTheme.js +62 -9
- package/dist/theme/kitSxRegistry.js +5 -1
- package/dist/theme/tokens.js +1 -1
- package/package.json +1 -1
- package/src/auth/UserMenu.jsx +26 -1
- package/src/components/EmptyState.jsx +38 -0
- package/src/components/UserListComponent.jsx +2 -0
- package/src/i18n/authTranslations.ts +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/createAppTheme.js +68 -4
- package/src/theme/kitSxRegistry.js +9 -1
- package/src/theme/tokens.js +1 -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/createAppThemePageContrast.test.js +108 -0
- package/tests/i18nGermanVariant.test.js +65 -0
- package/tests/messagingComposer.test.jsx +25 -0
- package/tests/messagingLinkify.test.jsx +51 -0
- package/tests/messagingReadTicksTooltip.test.jsx +36 -0
- package/tests/userScreenTranslations.test.jsx +165 -0
- package/work-orders/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 +142 -0
- package/work-orders/UCM-THEME-15.md +356 -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
|
-
|
|
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
|
-
|
|
103
|
+
setContinueTarget(target);
|
|
103
104
|
} catch (err) {
|
|
104
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
|
package/src/pages/SignUpPage.jsx
CHANGED
|
@@ -163,82 +163,93 @@ export function SignUpPage() {
|
|
|
163
163
|
</title>
|
|
164
164
|
</Helmet>
|
|
165
165
|
|
|
166
|
-
{
|
|
167
|
-
<Alert severity="
|
|
168
|
-
{t(
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
229
|
+
{turnstileRequired && (
|
|
230
|
+
<TurnstileWidget
|
|
231
|
+
ref={turnstileWidgetRef}
|
|
232
|
+
siteKey={authMethods.turnstile_site_key}
|
|
233
|
+
onToken={setTurnstileToken}
|
|
234
|
+
/>
|
|
235
|
+
)}
|
|
230
236
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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')}{' '}
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
BASELINE_PALETTE,
|
|
8
8
|
BASELINE_STATIC,
|
|
9
9
|
MOTION,
|
|
10
|
+
withMainShades,
|
|
10
11
|
} from './tokens';
|
|
12
|
+
import { calculateContrastRatio } from './themeCompleteness';
|
|
11
13
|
|
|
12
14
|
function findFunction(value, path) {
|
|
13
15
|
if (typeof value === 'function') return path;
|
|
@@ -53,6 +55,43 @@ function deriveFocusColour(primary, surfaces) {
|
|
|
53
55
|
return focus;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
function clearsPageContrast(colour, surfaces, threshold) {
|
|
59
|
+
return surfaces.every((surface) => {
|
|
60
|
+
const ratio = calculateContrastRatio(colour, surface);
|
|
61
|
+
return Number.isFinite(ratio) && ratio >= threshold;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function deriveDarkenedPageColour(original, surfaces, threshold) {
|
|
66
|
+
if (clearsPageContrast(original, surfaces, threshold)) return original;
|
|
67
|
+
|
|
68
|
+
let colour = original;
|
|
69
|
+
for (let coefficient = 0.05; !clearsPageContrast(colour, surfaces, threshold); coefficient += 0.05) {
|
|
70
|
+
colour = darken(original, Math.min(coefficient, 0.9));
|
|
71
|
+
if (coefficient >= 0.9) break;
|
|
72
|
+
}
|
|
73
|
+
return colour;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const RGBA_PATTERN = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)$/;
|
|
77
|
+
|
|
78
|
+
function deriveAlphaPageColour(original, surfaces, threshold) {
|
|
79
|
+
if (clearsPageContrast(original, surfaces, threshold)) return original;
|
|
80
|
+
|
|
81
|
+
const match = original.match(RGBA_PATTERN);
|
|
82
|
+
if (!match) return original;
|
|
83
|
+
|
|
84
|
+
const [, red, green, blue, alphaText] = match;
|
|
85
|
+
let alpha = Number(alphaText);
|
|
86
|
+
let colour = original;
|
|
87
|
+
while (alpha < 1) {
|
|
88
|
+
alpha = Math.min(1, Number((alpha + 0.05).toFixed(2)));
|
|
89
|
+
colour = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
|
90
|
+
if (clearsPageContrast(colour, surfaces, threshold)) return colour;
|
|
91
|
+
}
|
|
92
|
+
return colour;
|
|
93
|
+
}
|
|
94
|
+
|
|
56
95
|
function createPaletteAwareComponents(palette) {
|
|
57
96
|
const stateTransition = `border-color ${MOTION.duration.fast}ms ${MOTION.easing.state}, color ${MOTION.duration.fast}ms ${MOTION.easing.state}, background-color ${MOTION.duration.fast}ms ${MOTION.easing.state}`;
|
|
58
97
|
const autofill = {
|
|
@@ -179,13 +218,38 @@ export function createAppTheme(appConfig = {}) {
|
|
|
179
218
|
{ palette: appConfig.palette },
|
|
180
219
|
);
|
|
181
220
|
const finalPalette = paletteTheme.palette;
|
|
221
|
+
const contrastSurfaces = ['#FFFFFF', finalPalette.background.default];
|
|
222
|
+
const derivedStatusEntries = {};
|
|
223
|
+
for (const status of ['success', 'warning', 'error', 'info']) {
|
|
224
|
+
if (appConfig.palette?.[status]?.main !== undefined) continue;
|
|
225
|
+
|
|
226
|
+
const current = finalPalette[status];
|
|
227
|
+
const main = deriveDarkenedPageColour(current.main, contrastSurfaces, 4.5);
|
|
228
|
+
if (main !== current.main) {
|
|
229
|
+
derivedStatusEntries[status] = {
|
|
230
|
+
...current,
|
|
231
|
+
...withMainShades(main, current.contrastText),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const derivedControlBorderEntries = {};
|
|
237
|
+
for (const state of ['main', 'hover', 'error']) {
|
|
238
|
+
if (appConfig.palette?.controlBorder?.[state] !== undefined) continue;
|
|
239
|
+
|
|
240
|
+
const current = finalPalette.controlBorder[state];
|
|
241
|
+
const derived = state === 'error'
|
|
242
|
+
? deriveDarkenedPageColour(current, contrastSurfaces, 3)
|
|
243
|
+
: deriveAlphaPageColour(current, contrastSurfaces, 3);
|
|
244
|
+
if (derived !== current) derivedControlBorderEntries[state] = derived;
|
|
245
|
+
}
|
|
246
|
+
|
|
182
247
|
const computedPalette = {
|
|
248
|
+
...derivedStatusEntries,
|
|
183
249
|
controlBorder: {
|
|
184
250
|
...finalPalette.controlBorder,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
finalPalette.background.default,
|
|
188
|
-
]),
|
|
251
|
+
...derivedControlBorderEntries,
|
|
252
|
+
focus: deriveFocusColour(finalPalette.primary.main, contrastSurfaces),
|
|
189
253
|
},
|
|
190
254
|
};
|
|
191
255
|
const resolvedPalette = { ...finalPalette, ...computedPalette };
|
|
@@ -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 {
|
|
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),
|
package/src/theme/tokens.js
CHANGED
|
@@ -8,7 +8,7 @@ import { darken, lighten } from '@mui/material/styles';
|
|
|
8
8
|
// though `text`/`fill`/`bg` are set correctly, so every status channel MUI
|
|
9
9
|
// recognises (success/warning/error/info) must carry its own explicit
|
|
10
10
|
// main/light/dark/contrastText, computed the same way augmentColor would.
|
|
11
|
-
const withMainShades = (main, contrastText) => ({
|
|
11
|
+
export const withMainShades = (main, contrastText) => ({
|
|
12
12
|
main,
|
|
13
13
|
light: lighten(main, 0.2),
|
|
14
14
|
dark: darken(main, 0.3),
|
|
@@ -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
|
|
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
|
|