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

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.
@@ -9,25 +9,52 @@ let redirectingToLogin = false;
9
9
  function isBrowser() {
10
10
  return typeof window !== "undefined";
11
11
  }
12
- // WICHTIG: Liste aller Routen, die OHNE Login funktionieren müssen.
13
- // Beginnt der Pfad mit einem dieser Strings, wird kein Auto-Redirect ausgelöst.
14
- const PUBLIC_PATHS = [
12
+ // Routen, die OHNE Login funktionieren müssen. Library-eigene Pfade sind eingefroren
13
+ // und können nicht entfernt werden sonst würde z. B. `removePublicPath("/login")`
14
+ // auf der Login-Page selbst einen Redirect-Loop auslösen.
15
+ const BUILTIN_PUBLIC_PATHS = Object.freeze([
15
16
  "/login",
16
17
  "/signup",
17
- "/reset-request-password", // Request Page
18
- "/invite", // Invite Link (/invite/:uid/:token)
19
- "/reset", // Reset Link (/reset/:uid/:token)
20
- "/welcome" // Optional: Falls Welcome auch öffentlich ist
21
- ];
18
+ "/reset-request-password",
19
+ "/invite", // /invite/:uid/:token
20
+ "/reset", // /reset/:uid/:token
21
+ "/welcome",
22
+ ]);
23
+ const CONSUMER_PUBLIC_PATHS = new Set();
24
+ /**
25
+ * Register an additional public path so a 401 on that route does not auto-redirect
26
+ * to `/login`. Typical use: a public landing on `/` in an otherwise authenticated app.
27
+ *
28
+ * MUST be called before the AuthProvider mounts (i.e. before `ReactDOM.render`).
29
+ * Calling it later won't help the bootstrap probe which fires on AuthProvider mount.
30
+ */
31
+ export function addPublicPath(path) {
32
+ if (typeof path === "string" && path) {
33
+ CONSUMER_PUBLIC_PATHS.add(path);
34
+ }
35
+ }
36
+ /** Remove a consumer-added public path. Library-internal paths are protected. */
37
+ export function removePublicPath(path) {
38
+ CONSUMER_PUBLIC_PATHS.delete(path);
39
+ }
22
40
  function isPublicSitePath(pathname) {
23
41
  return pathname === "/sites" || pathname.startsWith("/sites/");
24
42
  }
43
+ // Match rule: an entry of exactly "/" requires strict equality (avoids matching
44
+ // every path with startsWith). Other entries keep the looser prefix match so
45
+ // dynamic routes like /invite/:uid/:token still work.
46
+ function matchesPublicPath(pathname, entry) {
47
+ if (entry === "/")
48
+ return pathname === "/";
49
+ return pathname.startsWith(entry);
50
+ }
25
51
  function redirectToLoginOnce() {
26
52
  if (!isBrowser())
27
53
  return;
28
54
  const currentPath = window.location.pathname;
29
- // 1. Check: Sind wir auf einer öffentlichen Seite?
30
- const isPublicPage = isPublicSitePath(currentPath) || PUBLIC_PATHS.some(path => currentPath.startsWith(path));
55
+ const isPublicPage = isPublicSitePath(currentPath) ||
56
+ BUILTIN_PUBLIC_PATHS.some((path) => matchesPublicPath(currentPath, path)) ||
57
+ Array.from(CONSUMER_PUBLIC_PATHS).some((path) => matchesPublicPath(currentPath, path));
31
58
  // Wenn ja: NICHT weiterleiten. Der 401 Fehler wird an die Komponente durchgereicht.
32
59
  if (isPublicPage)
33
60
  return;
@@ -83,13 +110,18 @@ function extractAuthSignal(data) {
83
110
  return { code: null, i18nKey: null };
84
111
  }
85
112
  apiClient.interceptors.response.use((response) => response, (error) => {
86
- var _a, _b, _c, _d;
113
+ var _a, _b, _c, _d, _e;
87
114
  const status = (_b = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : null;
88
115
  const data = (_d = (_c = error === null || error === void 0 ? void 0 : error.response) === null || _c === void 0 ? void 0 : _c.data) !== null && _d !== void 0 ? _d : {};
89
116
  const { code, i18nKey } = extractAuthSignal(data);
90
117
  const isAuthStatus = status === 401 || status === 403;
91
118
  const isNotAuthenticated = code === "not_authenticated" || i18nKey === "auth.not_authenticated";
92
- if (isAuthStatus && isNotAuthenticated) {
119
+ // Per-request opt-out: bootstrap probes (e.g. fetchCurrentUser on app start)
120
+ // expect to handle 401 silently and must not trigger a redirect-on-mount.
121
+ // Carried as an axios config property, so it never travels to the backend
122
+ // (would otherwise trigger a CORS preflight on cross-origin requests).
123
+ const skipRedirect = ((_e = error === null || error === void 0 ? void 0 : error.config) === null || _e === void 0 ? void 0 : _e.skipAuthRedirect) === true;
124
+ if (isAuthStatus && isNotAuthenticated && !skipRedirect) {
93
125
  redirectToLoginOnce();
94
126
  }
95
127
  return Promise.reject(error);
@@ -12,7 +12,12 @@ function getCsrfToken() {
12
12
  // Session & User Core
13
13
  // -----------------------------
14
14
  export async function fetchCurrentUser() {
15
- const res = await apiClient.get(`${USERS_BASE}/current/`);
15
+ // Bootstrap-Probe: 401 darf nicht in einen Login-Redirect umschlagen,
16
+ // damit Public-Landings auf "/" sichtbar bleiben. `skipAuthRedirect` ist eine
17
+ // client-seitige axios-Config-Property und wird nicht ans Backend gesendet.
18
+ const res = await apiClient.get(`${USERS_BASE}/current/`, {
19
+ skipAuthRedirect: true,
20
+ });
16
21
  return res.data;
17
22
  }
18
23
  export async function fetchAuthMethods() {
@@ -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
+ }
@@ -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 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
+ }
@@ -1643,5 +1643,53 @@ export const authTranslations = {
1643
1643
  "fr": "Chargement…",
1644
1644
  "en": "Loading…",
1645
1645
  "sw": "Inapakia..."
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."
1670
+ },
1671
+ "Auth.ACCESS_CODE_SINGLE_USE_TITLE": {
1672
+ "de": "Zugangscode: Einmalverwendung",
1673
+ "fr": "Code d'accès : usage unique",
1674
+ "en": "Access Code Single-Use",
1675
+ "sw": "Msimbo wa Ufikiaji: Matumizi ya Mara Moja"
1676
+ },
1677
+ "Auth.ACCESS_CODE_SINGLE_USE_HINT": {
1678
+ "de": "Wenn aktiviert, kann jeder Zugangscode nur einmal eingelöst werden. Empfohlen.",
1679
+ "fr": "Lorsqu'activé, chaque code d'accès ne peut être utilisé qu'une seule fois. Recommandé.",
1680
+ "en": "When enabled, each access code can be redeemed only once. Recommended.",
1681
+ "sw": "Ikiwa imewashwa, kila msimbo wa ufikiaji unaweza kutumika mara moja tu. Inapendekezwa."
1682
+ },
1683
+ "Auth.ACCESS_CODE_SINGLE_USE_TOGGLE": {
1684
+ "de": "Codes sind einmalig verwendbar",
1685
+ "fr": "Codes à usage unique",
1686
+ "en": "Codes are single-use",
1687
+ "sw": "Misimbo ni ya matumizi ya mara moja"
1688
+ },
1689
+ "Auth.ACCESS_CODE_SINGLE_USE_SAVE_SUCCESS": {
1690
+ "de": "Zugangscode-Richtlinie gespeichert.",
1691
+ "fr": "Politique de code d'accès enregistrée.",
1692
+ "en": "Access code policy saved.",
1693
+ "sw": "Sera ya msimbo wa ufikiaji imehifadhiwa."
1646
1694
  }
1647
1695
  };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // index.js (Entry Point deiner Library)
2
2
  // --- 1. Auth Context (Essentiell für den Wrapper) ---
3
3
  export { AuthContext, AuthProvider } from './auth/AuthContext';
4
- export { default as apiClient, ensureCsrfToken } from "./auth/apiClient";
4
+ export { default as apiClient, ensureCsrfToken, addPublicPath, removePublicPath, } from "./auth/apiClient";
5
5
  // --- 2. API & Services (Neue Struktur) ---
6
6
  // Statt dem 'authApi'-Objekt exportieren wir die Funktionen direkt.
7
7
  // Konsumenten können dann machen: import { loginWithPassword } from 'django-core-micha';
@@ -24,6 +24,8 @@ export { UserInviteComponent } from './components/UserInviteComponent';
24
24
  export { BulkInviteCsvTab } from './components/BulkInviteCsvTab';
25
25
  export { RegistrationMethodsManager } from './components/RegistrationMethodsManager';
26
26
  export { AuthFactorRequirementCard } from './components/AuthFactorRequirementCard';
27
+ export { EmailVerificationRequirementCard } from './components/EmailVerificationRequirementCard';
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,8 @@ 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
+ import { AccessCodeSingleUseToggle } from '../components/AccessCodeSingleUseToggle';
19
21
  import { QrSignupManager } from '../components/QrSignupManager';
20
22
  import { QrSignupValidityManager } from '../components/QrSignupValidityManager';
21
23
  import { SupportRecoveryRequestsTab } from '../components/SupportRecoveryRequestsTab';
@@ -129,5 +131,5 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
129
131
  const activeExtraTab = builtInTabValues.has(safeTab)
130
132
  ? null
131
133
  : 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 }) }))] }));
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
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.2.13",
3
+ "version": "2.3.0",
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
-
@@ -13,29 +13,60 @@ function isBrowser() {
13
13
  return typeof window !== "undefined";
14
14
  }
15
15
 
16
- // WICHTIG: Liste aller Routen, die OHNE Login funktionieren müssen.
17
- // Beginnt der Pfad mit einem dieser Strings, wird kein Auto-Redirect ausgelöst.
18
- const PUBLIC_PATHS = [
16
+ // Routen, die OHNE Login funktionieren müssen. Library-eigene Pfade sind eingefroren
17
+ // und können nicht entfernt werden sonst würde z. B. `removePublicPath("/login")`
18
+ // auf der Login-Page selbst einen Redirect-Loop auslösen.
19
+ const BUILTIN_PUBLIC_PATHS = Object.freeze([
19
20
  "/login",
20
21
  "/signup",
21
- "/reset-request-password", // Request Page
22
- "/invite", // Invite Link (/invite/:uid/:token)
23
- "/reset", // Reset Link (/reset/:uid/:token)
24
- "/welcome" // Optional: Falls Welcome auch öffentlich ist
25
- ];
22
+ "/reset-request-password",
23
+ "/invite", // /invite/:uid/:token
24
+ "/reset", // /reset/:uid/:token
25
+ "/welcome",
26
+ ]);
27
+
28
+ const CONSUMER_PUBLIC_PATHS = new Set();
29
+
30
+ /**
31
+ * Register an additional public path so a 401 on that route does not auto-redirect
32
+ * to `/login`. Typical use: a public landing on `/` in an otherwise authenticated app.
33
+ *
34
+ * MUST be called before the AuthProvider mounts (i.e. before `ReactDOM.render`).
35
+ * Calling it later won't help the bootstrap probe which fires on AuthProvider mount.
36
+ */
37
+ export function addPublicPath(path) {
38
+ if (typeof path === "string" && path) {
39
+ CONSUMER_PUBLIC_PATHS.add(path);
40
+ }
41
+ }
42
+
43
+ /** Remove a consumer-added public path. Library-internal paths are protected. */
44
+ export function removePublicPath(path) {
45
+ CONSUMER_PUBLIC_PATHS.delete(path);
46
+ }
26
47
 
27
48
  function isPublicSitePath(pathname) {
28
49
  return pathname === "/sites" || pathname.startsWith("/sites/");
29
50
  }
30
51
 
52
+ // Match rule: an entry of exactly "/" requires strict equality (avoids matching
53
+ // every path with startsWith). Other entries keep the looser prefix match so
54
+ // dynamic routes like /invite/:uid/:token still work.
55
+ function matchesPublicPath(pathname, entry) {
56
+ if (entry === "/") return pathname === "/";
57
+ return pathname.startsWith(entry);
58
+ }
59
+
31
60
  function redirectToLoginOnce() {
32
61
  if (!isBrowser()) return;
33
62
 
34
63
  const currentPath = window.location.pathname;
35
64
 
36
- // 1. Check: Sind wir auf einer öffentlichen Seite?
37
- const isPublicPage = isPublicSitePath(currentPath) || PUBLIC_PATHS.some(path => currentPath.startsWith(path));
38
-
65
+ const isPublicPage =
66
+ isPublicSitePath(currentPath) ||
67
+ BUILTIN_PUBLIC_PATHS.some((path) => matchesPublicPath(currentPath, path)) ||
68
+ Array.from(CONSUMER_PUBLIC_PATHS).some((path) => matchesPublicPath(currentPath, path));
69
+
39
70
  // Wenn ja: NICHT weiterleiten. Der 401 Fehler wird an die Komponente durchgereicht.
40
71
  if (isPublicPage) return;
41
72
 
@@ -108,7 +139,13 @@ apiClient.interceptors.response.use(
108
139
  const isNotAuthenticated =
109
140
  code === "not_authenticated" || i18nKey === "auth.not_authenticated";
110
141
 
111
- if (isAuthStatus && isNotAuthenticated) {
142
+ // Per-request opt-out: bootstrap probes (e.g. fetchCurrentUser on app start)
143
+ // expect to handle 401 silently and must not trigger a redirect-on-mount.
144
+ // Carried as an axios config property, so it never travels to the backend
145
+ // (would otherwise trigger a CORS preflight on cross-origin requests).
146
+ const skipRedirect = error?.config?.skipAuthRedirect === true;
147
+
148
+ if (isAuthStatus && isNotAuthenticated && !skipRedirect) {
112
149
  redirectToLoginOnce();
113
150
  }
114
151
  return Promise.reject(error);
@@ -14,7 +14,12 @@ function getCsrfToken() {
14
14
  // -----------------------------
15
15
 
16
16
  export async function fetchCurrentUser() {
17
- const res = await apiClient.get(`${USERS_BASE}/current/`);
17
+ // Bootstrap-Probe: 401 darf nicht in einen Login-Redirect umschlagen,
18
+ // damit Public-Landings auf "/" sichtbar bleiben. `skipAuthRedirect` ist eine
19
+ // client-seitige axios-Config-Property und wird nicht ans Backend gesendet.
20
+ const res = await apiClient.get(`${USERS_BASE}/current/`, {
21
+ skipAuthRedirect: true,
22
+ });
18
23
  return res.data;
19
24
  }
20
25
 
@@ -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
+ }
@@ -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 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
+ }
@@ -1691,5 +1691,53 @@ export const authTranslations = {
1691
1691
  "fr": "Chargement…",
1692
1692
  "en": "Loading…",
1693
1693
  "sw": "Inapakia..."
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."
1718
+ },
1719
+ "Auth.ACCESS_CODE_SINGLE_USE_TITLE": {
1720
+ "de": "Zugangscode: Einmalverwendung",
1721
+ "fr": "Code d'accès : usage unique",
1722
+ "en": "Access Code Single-Use",
1723
+ "sw": "Msimbo wa Ufikiaji: Matumizi ya Mara Moja"
1724
+ },
1725
+ "Auth.ACCESS_CODE_SINGLE_USE_HINT": {
1726
+ "de": "Wenn aktiviert, kann jeder Zugangscode nur einmal eingelöst werden. Empfohlen.",
1727
+ "fr": "Lorsqu'activé, chaque code d'accès ne peut être utilisé qu'une seule fois. Recommandé.",
1728
+ "en": "When enabled, each access code can be redeemed only once. Recommended.",
1729
+ "sw": "Ikiwa imewashwa, kila msimbo wa ufikiaji unaweza kutumika mara moja tu. Inapendekezwa."
1730
+ },
1731
+ "Auth.ACCESS_CODE_SINGLE_USE_TOGGLE": {
1732
+ "de": "Codes sind einmalig verwendbar",
1733
+ "fr": "Codes à usage unique",
1734
+ "en": "Codes are single-use",
1735
+ "sw": "Misimbo ni ya matumizi ya mara moja"
1736
+ },
1737
+ "Auth.ACCESS_CODE_SINGLE_USE_SAVE_SUCCESS": {
1738
+ "de": "Zugangscode-Richtlinie gespeichert.",
1739
+ "fr": "Politique de code d'accès enregistrée.",
1740
+ "en": "Access code policy saved.",
1741
+ "sw": "Sera ya msimbo wa ufikiaji imehifadhiwa."
1694
1742
  }
1695
1743
  };
package/src/index.js CHANGED
@@ -3,7 +3,12 @@
3
3
  // --- 1. Auth Context (Essentiell für den Wrapper) ---
4
4
  export { AuthContext, AuthProvider } from './auth/AuthContext';
5
5
 
6
- export { default as apiClient, ensureCsrfToken } from "./auth/apiClient";
6
+ export {
7
+ default as apiClient,
8
+ ensureCsrfToken,
9
+ addPublicPath,
10
+ removePublicPath,
11
+ } from "./auth/apiClient";
7
12
 
8
13
  // --- 2. API & Services (Neue Struktur) ---
9
14
  // Statt dem 'authApi'-Objekt exportieren wir die Funktionen direkt.
@@ -30,6 +35,8 @@ export { UserInviteComponent } from './components/UserInviteComponent';
30
35
  export { BulkInviteCsvTab } from './components/BulkInviteCsvTab';
31
36
  export { RegistrationMethodsManager } from './components/RegistrationMethodsManager';
32
37
  export { AuthFactorRequirementCard } from './components/AuthFactorRequirementCard';
38
+ export { EmailVerificationRequirementCard } from './components/EmailVerificationRequirementCard';
39
+ export { AccessCodeSingleUseToggle } from './components/AccessCodeSingleUseToggle';
33
40
  export { QrSignupManager } from './components/QrSignupManager';
34
41
 
35
42
  // --- 6. Translations ---
@@ -26,6 +26,8 @@ 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
+ import { AccessCodeSingleUseToggle } from '../components/AccessCodeSingleUseToggle';
29
31
  import { QrSignupManager } from '../components/QrSignupManager';
30
32
  import { QrSignupValidityManager } from '../components/QrSignupValidityManager';
31
33
  import { SupportRecoveryRequestsTab } from '../components/SupportRecoveryRequestsTab';
@@ -254,6 +256,16 @@ export function AccountPage({
254
256
  </Paper>
255
257
  )}
256
258
 
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
+
257
269
  {canViewAuthPolicy && (
258
270
  <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
259
271
  <RegistrationMethodsManager
@@ -283,6 +295,16 @@ export function AccountPage({
283
295
  </Paper>
284
296
  )}
285
297
 
298
+ {canManageAccessCodes && Boolean(authPolicy?.allow_self_signup_access_code) && (
299
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
300
+ <AccessCodeSingleUseToggle
301
+ canEdit={canWriteAuthPolicy}
302
+ policy={authPolicy}
303
+ onPolicyChange={setAuthPolicy}
304
+ />
305
+ </Paper>
306
+ )}
307
+
286
308
  {canSendInvites && showBulkInviteCsvTab && Boolean(authPolicy?.allow_admin_invite) && (
287
309
  <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
288
310
  <BulkInviteCsvTab {...bulkInviteCsvProps} />