@micha.bigler/ui-core-micha 2.3.0 → 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/QrSignupManager.js +15 -2
- package/dist/i18n/authTranslations.js +89 -23
- package/dist/index.js +1 -1
- package/dist/pages/AccountPage.js +1 -2
- package/dist/pages/SignupConfirmPage.js +69 -0
- package/package.json +1 -1
- package/src/auth/authApi.jsx +16 -0
- package/src/components/QrSignupManager.jsx +31 -0
- package/src/i18n/authTranslations.ts +89 -23
- package/src/index.js +1 -1
- package/src/pages/AccountPage.jsx +0 -11
- package/src/pages/SignupConfirmPage.jsx +157 -0
- package/dist/components/EmailVerificationRequirementCard.js +0 -59
- package/src/components/EmailVerificationRequirementCard.jsx +0 -89
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);
|
|
@@ -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',
|
|
@@ -1644,29 +1644,95 @@ export const authTranslations = {
|
|
|
1644
1644
|
"en": "Loading…",
|
|
1645
1645
|
"sw": "Inapakia..."
|
|
1646
1646
|
},
|
|
1647
|
-
"Auth.
|
|
1648
|
-
"de": "
|
|
1649
|
-
"fr": "
|
|
1650
|
-
"en": "
|
|
1651
|
-
"sw": "
|
|
1652
|
-
},
|
|
1653
|
-
"Auth.
|
|
1654
|
-
"de": "
|
|
1655
|
-
"fr": "
|
|
1656
|
-
"en": "
|
|
1657
|
-
"sw": "
|
|
1658
|
-
},
|
|
1659
|
-
"Auth.
|
|
1660
|
-
"de": "
|
|
1661
|
-
"fr": "
|
|
1662
|
-
"en": "
|
|
1663
|
-
"sw": "
|
|
1664
|
-
},
|
|
1665
|
-
"Auth.
|
|
1666
|
-
"de": "
|
|
1667
|
-
"fr": "
|
|
1668
|
-
"en": "
|
|
1669
|
-
"sw": "
|
|
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."
|
|
1670
1736
|
},
|
|
1671
1737
|
"Auth.ACCESS_CODE_SINGLE_USE_TITLE": {
|
|
1672
1738
|
"de": "Zugangscode: Einmalverwendung",
|
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,7 +25,6 @@ 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';
|
|
27
|
-
export { EmailVerificationRequirementCard } from './components/EmailVerificationRequirementCard';
|
|
28
28
|
export { AccessCodeSingleUseToggle } from './components/AccessCodeSingleUseToggle';
|
|
29
29
|
export { QrSignupManager } from './components/QrSignupManager';
|
|
30
30
|
// --- 6. Translations ---
|
|
@@ -16,7 +16,6 @@ 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 { EmailVerificationRequirementCard } from '../components/EmailVerificationRequirementCard';
|
|
20
19
|
import { AccessCodeSingleUseToggle } from '../components/AccessCodeSingleUseToggle';
|
|
21
20
|
import { QrSignupManager } from '../components/QrSignupManager';
|
|
22
21
|
import { QrSignupValidityManager } from '../components/QrSignupValidityManager';
|
|
@@ -131,5 +130,5 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
|
|
|
131
130
|
const activeExtraTab = builtInTabValues.has(safeTab)
|
|
132
131
|
? null
|
|
133
132
|
: extraTabs.find((tab) => tab.value === safeTab);
|
|
134
|
-
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(
|
|
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 }) }))] }));
|
|
135
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
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);
|
|
@@ -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' && (
|
|
@@ -1692,29 +1692,95 @@ export const authTranslations = {
|
|
|
1692
1692
|
"en": "Loading…",
|
|
1693
1693
|
"sw": "Inapakia..."
|
|
1694
1694
|
},
|
|
1695
|
-
"Auth.
|
|
1696
|
-
"de": "
|
|
1697
|
-
"fr": "
|
|
1698
|
-
"en": "
|
|
1699
|
-
"sw": "
|
|
1700
|
-
},
|
|
1701
|
-
"Auth.
|
|
1702
|
-
"de": "
|
|
1703
|
-
"fr": "
|
|
1704
|
-
"en": "
|
|
1705
|
-
"sw": "
|
|
1706
|
-
},
|
|
1707
|
-
"Auth.
|
|
1708
|
-
"de": "
|
|
1709
|
-
"fr": "
|
|
1710
|
-
"en": "
|
|
1711
|
-
"sw": "
|
|
1712
|
-
},
|
|
1713
|
-
"Auth.
|
|
1714
|
-
"de": "
|
|
1715
|
-
"fr": "
|
|
1716
|
-
"en": "
|
|
1717
|
-
"sw": "
|
|
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."
|
|
1718
1784
|
},
|
|
1719
1785
|
"Auth.ACCESS_CODE_SINGLE_USE_TITLE": {
|
|
1720
1786
|
"de": "Zugangscode: Einmalverwendung",
|
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,7 +36,6 @@ 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';
|
|
38
|
-
export { EmailVerificationRequirementCard } from './components/EmailVerificationRequirementCard';
|
|
39
39
|
export { AccessCodeSingleUseToggle } from './components/AccessCodeSingleUseToggle';
|
|
40
40
|
export { QrSignupManager } from './components/QrSignupManager';
|
|
41
41
|
|
|
@@ -26,7 +26,6 @@ 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 { EmailVerificationRequirementCard } from '../components/EmailVerificationRequirementCard';
|
|
30
29
|
import { AccessCodeSingleUseToggle } from '../components/AccessCodeSingleUseToggle';
|
|
31
30
|
import { QrSignupManager } from '../components/QrSignupManager';
|
|
32
31
|
import { QrSignupValidityManager } from '../components/QrSignupValidityManager';
|
|
@@ -256,16 +255,6 @@ export function AccountPage({
|
|
|
256
255
|
</Paper>
|
|
257
256
|
)}
|
|
258
257
|
|
|
259
|
-
{canViewAuthPolicy && (
|
|
260
|
-
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
|
|
261
|
-
<EmailVerificationRequirementCard
|
|
262
|
-
canEdit={canWriteAuthPolicy}
|
|
263
|
-
policy={authPolicy}
|
|
264
|
-
onPolicyChange={setAuthPolicy}
|
|
265
|
-
/>
|
|
266
|
-
</Paper>
|
|
267
|
-
)}
|
|
268
|
-
|
|
269
258
|
{canViewAuthPolicy && (
|
|
270
259
|
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
|
|
271
260
|
<RegistrationMethodsManager
|
|
@@ -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
|
+
}
|
|
@@ -1,59 +0,0 @@
|
|
|
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 EmailVerificationRequirementCard({ 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.require_email_verification));
|
|
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.require_email_verification));
|
|
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({ require_email_verification: nextValue });
|
|
45
|
-
if (onPolicyChange) {
|
|
46
|
-
onPolicyChange((current) => (Object.assign(Object.assign({}, (current || {})), (updated || {}))));
|
|
47
|
-
}
|
|
48
|
-
setSuccess(t('Auth.EMAIL_VERIFICATION_SAVE_SUCCESS', 'Email verification requirement 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 email verification requirement.'));
|
|
53
|
-
}
|
|
54
|
-
finally {
|
|
55
|
-
setBusy(false);
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.EMAIL_VERIFICATION_TITLE', 'Email Verification') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.EMAIL_VERIFICATION_HINT', 'Require verified email ownership before social sign-in can link to an account. 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.EMAIL_VERIFICATION_TOGGLE', 'Require email verification') })] }));
|
|
59
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
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 EmailVerificationRequirementCard({ 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?.require_email_verification));
|
|
22
|
-
return undefined;
|
|
23
|
-
}
|
|
24
|
-
let active = true;
|
|
25
|
-
(async () => {
|
|
26
|
-
try {
|
|
27
|
-
const data = await fetchAuthPolicy();
|
|
28
|
-
if (active) {
|
|
29
|
-
setValue(Boolean(data?.require_email_verification));
|
|
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({ require_email_verification: nextValue });
|
|
52
|
-
if (onPolicyChange) {
|
|
53
|
-
onPolicyChange((current) => ({ ...(current || {}), ...(updated || {}) }));
|
|
54
|
-
}
|
|
55
|
-
setSuccess(t('Auth.EMAIL_VERIFICATION_SAVE_SUCCESS', 'Email verification requirement saved.'));
|
|
56
|
-
} catch (err) {
|
|
57
|
-
setValue(previous);
|
|
58
|
-
setError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save email verification requirement.'));
|
|
59
|
-
} finally {
|
|
60
|
-
setBusy(false);
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
return (
|
|
65
|
-
<Box>
|
|
66
|
-
<Typography variant="h6" gutterBottom>
|
|
67
|
-
{t('Auth.EMAIL_VERIFICATION_TITLE', 'Email Verification')}
|
|
68
|
-
</Typography>
|
|
69
|
-
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
|
70
|
-
{t(
|
|
71
|
-
'Auth.EMAIL_VERIFICATION_HINT',
|
|
72
|
-
'Require verified email ownership before social sign-in can link to an account. 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.EMAIL_VERIFICATION_TOGGLE', 'Require email verification')}
|
|
86
|
-
/>
|
|
87
|
-
</Box>
|
|
88
|
-
);
|
|
89
|
-
}
|