@micha.bigler/ui-core-micha 2.2.14 → 2.3.1

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