@micha.bigler/ui-core-micha 2.2.14 → 2.3.1
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/dist/auth/authApi.js +16 -0
- package/dist/components/AccessCodeSingleUseToggle.js +59 -0
- package/dist/components/QrSignupManager.js +15 -2
- package/dist/i18n/authTranslations.js +114 -0
- package/dist/index.js +2 -0
- package/dist/pages/AccountPage.js +2 -1
- package/dist/pages/SignupConfirmPage.js +69 -0
- package/package.json +2 -2
- package/src/auth/authApi.jsx +16 -0
- package/src/components/AccessCodeSingleUseToggle.jsx +89 -0
- package/src/components/QrSignupManager.jsx +31 -0
- package/src/i18n/authTranslations.ts +114 -0
- package/src/index.js +2 -0
- package/src/pages/AccountPage.jsx +11 -0
- package/src/pages/SignupConfirmPage.jsx +157 -0
package/dist/auth/authApi.js
CHANGED
|
@@ -359,6 +359,22 @@ export async function submitRegistrationRequest({ email, mode, accessCode, regis
|
|
|
359
359
|
throw normaliseApiError(error, 'Auth.INVITE_FAILED');
|
|
360
360
|
}
|
|
361
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* S13: confirm a pending registration. The token comes from the email link;
|
|
364
|
+
* the password is the new account's first password.
|
|
365
|
+
*/
|
|
366
|
+
export async function confirmRegistration({ token, password }) {
|
|
367
|
+
try {
|
|
368
|
+
const res = await apiClient.post(`${USERS_BASE}/register-confirm/`, {
|
|
369
|
+
token,
|
|
370
|
+
password,
|
|
371
|
+
});
|
|
372
|
+
return res.data;
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
throw normaliseApiError(error, 'Auth.PENDING_TOKEN_INVALID');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
362
378
|
export async function createSignupQr(payload = {}) {
|
|
363
379
|
try {
|
|
364
380
|
const res = await apiClient.post(`${USERS_BASE}/signup-qr/`, payload);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useEffect, useState } from 'react';
|
|
3
|
+
import { Alert, Box, FormControlLabel, Switch, Typography, } from '@mui/material';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { fetchAuthPolicy, updateAuthPolicy } from '../auth/authApi';
|
|
6
|
+
export function AccessCodeSingleUseToggle({ canEdit = true, policy = null, onPolicyChange = null }) {
|
|
7
|
+
const { t } = useTranslation();
|
|
8
|
+
const [value, setValue] = useState(false);
|
|
9
|
+
const [busy, setBusy] = useState(false);
|
|
10
|
+
const [error, setError] = useState('');
|
|
11
|
+
const [success, setSuccess] = useState('');
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (policy) {
|
|
14
|
+
setValue(Boolean(policy === null || policy === void 0 ? void 0 : policy.access_code_single_use));
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
let active = true;
|
|
18
|
+
(async () => {
|
|
19
|
+
try {
|
|
20
|
+
const data = await fetchAuthPolicy();
|
|
21
|
+
if (active) {
|
|
22
|
+
setValue(Boolean(data === null || data === void 0 ? void 0 : data.access_code_single_use));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (_a) {
|
|
26
|
+
// Keep defaults when policy is unavailable.
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
29
|
+
return () => {
|
|
30
|
+
active = false;
|
|
31
|
+
};
|
|
32
|
+
}, [policy]);
|
|
33
|
+
const handleChange = async (event) => {
|
|
34
|
+
if (!canEdit) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const nextValue = event.target.checked;
|
|
38
|
+
const previous = value;
|
|
39
|
+
setValue(nextValue);
|
|
40
|
+
setBusy(true);
|
|
41
|
+
setError('');
|
|
42
|
+
setSuccess('');
|
|
43
|
+
try {
|
|
44
|
+
const updated = await updateAuthPolicy({ access_code_single_use: nextValue });
|
|
45
|
+
if (onPolicyChange) {
|
|
46
|
+
onPolicyChange((current) => (Object.assign(Object.assign({}, (current || {})), (updated || {}))));
|
|
47
|
+
}
|
|
48
|
+
setSuccess(t('Auth.ACCESS_CODE_SINGLE_USE_SAVE_SUCCESS', 'Access code policy saved.'));
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
setValue(previous);
|
|
52
|
+
setError(t((err === null || err === void 0 ? void 0 : err.code) || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save access code policy.'));
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
setBusy(false);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.ACCESS_CODE_SINGLE_USE_TITLE', 'Access Code Single-Use') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.ACCESS_CODE_SINGLE_USE_HINT', 'When enabled, each access code can be redeemed only once. Recommended.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsx(FormControlLabel, { control: _jsx(Switch, { checked: value, disabled: busy || !canEdit, onChange: handleChange }), label: t('Auth.ACCESS_CODE_SINGLE_USE_TOGGLE', 'Codes are single-use') })] }));
|
|
59
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { Alert, Box, Button, Stack, Typography, } from '@mui/material';
|
|
3
|
+
import { Alert, Box, Button, Stack, TextField, Typography, } from '@mui/material';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { QRCodeSVG } from 'qrcode.react';
|
|
6
6
|
import { createSignupQr } from '../auth/authApi';
|
|
7
7
|
const DEFAULT_EXPIRY_DAYS = 90;
|
|
8
|
+
const DEFAULT_MAX_REDEMPTIONS = 1;
|
|
8
9
|
function clampExpiryDays(value) {
|
|
9
10
|
const parsed = parseInt(value, 10);
|
|
10
11
|
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
@@ -12,6 +13,13 @@ function clampExpiryDays(value) {
|
|
|
12
13
|
}
|
|
13
14
|
return parsed;
|
|
14
15
|
}
|
|
16
|
+
function clampMaxRedemptions(value) {
|
|
17
|
+
const parsed = parseInt(value, 10);
|
|
18
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
19
|
+
return DEFAULT_MAX_REDEMPTIONS;
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
15
23
|
function escapeHtml(value) {
|
|
16
24
|
return String(value !== null && value !== void 0 ? value : '')
|
|
17
25
|
.replace(/&/g, '&')
|
|
@@ -28,6 +36,7 @@ export function QrSignupManager({ enabled = false, expiryDays = DEFAULT_EXPIRY_D
|
|
|
28
36
|
const [success, setSuccess] = useState('');
|
|
29
37
|
const [result, setResult] = useState(null);
|
|
30
38
|
const [copyState, setCopyState] = useState('idle');
|
|
39
|
+
const [maxRedemptions, setMaxRedemptions] = useState(DEFAULT_MAX_REDEMPTIONS);
|
|
31
40
|
const hasGeneratedRef = useRef(false);
|
|
32
41
|
const formattedExpiry = useMemo(() => {
|
|
33
42
|
if (!(result === null || result === void 0 ? void 0 : result.expires_at)) {
|
|
@@ -51,6 +60,7 @@ export function QrSignupManager({ enabled = false, expiryDays = DEFAULT_EXPIRY_D
|
|
|
51
60
|
return;
|
|
52
61
|
}
|
|
53
62
|
const nextDays = clampExpiryDays(daysOverride !== null && daysOverride !== void 0 ? daysOverride : expiryDays);
|
|
63
|
+
const nextRedemptions = clampMaxRedemptions(maxRedemptions);
|
|
54
64
|
setBusy(true);
|
|
55
65
|
setError('');
|
|
56
66
|
setSuccess('');
|
|
@@ -58,6 +68,7 @@ export function QrSignupManager({ enabled = false, expiryDays = DEFAULT_EXPIRY_D
|
|
|
58
68
|
try {
|
|
59
69
|
const data = await createSignupQr({
|
|
60
70
|
expires_minutes: nextDays * 24 * 60,
|
|
71
|
+
max_redemptions: nextRedemptions,
|
|
61
72
|
});
|
|
62
73
|
setResult(data);
|
|
63
74
|
hasGeneratedRef.current = true;
|
|
@@ -76,6 +87,7 @@ export function QrSignupManager({ enabled = false, expiryDays = DEFAULT_EXPIRY_D
|
|
|
76
87
|
setError('');
|
|
77
88
|
setSuccess('');
|
|
78
89
|
setCopyState('idle');
|
|
90
|
+
setMaxRedemptions(DEFAULT_MAX_REDEMPTIONS);
|
|
79
91
|
hasGeneratedRef.current = false;
|
|
80
92
|
return;
|
|
81
93
|
}
|
|
@@ -92,6 +104,7 @@ export function QrSignupManager({ enabled = false, expiryDays = DEFAULT_EXPIRY_D
|
|
|
92
104
|
const days = clampExpiryDays(expiryDays);
|
|
93
105
|
const data = await createSignupQr({
|
|
94
106
|
expires_minutes: days * 24 * 60,
|
|
107
|
+
max_redemptions: clampMaxRedemptions(maxRedemptions),
|
|
95
108
|
});
|
|
96
109
|
if (!active)
|
|
97
110
|
return;
|
|
@@ -244,7 +257,7 @@ export function QrSignupManager({ enabled = false, expiryDays = DEFAULT_EXPIRY_D
|
|
|
244
257
|
if (!enabled) {
|
|
245
258
|
return null;
|
|
246
259
|
}
|
|
247
|
-
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.SIGNUP_QR_MANAGER_TITLE', 'QR Signup') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.SIGNUP_QR_MANAGER_HINT', 'Generate and share QR signup links below.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), copyState === 'copied' && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: t('Auth.SIGNUP_QR_LINK_COPIED', 'Signup link copied.') })), copyState === 'error' && (_jsx(Alert, { severity: "warning", sx: { mb: 2 }, children: t('Auth.SIGNUP_QR_COPY_UNAVAILABLE', 'Copying the link is not available in this browser.') })), (result === null || result === void 0 ? void 0 : result.signup_url) && (_jsxs(Box, { sx: { mt: 1 }, children: [_jsx(Box, { sx: {
|
|
260
|
+
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.SIGNUP_QR_MANAGER_TITLE', 'QR Signup') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.SIGNUP_QR_MANAGER_HINT', 'Generate and share QR signup links below.') }), _jsx(Box, { sx: { mb: 2 }, children: _jsx(TextField, { label: t('Auth.SIGNUP_QR_MAX_REDEMPTIONS_LABEL', 'Max redemptions'), type: "number", size: "small", value: maxRedemptions, onChange: (e) => setMaxRedemptions(e.target.value), inputProps: { min: 1, step: 1 }, helperText: t('Auth.SIGNUP_QR_MAX_REDEMPTIONS_HINT', 'How many people may sign up with the same QR. Default: 1.'), disabled: busy }) }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), copyState === 'copied' && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: t('Auth.SIGNUP_QR_LINK_COPIED', 'Signup link copied.') })), copyState === 'error' && (_jsx(Alert, { severity: "warning", sx: { mb: 2 }, children: t('Auth.SIGNUP_QR_COPY_UNAVAILABLE', 'Copying the link is not available in this browser.') })), (result === null || result === void 0 ? void 0 : result.signup_url) && (_jsxs(Box, { sx: { mt: 1 }, children: [_jsx(Box, { sx: {
|
|
248
261
|
display: 'flex',
|
|
249
262
|
justifyContent: 'center',
|
|
250
263
|
alignItems: 'center',
|
|
@@ -1643,5 +1643,119 @@ export const authTranslations = {
|
|
|
1643
1643
|
"fr": "Chargement…",
|
|
1644
1644
|
"en": "Loading…",
|
|
1645
1645
|
"sw": "Inapakia..."
|
|
1646
|
+
},
|
|
1647
|
+
"Auth.SIGNUP_CONFIRM_TITLE": {
|
|
1648
|
+
"de": "Registrierung bestätigen",
|
|
1649
|
+
"fr": "Confirmer l'inscription",
|
|
1650
|
+
"en": "Confirm your registration",
|
|
1651
|
+
"sw": "Thibitisha usajili wako"
|
|
1652
|
+
},
|
|
1653
|
+
"Auth.SIGNUP_CONFIRM_SUBTITLE": {
|
|
1654
|
+
"de": "Wähle ein Passwort, um dein Konto zu erstellen.",
|
|
1655
|
+
"fr": "Choisissez un mot de passe pour finaliser votre compte.",
|
|
1656
|
+
"en": "Choose a password to finish creating your account.",
|
|
1657
|
+
"sw": "Chagua nenosiri ili kukamilisha kuunda akaunti yako."
|
|
1658
|
+
},
|
|
1659
|
+
"Auth.SIGNUP_CONFIRM_SUBMIT": {
|
|
1660
|
+
"de": "Registrierung abschließen",
|
|
1661
|
+
"fr": "Confirmer l'inscription",
|
|
1662
|
+
"en": "Confirm registration",
|
|
1663
|
+
"sw": "Thibitisha usajili"
|
|
1664
|
+
},
|
|
1665
|
+
"Auth.SIGNUP_CONFIRM_SUBMITTING": {
|
|
1666
|
+
"de": "Wird bestätigt …",
|
|
1667
|
+
"fr": "Confirmation en cours…",
|
|
1668
|
+
"en": "Confirming…",
|
|
1669
|
+
"sw": "Inathibitisha…"
|
|
1670
|
+
},
|
|
1671
|
+
"Auth.PASSWORD_TOO_SHORT": {
|
|
1672
|
+
"de": "Das Passwort muss mindestens 8 Zeichen lang sein.",
|
|
1673
|
+
"fr": "Le mot de passe doit comporter au moins 8 caractères.",
|
|
1674
|
+
"en": "Password must be at least 8 characters long.",
|
|
1675
|
+
"sw": "Nenosiri lazima liwe na herufi 8 au zaidi."
|
|
1676
|
+
},
|
|
1677
|
+
"Auth.PASSWORD_MISMATCH": {
|
|
1678
|
+
"de": "Die Passwörter stimmen nicht überein.",
|
|
1679
|
+
"fr": "Les mots de passe ne correspondent pas.",
|
|
1680
|
+
"en": "Passwords do not match.",
|
|
1681
|
+
"sw": "Manenosiri hayalingani."
|
|
1682
|
+
},
|
|
1683
|
+
"Auth.SIGNUP_REQUEST_NEW": {
|
|
1684
|
+
"de": "Neue Einladung anfordern",
|
|
1685
|
+
"fr": "Demander une nouvelle invitation",
|
|
1686
|
+
"en": "Request a new invitation",
|
|
1687
|
+
"sw": "Omba mwaliko mpya"
|
|
1688
|
+
},
|
|
1689
|
+
"Auth.PASSWORD_INVALID": {
|
|
1690
|
+
"de": "Das Passwort erfüllt die Sicherheitsanforderungen nicht.",
|
|
1691
|
+
"fr": "Le mot de passe ne respecte pas les exigences de sécurité.",
|
|
1692
|
+
"en": "The password does not meet the security requirements.",
|
|
1693
|
+
"sw": "Nenosiri halikidhi mahitaji ya usalama."
|
|
1694
|
+
},
|
|
1695
|
+
"Auth.PENDING_TOKEN_INVALID": {
|
|
1696
|
+
"de": "Dieser Bestätigungslink ist ungültig oder abgelaufen.",
|
|
1697
|
+
"fr": "Ce lien de confirmation est invalide ou expiré.",
|
|
1698
|
+
"en": "This confirmation link is invalid or expired.",
|
|
1699
|
+
"sw": "Kiungo hiki cha uthibitishaji si halali au kimemalizika muda."
|
|
1700
|
+
},
|
|
1701
|
+
"Auth.PENDING_TOKEN_EXPIRED": {
|
|
1702
|
+
"de": "Diese Einladung ist abgelaufen. Bitte fordere eine neue an.",
|
|
1703
|
+
"fr": "Cette invitation a expiré. Veuillez en demander une nouvelle.",
|
|
1704
|
+
"en": "This invitation has expired. Please request a new one.",
|
|
1705
|
+
"sw": "Mwaliko huu umemalizika muda. Tafadhali omba mpya."
|
|
1706
|
+
},
|
|
1707
|
+
"Auth.ACCESS_CODE_ALREADY_USED": {
|
|
1708
|
+
"de": "Der verwendete Zugangscode ist nicht mehr gültig.",
|
|
1709
|
+
"fr": "Le code d'accès utilisé n'est plus valide.",
|
|
1710
|
+
"en": "The access code used is no longer valid.",
|
|
1711
|
+
"sw": "Msimbo wa ufikiaji uliotumika hauwezi kutumika tena."
|
|
1712
|
+
},
|
|
1713
|
+
"Auth.QR_TOKEN_EXHAUSTED": {
|
|
1714
|
+
"de": "Dieser QR-Code wurde bereits vollständig genutzt.",
|
|
1715
|
+
"fr": "Ce QR-code a déjà été entièrement utilisé.",
|
|
1716
|
+
"en": "This QR code has already been fully used.",
|
|
1717
|
+
"sw": "Msimbo huu wa QR tayari umetumika kabisa."
|
|
1718
|
+
},
|
|
1719
|
+
"Auth.USER_ALREADY_EXISTS": {
|
|
1720
|
+
"de": "Für diese E-Mail-Adresse existiert bereits ein Konto. Bitte melde dich an.",
|
|
1721
|
+
"fr": "Un compte existe déjà pour cette adresse e-mail. Veuillez vous connecter.",
|
|
1722
|
+
"en": "An account already exists for this email address. Please log in.",
|
|
1723
|
+
"sw": "Akaunti tayari ipo kwa anwani hii ya barua pepe. Tafadhali ingia."
|
|
1724
|
+
},
|
|
1725
|
+
"Auth.SIGNUP_QR_MAX_REDEMPTIONS_LABEL": {
|
|
1726
|
+
"de": "Max. Verwendungen",
|
|
1727
|
+
"fr": "Utilisations max.",
|
|
1728
|
+
"en": "Max redemptions",
|
|
1729
|
+
"sw": "Idadi ya juu ya matumizi"
|
|
1730
|
+
},
|
|
1731
|
+
"Auth.SIGNUP_QR_MAX_REDEMPTIONS_HINT": {
|
|
1732
|
+
"de": "Wie viele Personen dürfen sich mit demselben QR registrieren. Standard: 1.",
|
|
1733
|
+
"fr": "Combien de personnes peuvent s'inscrire avec le même QR. Par défaut : 1.",
|
|
1734
|
+
"en": "How many people may sign up with the same QR. Default: 1.",
|
|
1735
|
+
"sw": "Watu wangapi wanaweza kujisajili na QR sawa. Chaguo-msingi: 1."
|
|
1736
|
+
},
|
|
1737
|
+
"Auth.ACCESS_CODE_SINGLE_USE_TITLE": {
|
|
1738
|
+
"de": "Zugangscode: Einmalverwendung",
|
|
1739
|
+
"fr": "Code d'accès : usage unique",
|
|
1740
|
+
"en": "Access Code Single-Use",
|
|
1741
|
+
"sw": "Msimbo wa Ufikiaji: Matumizi ya Mara Moja"
|
|
1742
|
+
},
|
|
1743
|
+
"Auth.ACCESS_CODE_SINGLE_USE_HINT": {
|
|
1744
|
+
"de": "Wenn aktiviert, kann jeder Zugangscode nur einmal eingelöst werden. Empfohlen.",
|
|
1745
|
+
"fr": "Lorsqu'activé, chaque code d'accès ne peut être utilisé qu'une seule fois. Recommandé.",
|
|
1746
|
+
"en": "When enabled, each access code can be redeemed only once. Recommended.",
|
|
1747
|
+
"sw": "Ikiwa imewashwa, kila msimbo wa ufikiaji unaweza kutumika mara moja tu. Inapendekezwa."
|
|
1748
|
+
},
|
|
1749
|
+
"Auth.ACCESS_CODE_SINGLE_USE_TOGGLE": {
|
|
1750
|
+
"de": "Codes sind einmalig verwendbar",
|
|
1751
|
+
"fr": "Codes à usage unique",
|
|
1752
|
+
"en": "Codes are single-use",
|
|
1753
|
+
"sw": "Misimbo ni ya matumizi ya mara moja"
|
|
1754
|
+
},
|
|
1755
|
+
"Auth.ACCESS_CODE_SINGLE_USE_SAVE_SUCCESS": {
|
|
1756
|
+
"de": "Zugangscode-Richtlinie gespeichert.",
|
|
1757
|
+
"fr": "Politique de code d'accès enregistrée.",
|
|
1758
|
+
"en": "Access code policy saved.",
|
|
1759
|
+
"sw": "Sera ya msimbo wa ufikiaji imehifadhiwa."
|
|
1646
1760
|
}
|
|
1647
1761
|
};
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { PasswordResetRequestPage } from './pages/PasswordResetRequestPage';
|
|
|
15
15
|
export { PasswordChangePage } from './pages/PasswordChangePage';
|
|
16
16
|
export { PasswordInvitePage } from './pages/PasswordInvitePage';
|
|
17
17
|
export { SignUpPage } from './pages/SignUpPage';
|
|
18
|
+
export { SignupConfirmPage } from './pages/SignupConfirmPage';
|
|
18
19
|
export { AccountPage } from './pages/AccountPage';
|
|
19
20
|
// --- 5. Components (Wiederverwendbare UI-Teile) ---
|
|
20
21
|
export { ProfileComponent } from './components/ProfileComponent';
|
|
@@ -24,6 +25,7 @@ export { UserInviteComponent } from './components/UserInviteComponent';
|
|
|
24
25
|
export { BulkInviteCsvTab } from './components/BulkInviteCsvTab';
|
|
25
26
|
export { RegistrationMethodsManager } from './components/RegistrationMethodsManager';
|
|
26
27
|
export { AuthFactorRequirementCard } from './components/AuthFactorRequirementCard';
|
|
28
|
+
export { AccessCodeSingleUseToggle } from './components/AccessCodeSingleUseToggle';
|
|
27
29
|
export { QrSignupManager } from './components/QrSignupManager';
|
|
28
30
|
// --- 6. Translations ---
|
|
29
31
|
export { authTranslations } from './i18n/authTranslations';
|
|
@@ -16,6 +16,7 @@ import { AccessCodeManager } from '../components/AccessCodeManager';
|
|
|
16
16
|
import { AllowedEmailDomainsManager } from '../components/AllowedEmailDomainsManager';
|
|
17
17
|
import { RegistrationMethodsManager } from '../components/RegistrationMethodsManager';
|
|
18
18
|
import { AuthFactorRequirementCard } from '../components/AuthFactorRequirementCard';
|
|
19
|
+
import { AccessCodeSingleUseToggle } from '../components/AccessCodeSingleUseToggle';
|
|
19
20
|
import { QrSignupManager } from '../components/QrSignupManager';
|
|
20
21
|
import { QrSignupValidityManager } from '../components/QrSignupValidityManager';
|
|
21
22
|
import { SupportRecoveryRequestsTab } from '../components/SupportRecoveryRequestsTab';
|
|
@@ -129,5 +130,5 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
|
|
|
129
130
|
const activeExtraTab = builtInTabValues.has(safeTab)
|
|
130
131
|
? null
|
|
131
132
|
: extraTabs.find((tab) => tab.value === safeTab);
|
|
132
|
-
return (_jsxs(WidePage, { title: t('Account.TITLE', 'Account & Administration'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('Account.PAGE_TITLE', 'Account'), " \u2013 ", user.email] }) }), _jsx(Tabs, { value: safeTab, onChange: handleTabChange, variant: "scrollable", scrollButtons: "auto", sx: { mb: 3, borderBottom: 1, borderColor: 'divider' }, children: tabs.map((tab) => (_jsx(Tab, { label: tab.label, value: tab.value }, tab.value))) }), safeTab === 'profile' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(ProfileComponent, { onSubmit: handleProfileSubmit, showName: true, showPrivacy: true, showCookies: true }) })), safeTab === 'security' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SecurityComponent, { fromRecovery: fromRecovery, fromWeakLogin: fromWeakLogin }) })), safeTab === 'users' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(UserListComponent, { roles: activeRoles, currentUser: user, extraColumns: userListExtraColumns, extraRowActions: userListExtraRowActions, extraContext: userListExtraContext, refreshTrigger: userListRefreshTrigger, canEditUser: userListCanEditUser, showNewColumn: userListShowNewColumn, showSuccessfulLoginColumn: userListShowSuccessfulLoginColumn, showRoleColumn: userListShowRoleColumn, onChangeRole: userListOnChangeRole, showDeleteAction: userListShowDeleteAction, canDeleteUser: userListCanDeleteUser, onDeleteUser: userListOnDeleteUser }) })), safeTab === 'invite' && (_jsx(Box, { sx: { mt: 2 }, children: _jsxs(Stack, { spacing: 2.5, children: [canViewAuthPolicy && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(AuthFactorRequirementCard, { canEdit: canWriteAuthPolicy, policy: authPolicy }) })), canViewAuthPolicy && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(RegistrationMethodsManager, { policy: authPolicy, error: authPolicyError, onPolicyChange: setAuthPolicy, canEdit: canWriteAuthPolicy }) })), canSendInvites && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_admin_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(UserInviteComponent, {}) })), canManageAccessCodes && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_access_code) && (_jsxs(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.ACCESS_CODE_MANAGER_TITLE', 'Access Codes') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.') }), _jsx(AccessCodeManager, {})] })), canSendInvites && showBulkInviteCsvTab && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_admin_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(BulkInviteCsvTab, Object.assign({}, bulkInviteCsvProps)) })), canViewAuthPolicy && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_email_domain) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(AllowedEmailDomainsManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_email_domain), domains: (authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allowed_email_domains) || [], onPolicyChange: setAuthPolicy, canEdit: canWriteAuthPolicy }) })), canViewAuthPolicy && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(QrSignupValidityManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr), expiryDays: authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.signup_qr_expiry_days, onPolicyChange: setAuthPolicy, canEdit: canWriteAuthPolicy }) })), canManageSignupQr && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(QrSignupManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr), expiryDays: authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.signup_qr_expiry_days }) }))] }) })), safeTab === 'support' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SupportRecoveryRequestsTab, {}) })), activeExtraTab && (_jsx(Box, { sx: { mt: 2 }, children: (_a = activeExtraTab.render) === null || _a === void 0 ? void 0 : _a.call(activeExtraTab, { user, perms, isSuperUser, t }) }))] }));
|
|
133
|
+
return (_jsxs(WidePage, { title: t('Account.TITLE', 'Account & Administration'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('Account.PAGE_TITLE', 'Account'), " \u2013 ", user.email] }) }), _jsx(Tabs, { value: safeTab, onChange: handleTabChange, variant: "scrollable", scrollButtons: "auto", sx: { mb: 3, borderBottom: 1, borderColor: 'divider' }, children: tabs.map((tab) => (_jsx(Tab, { label: tab.label, value: tab.value }, tab.value))) }), safeTab === 'profile' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(ProfileComponent, { onSubmit: handleProfileSubmit, showName: true, showPrivacy: true, showCookies: true }) })), safeTab === 'security' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SecurityComponent, { fromRecovery: fromRecovery, fromWeakLogin: fromWeakLogin }) })), safeTab === 'users' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(UserListComponent, { roles: activeRoles, currentUser: user, extraColumns: userListExtraColumns, extraRowActions: userListExtraRowActions, extraContext: userListExtraContext, refreshTrigger: userListRefreshTrigger, canEditUser: userListCanEditUser, showNewColumn: userListShowNewColumn, showSuccessfulLoginColumn: userListShowSuccessfulLoginColumn, showRoleColumn: userListShowRoleColumn, onChangeRole: userListOnChangeRole, showDeleteAction: userListShowDeleteAction, canDeleteUser: userListCanDeleteUser, onDeleteUser: userListOnDeleteUser }) })), safeTab === 'invite' && (_jsx(Box, { sx: { mt: 2 }, children: _jsxs(Stack, { spacing: 2.5, children: [canViewAuthPolicy && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(AuthFactorRequirementCard, { canEdit: canWriteAuthPolicy, policy: authPolicy }) })), canViewAuthPolicy && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(RegistrationMethodsManager, { policy: authPolicy, error: authPolicyError, onPolicyChange: setAuthPolicy, canEdit: canWriteAuthPolicy }) })), canSendInvites && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_admin_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(UserInviteComponent, {}) })), canManageAccessCodes && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_access_code) && (_jsxs(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.ACCESS_CODE_MANAGER_TITLE', 'Access Codes') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.') }), _jsx(AccessCodeManager, {})] })), canManageAccessCodes && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_access_code) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(AccessCodeSingleUseToggle, { canEdit: canWriteAuthPolicy, policy: authPolicy, onPolicyChange: setAuthPolicy }) })), canSendInvites && showBulkInviteCsvTab && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_admin_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(BulkInviteCsvTab, Object.assign({}, bulkInviteCsvProps)) })), canViewAuthPolicy && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_email_domain) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(AllowedEmailDomainsManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_email_domain), domains: (authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allowed_email_domains) || [], onPolicyChange: setAuthPolicy, canEdit: canWriteAuthPolicy }) })), canViewAuthPolicy && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(QrSignupValidityManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr), expiryDays: authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.signup_qr_expiry_days, onPolicyChange: setAuthPolicy, canEdit: canWriteAuthPolicy }) })), canManageSignupQr && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(QrSignupManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr), expiryDays: authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.signup_qr_expiry_days }) }))] }) })), safeTab === 'support' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SupportRecoveryRequestsTab, {}) })), activeExtraTab && (_jsx(Box, { sx: { mt: 2 }, children: (_a = activeExtraTab.render) === null || _a === void 0 ? void 0 : _a.call(activeExtraTab, { user, perms, isSuperUser, t }) }))] }));
|
|
133
134
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// src/pages/SignupConfirmPage.jsx
|
|
3
|
+
//
|
|
4
|
+
// S13: Completes a pending registration. Reads the signed pending-token from
|
|
5
|
+
// the URL (`?token=...`), asks the user for a password, and POSTs to
|
|
6
|
+
// `register_confirm`. On success, the user is logged in by the backend session
|
|
7
|
+
// cookie; we hand off to AuthContext.login so the SPA picks it up immediately.
|
|
8
|
+
import React, { useContext, useMemo, useState } from 'react';
|
|
9
|
+
import { useLocation, useNavigate } from 'react-router-dom';
|
|
10
|
+
import { Alert, Box, Button, Stack, TextField, Typography, } from '@mui/material';
|
|
11
|
+
import { Helmet } from 'react-helmet';
|
|
12
|
+
import { useTranslation } from 'react-i18next';
|
|
13
|
+
import { NarrowPage } from '../layout/PageLayout';
|
|
14
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
15
|
+
import { confirmRegistration, fetchCurrentUser } from '../auth/authApi';
|
|
16
|
+
export function SignupConfirmPage() {
|
|
17
|
+
const { t } = useTranslation();
|
|
18
|
+
const location = useLocation();
|
|
19
|
+
const navigate = useNavigate();
|
|
20
|
+
const { login } = useContext(AuthContext);
|
|
21
|
+
const tokenFromUrl = useMemo(() => {
|
|
22
|
+
const query = new URLSearchParams(location.search);
|
|
23
|
+
return query.get('token') || '';
|
|
24
|
+
}, [location.search]);
|
|
25
|
+
const [password, setPassword] = useState('');
|
|
26
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
27
|
+
const [submitting, setSubmitting] = useState(false);
|
|
28
|
+
const [errorKey, setErrorKey] = useState(null);
|
|
29
|
+
const handleSubmit = async (event) => {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
setErrorKey(null);
|
|
32
|
+
if (!tokenFromUrl) {
|
|
33
|
+
setErrorKey('Auth.PENDING_TOKEN_INVALID');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!password || password.length < 8) {
|
|
37
|
+
setErrorKey('Auth.PASSWORD_TOO_SHORT');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (password !== confirmPassword) {
|
|
41
|
+
setErrorKey('Auth.PASSWORD_MISMATCH');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
setSubmitting(true);
|
|
45
|
+
try {
|
|
46
|
+
await confirmRegistration({ token: tokenFromUrl, password });
|
|
47
|
+
// Refresh user state from session before navigating home.
|
|
48
|
+
try {
|
|
49
|
+
const user = await fetchCurrentUser();
|
|
50
|
+
login(user);
|
|
51
|
+
}
|
|
52
|
+
catch (_a) {
|
|
53
|
+
// If user fetch fails, still navigate — the session cookie is set
|
|
54
|
+
// server-side and the next page-load will pick the user up.
|
|
55
|
+
}
|
|
56
|
+
navigate('/', { replace: true });
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
setErrorKey((err === null || err === void 0 ? void 0 : err.code) || 'Auth.PENDING_TOKEN_INVALID');
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
setSubmitting(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const tokenMissing = !tokenFromUrl;
|
|
66
|
+
return (_jsxs(NarrowPage, { title: t('Auth.SIGNUP_CONFIRM_TITLE', 'Confirm your registration'), subtitle: t('Auth.SIGNUP_CONFIRM_SUBTITLE', 'Choose a password to finish creating your account.'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.SIGNUP_CONFIRM_TITLE', 'Confirm your registration')] }) }), tokenMissing ? (_jsxs(_Fragment, { children: [_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t('Auth.PENDING_TOKEN_INVALID', 'This confirmation link is invalid or expired.') }), _jsxs(Stack, { direction: "row", spacing: 1, sx: { mt: 1 }, children: [_jsx(Button, { onClick: () => navigate('/signup'), variant: "contained", children: t('Auth.SIGNUP_REQUEST_NEW', 'Request a new invitation') }), _jsx(Button, { onClick: () => navigate('/login'), variant: "text", children: t('Auth.SIGNUP_GO_TO_LOGIN', 'Go to login') })] })] })) : (_jsxs(_Fragment, { children: [errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey, t('Auth.PENDING_TOKEN_INVALID', 'Could not confirm registration.')) })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.NEW_PASSWORD_LABEL', 'New password'), type: "password", required: true, fullWidth: true, value: password, onChange: (e) => setPassword(e.target.value), disabled: submitting, autoComplete: "new-password" }), _jsx(TextField, { label: t('Auth.PASSWORD_CONFIRM_LABEL', 'Confirm new password'), type: "password", required: true, fullWidth: true, value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), disabled: submitting, autoComplete: "new-password" }), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting, children: submitting
|
|
67
|
+
? t('Auth.SIGNUP_CONFIRM_SUBMITTING', 'Confirming…')
|
|
68
|
+
: t('Auth.SIGNUP_CONFIRM_SUBMIT', 'Confirm registration') })] }), _jsxs(Stack, { direction: "row", spacing: 1, sx: { mt: 3 }, children: [_jsx(Typography, { variant: "body2", children: t('Auth.SIGNUP_ALREADY_HAVE_ACCOUNT', 'Already have an account?') }), _jsx(Button, { onClick: () => navigate('/login'), variant: "text", size: "small", children: t('Auth.SIGNUP_GO_TO_LOGIN', 'Go to login') })] })] }))] }));
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@micha.bigler/ui-core-micha",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"module": "dist/index.js",
|
|
6
6
|
"repository": {
|
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
"build": "tsc -p tsconfig.build.json"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
+
"@mui/icons-material": "^7.3.11",
|
|
27
28
|
"typescript": "^5.9.3"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
31
|
"react-i18next": "^16.3.5"
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
|
-
|
package/src/auth/authApi.jsx
CHANGED
|
@@ -377,6 +377,22 @@ export async function submitRegistrationRequest({
|
|
|
377
377
|
}
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
/**
|
|
381
|
+
* S13: confirm a pending registration. The token comes from the email link;
|
|
382
|
+
* the password is the new account's first password.
|
|
383
|
+
*/
|
|
384
|
+
export async function confirmRegistration({ token, password }) {
|
|
385
|
+
try {
|
|
386
|
+
const res = await apiClient.post(`${USERS_BASE}/register-confirm/`, {
|
|
387
|
+
token,
|
|
388
|
+
password,
|
|
389
|
+
});
|
|
390
|
+
return res.data;
|
|
391
|
+
} catch (error) {
|
|
392
|
+
throw normaliseApiError(error, 'Auth.PENDING_TOKEN_INVALID');
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
380
396
|
export async function createSignupQr(payload = {}) {
|
|
381
397
|
try {
|
|
382
398
|
const res = await apiClient.post(`${USERS_BASE}/signup-qr/`, payload);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
Box,
|
|
5
|
+
FormControlLabel,
|
|
6
|
+
Switch,
|
|
7
|
+
Typography,
|
|
8
|
+
} from '@mui/material';
|
|
9
|
+
import { useTranslation } from 'react-i18next';
|
|
10
|
+
import { fetchAuthPolicy, updateAuthPolicy } from '../auth/authApi';
|
|
11
|
+
|
|
12
|
+
export function AccessCodeSingleUseToggle({ canEdit = true, policy = null, onPolicyChange = null }) {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const [value, setValue] = useState(false);
|
|
15
|
+
const [busy, setBusy] = useState(false);
|
|
16
|
+
const [error, setError] = useState('');
|
|
17
|
+
const [success, setSuccess] = useState('');
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (policy) {
|
|
21
|
+
setValue(Boolean(policy?.access_code_single_use));
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
let active = true;
|
|
25
|
+
(async () => {
|
|
26
|
+
try {
|
|
27
|
+
const data = await fetchAuthPolicy();
|
|
28
|
+
if (active) {
|
|
29
|
+
setValue(Boolean(data?.access_code_single_use));
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Keep defaults when policy is unavailable.
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
return () => {
|
|
36
|
+
active = false;
|
|
37
|
+
};
|
|
38
|
+
}, [policy]);
|
|
39
|
+
|
|
40
|
+
const handleChange = async (event) => {
|
|
41
|
+
if (!canEdit) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const nextValue = event.target.checked;
|
|
45
|
+
const previous = value;
|
|
46
|
+
setValue(nextValue);
|
|
47
|
+
setBusy(true);
|
|
48
|
+
setError('');
|
|
49
|
+
setSuccess('');
|
|
50
|
+
try {
|
|
51
|
+
const updated = await updateAuthPolicy({ access_code_single_use: nextValue });
|
|
52
|
+
if (onPolicyChange) {
|
|
53
|
+
onPolicyChange((current) => ({ ...(current || {}), ...(updated || {}) }));
|
|
54
|
+
}
|
|
55
|
+
setSuccess(t('Auth.ACCESS_CODE_SINGLE_USE_SAVE_SUCCESS', 'Access code policy saved.'));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
setValue(previous);
|
|
58
|
+
setError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save access code policy.'));
|
|
59
|
+
} finally {
|
|
60
|
+
setBusy(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Box>
|
|
66
|
+
<Typography variant="h6" gutterBottom>
|
|
67
|
+
{t('Auth.ACCESS_CODE_SINGLE_USE_TITLE', 'Access Code Single-Use')}
|
|
68
|
+
</Typography>
|
|
69
|
+
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
|
70
|
+
{t(
|
|
71
|
+
'Auth.ACCESS_CODE_SINGLE_USE_HINT',
|
|
72
|
+
'When enabled, each access code can be redeemed only once. Recommended.',
|
|
73
|
+
)}
|
|
74
|
+
</Typography>
|
|
75
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
76
|
+
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
|
|
77
|
+
<FormControlLabel
|
|
78
|
+
control={
|
|
79
|
+
<Switch
|
|
80
|
+
checked={value}
|
|
81
|
+
disabled={busy || !canEdit}
|
|
82
|
+
onChange={handleChange}
|
|
83
|
+
/>
|
|
84
|
+
}
|
|
85
|
+
label={t('Auth.ACCESS_CODE_SINGLE_USE_TOGGLE', 'Codes are single-use')}
|
|
86
|
+
/>
|
|
87
|
+
</Box>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
Box,
|
|
5
5
|
Button,
|
|
6
6
|
Stack,
|
|
7
|
+
TextField,
|
|
7
8
|
Typography,
|
|
8
9
|
} from '@mui/material';
|
|
9
10
|
import { useTranslation } from 'react-i18next';
|
|
@@ -11,6 +12,7 @@ import { QRCodeSVG } from 'qrcode.react';
|
|
|
11
12
|
import { createSignupQr } from '../auth/authApi';
|
|
12
13
|
|
|
13
14
|
const DEFAULT_EXPIRY_DAYS = 90;
|
|
15
|
+
const DEFAULT_MAX_REDEMPTIONS = 1;
|
|
14
16
|
|
|
15
17
|
function clampExpiryDays(value) {
|
|
16
18
|
const parsed = parseInt(value, 10);
|
|
@@ -20,6 +22,14 @@ function clampExpiryDays(value) {
|
|
|
20
22
|
return parsed;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
function clampMaxRedemptions(value) {
|
|
26
|
+
const parsed = parseInt(value, 10);
|
|
27
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
28
|
+
return DEFAULT_MAX_REDEMPTIONS;
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
function escapeHtml(value) {
|
|
24
34
|
return String(value ?? '')
|
|
25
35
|
.replace(/&/g, '&')
|
|
@@ -40,6 +50,7 @@ export function QrSignupManager({
|
|
|
40
50
|
const [success, setSuccess] = useState('');
|
|
41
51
|
const [result, setResult] = useState(null);
|
|
42
52
|
const [copyState, setCopyState] = useState('idle');
|
|
53
|
+
const [maxRedemptions, setMaxRedemptions] = useState(DEFAULT_MAX_REDEMPTIONS);
|
|
43
54
|
const hasGeneratedRef = useRef(false);
|
|
44
55
|
|
|
45
56
|
const formattedExpiry = useMemo(() => {
|
|
@@ -65,6 +76,7 @@ export function QrSignupManager({
|
|
|
65
76
|
return;
|
|
66
77
|
}
|
|
67
78
|
const nextDays = clampExpiryDays(daysOverride ?? expiryDays);
|
|
79
|
+
const nextRedemptions = clampMaxRedemptions(maxRedemptions);
|
|
68
80
|
setBusy(true);
|
|
69
81
|
setError('');
|
|
70
82
|
setSuccess('');
|
|
@@ -72,6 +84,7 @@ export function QrSignupManager({
|
|
|
72
84
|
try {
|
|
73
85
|
const data = await createSignupQr({
|
|
74
86
|
expires_minutes: nextDays * 24 * 60,
|
|
87
|
+
max_redemptions: nextRedemptions,
|
|
75
88
|
});
|
|
76
89
|
setResult(data);
|
|
77
90
|
hasGeneratedRef.current = true;
|
|
@@ -89,6 +102,7 @@ export function QrSignupManager({
|
|
|
89
102
|
setError('');
|
|
90
103
|
setSuccess('');
|
|
91
104
|
setCopyState('idle');
|
|
105
|
+
setMaxRedemptions(DEFAULT_MAX_REDEMPTIONS);
|
|
92
106
|
hasGeneratedRef.current = false;
|
|
93
107
|
return;
|
|
94
108
|
}
|
|
@@ -105,6 +119,7 @@ export function QrSignupManager({
|
|
|
105
119
|
const days = clampExpiryDays(expiryDays);
|
|
106
120
|
const data = await createSignupQr({
|
|
107
121
|
expires_minutes: days * 24 * 60,
|
|
122
|
+
max_redemptions: clampMaxRedemptions(maxRedemptions),
|
|
108
123
|
});
|
|
109
124
|
if (!active) return;
|
|
110
125
|
setResult(data);
|
|
@@ -266,6 +281,22 @@ export function QrSignupManager({
|
|
|
266
281
|
{t('Auth.SIGNUP_QR_MANAGER_HINT', 'Generate and share QR signup links below.')}
|
|
267
282
|
</Typography>
|
|
268
283
|
|
|
284
|
+
<Box sx={{ mb: 2 }}>
|
|
285
|
+
<TextField
|
|
286
|
+
label={t('Auth.SIGNUP_QR_MAX_REDEMPTIONS_LABEL', 'Max redemptions')}
|
|
287
|
+
type="number"
|
|
288
|
+
size="small"
|
|
289
|
+
value={maxRedemptions}
|
|
290
|
+
onChange={(e) => setMaxRedemptions(e.target.value)}
|
|
291
|
+
inputProps={{ min: 1, step: 1 }}
|
|
292
|
+
helperText={t(
|
|
293
|
+
'Auth.SIGNUP_QR_MAX_REDEMPTIONS_HINT',
|
|
294
|
+
'How many people may sign up with the same QR. Default: 1.',
|
|
295
|
+
)}
|
|
296
|
+
disabled={busy}
|
|
297
|
+
/>
|
|
298
|
+
</Box>
|
|
299
|
+
|
|
269
300
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
270
301
|
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
|
|
271
302
|
{copyState === 'copied' && (
|
|
@@ -1691,5 +1691,119 @@ export const authTranslations = {
|
|
|
1691
1691
|
"fr": "Chargement…",
|
|
1692
1692
|
"en": "Loading…",
|
|
1693
1693
|
"sw": "Inapakia..."
|
|
1694
|
+
},
|
|
1695
|
+
"Auth.SIGNUP_CONFIRM_TITLE": {
|
|
1696
|
+
"de": "Registrierung bestätigen",
|
|
1697
|
+
"fr": "Confirmer l'inscription",
|
|
1698
|
+
"en": "Confirm your registration",
|
|
1699
|
+
"sw": "Thibitisha usajili wako"
|
|
1700
|
+
},
|
|
1701
|
+
"Auth.SIGNUP_CONFIRM_SUBTITLE": {
|
|
1702
|
+
"de": "Wähle ein Passwort, um dein Konto zu erstellen.",
|
|
1703
|
+
"fr": "Choisissez un mot de passe pour finaliser votre compte.",
|
|
1704
|
+
"en": "Choose a password to finish creating your account.",
|
|
1705
|
+
"sw": "Chagua nenosiri ili kukamilisha kuunda akaunti yako."
|
|
1706
|
+
},
|
|
1707
|
+
"Auth.SIGNUP_CONFIRM_SUBMIT": {
|
|
1708
|
+
"de": "Registrierung abschließen",
|
|
1709
|
+
"fr": "Confirmer l'inscription",
|
|
1710
|
+
"en": "Confirm registration",
|
|
1711
|
+
"sw": "Thibitisha usajili"
|
|
1712
|
+
},
|
|
1713
|
+
"Auth.SIGNUP_CONFIRM_SUBMITTING": {
|
|
1714
|
+
"de": "Wird bestätigt …",
|
|
1715
|
+
"fr": "Confirmation en cours…",
|
|
1716
|
+
"en": "Confirming…",
|
|
1717
|
+
"sw": "Inathibitisha…"
|
|
1718
|
+
},
|
|
1719
|
+
"Auth.PASSWORD_TOO_SHORT": {
|
|
1720
|
+
"de": "Das Passwort muss mindestens 8 Zeichen lang sein.",
|
|
1721
|
+
"fr": "Le mot de passe doit comporter au moins 8 caractères.",
|
|
1722
|
+
"en": "Password must be at least 8 characters long.",
|
|
1723
|
+
"sw": "Nenosiri lazima liwe na herufi 8 au zaidi."
|
|
1724
|
+
},
|
|
1725
|
+
"Auth.PASSWORD_MISMATCH": {
|
|
1726
|
+
"de": "Die Passwörter stimmen nicht überein.",
|
|
1727
|
+
"fr": "Les mots de passe ne correspondent pas.",
|
|
1728
|
+
"en": "Passwords do not match.",
|
|
1729
|
+
"sw": "Manenosiri hayalingani."
|
|
1730
|
+
},
|
|
1731
|
+
"Auth.SIGNUP_REQUEST_NEW": {
|
|
1732
|
+
"de": "Neue Einladung anfordern",
|
|
1733
|
+
"fr": "Demander une nouvelle invitation",
|
|
1734
|
+
"en": "Request a new invitation",
|
|
1735
|
+
"sw": "Omba mwaliko mpya"
|
|
1736
|
+
},
|
|
1737
|
+
"Auth.PASSWORD_INVALID": {
|
|
1738
|
+
"de": "Das Passwort erfüllt die Sicherheitsanforderungen nicht.",
|
|
1739
|
+
"fr": "Le mot de passe ne respecte pas les exigences de sécurité.",
|
|
1740
|
+
"en": "The password does not meet the security requirements.",
|
|
1741
|
+
"sw": "Nenosiri halikidhi mahitaji ya usalama."
|
|
1742
|
+
},
|
|
1743
|
+
"Auth.PENDING_TOKEN_INVALID": {
|
|
1744
|
+
"de": "Dieser Bestätigungslink ist ungültig oder abgelaufen.",
|
|
1745
|
+
"fr": "Ce lien de confirmation est invalide ou expiré.",
|
|
1746
|
+
"en": "This confirmation link is invalid or expired.",
|
|
1747
|
+
"sw": "Kiungo hiki cha uthibitishaji si halali au kimemalizika muda."
|
|
1748
|
+
},
|
|
1749
|
+
"Auth.PENDING_TOKEN_EXPIRED": {
|
|
1750
|
+
"de": "Diese Einladung ist abgelaufen. Bitte fordere eine neue an.",
|
|
1751
|
+
"fr": "Cette invitation a expiré. Veuillez en demander une nouvelle.",
|
|
1752
|
+
"en": "This invitation has expired. Please request a new one.",
|
|
1753
|
+
"sw": "Mwaliko huu umemalizika muda. Tafadhali omba mpya."
|
|
1754
|
+
},
|
|
1755
|
+
"Auth.ACCESS_CODE_ALREADY_USED": {
|
|
1756
|
+
"de": "Der verwendete Zugangscode ist nicht mehr gültig.",
|
|
1757
|
+
"fr": "Le code d'accès utilisé n'est plus valide.",
|
|
1758
|
+
"en": "The access code used is no longer valid.",
|
|
1759
|
+
"sw": "Msimbo wa ufikiaji uliotumika hauwezi kutumika tena."
|
|
1760
|
+
},
|
|
1761
|
+
"Auth.QR_TOKEN_EXHAUSTED": {
|
|
1762
|
+
"de": "Dieser QR-Code wurde bereits vollständig genutzt.",
|
|
1763
|
+
"fr": "Ce QR-code a déjà été entièrement utilisé.",
|
|
1764
|
+
"en": "This QR code has already been fully used.",
|
|
1765
|
+
"sw": "Msimbo huu wa QR tayari umetumika kabisa."
|
|
1766
|
+
},
|
|
1767
|
+
"Auth.USER_ALREADY_EXISTS": {
|
|
1768
|
+
"de": "Für diese E-Mail-Adresse existiert bereits ein Konto. Bitte melde dich an.",
|
|
1769
|
+
"fr": "Un compte existe déjà pour cette adresse e-mail. Veuillez vous connecter.",
|
|
1770
|
+
"en": "An account already exists for this email address. Please log in.",
|
|
1771
|
+
"sw": "Akaunti tayari ipo kwa anwani hii ya barua pepe. Tafadhali ingia."
|
|
1772
|
+
},
|
|
1773
|
+
"Auth.SIGNUP_QR_MAX_REDEMPTIONS_LABEL": {
|
|
1774
|
+
"de": "Max. Verwendungen",
|
|
1775
|
+
"fr": "Utilisations max.",
|
|
1776
|
+
"en": "Max redemptions",
|
|
1777
|
+
"sw": "Idadi ya juu ya matumizi"
|
|
1778
|
+
},
|
|
1779
|
+
"Auth.SIGNUP_QR_MAX_REDEMPTIONS_HINT": {
|
|
1780
|
+
"de": "Wie viele Personen dürfen sich mit demselben QR registrieren. Standard: 1.",
|
|
1781
|
+
"fr": "Combien de personnes peuvent s'inscrire avec le même QR. Par défaut : 1.",
|
|
1782
|
+
"en": "How many people may sign up with the same QR. Default: 1.",
|
|
1783
|
+
"sw": "Watu wangapi wanaweza kujisajili na QR sawa. Chaguo-msingi: 1."
|
|
1784
|
+
},
|
|
1785
|
+
"Auth.ACCESS_CODE_SINGLE_USE_TITLE": {
|
|
1786
|
+
"de": "Zugangscode: Einmalverwendung",
|
|
1787
|
+
"fr": "Code d'accès : usage unique",
|
|
1788
|
+
"en": "Access Code Single-Use",
|
|
1789
|
+
"sw": "Msimbo wa Ufikiaji: Matumizi ya Mara Moja"
|
|
1790
|
+
},
|
|
1791
|
+
"Auth.ACCESS_CODE_SINGLE_USE_HINT": {
|
|
1792
|
+
"de": "Wenn aktiviert, kann jeder Zugangscode nur einmal eingelöst werden. Empfohlen.",
|
|
1793
|
+
"fr": "Lorsqu'activé, chaque code d'accès ne peut être utilisé qu'une seule fois. Recommandé.",
|
|
1794
|
+
"en": "When enabled, each access code can be redeemed only once. Recommended.",
|
|
1795
|
+
"sw": "Ikiwa imewashwa, kila msimbo wa ufikiaji unaweza kutumika mara moja tu. Inapendekezwa."
|
|
1796
|
+
},
|
|
1797
|
+
"Auth.ACCESS_CODE_SINGLE_USE_TOGGLE": {
|
|
1798
|
+
"de": "Codes sind einmalig verwendbar",
|
|
1799
|
+
"fr": "Codes à usage unique",
|
|
1800
|
+
"en": "Codes are single-use",
|
|
1801
|
+
"sw": "Misimbo ni ya matumizi ya mara moja"
|
|
1802
|
+
},
|
|
1803
|
+
"Auth.ACCESS_CODE_SINGLE_USE_SAVE_SUCCESS": {
|
|
1804
|
+
"de": "Zugangscode-Richtlinie gespeichert.",
|
|
1805
|
+
"fr": "Politique de code d'accès enregistrée.",
|
|
1806
|
+
"en": "Access code policy saved.",
|
|
1807
|
+
"sw": "Sera ya msimbo wa ufikiaji imehifadhiwa."
|
|
1694
1808
|
}
|
|
1695
1809
|
};
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,7 @@ export { PasswordResetRequestPage } from './pages/PasswordResetRequestPage';
|
|
|
25
25
|
export { PasswordChangePage } from './pages/PasswordChangePage';
|
|
26
26
|
export { PasswordInvitePage } from './pages/PasswordInvitePage';
|
|
27
27
|
export { SignUpPage } from './pages/SignUpPage';
|
|
28
|
+
export { SignupConfirmPage } from './pages/SignupConfirmPage';
|
|
28
29
|
export { AccountPage } from './pages/AccountPage';
|
|
29
30
|
|
|
30
31
|
// --- 5. Components (Wiederverwendbare UI-Teile) ---
|
|
@@ -35,6 +36,7 @@ export { UserInviteComponent } from './components/UserInviteComponent';
|
|
|
35
36
|
export { BulkInviteCsvTab } from './components/BulkInviteCsvTab';
|
|
36
37
|
export { RegistrationMethodsManager } from './components/RegistrationMethodsManager';
|
|
37
38
|
export { AuthFactorRequirementCard } from './components/AuthFactorRequirementCard';
|
|
39
|
+
export { AccessCodeSingleUseToggle } from './components/AccessCodeSingleUseToggle';
|
|
38
40
|
export { QrSignupManager } from './components/QrSignupManager';
|
|
39
41
|
|
|
40
42
|
// --- 6. Translations ---
|
|
@@ -26,6 +26,7 @@ import { AccessCodeManager } from '../components/AccessCodeManager';
|
|
|
26
26
|
import { AllowedEmailDomainsManager } from '../components/AllowedEmailDomainsManager';
|
|
27
27
|
import { RegistrationMethodsManager } from '../components/RegistrationMethodsManager';
|
|
28
28
|
import { AuthFactorRequirementCard } from '../components/AuthFactorRequirementCard';
|
|
29
|
+
import { AccessCodeSingleUseToggle } from '../components/AccessCodeSingleUseToggle';
|
|
29
30
|
import { QrSignupManager } from '../components/QrSignupManager';
|
|
30
31
|
import { QrSignupValidityManager } from '../components/QrSignupValidityManager';
|
|
31
32
|
import { SupportRecoveryRequestsTab } from '../components/SupportRecoveryRequestsTab';
|
|
@@ -283,6 +284,16 @@ export function AccountPage({
|
|
|
283
284
|
</Paper>
|
|
284
285
|
)}
|
|
285
286
|
|
|
287
|
+
{canManageAccessCodes && Boolean(authPolicy?.allow_self_signup_access_code) && (
|
|
288
|
+
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
|
|
289
|
+
<AccessCodeSingleUseToggle
|
|
290
|
+
canEdit={canWriteAuthPolicy}
|
|
291
|
+
policy={authPolicy}
|
|
292
|
+
onPolicyChange={setAuthPolicy}
|
|
293
|
+
/>
|
|
294
|
+
</Paper>
|
|
295
|
+
)}
|
|
296
|
+
|
|
286
297
|
{canSendInvites && showBulkInviteCsvTab && Boolean(authPolicy?.allow_admin_invite) && (
|
|
287
298
|
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
|
|
288
299
|
<BulkInviteCsvTab {...bulkInviteCsvProps} />
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// src/pages/SignupConfirmPage.jsx
|
|
2
|
+
//
|
|
3
|
+
// S13: Completes a pending registration. Reads the signed pending-token from
|
|
4
|
+
// the URL (`?token=...`), asks the user for a password, and POSTs to
|
|
5
|
+
// `register_confirm`. On success, the user is logged in by the backend session
|
|
6
|
+
// cookie; we hand off to AuthContext.login so the SPA picks it up immediately.
|
|
7
|
+
import React, { useContext, useMemo, useState } from 'react';
|
|
8
|
+
import { useLocation, useNavigate } from 'react-router-dom';
|
|
9
|
+
import {
|
|
10
|
+
Alert,
|
|
11
|
+
Box,
|
|
12
|
+
Button,
|
|
13
|
+
Stack,
|
|
14
|
+
TextField,
|
|
15
|
+
Typography,
|
|
16
|
+
} from '@mui/material';
|
|
17
|
+
import { Helmet } from 'react-helmet';
|
|
18
|
+
import { useTranslation } from 'react-i18next';
|
|
19
|
+
import { NarrowPage } from '../layout/PageLayout';
|
|
20
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
21
|
+
import { confirmRegistration, fetchCurrentUser } from '../auth/authApi';
|
|
22
|
+
|
|
23
|
+
export function SignupConfirmPage() {
|
|
24
|
+
const { t } = useTranslation();
|
|
25
|
+
const location = useLocation();
|
|
26
|
+
const navigate = useNavigate();
|
|
27
|
+
const { login } = useContext(AuthContext);
|
|
28
|
+
|
|
29
|
+
const tokenFromUrl = useMemo(() => {
|
|
30
|
+
const query = new URLSearchParams(location.search);
|
|
31
|
+
return query.get('token') || '';
|
|
32
|
+
}, [location.search]);
|
|
33
|
+
|
|
34
|
+
const [password, setPassword] = useState('');
|
|
35
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
36
|
+
const [submitting, setSubmitting] = useState(false);
|
|
37
|
+
const [errorKey, setErrorKey] = useState(null);
|
|
38
|
+
|
|
39
|
+
const handleSubmit = async (event) => {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
setErrorKey(null);
|
|
42
|
+
|
|
43
|
+
if (!tokenFromUrl) {
|
|
44
|
+
setErrorKey('Auth.PENDING_TOKEN_INVALID');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!password || password.length < 8) {
|
|
48
|
+
setErrorKey('Auth.PASSWORD_TOO_SHORT');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (password !== confirmPassword) {
|
|
52
|
+
setErrorKey('Auth.PASSWORD_MISMATCH');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setSubmitting(true);
|
|
57
|
+
try {
|
|
58
|
+
await confirmRegistration({ token: tokenFromUrl, password });
|
|
59
|
+
// Refresh user state from session before navigating home.
|
|
60
|
+
try {
|
|
61
|
+
const user = await fetchCurrentUser();
|
|
62
|
+
login(user);
|
|
63
|
+
} catch {
|
|
64
|
+
// If user fetch fails, still navigate — the session cookie is set
|
|
65
|
+
// server-side and the next page-load will pick the user up.
|
|
66
|
+
}
|
|
67
|
+
navigate('/', { replace: true });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
setErrorKey(err?.code || 'Auth.PENDING_TOKEN_INVALID');
|
|
70
|
+
} finally {
|
|
71
|
+
setSubmitting(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const tokenMissing = !tokenFromUrl;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<NarrowPage
|
|
79
|
+
title={t('Auth.SIGNUP_CONFIRM_TITLE', 'Confirm your registration')}
|
|
80
|
+
subtitle={t(
|
|
81
|
+
'Auth.SIGNUP_CONFIRM_SUBTITLE',
|
|
82
|
+
'Choose a password to finish creating your account.',
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
<Helmet>
|
|
86
|
+
<title>
|
|
87
|
+
{t('App.NAME')} – {t('Auth.SIGNUP_CONFIRM_TITLE', 'Confirm your registration')}
|
|
88
|
+
</title>
|
|
89
|
+
</Helmet>
|
|
90
|
+
|
|
91
|
+
{tokenMissing ? (
|
|
92
|
+
<>
|
|
93
|
+
<Alert severity="error" sx={{ mb: 2 }}>
|
|
94
|
+
{t('Auth.PENDING_TOKEN_INVALID', 'This confirmation link is invalid or expired.')}
|
|
95
|
+
</Alert>
|
|
96
|
+
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
|
97
|
+
<Button onClick={() => navigate('/signup')} variant="contained">
|
|
98
|
+
{t('Auth.SIGNUP_REQUEST_NEW', 'Request a new invitation')}
|
|
99
|
+
</Button>
|
|
100
|
+
<Button onClick={() => navigate('/login')} variant="text">
|
|
101
|
+
{t('Auth.SIGNUP_GO_TO_LOGIN', 'Go to login')}
|
|
102
|
+
</Button>
|
|
103
|
+
</Stack>
|
|
104
|
+
</>
|
|
105
|
+
) : (
|
|
106
|
+
<>
|
|
107
|
+
{errorKey && (
|
|
108
|
+
<Alert severity="error" sx={{ mb: 2 }}>
|
|
109
|
+
{t(errorKey, t('Auth.PENDING_TOKEN_INVALID', 'Could not confirm registration.'))}
|
|
110
|
+
</Alert>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<Box
|
|
114
|
+
component="form"
|
|
115
|
+
onSubmit={handleSubmit}
|
|
116
|
+
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
|
117
|
+
>
|
|
118
|
+
<TextField
|
|
119
|
+
label={t('Auth.NEW_PASSWORD_LABEL', 'New password')}
|
|
120
|
+
type="password"
|
|
121
|
+
required
|
|
122
|
+
fullWidth
|
|
123
|
+
value={password}
|
|
124
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
125
|
+
disabled={submitting}
|
|
126
|
+
autoComplete="new-password"
|
|
127
|
+
/>
|
|
128
|
+
<TextField
|
|
129
|
+
label={t('Auth.PASSWORD_CONFIRM_LABEL', 'Confirm new password')}
|
|
130
|
+
type="password"
|
|
131
|
+
required
|
|
132
|
+
fullWidth
|
|
133
|
+
value={confirmPassword}
|
|
134
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
135
|
+
disabled={submitting}
|
|
136
|
+
autoComplete="new-password"
|
|
137
|
+
/>
|
|
138
|
+
<Button type="submit" variant="contained" disabled={submitting}>
|
|
139
|
+
{submitting
|
|
140
|
+
? t('Auth.SIGNUP_CONFIRM_SUBMITTING', 'Confirming…')
|
|
141
|
+
: t('Auth.SIGNUP_CONFIRM_SUBMIT', 'Confirm registration')}
|
|
142
|
+
</Button>
|
|
143
|
+
</Box>
|
|
144
|
+
|
|
145
|
+
<Stack direction="row" spacing={1} sx={{ mt: 3 }}>
|
|
146
|
+
<Typography variant="body2">
|
|
147
|
+
{t('Auth.SIGNUP_ALREADY_HAVE_ACCOUNT', 'Already have an account?')}
|
|
148
|
+
</Typography>
|
|
149
|
+
<Button onClick={() => navigate('/login')} variant="text" size="small">
|
|
150
|
+
{t('Auth.SIGNUP_GO_TO_LOGIN', 'Go to login')}
|
|
151
|
+
</Button>
|
|
152
|
+
</Stack>
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
155
|
+
</NarrowPage>
|
|
156
|
+
);
|
|
157
|
+
}
|