@micha.bigler/ui-core-micha 2.26.4 → 2.28.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +2 -1
- package/WORK_ORDERS.md +5 -0
- package/dist/components/PasswordChangeForm.js +14 -4
- package/dist/components/PasswordSetForm.js +10 -2
- package/dist/components/charts/TimeSeriesChart.js +44 -4
- package/dist/i18n/authTranslations.js +93 -0
- package/dist/i18n/notificationsTranslations.js +8 -0
- package/dist/notifications/NotificationSettings.js +48 -3
- package/dist/notifications/api.js +5 -0
- package/package.json +1 -1
- package/src/components/PasswordChangeForm.jsx +26 -1
- package/src/components/PasswordSetForm.jsx +18 -1
- package/src/components/charts/TimeSeriesChart.jsx +47 -5
- package/src/i18n/authTranslations.ts +94 -0
- package/src/i18n/notificationsTranslations.ts +8 -0
- package/src/notifications/NotificationSettings.jsx +95 -0
- package/src/notifications/api.js +6 -0
- package/tests/NotificationSettings.test.jsx +105 -0
- package/tests/PasswordChangeForm.test.jsx +70 -0
- package/tests/PasswordSetForm.test.jsx +70 -0
- package/tests/TimeSeriesChart.test.jsx +81 -9
- package/tests/authErrorCodeRendering.test.jsx +77 -0
- package/tests/authTranslations.test.js +52 -0
- package/work-orders/AUTH-1.md +74 -0
- package/work-orders/AUTH-2.md +74 -0
- package/work-orders/CHART-3.md +163 -0
- package/work-orders/CHART-4.md +203 -0
|
@@ -15,6 +15,23 @@ import { useNeutralChartPalette } from './palette';
|
|
|
15
15
|
// Fixed platform-wide range -> granularity mapping (operator decision, CHART-2 /
|
|
16
16
|
// dcm ACT-1) — the resolution follows the window and is never an independent
|
|
17
17
|
// caller-supplied control. Every preset lands between a dozen and fifty points.
|
|
18
|
+
// Preset default height (CHART-4): MUI X-Charts' responsive container
|
|
19
|
+
// measures its actual parent height via ResizeObserver and draws nothing
|
|
20
|
+
// if that parent has none — ChartFrame's flex-centered content Box does
|
|
21
|
+
// NOT stretch its child to fill available height (alignItems: 'center'),
|
|
22
|
+
// so BarChart needs its own explicit size, not just ChartFrame's.
|
|
23
|
+
// `height` forwards straight to MuiBarChart's own native height prop
|
|
24
|
+
// (bypassing ResizeObserver-based measurement entirely), matching the
|
|
25
|
+
// confirmed-working dev/entries.jsx BarChartEntry reference.
|
|
26
|
+
// Deliberate, known exception to DESIGN.md #14 ("responsive = container-
|
|
27
|
+
// sized, not fixed px"): width stays responsive (unset, still auto-
|
|
28
|
+
// measured), only height is fixed, and only because MUI X-Charts' own
|
|
29
|
+
// ResizeObserver-based height measurement is what silently produced a
|
|
30
|
+
// zero-height chart in the first place (CHART-4). Do not "fix" this back
|
|
31
|
+
// to a responsive/aspect-ratio height without re-verifying the underlying
|
|
32
|
+
// MUI measurement bug is actually resolved.
|
|
33
|
+
const CHART_HEIGHT = 320;
|
|
34
|
+
|
|
18
35
|
const RANGE_OPTIONS = [
|
|
19
36
|
{ key: '1d', granularity: 'hour', labelKey: 'TimeSeriesChart.RANGE_1_DAY' },
|
|
20
37
|
{ key: '1w', granularity: '4hour', labelKey: 'TimeSeriesChart.RANGE_1_WEEK' },
|
|
@@ -52,11 +69,34 @@ export function TimeSeriesChart({
|
|
|
52
69
|
const seriesConfig = data?.series || [];
|
|
53
70
|
|
|
54
71
|
const [range, setRange] = useState(defaultRange);
|
|
55
|
-
// Series identity (keys) is assumed stable
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
// Series identity (keys) is NOT assumed stable at mount: the host commonly
|
|
73
|
+
// mounts with empty data and fills it asynchronously (fetch-after-mount),
|
|
74
|
+
// so visibleKeys must sync as new keys appear rather than being captured
|
|
75
|
+
// once. seenKeys tracks every key ever observed so that a key present in
|
|
76
|
+
// seriesConfig but absent from visibleKeys is recognized as a deliberate
|
|
77
|
+
// user toggle-off (stays off) rather than treated as new (defaulted
|
|
78
|
+
// visible) on the next data update. Both are plain state (not a ref) and
|
|
79
|
+
// updated together in the render body — React's documented pattern for
|
|
80
|
+
// syncing derived state from props — so an interrupted/discarded render
|
|
81
|
+
// (concurrent features) rolls both back together instead of leaving
|
|
82
|
+
// seenKeys mutated while the paired visibleKeys update is dropped.
|
|
83
|
+
const [seenKeys, setSeenKeys] = useState(() => new Set());
|
|
84
|
+
const [visibleKeys, setVisibleKeys] = useState(() => new Set());
|
|
85
|
+
|
|
86
|
+
const currentKeys = seriesConfig.map((series) => series.key);
|
|
87
|
+
const newKeys = currentKeys.filter((key) => !seenKeys.has(key));
|
|
88
|
+
if (newKeys.length > 0) {
|
|
89
|
+
setSeenKeys((previous) => {
|
|
90
|
+
const next = new Set(previous);
|
|
91
|
+
newKeys.forEach((key) => next.add(key));
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
setVisibleKeys((previous) => {
|
|
95
|
+
const next = new Set(previous);
|
|
96
|
+
newKeys.forEach((key) => next.add(key));
|
|
97
|
+
return next;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
60
100
|
|
|
61
101
|
const handleRangeChange = (event) => {
|
|
62
102
|
const nextRange = event.target.value;
|
|
@@ -132,6 +172,7 @@ export function TimeSeriesChart({
|
|
|
132
172
|
loading={loading}
|
|
133
173
|
error={error}
|
|
134
174
|
isEmpty={isDataEmpty}
|
|
175
|
+
minHeight={CHART_HEIGHT}
|
|
135
176
|
>
|
|
136
177
|
{!isDataEmpty && (
|
|
137
178
|
<BarChart
|
|
@@ -140,6 +181,7 @@ export function TimeSeriesChart({
|
|
|
140
181
|
xAxis={[{ data: data?.xLabels || [] }]}
|
|
141
182
|
series={visibleSeries.map((series) => ({ data: series.data, label: series.label }))}
|
|
142
183
|
palette={visiblePalette}
|
|
184
|
+
height={CHART_HEIGHT}
|
|
143
185
|
/>
|
|
144
186
|
)}
|
|
145
187
|
</ChartFrame>
|
|
@@ -927,6 +927,24 @@ export const authTranslations = {
|
|
|
927
927
|
"en": "The passwords do not match.",
|
|
928
928
|
"sw": "Manenosiri hayalingani."
|
|
929
929
|
},
|
|
930
|
+
"Auth.PASSWORD_RULES_HINT": {
|
|
931
|
+
"de": "Mindestens 8 Zeichen, nicht nur Zahlen, kein gängiges Passwort.",
|
|
932
|
+
"fr": "Au moins 8 caractères, pas uniquement des chiffres, pas un mot de passe courant.",
|
|
933
|
+
"en": "At least 8 characters, not entirely numeric, not a commonly used password.",
|
|
934
|
+
"sw": "Angalau herufi 8, si tarakimu tu, si nenosiri linalotumika sana."
|
|
935
|
+
},
|
|
936
|
+
"Auth.PASSWORD_TOO_SHORT_LOCAL": {
|
|
937
|
+
"de": "Das Passwort muss mindestens 8 Zeichen lang sein.",
|
|
938
|
+
"fr": "Le mot de passe doit contenir au moins 8 caractères.",
|
|
939
|
+
"en": "The password must be at least 8 characters long.",
|
|
940
|
+
"sw": "Nenosiri linahitajika kuwa na angalau herufi 8."
|
|
941
|
+
},
|
|
942
|
+
"Auth.PASSWORD_NUMERIC_LOCAL": {
|
|
943
|
+
"de": "Das Passwort darf nicht nur aus Zahlen bestehen.",
|
|
944
|
+
"fr": "Le mot de passe ne peut pas être uniquement composé de chiffres.",
|
|
945
|
+
"en": "The password cannot be entirely numeric.",
|
|
946
|
+
"sw": "Nenosiri haliwezi kuwa na tarakimu tu."
|
|
947
|
+
},
|
|
930
948
|
"Auth.PROFILE_LOAD_FAILED": {
|
|
931
949
|
"de": "Profil konnte nicht geladen werden.",
|
|
932
950
|
"fr": "Le profil n’a pas pu être chargé.",
|
|
@@ -1853,5 +1871,81 @@ export const authTranslations = {
|
|
|
1853
1871
|
"fr": "Le mot de passe est incorrect.",
|
|
1854
1872
|
"en": "The password is incorrect.",
|
|
1855
1873
|
"sw": "Nenosiri si sahihi."
|
|
1874
|
+
},
|
|
1875
|
+
|
|
1876
|
+
// Raw backend auth error codes (allauth-headless + Django password validators).
|
|
1877
|
+
// Keyed by the exact code string so the existing `t(err.code)` call sites resolve
|
|
1878
|
+
// as a drop-in, without any component change. See AUTH-2.
|
|
1879
|
+
"email_password_mismatch": {
|
|
1880
|
+
"de": "E-Mail-Adresse oder Passwort ist falsch.",
|
|
1881
|
+
"fr": "Adresse e-mail ou mot de passe incorrect.",
|
|
1882
|
+
"en": "Email address or password is incorrect.",
|
|
1883
|
+
"sw": "Barua pepe au nenosiri sio sahihi."
|
|
1884
|
+
},
|
|
1885
|
+
"username_password_mismatch": {
|
|
1886
|
+
"de": "Benutzername oder Passwort ist falsch.",
|
|
1887
|
+
"fr": "Nom d'utilisateur ou mot de passe incorrect.",
|
|
1888
|
+
"en": "Username or password is incorrect.",
|
|
1889
|
+
"sw": "Jina la mtumiaji au nenosiri sio sahihi."
|
|
1890
|
+
},
|
|
1891
|
+
"phone_password_mismatch": {
|
|
1892
|
+
"de": "Telefonnummer oder Passwort ist falsch.",
|
|
1893
|
+
"fr": "Numéro de téléphone ou mot de passe incorrect.",
|
|
1894
|
+
"en": "Phone number or password is incorrect.",
|
|
1895
|
+
"sw": "Nambari ya simu au nenosiri sio sahihi."
|
|
1896
|
+
},
|
|
1897
|
+
"too_many_login_attempts": {
|
|
1898
|
+
"de": "Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen Sie es später erneut.",
|
|
1899
|
+
"fr": "Trop de tentatives de connexion échouées. Veuillez réessayer plus tard.",
|
|
1900
|
+
"en": "Too many failed login attempts. Please try again later.",
|
|
1901
|
+
"sw": "Majaribio mengi ya kuingia yameshindwa. Tafadhali jaribu tena baadaye."
|
|
1902
|
+
},
|
|
1903
|
+
"invalid_login": {
|
|
1904
|
+
"de": "Ungültige Anmeldedaten.",
|
|
1905
|
+
"fr": "Données de connexion invalides.",
|
|
1906
|
+
"en": "Invalid login credentials.",
|
|
1907
|
+
"sw": "Taarifa za kuingia si sahihi."
|
|
1908
|
+
},
|
|
1909
|
+
"account_inactive": {
|
|
1910
|
+
"de": "Dieses Konto ist derzeit inaktiv.",
|
|
1911
|
+
"fr": "Ce compte est actuellement inactif.",
|
|
1912
|
+
"en": "This account is currently inactive.",
|
|
1913
|
+
"sw": "Akaunti hii haitumiki kwa sasa."
|
|
1914
|
+
},
|
|
1915
|
+
"enter_current_password": {
|
|
1916
|
+
"de": "Das aktuelle Passwort ist falsch.",
|
|
1917
|
+
"fr": "Le mot de passe actuel est incorrect.",
|
|
1918
|
+
"en": "The current password is incorrect.",
|
|
1919
|
+
"sw": "Nenosiri la sasa si sahihi."
|
|
1920
|
+
},
|
|
1921
|
+
"password_too_short": {
|
|
1922
|
+
"de": "Das Passwort muss mindestens 8 Zeichen lang sein.",
|
|
1923
|
+
"fr": "Le mot de passe doit contenir au moins 8 caractères.",
|
|
1924
|
+
"en": "The password must be at least 8 characters long.",
|
|
1925
|
+
"sw": "Nenosiri linahitajika kuwa na angalau herufi 8."
|
|
1926
|
+
},
|
|
1927
|
+
"password_too_common": {
|
|
1928
|
+
"de": "Dieses Passwort ist zu gängig und leicht zu erraten.",
|
|
1929
|
+
"fr": "Ce mot de passe est trop courant et facile à deviner.",
|
|
1930
|
+
"en": "This password is too common and easy to guess.",
|
|
1931
|
+
"sw": "Nenosiri hili linatumika sana na ni rahisi kubashiri."
|
|
1932
|
+
},
|
|
1933
|
+
"password_entirely_numeric": {
|
|
1934
|
+
"de": "Das Passwort darf nicht nur aus Zahlen bestehen.",
|
|
1935
|
+
"fr": "Le mot de passe ne peut pas être uniquement composé de chiffres.",
|
|
1936
|
+
"en": "The password cannot be entirely numeric.",
|
|
1937
|
+
"sw": "Nenosiri haliwezi kuwa na tarakimu tu."
|
|
1938
|
+
},
|
|
1939
|
+
"password_too_similar": {
|
|
1940
|
+
"de": "Das Passwort ist Ihren persönlichen Daten zu ähnlich.",
|
|
1941
|
+
"fr": "Le mot de passe est trop similaire à vos informations personnelles.",
|
|
1942
|
+
"en": "The password is too similar to your personal information.",
|
|
1943
|
+
"sw": "Nenosiri linafanana sana na taarifa zako binafsi."
|
|
1944
|
+
},
|
|
1945
|
+
"incorrect_code": {
|
|
1946
|
+
"de": "Der eingegebene Code ist falsch.",
|
|
1947
|
+
"fr": "Le code saisi est incorrect.",
|
|
1948
|
+
"en": "The code you entered is incorrect.",
|
|
1949
|
+
"sw": "Msimbo uliouweka si sahihi."
|
|
1856
1950
|
}
|
|
1857
1951
|
};
|
|
@@ -19,6 +19,14 @@ export const notificationsTranslations = {
|
|
|
19
19
|
'NotificationSettings.PUSH_CONFLICT': { de: 'Dieses Gerät ist bereits mit einem anderen Konto für Push-Benachrichtigungen verbunden.', fr: 'Cet appareil est déjà associé à un autre compte pour les notifications push.', en: 'This device is already associated with another account for push notifications.', sw: 'Kifaa hiki tayari kimeunganishwa na akaunti nyingine kwa arifa za push.' },
|
|
20
20
|
'NotificationSettings.PUSH_NOT_SUPPORTED': { de: 'Push-Benachrichtigungen werden von diesem Browser nicht unterstützt.', fr: 'Les notifications push ne sont pas prises en charge par ce navigateur.', en: 'Push notifications are not supported by this browser.', sw: 'Arifa za push hazitumiki na kivinjari hiki.' },
|
|
21
21
|
'NotificationSettings.IOS_HINT': { de: 'Füge diese App zuerst zum Home-Bildschirm hinzu, um Push-Benachrichtigungen zu aktivieren.', fr: 'Ajoutez d’abord cette application à l’écran d’accueil pour activer les notifications push.', en: 'Add this app to your home screen first to enable push notifications.', sw: 'Ongeza programu hii kwenye skrini yako ya mwanzo kwanza ili kuwasha arifa za push.' },
|
|
22
|
+
'NotificationSettings.REACH_TITLE': { de: 'Wie du erreicht wirst', fr: 'Comment vous êtes joint', en: 'How you’re reached', sw: 'Jinsi unavyofikiwa' },
|
|
23
|
+
'NotificationSettings.REACH_ACTIVE': { de: 'Erreicht dich per E-Mail oder Push, je nach deiner Wahl oben.', fr: 'Vous joint par e-mail ou push, selon votre choix ci-dessus.', en: 'Reaches you by email or push, whichever you’ve chosen above.', sw: 'Inakufikia kwa barua pepe au push, kulingana na uchaguzi wako hapo juu.' },
|
|
24
|
+
'NotificationSettings.REACH_PASSIVE': { de: 'Wird angezeigt, wenn du nachschaust – keine E-Mail oder Push.', fr: 'Affiché seulement quand vous regardez – pas d’e-mail ni de push.', en: 'Shown only when you look – no email or push.', sw: 'Inaonyeshwa tu ukiangalia – hakuna barua pepe au push.' },
|
|
25
|
+
'NotificationSettings.REACH_BOTH': { de: 'Erreicht dich aktiv und bleibt zusätzlich sichtbar, wenn du nachschaust.', fr: 'Vous joint activement et reste visible quand vous regardez.', en: 'Reaches you actively, and also stays visible when you look.', sw: 'Inakufikia kikamilifu, na pia inabaki kuonekana ukiangalia.' },
|
|
26
|
+
'NotificationSettings.REACH_NO_ACTIVE_CHANNEL': { de: 'Kein E-Mail oder Push konfiguriert – diese Benachrichtigung erreicht dich derzeit nicht.', fr: 'Aucun e-mail ou push configuré – cette notification ne vous atteint pas actuellement.', en: 'No email or push configured – this notification does not currently reach you.', sw: 'Hakuna barua pepe au push iliyowekwa – arifa hii kwa sasa haikufikii.' },
|
|
27
|
+
'NotificationSettings.SUBSCRIPTIONS_TITLE': { de: 'Abonnements', fr: 'Abonnements', en: 'Subscriptions', sw: 'Usajili' },
|
|
28
|
+
'NotificationSettings.SUBSCRIPTIONS_SUBTITLE': { de: 'Ereignisse, für die du dich freiwillig anmelden kannst.', fr: 'Événements auxquels vous pouvez vous inscrire volontairement.', en: 'Events you can opt in to receive.', sw: 'Matukio unayoweza kujiunga kupokea.' },
|
|
29
|
+
'NotificationSettings.SUBSCRIPTION_ERROR': { de: 'Abonnement konnte nicht aktualisiert werden.', fr: 'Impossible de mettre à jour l’abonnement.', en: 'Subscription could not be updated.', sw: 'Usajili haukuweza kusasishwa.' },
|
|
22
30
|
'PopupSurface.CLOSE': { de: 'Schliessen', fr: 'Fermer', en: 'Close', sw: 'Funga' },
|
|
23
31
|
'PopupSurface.TITLE': { de: 'Benachrichtigung', fr: 'Notification', en: 'Notification', sw: 'Arifa' },
|
|
24
32
|
};
|
|
@@ -14,9 +14,24 @@ import {
|
|
|
14
14
|
patchNotificationPreferences,
|
|
15
15
|
removePushSubscription,
|
|
16
16
|
savePushSubscription,
|
|
17
|
+
setNotificationCategorySubscription,
|
|
17
18
|
urlBase64ToUint8Array,
|
|
18
19
|
} from './api';
|
|
19
20
|
|
|
21
|
+
function reachLabelKey(notificationType) {
|
|
22
|
+
const noActiveChannel = notificationType.active && notificationType.has_active_channel === false;
|
|
23
|
+
if (notificationType.active && notificationType.passive) {
|
|
24
|
+
// Scope C's bounded fallback: a "both" type still reaches the user passively
|
|
25
|
+
// (chip) when no active channel is configured -- it is not undelivered, so it
|
|
26
|
+
// must not show the "does not reach you" warning meant for active-only types.
|
|
27
|
+
return noActiveChannel ? 'NotificationSettings.REACH_PASSIVE' : 'NotificationSettings.REACH_BOTH';
|
|
28
|
+
}
|
|
29
|
+
if (notificationType.active) {
|
|
30
|
+
return noActiveChannel ? 'NotificationSettings.REACH_NO_ACTIVE_CHANNEL' : 'NotificationSettings.REACH_ACTIVE';
|
|
31
|
+
}
|
|
32
|
+
return 'NotificationSettings.REACH_PASSIVE';
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
function getPushSupport() {
|
|
21
36
|
return typeof navigator !== 'undefined'
|
|
22
37
|
&& typeof window !== 'undefined'
|
|
@@ -42,6 +57,7 @@ export function NotificationSettings() {
|
|
|
42
57
|
const [pushSubscribed, setPushSubscribed] = useState(false);
|
|
43
58
|
const [error, setError] = useState('');
|
|
44
59
|
const [conflict, setConflict] = useState('');
|
|
60
|
+
const [savingSubscription, setSavingSubscription] = useState(null);
|
|
45
61
|
|
|
46
62
|
const pushSupported = getPushSupport();
|
|
47
63
|
const iosNeedsInstall = getIosInstallState();
|
|
@@ -138,6 +154,29 @@ export function NotificationSettings() {
|
|
|
138
154
|
}
|
|
139
155
|
};
|
|
140
156
|
|
|
157
|
+
const handleSubscriptionToggle = async (category, event) => {
|
|
158
|
+
const requested = event.target.checked;
|
|
159
|
+
setSavingSubscription(category);
|
|
160
|
+
setError('');
|
|
161
|
+
try {
|
|
162
|
+
const result = await setNotificationCategorySubscription(category, requested);
|
|
163
|
+
// Reflect what the server actually recorded, not the checkbox's optimistic
|
|
164
|
+
// value -- a 2xx response with a different `subscribed` (e.g. a server-side
|
|
165
|
+
// clamp) must not be silently overridden by what the user clicked.
|
|
166
|
+
const confirmed = result?.subscribed ?? requested;
|
|
167
|
+
setPreferences((current) => ({
|
|
168
|
+
...current,
|
|
169
|
+
subscribable_categories: current.subscribable_categories.map((row) => (
|
|
170
|
+
row.category === category ? { ...row, subscribed: confirmed } : row
|
|
171
|
+
)),
|
|
172
|
+
}));
|
|
173
|
+
} catch {
|
|
174
|
+
setError(t('NotificationSettings.SUBSCRIPTION_ERROR'));
|
|
175
|
+
} finally {
|
|
176
|
+
setSavingSubscription(null);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
141
180
|
const handleDisablePush = async () => {
|
|
142
181
|
setSavingPush(true);
|
|
143
182
|
setError('');
|
|
@@ -213,6 +252,62 @@ export function NotificationSettings() {
|
|
|
213
252
|
sx={{ alignItems: 'flex-start', ml: 0, mt: 1.5 }}
|
|
214
253
|
/>
|
|
215
254
|
</Box>
|
|
255
|
+
|
|
256
|
+
{preferences?.notification_types?.length > 0 && (
|
|
257
|
+
<>
|
|
258
|
+
<Divider />
|
|
259
|
+
<Box sx={{ py: 2 }}>
|
|
260
|
+
<Typography variant="subtitle2" gutterBottom>{t('NotificationSettings.REACH_TITLE')}</Typography>
|
|
261
|
+
{preferences.notification_types.map((notificationType) => {
|
|
262
|
+
const labelKey = reachLabelKey(notificationType);
|
|
263
|
+
const reachText = t(labelKey);
|
|
264
|
+
// Reserve Alert (a colored banner) for the one case that is actually a
|
|
265
|
+
// warning -- an active-only type nobody can currently reach. Routine
|
|
266
|
+
// reach description is static settings copy, not a status message.
|
|
267
|
+
if (labelKey === 'NotificationSettings.REACH_NO_ACTIVE_CHANNEL') {
|
|
268
|
+
return (
|
|
269
|
+
<Alert key={notificationType.key} severity="warning" sx={{ mb: 1 }}>
|
|
270
|
+
<strong>{notificationType.label}</strong> — {reachText}
|
|
271
|
+
</Alert>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return (
|
|
275
|
+
<Box key={notificationType.key} sx={{ py: 0.5 }}>
|
|
276
|
+
<Typography variant="body2">{notificationType.label}</Typography>
|
|
277
|
+
<Typography variant="caption" color="text.secondary">{reachText}</Typography>
|
|
278
|
+
</Box>
|
|
279
|
+
);
|
|
280
|
+
})}
|
|
281
|
+
</Box>
|
|
282
|
+
</>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{preferences?.subscribable_categories?.length > 0 && (
|
|
286
|
+
<>
|
|
287
|
+
<Divider />
|
|
288
|
+
<Box sx={{ py: 2 }}>
|
|
289
|
+
<Typography variant="subtitle2" gutterBottom>{t('NotificationSettings.SUBSCRIPTIONS_TITLE')}</Typography>
|
|
290
|
+
<Typography variant="body2" color="text.secondary" gutterBottom>
|
|
291
|
+
{t('NotificationSettings.SUBSCRIPTIONS_SUBTITLE')}
|
|
292
|
+
</Typography>
|
|
293
|
+
{preferences.subscribable_categories.map((row) => (
|
|
294
|
+
<FormControlLabel
|
|
295
|
+
key={row.category}
|
|
296
|
+
control={(
|
|
297
|
+
<Switch
|
|
298
|
+
checked={Boolean(row.subscribed)}
|
|
299
|
+
onChange={(event) => handleSubscriptionToggle(row.category, event)}
|
|
300
|
+
disabled={savingSubscription === row.category}
|
|
301
|
+
/>
|
|
302
|
+
)}
|
|
303
|
+
label={row.label}
|
|
304
|
+
labelPlacement="end"
|
|
305
|
+
sx={{ alignItems: 'center', ml: 0, display: 'flex' }}
|
|
306
|
+
/>
|
|
307
|
+
))}
|
|
308
|
+
</Box>
|
|
309
|
+
</>
|
|
310
|
+
)}
|
|
216
311
|
</Box>
|
|
217
312
|
);
|
|
218
313
|
}
|
package/src/notifications/api.js
CHANGED
|
@@ -2,6 +2,7 @@ import apiClient from '../auth/apiClient';
|
|
|
2
2
|
|
|
3
3
|
const PREFERENCES_URL = '/api/notifications/preferences/';
|
|
4
4
|
const PUSH_SUBSCRIPTION_URL = `${PREFERENCES_URL}push-subscription/`;
|
|
5
|
+
const SUBSCRIPTIONS_URL = `${PREFERENCES_URL}subscriptions/`;
|
|
5
6
|
|
|
6
7
|
export async function getNotificationPreferences() {
|
|
7
8
|
const response = await apiClient.get(PREFERENCES_URL);
|
|
@@ -13,6 +14,11 @@ export async function patchNotificationPreferences(patch) {
|
|
|
13
14
|
return response.data;
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
export async function setNotificationCategorySubscription(category, subscribed) {
|
|
18
|
+
const response = await apiClient.post(SUBSCRIPTIONS_URL, { category, subscribed });
|
|
19
|
+
return response.data;
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
export async function getVapidPublicKey() {
|
|
17
23
|
const response = await apiClient.get(`${PREFERENCES_URL}vapid-public-key/`);
|
|
18
24
|
return response.data?.vapidPublicKey;
|
|
@@ -9,6 +9,7 @@ const notificationsApi = vi.hoisted(() => ({
|
|
|
9
9
|
patchNotificationPreferences: vi.fn(),
|
|
10
10
|
removePushSubscription: vi.fn(),
|
|
11
11
|
savePushSubscription: vi.fn(),
|
|
12
|
+
setNotificationCategorySubscription: vi.fn(),
|
|
12
13
|
urlBase64ToUint8Array: vi.fn(() => new Uint8Array([1, 2, 3])),
|
|
13
14
|
}));
|
|
14
15
|
|
|
@@ -169,3 +170,107 @@ describe('NotificationSettings push preview toggle', () => {
|
|
|
169
170
|
expect(toggle.disabled).toBe(false);
|
|
170
171
|
});
|
|
171
172
|
});
|
|
173
|
+
|
|
174
|
+
describe('NotificationSettings reach axis (NOTIF-26)', () => {
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
vi.clearAllMocks();
|
|
177
|
+
notificationsApi.getVapidPublicKey.mockResolvedValue('AQID');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('renders no reach or subscriptions sections when the server reports none (no new props)', async () => {
|
|
181
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
182
|
+
email_opt_in: false, push_opt_in: false, notification_types: [], subscribable_categories: [],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
render(<NotificationSettings />);
|
|
186
|
+
await screen.findByRole('switch', { name: /EMAIL_LABEL/ });
|
|
187
|
+
|
|
188
|
+
expect(screen.queryByText('NotificationSettings.REACH_TITLE')).toBeNull();
|
|
189
|
+
expect(screen.queryByText('NotificationSettings.SUBSCRIPTIONS_TITLE')).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('shows the both-reach message for a type that is active and passive', async () => {
|
|
193
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
194
|
+
email_opt_in: true, push_opt_in: false,
|
|
195
|
+
notification_types: [{ key: 'status.deploy_failed', category: 'status', label: 'Deploy failed', active: true, passive: true, has_active_channel: true }],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
render(<NotificationSettings />);
|
|
199
|
+
|
|
200
|
+
expect(await screen.findByText('Deploy failed')).toBeTruthy();
|
|
201
|
+
expect(await screen.findByText('NotificationSettings.REACH_BOTH')).toBeTruthy();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('shows the passive-only message for a type with no active reach', async () => {
|
|
205
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
206
|
+
email_opt_in: false, push_opt_in: false,
|
|
207
|
+
notification_types: [{ key: 'status.deploy_recovered', category: 'status', label: 'Deploy recovered', active: false, passive: true, has_active_channel: null }],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
render(<NotificationSettings />);
|
|
211
|
+
|
|
212
|
+
expect(await screen.findByText('NotificationSettings.REACH_PASSIVE')).toBeTruthy();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('shows the no-active-channel warning for an active-only type the user cannot currently be reached on (scope C)', async () => {
|
|
216
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
217
|
+
email_opt_in: false, push_opt_in: false,
|
|
218
|
+
notification_types: [{ key: 'app.messaging.new_message', category: 'messaging', label: 'New messages', active: true, passive: false, has_active_channel: false }],
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
render(<NotificationSettings />);
|
|
222
|
+
|
|
223
|
+
expect(await screen.findByText(/NotificationSettings\.REACH_NO_ACTIVE_CHANNEL/)).toBeTruthy();
|
|
224
|
+
expect(await screen.findByText(/New messages/)).toBeTruthy();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('shows the passive fallback (not the undelivered warning) for a both-type with no active channel configured', async () => {
|
|
228
|
+
// Regression: scope C's bounded fallback means a both-type still reaches the
|
|
229
|
+
// user passively (chip) with no active channel -- it must not be shown as
|
|
230
|
+
// undelivered, which is reserved for active-only types.
|
|
231
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
232
|
+
email_opt_in: false, push_opt_in: false,
|
|
233
|
+
notification_types: [{ key: 'status.deploy_failed', category: 'status', label: 'Deploy failed', active: true, passive: true, has_active_channel: false }],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
render(<NotificationSettings />);
|
|
237
|
+
|
|
238
|
+
expect(await screen.findByText('NotificationSettings.REACH_PASSIVE')).toBeTruthy();
|
|
239
|
+
expect(screen.queryByText(/NotificationSettings\.REACH_NO_ACTIVE_CHANNEL/)).toBeNull();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('renders server-reported subscribable categories and toggles a subscription', async () => {
|
|
243
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
244
|
+
email_opt_in: false, push_opt_in: false,
|
|
245
|
+
subscribable_categories: [{ category: 'ops', label: 'Operations', subscribed: false }],
|
|
246
|
+
});
|
|
247
|
+
notificationsApi.setNotificationCategorySubscription.mockResolvedValue({ category: 'ops', subscribed: true });
|
|
248
|
+
|
|
249
|
+
render(<NotificationSettings />);
|
|
250
|
+
const toggle = await screen.findByRole('switch', { name: 'Operations' });
|
|
251
|
+
expect(toggle.checked).toBe(false);
|
|
252
|
+
|
|
253
|
+
fireEvent.click(toggle);
|
|
254
|
+
|
|
255
|
+
await waitFor(() => expect(notificationsApi.setNotificationCategorySubscription).toHaveBeenCalledWith('ops', true));
|
|
256
|
+
await waitFor(() => expect(toggle.checked).toBe(true));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('reflects the server response, not the clicked checkbox value, when they disagree', async () => {
|
|
260
|
+
// Regression: a 2xx response reporting a different `subscribed` (e.g. a
|
|
261
|
+
// server-side clamp) must win over what the user clicked.
|
|
262
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({
|
|
263
|
+
email_opt_in: false, push_opt_in: false,
|
|
264
|
+
subscribable_categories: [{ category: 'ops', label: 'Operations', subscribed: false }],
|
|
265
|
+
});
|
|
266
|
+
notificationsApi.setNotificationCategorySubscription.mockResolvedValue({ category: 'ops', subscribed: false });
|
|
267
|
+
|
|
268
|
+
render(<NotificationSettings />);
|
|
269
|
+
const toggle = await screen.findByRole('switch', { name: 'Operations' });
|
|
270
|
+
|
|
271
|
+
fireEvent.click(toggle);
|
|
272
|
+
|
|
273
|
+
await waitFor(() => expect(notificationsApi.setNotificationCategorySubscription).toHaveBeenCalledWith('ops', true));
|
|
274
|
+
await waitFor(() => expect(toggle.checked).toBe(false));
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({
|
|
7
|
+
useTranslation: () => ({ t: (key, fallback) => (typeof fallback === 'string' ? fallback : key) }),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { PasswordChangeForm } from '../src/components/PasswordChangeForm';
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
cleanup();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function fillAndSubmit(currentPassword, newPassword) {
|
|
17
|
+
fireEvent.change(screen.getByLabelText(/Auth\.CURRENT_PASSWORD_LABEL/), { target: { value: currentPassword } });
|
|
18
|
+
fireEvent.change(screen.getByLabelText(/Auth\.NEW_PASSWORD_LABEL/), { target: { value: newPassword } });
|
|
19
|
+
fireEvent.click(screen.getByRole('button', { name: 'Auth.CHANGE_PASSWORD_BUTTON' }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('PasswordChangeForm', () => {
|
|
23
|
+
it('renders the default password-rules helperText', () => {
|
|
24
|
+
render(<PasswordChangeForm onSubmit={vi.fn()} />);
|
|
25
|
+
expect(screen.getAllByText('Auth.PASSWORD_RULES_HINT').length).toBeGreaterThan(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('lets a caller override the helperText via passwordRulesHint', () => {
|
|
29
|
+
render(<PasswordChangeForm onSubmit={vi.fn()} passwordRulesHint="Custom rule text" />);
|
|
30
|
+
expect(screen.getAllByText('Custom rule text').length).toBeGreaterThan(0);
|
|
31
|
+
expect(screen.queryByText('Auth.PASSWORD_RULES_HINT')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('blocks submit and shows the local error for a new password shorter than 8 characters', () => {
|
|
35
|
+
const onSubmit = vi.fn();
|
|
36
|
+
render(<PasswordChangeForm onSubmit={onSubmit} />);
|
|
37
|
+
fillAndSubmit('currentpw', 'short1');
|
|
38
|
+
expect(screen.getByText('Auth.PASSWORD_TOO_SHORT_LOCAL')).toBeTruthy();
|
|
39
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('blocks submit and shows the local error for an entirely numeric new password', () => {
|
|
43
|
+
const onSubmit = vi.fn();
|
|
44
|
+
render(<PasswordChangeForm onSubmit={onSubmit} />);
|
|
45
|
+
fillAndSubmit('currentpw', '12345678');
|
|
46
|
+
expect(screen.getByText('Auth.PASSWORD_NUMERIC_LOCAL')).toBeTruthy();
|
|
47
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('calls onSubmit for a valid, non-numeric new password of sufficient length', async () => {
|
|
51
|
+
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
|
52
|
+
render(<PasswordChangeForm onSubmit={onSubmit} />);
|
|
53
|
+
fillAndSubmit('currentpw', 'correct-horse-1');
|
|
54
|
+
expect(onSubmit).toHaveBeenCalledWith('currentpw', 'correct-horse-1');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('honours a custom minPasswordLength for an app with a looser backend policy', () => {
|
|
58
|
+
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
|
59
|
+
render(<PasswordChangeForm onSubmit={onSubmit} minPasswordLength={4} />);
|
|
60
|
+
fillAndSubmit('currentpw', 'ab12');
|
|
61
|
+
expect(onSubmit).toHaveBeenCalledWith('currentpw', 'ab12');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('allows an entirely numeric new password when allowNumericPassword is set', () => {
|
|
65
|
+
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
|
66
|
+
render(<PasswordChangeForm onSubmit={onSubmit} allowNumericPassword />);
|
|
67
|
+
fillAndSubmit('currentpw', '12345678');
|
|
68
|
+
expect(onSubmit).toHaveBeenCalledWith('currentpw', '12345678');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({
|
|
7
|
+
useTranslation: () => ({ t: (key, fallback) => (typeof fallback === 'string' ? fallback : key) }),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { PasswordSetForm } from '../src/components/PasswordSetForm';
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
cleanup();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function fillAndSubmit(password1, password2) {
|
|
17
|
+
fireEvent.change(screen.getByLabelText('Auth.NEW_PASSWORD_LABEL'), { target: { value: password1 } });
|
|
18
|
+
fireEvent.change(screen.getByLabelText('Auth.PASSWORD_CONFIRM_LABEL'), { target: { value: password2 } });
|
|
19
|
+
fireEvent.click(screen.getByRole('button', { name: 'Auth.PASSWORD_SET_BUTTON' }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('PasswordSetForm', () => {
|
|
23
|
+
it('renders the default password-rules helperText', () => {
|
|
24
|
+
render(<PasswordSetForm onSubmit={vi.fn()} />);
|
|
25
|
+
expect(screen.getAllByText('Auth.PASSWORD_RULES_HINT').length).toBeGreaterThan(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('lets a caller override the helperText via passwordRulesHint', () => {
|
|
29
|
+
render(<PasswordSetForm onSubmit={vi.fn()} passwordRulesHint="Custom rule text" />);
|
|
30
|
+
expect(screen.getAllByText('Custom rule text').length).toBeGreaterThan(0);
|
|
31
|
+
expect(screen.queryByText('Auth.PASSWORD_RULES_HINT')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('blocks submit and shows the local error for a password shorter than 8 characters', () => {
|
|
35
|
+
const onSubmit = vi.fn();
|
|
36
|
+
render(<PasswordSetForm onSubmit={onSubmit} />);
|
|
37
|
+
fillAndSubmit('short1', 'short1');
|
|
38
|
+
expect(screen.getByText('Auth.PASSWORD_TOO_SHORT_LOCAL')).toBeTruthy();
|
|
39
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('blocks submit and shows the local error for an entirely numeric password', () => {
|
|
43
|
+
const onSubmit = vi.fn();
|
|
44
|
+
render(<PasswordSetForm onSubmit={onSubmit} />);
|
|
45
|
+
fillAndSubmit('12345678', '12345678');
|
|
46
|
+
expect(screen.getByText('Auth.PASSWORD_NUMERIC_LOCAL')).toBeTruthy();
|
|
47
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('calls onSubmit for a valid, non-numeric password of sufficient length', () => {
|
|
51
|
+
const onSubmit = vi.fn();
|
|
52
|
+
render(<PasswordSetForm onSubmit={onSubmit} />);
|
|
53
|
+
fillAndSubmit('correct-horse-1', 'correct-horse-1');
|
|
54
|
+
expect(onSubmit).toHaveBeenCalledWith('correct-horse-1');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('honours a custom minPasswordLength for an app with a looser backend policy', () => {
|
|
58
|
+
const onSubmit = vi.fn();
|
|
59
|
+
render(<PasswordSetForm onSubmit={onSubmit} minPasswordLength={4} />);
|
|
60
|
+
fillAndSubmit('ab12', 'ab12');
|
|
61
|
+
expect(onSubmit).toHaveBeenCalledWith('ab12');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('allows an entirely numeric password when allowNumericPassword is set', () => {
|
|
65
|
+
const onSubmit = vi.fn();
|
|
66
|
+
render(<PasswordSetForm onSubmit={onSubmit} allowNumericPassword />);
|
|
67
|
+
fillAndSubmit('12345678', '12345678');
|
|
68
|
+
expect(onSubmit).toHaveBeenCalledWith('12345678');
|
|
69
|
+
});
|
|
70
|
+
});
|