@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.
- package/dist/auth/apiClient.js +44 -12
- package/dist/auth/authApi.js +6 -1
- package/dist/components/AccessCodeSingleUseToggle.js +59 -0
- package/dist/components/EmailVerificationRequirementCard.js +59 -0
- package/dist/i18n/authTranslations.js +48 -0
- package/dist/index.js +3 -1
- package/dist/pages/AccountPage.js +3 -1
- package/package.json +2 -2
- package/src/auth/apiClient.jsx +49 -12
- package/src/auth/authApi.jsx +6 -1
- package/src/components/AccessCodeSingleUseToggle.jsx +89 -0
- package/src/components/EmailVerificationRequirementCard.jsx +89 -0
- package/src/i18n/authTranslations.ts +48 -0
- package/src/index.js +8 -1
- package/src/pages/AccountPage.jsx +22 -0
package/dist/auth/apiClient.js
CHANGED
|
@@ -9,25 +9,52 @@ let redirectingToLogin = false;
|
|
|
9
9
|
function isBrowser() {
|
|
10
10
|
return typeof window !== "undefined";
|
|
11
11
|
}
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
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",
|
|
18
|
-
"/invite", //
|
|
19
|
-
"/reset", //
|
|
20
|
-
"/welcome"
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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);
|
package/dist/auth/authApi.js
CHANGED
|
@@ -12,7 +12,12 @@ function getCsrfToken() {
|
|
|
12
12
|
// Session & User Core
|
|
13
13
|
// -----------------------------
|
|
14
14
|
export async function fetchCurrentUser() {
|
|
15
|
-
|
|
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.
|
|
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
|
-
|
package/src/auth/apiClient.jsx
CHANGED
|
@@ -13,29 +13,60 @@ function isBrowser() {
|
|
|
13
13
|
return typeof window !== "undefined";
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
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",
|
|
22
|
-
"/invite", //
|
|
23
|
-
"/reset", //
|
|
24
|
-
"/welcome"
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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);
|
package/src/auth/authApi.jsx
CHANGED
|
@@ -14,7 +14,12 @@ function getCsrfToken() {
|
|
|
14
14
|
// -----------------------------
|
|
15
15
|
|
|
16
16
|
export async function fetchCurrentUser() {
|
|
17
|
-
|
|
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 {
|
|
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} />
|