@micha.bigler/ui-core-micha 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, '&amp;')
@@ -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.EMAIL_VERIFICATION_TITLE": {
1648
- "de": "E-Mail-Verifizierung",
1649
- "fr": "Vérification de l'e-mail",
1650
- "en": "Email Verification",
1651
- "sw": "Uthibitishaji wa Barua Pepe"
1652
- },
1653
- "Auth.EMAIL_VERIFICATION_HINT": {
1654
- "de": "Verlangt einen Nachweis des E-Mail-Besitzes, bevor ein Social-Login mit einem Konto verknüpft werden kann. Empfohlen.",
1655
- "fr": "Exige une preuve de propriété de l'adresse e-mail avant qu'une connexion sociale ne puisse être liée à un compte. Recommandé.",
1656
- "en": "Require verified email ownership before social sign-in can link to an account. Recommended.",
1657
- "sw": "Inahitaji uthibitishaji wa umiliki wa barua pepe kabla ya kuingia kwa mitandao kuunganishwa na akaunti. Inapendekezwa."
1658
- },
1659
- "Auth.EMAIL_VERIFICATION_TOGGLE": {
1660
- "de": "E-Mail-Verifizierung erforderlich",
1661
- "fr": "Vérification de l'e-mail requise",
1662
- "en": "Require email verification",
1663
- "sw": "Hitaji uthibitishaji wa barua pepe"
1664
- },
1665
- "Auth.EMAIL_VERIFICATION_SAVE_SUCCESS": {
1666
- "de": "Anforderung zur E-Mail-Verifizierung gespeichert.",
1667
- "fr": "Exigence de vérification de l'e-mail enregistrée.",
1668
- "en": "Email verification requirement saved.",
1669
- "sw": "Mahitaji ya uthibitishaji wa barua pepe yamehifadhiwa."
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(EmailVerificationRequirementCard, { canEdit: canWriteAuthPolicy, policy: authPolicy, onPolicyChange: setAuthPolicy }) })), 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
+ 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,63 @@
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 backend has set the session cookie; we
7
+ // trigger a full page reload to `/` so AuthProvider re-initialises and picks
8
+ // the user up at mount — avoids React-state races inside the SPA.
9
+ import React, { useMemo, useState } from 'react';
10
+ import { useLocation, useNavigate } from 'react-router-dom';
11
+ import { Alert, Box, Button, Stack, TextField, Typography, } from '@mui/material';
12
+ import { Helmet } from 'react-helmet';
13
+ import { useTranslation } from 'react-i18next';
14
+ import { NarrowPage } from '../layout/PageLayout';
15
+ import { confirmRegistration } from '../auth/authApi';
16
+ export function SignupConfirmPage() {
17
+ const { t } = useTranslation();
18
+ const location = useLocation();
19
+ const navigate = useNavigate();
20
+ const tokenFromUrl = useMemo(() => {
21
+ const query = new URLSearchParams(location.search);
22
+ return query.get('token') || '';
23
+ }, [location.search]);
24
+ const [password, setPassword] = useState('');
25
+ const [confirmPassword, setConfirmPassword] = useState('');
26
+ const [submitting, setSubmitting] = useState(false);
27
+ const [errorKey, setErrorKey] = useState(null);
28
+ const handleSubmit = async (event) => {
29
+ event.preventDefault();
30
+ setErrorKey(null);
31
+ if (!tokenFromUrl) {
32
+ setErrorKey('Auth.PENDING_TOKEN_INVALID');
33
+ return;
34
+ }
35
+ if (!password || password.length < 8) {
36
+ setErrorKey('Auth.PASSWORD_TOO_SHORT');
37
+ return;
38
+ }
39
+ if (password !== confirmPassword) {
40
+ setErrorKey('Auth.PASSWORD_MISMATCH');
41
+ return;
42
+ }
43
+ setSubmitting(true);
44
+ try {
45
+ await confirmRegistration({ token: tokenFromUrl, password });
46
+ // Full page reload so AuthProvider re-initialises with the just-set
47
+ // session cookie. Avoids React-state races where the SPA's route guard
48
+ // could read a stale (null) user between login() and navigate('/').
49
+ window.location.assign('/');
50
+ return;
51
+ }
52
+ catch (err) {
53
+ setErrorKey((err === null || err === void 0 ? void 0 : err.code) || 'Auth.PENDING_TOKEN_INVALID');
54
+ }
55
+ finally {
56
+ setSubmitting(false);
57
+ }
58
+ };
59
+ const tokenMissing = !tokenFromUrl;
60
+ 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
61
+ ? t('Auth.SIGNUP_CONFIRM_SUBMITTING', 'Confirming…')
62
+ : 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') })] })] }))] }));
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -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, '&amp;')
@@ -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.EMAIL_VERIFICATION_TITLE": {
1696
- "de": "E-Mail-Verifizierung",
1697
- "fr": "Vérification de l'e-mail",
1698
- "en": "Email Verification",
1699
- "sw": "Uthibitishaji wa Barua Pepe"
1700
- },
1701
- "Auth.EMAIL_VERIFICATION_HINT": {
1702
- "de": "Verlangt einen Nachweis des E-Mail-Besitzes, bevor ein Social-Login mit einem Konto verknüpft werden kann. Empfohlen.",
1703
- "fr": "Exige une preuve de propriété de l'adresse e-mail avant qu'une connexion sociale ne puisse être liée à un compte. Recommandé.",
1704
- "en": "Require verified email ownership before social sign-in can link to an account. Recommended.",
1705
- "sw": "Inahitaji uthibitishaji wa umiliki wa barua pepe kabla ya kuingia kwa mitandao kuunganishwa na akaunti. Inapendekezwa."
1706
- },
1707
- "Auth.EMAIL_VERIFICATION_TOGGLE": {
1708
- "de": "E-Mail-Verifizierung erforderlich",
1709
- "fr": "Vérification de l'e-mail requise",
1710
- "en": "Require email verification",
1711
- "sw": "Hitaji uthibitishaji wa barua pepe"
1712
- },
1713
- "Auth.EMAIL_VERIFICATION_SAVE_SUCCESS": {
1714
- "de": "Anforderung zur E-Mail-Verifizierung gespeichert.",
1715
- "fr": "Exigence de vérification de l'e-mail enregistrée.",
1716
- "en": "Email verification requirement saved.",
1717
- "sw": "Mahitaji ya uthibitishaji wa barua pepe yamehifadhiwa."
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,152 @@
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 backend has set the session cookie; we
6
+ // trigger a full page reload to `/` so AuthProvider re-initialises and picks
7
+ // the user up at mount — avoids React-state races inside the SPA.
8
+ import React, { useMemo, useState } from 'react';
9
+ import { useLocation, useNavigate } from 'react-router-dom';
10
+ import {
11
+ Alert,
12
+ Box,
13
+ Button,
14
+ Stack,
15
+ TextField,
16
+ Typography,
17
+ } from '@mui/material';
18
+ import { Helmet } from 'react-helmet';
19
+ import { useTranslation } from 'react-i18next';
20
+ import { NarrowPage } from '../layout/PageLayout';
21
+ import { confirmRegistration } from '../auth/authApi';
22
+
23
+ export function SignupConfirmPage() {
24
+ const { t } = useTranslation();
25
+ const location = useLocation();
26
+ const navigate = useNavigate();
27
+
28
+ const tokenFromUrl = useMemo(() => {
29
+ const query = new URLSearchParams(location.search);
30
+ return query.get('token') || '';
31
+ }, [location.search]);
32
+
33
+ const [password, setPassword] = useState('');
34
+ const [confirmPassword, setConfirmPassword] = useState('');
35
+ const [submitting, setSubmitting] = useState(false);
36
+ const [errorKey, setErrorKey] = useState(null);
37
+
38
+ const handleSubmit = async (event) => {
39
+ event.preventDefault();
40
+ setErrorKey(null);
41
+
42
+ if (!tokenFromUrl) {
43
+ setErrorKey('Auth.PENDING_TOKEN_INVALID');
44
+ return;
45
+ }
46
+ if (!password || password.length < 8) {
47
+ setErrorKey('Auth.PASSWORD_TOO_SHORT');
48
+ return;
49
+ }
50
+ if (password !== confirmPassword) {
51
+ setErrorKey('Auth.PASSWORD_MISMATCH');
52
+ return;
53
+ }
54
+
55
+ setSubmitting(true);
56
+ try {
57
+ await confirmRegistration({ token: tokenFromUrl, password });
58
+ // Full page reload so AuthProvider re-initialises with the just-set
59
+ // session cookie. Avoids React-state races where the SPA's route guard
60
+ // could read a stale (null) user between login() and navigate('/').
61
+ window.location.assign('/');
62
+ return;
63
+ } catch (err) {
64
+ setErrorKey(err?.code || 'Auth.PENDING_TOKEN_INVALID');
65
+ } finally {
66
+ setSubmitting(false);
67
+ }
68
+ };
69
+
70
+ const tokenMissing = !tokenFromUrl;
71
+
72
+ return (
73
+ <NarrowPage
74
+ title={t('Auth.SIGNUP_CONFIRM_TITLE', 'Confirm your registration')}
75
+ subtitle={t(
76
+ 'Auth.SIGNUP_CONFIRM_SUBTITLE',
77
+ 'Choose a password to finish creating your account.',
78
+ )}
79
+ >
80
+ <Helmet>
81
+ <title>
82
+ {t('App.NAME')} – {t('Auth.SIGNUP_CONFIRM_TITLE', 'Confirm your registration')}
83
+ </title>
84
+ </Helmet>
85
+
86
+ {tokenMissing ? (
87
+ <>
88
+ <Alert severity="error" sx={{ mb: 2 }}>
89
+ {t('Auth.PENDING_TOKEN_INVALID', 'This confirmation link is invalid or expired.')}
90
+ </Alert>
91
+ <Stack direction="row" spacing={1} sx={{ mt: 1 }}>
92
+ <Button onClick={() => navigate('/signup')} variant="contained">
93
+ {t('Auth.SIGNUP_REQUEST_NEW', 'Request a new invitation')}
94
+ </Button>
95
+ <Button onClick={() => navigate('/login')} variant="text">
96
+ {t('Auth.SIGNUP_GO_TO_LOGIN', 'Go to login')}
97
+ </Button>
98
+ </Stack>
99
+ </>
100
+ ) : (
101
+ <>
102
+ {errorKey && (
103
+ <Alert severity="error" sx={{ mb: 2 }}>
104
+ {t(errorKey, t('Auth.PENDING_TOKEN_INVALID', 'Could not confirm registration.'))}
105
+ </Alert>
106
+ )}
107
+
108
+ <Box
109
+ component="form"
110
+ onSubmit={handleSubmit}
111
+ sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
112
+ >
113
+ <TextField
114
+ label={t('Auth.NEW_PASSWORD_LABEL', 'New password')}
115
+ type="password"
116
+ required
117
+ fullWidth
118
+ value={password}
119
+ onChange={(e) => setPassword(e.target.value)}
120
+ disabled={submitting}
121
+ autoComplete="new-password"
122
+ />
123
+ <TextField
124
+ label={t('Auth.PASSWORD_CONFIRM_LABEL', 'Confirm new password')}
125
+ type="password"
126
+ required
127
+ fullWidth
128
+ value={confirmPassword}
129
+ onChange={(e) => setConfirmPassword(e.target.value)}
130
+ disabled={submitting}
131
+ autoComplete="new-password"
132
+ />
133
+ <Button type="submit" variant="contained" disabled={submitting}>
134
+ {submitting
135
+ ? t('Auth.SIGNUP_CONFIRM_SUBMITTING', 'Confirming…')
136
+ : t('Auth.SIGNUP_CONFIRM_SUBMIT', 'Confirm registration')}
137
+ </Button>
138
+ </Box>
139
+
140
+ <Stack direction="row" spacing={1} sx={{ mt: 3 }}>
141
+ <Typography variant="body2">
142
+ {t('Auth.SIGNUP_ALREADY_HAVE_ACCOUNT', 'Already have an account?')}
143
+ </Typography>
144
+ <Button onClick={() => navigate('/login')} variant="text" size="small">
145
+ {t('Auth.SIGNUP_GO_TO_LOGIN', 'Go to login')}
146
+ </Button>
147
+ </Stack>
148
+ </>
149
+ )}
150
+ </NarrowPage>
151
+ );
152
+ }
@@ -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
- }