@micha.bigler/ui-core-micha 1.4.3 → 1.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/authApi.js +25 -0
- package/dist/components/MfaLoginComponent.js +24 -6
- package/dist/components/SupportRecoveryRequestsTab.js +59 -0
- package/dist/i18n/authTranslations.js +10 -0
- package/dist/pages/AccountPage.js +8 -3
- package/dist/pages/LoginPage.js +2 -1
- package/package.json +1 -1
- package/src/auth/authApi.jsx +45 -0
- package/src/components/MfaLoginComponent.jsx +48 -29
- package/src/components/SupportRecoveryRequestsTab.jsx +180 -0
- package/src/i18n/authTranslations.js +10 -0
- package/src/pages/AccountPage.jsx +24 -12
- package/src/pages/LoginPage.jsx +2 -0
package/dist/auth/authApi.js
CHANGED
|
@@ -596,6 +596,27 @@ export function isStrongSession(session) {
|
|
|
596
596
|
const strongMethods = ['totp', 'recovery_codes', 'webauthn'];
|
|
597
597
|
return used.some((m) => strongMethods.includes(m));
|
|
598
598
|
}
|
|
599
|
+
export async function requestMfaSupportHelp(emailOrIdentifier, message = '') {
|
|
600
|
+
// Wir nutzen hier die Users-API, nicht HEADLESS_BASE
|
|
601
|
+
const payload = { email: emailOrIdentifier, message };
|
|
602
|
+
const res = await axios.post(`${USERS_BASE}/mfa/support-help/`, payload, { withCredentials: true });
|
|
603
|
+
return res.data;
|
|
604
|
+
}
|
|
605
|
+
export async function fetchRecoveryRequests(status = 'pending') {
|
|
606
|
+
const res = await axios.get('/api/support/recovery-requests/', {
|
|
607
|
+
params: { status },
|
|
608
|
+
withCredentials: true,
|
|
609
|
+
});
|
|
610
|
+
return res.data;
|
|
611
|
+
}
|
|
612
|
+
export async function approveRecoveryRequest(id) {
|
|
613
|
+
const res = await axios.post(`/api/support/recovery-requests/${id}/approve/`, {}, { withCredentials: true });
|
|
614
|
+
return res.data;
|
|
615
|
+
}
|
|
616
|
+
export async function rejectRecoveryRequest(id) {
|
|
617
|
+
const res = await axios.post(`/api/support/recovery-requests/${id}/reject/`, {}, { withCredentials: true });
|
|
618
|
+
return res.data;
|
|
619
|
+
}
|
|
599
620
|
// -----------------------------
|
|
600
621
|
// Aggregated API object
|
|
601
622
|
// -----------------------------
|
|
@@ -625,5 +646,9 @@ export const authApi = {
|
|
|
625
646
|
validateAccessCode,
|
|
626
647
|
requestInviteWithCode,
|
|
627
648
|
isStrongSession,
|
|
649
|
+
requestMfaSupportHelp,
|
|
650
|
+
fetchRecoveryRequests,
|
|
651
|
+
approveRecoveryRequest,
|
|
652
|
+
rejectRecoveryRequest,
|
|
628
653
|
};
|
|
629
654
|
export default authApi;
|
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// src/auth/components/MfaLoginComponent.jsx
|
|
3
3
|
import React, { useState } from 'react';
|
|
4
|
-
import { Box, Typography, TextField, Button, Stack, Alert,
|
|
4
|
+
import { Box, Typography, TextField, Button, Stack, Alert, } from '@mui/material';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import { authApi } from '../auth/authApi';
|
|
7
|
-
const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel
|
|
8
|
-
//onNeedHelp, // <--- NEU: Callback für "I can't use..."
|
|
9
|
-
}) => {
|
|
7
|
+
const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel }) => {
|
|
10
8
|
const { t } = useTranslation();
|
|
11
9
|
const [code, setCode] = useState('');
|
|
12
10
|
const [submitting, setSubmitting] = useState(false);
|
|
13
11
|
const [errorKey, setErrorKey] = useState(null);
|
|
12
|
+
const [infoKey, setInfoKey] = useState(null);
|
|
13
|
+
const [helpRequested, setHelpRequested] = useState(false);
|
|
14
14
|
const types = Array.isArray(availableTypes) ? availableTypes : [];
|
|
15
15
|
const supportsTotpOrRecovery = types.includes('totp') || types.includes('recovery_codes');
|
|
16
16
|
const supportsWebauthn = types.includes('webauthn');
|
|
17
17
|
const handleSubmitCode = async (event) => {
|
|
18
18
|
event.preventDefault();
|
|
19
19
|
setErrorKey(null);
|
|
20
|
+
setInfoKey(null);
|
|
20
21
|
setSubmitting(true);
|
|
21
22
|
try {
|
|
22
23
|
const trimmed = code.trim();
|
|
23
|
-
const isRecovery = trimmed.length > 6;
|
|
24
|
+
const isRecovery = trimmed.length > 6;
|
|
24
25
|
await authApi.authenticateWithMFA({ code: trimmed });
|
|
25
26
|
const user = await authApi.fetchCurrentUser();
|
|
26
27
|
onSuccess({
|
|
@@ -37,6 +38,7 @@ const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel,
|
|
|
37
38
|
};
|
|
38
39
|
const handlePasskey = async () => {
|
|
39
40
|
setErrorKey(null);
|
|
41
|
+
setInfoKey(null);
|
|
40
42
|
setSubmitting(true);
|
|
41
43
|
try {
|
|
42
44
|
const { user } = await authApi.loginWithPasskey();
|
|
@@ -49,6 +51,22 @@ const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel,
|
|
|
49
51
|
setSubmitting(false);
|
|
50
52
|
}
|
|
51
53
|
};
|
|
52
|
-
|
|
54
|
+
const handleNeedHelp = async () => {
|
|
55
|
+
setErrorKey(null);
|
|
56
|
+
setInfoKey(null);
|
|
57
|
+
setSubmitting(true);
|
|
58
|
+
try {
|
|
59
|
+
await authApi.requestMfaSupportHelp(identifier || '');
|
|
60
|
+
setHelpRequested(true);
|
|
61
|
+
setInfoKey('Auth.MFA_HELP_REQUESTED');
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
setErrorKey(err.code || 'Auth.MFA_HELP_REQUEST_FAILED');
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
setSubmitting(false);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.MFA_TITLE', 'Additional verification required') }), _jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: t('Auth.MFA_SUBTITLE', 'Please confirm your login using one of the available methods.') }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), infoKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t(infoKey) })), _jsxs(Stack, { spacing: 2, children: [supportsTotpOrRecovery && (_jsxs(Box, { component: "form", onSubmit: handleSubmitCode, children: [_jsx(TextField, { label: t('Auth.MFA_CODE_LABEL', 'Authenticator code (or recovery code)'), value: code, onChange: (e) => setCode(e.target.value), fullWidth: true, disabled: submitting || helpRequested, autoComplete: "one-time-code", sx: { mb: 2 } }), _jsx(Button, { type: "submit", variant: "contained", fullWidth: true, disabled: submitting || !code.trim() || helpRequested, children: t('Auth.MFA_VERIFY', 'Verify') })] })), supportsWebauthn && (_jsx(Button, { variant: "outlined", fullWidth: true, onClick: handlePasskey, disabled: submitting || helpRequested, children: t('Auth.MFA_USE_PASSKEY', 'Use passkey / security key') })), _jsx(Button, { size: "small", onClick: onCancel, disabled: submitting, children: t('Auth.MFA_BACK_TO_LOGIN', 'Back to login') }), _jsx(Button, { size: "small", color: "secondary", onClick: handleNeedHelp, disabled: submitting || helpRequested, children: t('Auth.MFA_NEED_HELP', "I can't use any of these methods") })] })] }));
|
|
53
71
|
};
|
|
54
72
|
export default MfaLoginComponent;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// src/components/SupportRecoveryRequestsTab.jsx
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import { Box, Typography, Table, TableHead, TableBody, TableRow, TableCell, TableContainer, Paper, Button, CircularProgress, Alert, Stack, } from '@mui/material';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { authApi } from '../auth/authApi';
|
|
7
|
+
const SupportRecoveryRequestsTab = () => {
|
|
8
|
+
const { t } = useTranslation();
|
|
9
|
+
const [requests, setRequests] = useState([]);
|
|
10
|
+
const [loading, setLoading] = useState(true);
|
|
11
|
+
const [errorKey, setErrorKey] = useState(null);
|
|
12
|
+
const [statusFilter, setStatusFilter] = useState('pending'); // optional erweiterbar
|
|
13
|
+
const loadRequests = async () => {
|
|
14
|
+
setLoading(true);
|
|
15
|
+
setErrorKey(null);
|
|
16
|
+
try {
|
|
17
|
+
const data = await authApi.fetchRecoveryRequests(statusFilter);
|
|
18
|
+
setRequests(Array.isArray(data) ? data : []);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
setErrorKey(err.code || 'Support.RECOVERY_REQUESTS_LOAD_FAILED');
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
setLoading(false);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
loadRequests();
|
|
29
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
30
|
+
}, [statusFilter]);
|
|
31
|
+
const handleSendRecoveryLink = async (id) => {
|
|
32
|
+
setErrorKey(null);
|
|
33
|
+
try {
|
|
34
|
+
await authApi.approveRecoveryRequest(id);
|
|
35
|
+
// Nach Erfolg Liste neu laden
|
|
36
|
+
await loadRequests();
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
setErrorKey(err.code || 'Support.RECOVERY_REQUEST_APPROVE_FAILED');
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const handleReject = async (id) => {
|
|
43
|
+
setErrorKey(null);
|
|
44
|
+
try {
|
|
45
|
+
await authApi.rejectRecoveryRequest(id);
|
|
46
|
+
await loadRequests();
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
setErrorKey(err.code || 'Support.RECOVERY_REQUEST_REJECT_FAILED');
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
if (loading) {
|
|
53
|
+
return (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', mt: 4 }, children: _jsx(CircularProgress, {}) }));
|
|
54
|
+
}
|
|
55
|
+
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Support.RECOVERY_REQUESTS_TITLE', 'Account recovery requests') }), _jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: t('Support.RECOVERY_REQUESTS_DESCRIPTION', 'Users who cannot complete MFA can request support. You can send them a recovery link or reject the request after verification.') }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), _jsxs(Stack, { direction: "row", spacing: 2, sx: { mb: 2 }, children: [_jsx(Button, { variant: statusFilter === 'pending' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('pending'), children: t('Support.RECOVERY_FILTER_PENDING', 'Open') }), _jsx(Button, { variant: statusFilter === 'approved' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('approved'), children: t('Support.RECOVERY_FILTER_APPROVED', 'Approved') }), _jsx(Button, { variant: statusFilter === 'rejected' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('rejected'), children: t('Support.RECOVERY_FILTER_REJECTED', 'Rejected') })] }), requests.length === 0 ? (_jsx(Typography, { variant: "body2", children: t('Support.RECOVERY_REQUESTS_EMPTY', 'No recovery requests for this filter.') })) : (_jsx(TableContainer, { component: Paper, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('Support.RECOVERY_COL_CREATED', 'Created') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_USER', 'User') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_SUPPORT', 'Assigned support') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_STATUS', 'Status') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_ACTIONS', 'Actions') })] }) }), _jsx(TableBody, { children: requests.map((req) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: req.created_at
|
|
56
|
+
? new Date(req.created_at).toLocaleString()
|
|
57
|
+
: '-' }), _jsx(TableCell, { children: req.user_email || req.user }), _jsx(TableCell, { children: req.support_email || '–' }), _jsx(TableCell, { children: req.status }), _jsx(TableCell, { children: _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { variant: "contained", size: "small", onClick: () => handleSendRecoveryLink(req.id), disabled: req.status !== 'pending', children: t('Support.RECOVERY_ACTION_SEND_LINK', 'Send recovery link') }), _jsx(Button, { variant: "outlined", size: "small", color: "error", onClick: () => handleReject(req.id), disabled: req.status !== 'pending', children: t('Support.RECOVERY_ACTION_REJECT', 'Reject') })] }) })] }, req.id))) })] }) }))] }));
|
|
58
|
+
};
|
|
59
|
+
export default SupportRecoveryRequestsTab;
|
|
@@ -816,5 +816,15 @@ export const authTranslations = {
|
|
|
816
816
|
"fr": "Votre connexion actuelle ne satisfait pas aux exigences de sécurité recommandées. Veuillez configurer un facteur de sécurité supplémentaire (clé d’accès ou application d’authentification) et générer des codes de récupération.",
|
|
817
817
|
"en": "Your current sign-in does not meet the recommended security requirements. Please set up an additional security factor (passkey or authenticator app) and generate recovery codes."
|
|
818
818
|
},
|
|
819
|
+
"Auth.MFA_HELP_REQUESTED": {
|
|
820
|
+
"de": "Wir haben deinen Support-Kontakt informiert. Du erhältst eine Rückmeldung, sobald deine Identität geprüft wurde.",
|
|
821
|
+
"fr": "Nous avons informé votre contact de support. Vous recevrez une réponse dès que votre identité aura été vérifiée.",
|
|
822
|
+
"en": "We have informed your support contact. You will hear back as soon as your identity has been verified."
|
|
823
|
+
},
|
|
824
|
+
"Auth.MFA_HELP_REQUEST_FAILED": {
|
|
825
|
+
"de": "Die Support-Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut oder kontaktiere den Support direkt.",
|
|
826
|
+
"fr": "La demande d'assistance n'a pas pu être envoyée. Veuillez réessayer plus tard ou contacter le support directement.",
|
|
827
|
+
"en": "Could not send the support request. Please try again later or contact support directly."
|
|
828
|
+
}
|
|
819
829
|
// ...
|
|
820
830
|
};
|
|
@@ -6,14 +6,19 @@ import { Tabs, Tab, Box } from '@mui/material';
|
|
|
6
6
|
import { WidePage } from '../layout/PageLayout';
|
|
7
7
|
import ProfileComponent from '../components/ProfileComponent';
|
|
8
8
|
import SecurityComponent from '../components/SecurityComponent';
|
|
9
|
+
import SupportRecoveryRequestsTab from '../components/SupportRecoveryRequestsTab';
|
|
9
10
|
import { authApi } from '../auth/authApi';
|
|
10
11
|
import { AuthContext } from '../auth/AuthContext';
|
|
11
12
|
import { useSearchParams } from 'react-router-dom';
|
|
12
13
|
export function AccountPage() {
|
|
13
14
|
const { login } = useContext(AuthContext);
|
|
14
15
|
const [searchParams] = useSearchParams();
|
|
15
|
-
|
|
16
|
-
const initialTab =
|
|
16
|
+
const initialTabParam = searchParams.get('tab');
|
|
17
|
+
const initialTab = initialTabParam === 'security'
|
|
18
|
+
? 'security'
|
|
19
|
+
: initialTabParam === 'support'
|
|
20
|
+
? 'support'
|
|
21
|
+
: 'account';
|
|
17
22
|
const fromParam = searchParams.get('from');
|
|
18
23
|
const fromRecovery = fromParam === 'recovery';
|
|
19
24
|
const fromWeakLogin = fromParam === 'weak_login';
|
|
@@ -25,6 +30,6 @@ export function AccountPage() {
|
|
|
25
30
|
const updatedUser = await authApi.updateUserProfile(payload);
|
|
26
31
|
login(updatedUser);
|
|
27
32
|
};
|
|
28
|
-
return (_jsxs(WidePage, { title: "Account", children: [_jsx(Helmet, { children: _jsx("title", { children: "PROJECT_NAME \u2013 Account" }) }), _jsxs(Tabs, { value: tab, onChange: handleTabChange, sx: { mb: 3 }, children: [_jsx(Tab, { label: "Security", value: "security" }), _jsx(Tab, { label: "
|
|
33
|
+
return (_jsxs(WidePage, { title: "Account", children: [_jsx(Helmet, { children: _jsx("title", { children: "PROJECT_NAME \u2013 Account" }) }), _jsxs(Tabs, { value: tab, onChange: handleTabChange, sx: { mb: 3 }, children: [_jsx(Tab, { label: "Account", value: "account" }), _jsx(Tab, { label: "Security", value: "security" }), _jsx(Tab, { label: "Support", value: "support" })] }), tab === 'account' && (_jsx(Box, { sx: { mt: 1 }, children: _jsx(ProfileComponent, { onLoad: () => { }, onSubmit: handleProfileSubmit, submitText: "Save", showName: true, showPrivacy: true, showCookies: true }) })), tab === 'security' && (_jsx(Box, { sx: { mt: 1 }, children: _jsx(SecurityComponent, { fromRecovery: fromRecovery, fromWeakLogin: fromWeakLogin }) })), tab === 'support' && (_jsx(Box, { sx: { mt: 1 }, children: _jsx(SupportRecoveryRequestsTab, {}) }))] }));
|
|
29
34
|
}
|
|
30
35
|
export default AccountPage;
|
package/dist/pages/LoginPage.js
CHANGED
|
@@ -27,6 +27,7 @@ export function LoginPage() {
|
|
|
27
27
|
if (result.needsMfa) {
|
|
28
28
|
setMfaState({
|
|
29
29
|
availableTypes: result.availableTypes || [],
|
|
30
|
+
identifier,
|
|
30
31
|
});
|
|
31
32
|
setStep('mfa');
|
|
32
33
|
}
|
|
@@ -88,6 +89,6 @@ export function LoginPage() {
|
|
|
88
89
|
setMfaState(null);
|
|
89
90
|
setErrorKey(null);
|
|
90
91
|
};
|
|
91
|
-
return (_jsxs(NarrowPage, { title: t('Auth.PAGE_LOGIN_TITLE'), subtitle: t('Auth.PAGE_LOGIN_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_LOGIN_TITLE')] }) }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), step === 'credentials' && (_jsx(LoginForm, { onSubmit: handleSubmitCredentials, onForgotPassword: handleForgotPassword, onSocialLogin: handleSocialLogin, onPasskeyLogin: handlePasskeyLoginInitial, onSignUp: handleSignUp, disabled: submitting })), step === 'mfa' && mfaState && (_jsx(Box, { children: _jsx(MfaLoginComponent, { availableTypes: mfaState.availableTypes, onSuccess: handleMfaSuccess, onCancel: handleMfaCancel }) }))] }));
|
|
92
|
+
return (_jsxs(NarrowPage, { title: t('Auth.PAGE_LOGIN_TITLE'), subtitle: t('Auth.PAGE_LOGIN_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_LOGIN_TITLE')] }) }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), step === 'credentials' && (_jsx(LoginForm, { onSubmit: handleSubmitCredentials, onForgotPassword: handleForgotPassword, onSocialLogin: handleSocialLogin, onPasskeyLogin: handlePasskeyLoginInitial, onSignUp: handleSignUp, disabled: submitting })), step === 'mfa' && mfaState && (_jsx(Box, { children: _jsx(MfaLoginComponent, { availableTypes: mfaState.availableTypes, identifier: mfaState.identifier, onSuccess: handleMfaSuccess, onCancel: handleMfaCancel }) }))] }));
|
|
92
93
|
}
|
|
93
94
|
export default LoginPage;
|
package/package.json
CHANGED
package/src/auth/authApi.jsx
CHANGED
|
@@ -777,6 +777,47 @@ export function isStrongSession(session) {
|
|
|
777
777
|
return used.some((m) => strongMethods.includes(m));
|
|
778
778
|
}
|
|
779
779
|
|
|
780
|
+
export async function requestMfaSupportHelp(emailOrIdentifier, message = '') {
|
|
781
|
+
// Wir nutzen hier die Users-API, nicht HEADLESS_BASE
|
|
782
|
+
const payload = { email: emailOrIdentifier, message };
|
|
783
|
+
|
|
784
|
+
const res = await axios.post(
|
|
785
|
+
`${USERS_BASE}/mfa/support-help/`,
|
|
786
|
+
payload,
|
|
787
|
+
{ withCredentials: true }
|
|
788
|
+
);
|
|
789
|
+
return res.data;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export async function fetchRecoveryRequests(status = 'pending') {
|
|
793
|
+
const res = await axios.get(
|
|
794
|
+
'/api/support/recovery-requests/',
|
|
795
|
+
{
|
|
796
|
+
params: { status },
|
|
797
|
+
withCredentials: true,
|
|
798
|
+
},
|
|
799
|
+
);
|
|
800
|
+
return res.data;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export async function approveRecoveryRequest(id) {
|
|
804
|
+
const res = await axios.post(
|
|
805
|
+
`/api/support/recovery-requests/${id}/approve/`,
|
|
806
|
+
{},
|
|
807
|
+
{ withCredentials: true },
|
|
808
|
+
);
|
|
809
|
+
return res.data;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export async function rejectRecoveryRequest(id) {
|
|
813
|
+
const res = await axios.post(
|
|
814
|
+
`/api/support/recovery-requests/${id}/reject/`,
|
|
815
|
+
{},
|
|
816
|
+
{ withCredentials: true },
|
|
817
|
+
);
|
|
818
|
+
return res.data;
|
|
819
|
+
}
|
|
820
|
+
|
|
780
821
|
|
|
781
822
|
|
|
782
823
|
// -----------------------------
|
|
@@ -808,6 +849,10 @@ export const authApi = {
|
|
|
808
849
|
validateAccessCode,
|
|
809
850
|
requestInviteWithCode,
|
|
810
851
|
isStrongSession,
|
|
852
|
+
requestMfaSupportHelp,
|
|
853
|
+
fetchRecoveryRequests,
|
|
854
|
+
approveRecoveryRequest,
|
|
855
|
+
rejectRecoveryRequest,
|
|
811
856
|
};
|
|
812
857
|
|
|
813
858
|
export default authApi;
|
|
@@ -7,21 +7,17 @@ import {
|
|
|
7
7
|
Button,
|
|
8
8
|
Stack,
|
|
9
9
|
Alert,
|
|
10
|
-
Divider,
|
|
11
10
|
} from '@mui/material';
|
|
12
11
|
import { useTranslation } from 'react-i18next';
|
|
13
12
|
import { authApi } from '../auth/authApi';
|
|
14
13
|
|
|
15
|
-
const MfaLoginComponent = ({
|
|
16
|
-
availableTypes,
|
|
17
|
-
onSuccess,
|
|
18
|
-
onCancel,
|
|
19
|
-
//onNeedHelp, // <--- NEU: Callback für "I can't use..."
|
|
20
|
-
}) => {
|
|
14
|
+
const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel }) => {
|
|
21
15
|
const { t } = useTranslation();
|
|
22
16
|
const [code, setCode] = useState('');
|
|
23
17
|
const [submitting, setSubmitting] = useState(false);
|
|
24
18
|
const [errorKey, setErrorKey] = useState(null);
|
|
19
|
+
const [infoKey, setInfoKey] = useState(null);
|
|
20
|
+
const [helpRequested, setHelpRequested] = useState(false);
|
|
25
21
|
|
|
26
22
|
const types = Array.isArray(availableTypes) ? availableTypes : [];
|
|
27
23
|
const supportsTotpOrRecovery =
|
|
@@ -31,12 +27,14 @@ const MfaLoginComponent = ({
|
|
|
31
27
|
const handleSubmitCode = async (event) => {
|
|
32
28
|
event.preventDefault();
|
|
33
29
|
setErrorKey(null);
|
|
30
|
+
setInfoKey(null);
|
|
34
31
|
setSubmitting(true);
|
|
35
32
|
try {
|
|
36
33
|
const trimmed = code.trim();
|
|
37
|
-
const isRecovery = trimmed.length > 6;
|
|
34
|
+
const isRecovery = trimmed.length > 6;
|
|
38
35
|
|
|
39
36
|
await authApi.authenticateWithMFA({ code: trimmed });
|
|
37
|
+
|
|
40
38
|
const user = await authApi.fetchCurrentUser();
|
|
41
39
|
|
|
42
40
|
onSuccess({
|
|
@@ -52,6 +50,7 @@ const MfaLoginComponent = ({
|
|
|
52
50
|
|
|
53
51
|
const handlePasskey = async () => {
|
|
54
52
|
setErrorKey(null);
|
|
53
|
+
setInfoKey(null);
|
|
55
54
|
setSubmitting(true);
|
|
56
55
|
try {
|
|
57
56
|
const { user } = await authApi.loginWithPasskey();
|
|
@@ -63,13 +62,31 @@ const MfaLoginComponent = ({
|
|
|
63
62
|
}
|
|
64
63
|
};
|
|
65
64
|
|
|
65
|
+
const handleNeedHelp = async () => {
|
|
66
|
+
setErrorKey(null);
|
|
67
|
+
setInfoKey(null);
|
|
68
|
+
setSubmitting(true);
|
|
69
|
+
try {
|
|
70
|
+
await authApi.requestMfaSupportHelp(identifier || '');
|
|
71
|
+
setHelpRequested(true);
|
|
72
|
+
setInfoKey('Auth.MFA_HELP_REQUESTED');
|
|
73
|
+
} catch (err) {
|
|
74
|
+
setErrorKey(err.code || 'Auth.MFA_HELP_REQUEST_FAILED');
|
|
75
|
+
} finally {
|
|
76
|
+
setSubmitting(false);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
66
80
|
return (
|
|
67
81
|
<Box>
|
|
68
82
|
<Typography variant="h6" gutterBottom>
|
|
69
|
-
{t('Auth.MFA_TITLE', 'Additional verification')}
|
|
83
|
+
{t('Auth.MFA_TITLE', 'Additional verification required')}
|
|
70
84
|
</Typography>
|
|
71
85
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
|
72
|
-
{t(
|
|
86
|
+
{t(
|
|
87
|
+
'Auth.MFA_SUBTITLE',
|
|
88
|
+
'Please confirm your login using one of the available methods.',
|
|
89
|
+
)}
|
|
73
90
|
</Typography>
|
|
74
91
|
|
|
75
92
|
{errorKey && (
|
|
@@ -78,22 +95,13 @@ const MfaLoginComponent = ({
|
|
|
78
95
|
</Alert>
|
|
79
96
|
)}
|
|
80
97
|
|
|
81
|
-
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
onClick={handlePasskey}
|
|
87
|
-
disabled={submitting}
|
|
88
|
-
>
|
|
89
|
-
{t('Auth.MFA_USE_PASSKEY', 'Use passkey / security key')}
|
|
90
|
-
</Button>
|
|
91
|
-
)}
|
|
92
|
-
|
|
93
|
-
{supportsWebauthn && supportsTotpOrRecovery && (
|
|
94
|
-
<Divider>{t('Auth.MFA_OR', 'or')}</Divider>
|
|
95
|
-
)}
|
|
98
|
+
{infoKey && (
|
|
99
|
+
<Alert severity="info" sx={{ mb: 2 }}>
|
|
100
|
+
{t(infoKey)}
|
|
101
|
+
</Alert>
|
|
102
|
+
)}
|
|
96
103
|
|
|
104
|
+
<Stack spacing={2}>
|
|
97
105
|
{supportsTotpOrRecovery && (
|
|
98
106
|
<Box component="form" onSubmit={handleSubmitCode}>
|
|
99
107
|
<TextField
|
|
@@ -104,7 +112,7 @@ const MfaLoginComponent = ({
|
|
|
104
112
|
value={code}
|
|
105
113
|
onChange={(e) => setCode(e.target.value)}
|
|
106
114
|
fullWidth
|
|
107
|
-
disabled={submitting}
|
|
115
|
+
disabled={submitting || helpRequested}
|
|
108
116
|
autoComplete="one-time-code"
|
|
109
117
|
sx={{ mb: 2 }}
|
|
110
118
|
/>
|
|
@@ -112,13 +120,24 @@ const MfaLoginComponent = ({
|
|
|
112
120
|
type="submit"
|
|
113
121
|
variant="contained"
|
|
114
122
|
fullWidth
|
|
115
|
-
disabled={submitting || !code.trim()}
|
|
123
|
+
disabled={submitting || !code.trim() || helpRequested}
|
|
116
124
|
>
|
|
117
125
|
{t('Auth.MFA_VERIFY', 'Verify')}
|
|
118
126
|
</Button>
|
|
119
127
|
</Box>
|
|
120
128
|
)}
|
|
121
129
|
|
|
130
|
+
{supportsWebauthn && (
|
|
131
|
+
<Button
|
|
132
|
+
variant="outlined"
|
|
133
|
+
fullWidth
|
|
134
|
+
onClick={handlePasskey}
|
|
135
|
+
disabled={submitting || helpRequested}
|
|
136
|
+
>
|
|
137
|
+
{t('Auth.MFA_USE_PASSKEY', 'Use passkey / security key')}
|
|
138
|
+
</Button>
|
|
139
|
+
)}
|
|
140
|
+
|
|
122
141
|
<Button
|
|
123
142
|
size="small"
|
|
124
143
|
onClick={onCancel}
|
|
@@ -130,8 +149,8 @@ const MfaLoginComponent = ({
|
|
|
130
149
|
<Button
|
|
131
150
|
size="small"
|
|
132
151
|
color="secondary"
|
|
133
|
-
onClick={
|
|
134
|
-
disabled={submitting}
|
|
152
|
+
onClick={handleNeedHelp}
|
|
153
|
+
disabled={submitting || helpRequested}
|
|
135
154
|
>
|
|
136
155
|
{t(
|
|
137
156
|
'Auth.MFA_NEED_HELP',
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// src/components/SupportRecoveryRequestsTab.jsx
|
|
2
|
+
import React, { useEffect, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
Box,
|
|
5
|
+
Typography,
|
|
6
|
+
Table,
|
|
7
|
+
TableHead,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableRow,
|
|
10
|
+
TableCell,
|
|
11
|
+
TableContainer,
|
|
12
|
+
Paper,
|
|
13
|
+
Button,
|
|
14
|
+
CircularProgress,
|
|
15
|
+
Alert,
|
|
16
|
+
Stack,
|
|
17
|
+
} from '@mui/material';
|
|
18
|
+
import { useTranslation } from 'react-i18next';
|
|
19
|
+
import { authApi } from '../auth/authApi';
|
|
20
|
+
|
|
21
|
+
const SupportRecoveryRequestsTab = () => {
|
|
22
|
+
const { t } = useTranslation();
|
|
23
|
+
|
|
24
|
+
const [requests, setRequests] = useState([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [errorKey, setErrorKey] = useState(null);
|
|
27
|
+
const [statusFilter, setStatusFilter] = useState('pending'); // optional erweiterbar
|
|
28
|
+
|
|
29
|
+
const loadRequests = async () => {
|
|
30
|
+
setLoading(true);
|
|
31
|
+
setErrorKey(null);
|
|
32
|
+
try {
|
|
33
|
+
const data = await authApi.fetchRecoveryRequests(statusFilter);
|
|
34
|
+
setRequests(Array.isArray(data) ? data : []);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
setErrorKey(err.code || 'Support.RECOVERY_REQUESTS_LOAD_FAILED');
|
|
37
|
+
} finally {
|
|
38
|
+
setLoading(false);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
loadRequests();
|
|
44
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
|
+
}, [statusFilter]);
|
|
46
|
+
|
|
47
|
+
const handleSendRecoveryLink = async (id) => {
|
|
48
|
+
setErrorKey(null);
|
|
49
|
+
try {
|
|
50
|
+
await authApi.approveRecoveryRequest(id);
|
|
51
|
+
// Nach Erfolg Liste neu laden
|
|
52
|
+
await loadRequests();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
setErrorKey(err.code || 'Support.RECOVERY_REQUEST_APPROVE_FAILED');
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleReject = async (id) => {
|
|
59
|
+
setErrorKey(null);
|
|
60
|
+
try {
|
|
61
|
+
await authApi.rejectRecoveryRequest(id);
|
|
62
|
+
await loadRequests();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
setErrorKey(err.code || 'Support.RECOVERY_REQUEST_REJECT_FAILED');
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (loading) {
|
|
69
|
+
return (
|
|
70
|
+
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
|
71
|
+
<CircularProgress />
|
|
72
|
+
</Box>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Box>
|
|
78
|
+
<Typography variant="h6" gutterBottom>
|
|
79
|
+
{t('Support.RECOVERY_REQUESTS_TITLE', 'Account recovery requests')}
|
|
80
|
+
</Typography>
|
|
81
|
+
|
|
82
|
+
<Typography variant="body2" sx={{ mb: 2 }}>
|
|
83
|
+
{t(
|
|
84
|
+
'Support.RECOVERY_REQUESTS_DESCRIPTION',
|
|
85
|
+
'Users who cannot complete MFA can request support. You can send them a recovery link or reject the request after verification.',
|
|
86
|
+
)}
|
|
87
|
+
</Typography>
|
|
88
|
+
|
|
89
|
+
{errorKey && (
|
|
90
|
+
<Alert severity="error" sx={{ mb: 2 }}>
|
|
91
|
+
{t(errorKey)}
|
|
92
|
+
</Alert>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
|
96
|
+
<Button
|
|
97
|
+
variant={statusFilter === 'pending' ? 'contained' : 'outlined'}
|
|
98
|
+
size="small"
|
|
99
|
+
onClick={() => setStatusFilter('pending')}
|
|
100
|
+
>
|
|
101
|
+
{t('Support.RECOVERY_FILTER_PENDING', 'Open')}
|
|
102
|
+
</Button>
|
|
103
|
+
<Button
|
|
104
|
+
variant={statusFilter === 'approved' ? 'contained' : 'outlined'}
|
|
105
|
+
size="small"
|
|
106
|
+
onClick={() => setStatusFilter('approved')}
|
|
107
|
+
>
|
|
108
|
+
{t('Support.RECOVERY_FILTER_APPROVED', 'Approved')}
|
|
109
|
+
</Button>
|
|
110
|
+
<Button
|
|
111
|
+
variant={statusFilter === 'rejected' ? 'contained' : 'outlined'}
|
|
112
|
+
size="small"
|
|
113
|
+
onClick={() => setStatusFilter('rejected')}
|
|
114
|
+
>
|
|
115
|
+
{t('Support.RECOVERY_FILTER_REJECTED', 'Rejected')}
|
|
116
|
+
</Button>
|
|
117
|
+
</Stack>
|
|
118
|
+
|
|
119
|
+
{requests.length === 0 ? (
|
|
120
|
+
<Typography variant="body2">
|
|
121
|
+
{t('Support.RECOVERY_REQUESTS_EMPTY', 'No recovery requests for this filter.')}
|
|
122
|
+
</Typography>
|
|
123
|
+
) : (
|
|
124
|
+
<TableContainer component={Paper}>
|
|
125
|
+
<Table size="small">
|
|
126
|
+
<TableHead>
|
|
127
|
+
<TableRow>
|
|
128
|
+
<TableCell>{t('Support.RECOVERY_COL_CREATED', 'Created')}</TableCell>
|
|
129
|
+
<TableCell>{t('Support.RECOVERY_COL_USER', 'User')}</TableCell>
|
|
130
|
+
<TableCell>{t('Support.RECOVERY_COL_SUPPORT', 'Assigned support')}</TableCell>
|
|
131
|
+
<TableCell>{t('Support.RECOVERY_COL_STATUS', 'Status')}</TableCell>
|
|
132
|
+
<TableCell>{t('Support.RECOVERY_COL_ACTIONS', 'Actions')}</TableCell>
|
|
133
|
+
</TableRow>
|
|
134
|
+
</TableHead>
|
|
135
|
+
<TableBody>
|
|
136
|
+
{requests.map((req) => (
|
|
137
|
+
<TableRow key={req.id}>
|
|
138
|
+
<TableCell>
|
|
139
|
+
{req.created_at
|
|
140
|
+
? new Date(req.created_at).toLocaleString()
|
|
141
|
+
: '-'}
|
|
142
|
+
</TableCell>
|
|
143
|
+
<TableCell>{req.user_email || req.user}</TableCell>
|
|
144
|
+
<TableCell>{req.support_email || '–'}</TableCell>
|
|
145
|
+
<TableCell>{req.status}</TableCell>
|
|
146
|
+
<TableCell>
|
|
147
|
+
<Stack direction="row" spacing={1}>
|
|
148
|
+
<Button
|
|
149
|
+
variant="contained"
|
|
150
|
+
size="small"
|
|
151
|
+
onClick={() => handleSendRecoveryLink(req.id)}
|
|
152
|
+
disabled={req.status !== 'pending'}
|
|
153
|
+
>
|
|
154
|
+
{t(
|
|
155
|
+
'Support.RECOVERY_ACTION_SEND_LINK',
|
|
156
|
+
'Send recovery link',
|
|
157
|
+
)}
|
|
158
|
+
</Button>
|
|
159
|
+
<Button
|
|
160
|
+
variant="outlined"
|
|
161
|
+
size="small"
|
|
162
|
+
color="error"
|
|
163
|
+
onClick={() => handleReject(req.id)}
|
|
164
|
+
disabled={req.status !== 'pending'}
|
|
165
|
+
>
|
|
166
|
+
{t('Support.RECOVERY_ACTION_REJECT', 'Reject')}
|
|
167
|
+
</Button>
|
|
168
|
+
</Stack>
|
|
169
|
+
</TableCell>
|
|
170
|
+
</TableRow>
|
|
171
|
+
))}
|
|
172
|
+
</TableBody>
|
|
173
|
+
</Table>
|
|
174
|
+
</TableContainer>
|
|
175
|
+
)}
|
|
176
|
+
</Box>
|
|
177
|
+
);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export default SupportRecoveryRequestsTab;
|
|
@@ -863,6 +863,16 @@ export const authTranslations = {
|
|
|
863
863
|
"fr": "Votre connexion actuelle ne satisfait pas aux exigences de sécurité recommandées. Veuillez configurer un facteur de sécurité supplémentaire (clé d’accès ou application d’authentification) et générer des codes de récupération.",
|
|
864
864
|
"en": "Your current sign-in does not meet the recommended security requirements. Please set up an additional security factor (passkey or authenticator app) and generate recovery codes."
|
|
865
865
|
},
|
|
866
|
+
"Auth.MFA_HELP_REQUESTED": {
|
|
867
|
+
"de": "Wir haben deinen Support-Kontakt informiert. Du erhältst eine Rückmeldung, sobald deine Identität geprüft wurde.",
|
|
868
|
+
"fr": "Nous avons informé votre contact de support. Vous recevrez une réponse dès que votre identité aura été vérifiée.",
|
|
869
|
+
"en": "We have informed your support contact. You will hear back as soon as your identity has been verified."
|
|
870
|
+
},
|
|
871
|
+
"Auth.MFA_HELP_REQUEST_FAILED": {
|
|
872
|
+
"de": "Die Support-Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut oder kontaktiere den Support direkt.",
|
|
873
|
+
"fr": "La demande d'assistance n'a pas pu être envoyée. Veuillez réessayer plus tard ou contacter le support directement.",
|
|
874
|
+
"en": "Could not send the support request. Please try again later or contact support directly."
|
|
875
|
+
}
|
|
866
876
|
|
|
867
877
|
|
|
868
878
|
|
|
@@ -5,6 +5,7 @@ import { Tabs, Tab, Box } from '@mui/material';
|
|
|
5
5
|
import { WidePage } from '../layout/PageLayout';
|
|
6
6
|
import ProfileComponent from '../components/ProfileComponent';
|
|
7
7
|
import SecurityComponent from '../components/SecurityComponent';
|
|
8
|
+
import SupportRecoveryRequestsTab from '../components/SupportRecoveryRequestsTab';
|
|
8
9
|
import { authApi } from '../auth/authApi';
|
|
9
10
|
import { AuthContext } from '../auth/AuthContext';
|
|
10
11
|
import { useSearchParams } from 'react-router-dom';
|
|
@@ -13,9 +14,13 @@ export function AccountPage() {
|
|
|
13
14
|
const { login } = useContext(AuthContext);
|
|
14
15
|
const [searchParams] = useSearchParams();
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
const initialTabParam = searchParams.get('tab');
|
|
17
18
|
const initialTab =
|
|
18
|
-
|
|
19
|
+
initialTabParam === 'security'
|
|
20
|
+
? 'security'
|
|
21
|
+
: initialTabParam === 'support'
|
|
22
|
+
? 'support'
|
|
23
|
+
: 'account';
|
|
19
24
|
|
|
20
25
|
const fromParam = searchParams.get('from');
|
|
21
26
|
const fromRecovery = fromParam === 'recovery';
|
|
@@ -43,19 +48,11 @@ export function AccountPage() {
|
|
|
43
48
|
onChange={handleTabChange}
|
|
44
49
|
sx={{ mb: 3 }}
|
|
45
50
|
>
|
|
46
|
-
<Tab label="Security" value="security" />
|
|
47
51
|
<Tab label="Account" value="account" />
|
|
52
|
+
<Tab label="Security" value="security" />
|
|
53
|
+
<Tab label="Support" value="support" />
|
|
48
54
|
</Tabs>
|
|
49
55
|
|
|
50
|
-
{tab === 'security' && (
|
|
51
|
-
<Box sx={{ mt: 1 }}>
|
|
52
|
-
<SecurityComponent
|
|
53
|
-
fromRecovery={fromRecovery}
|
|
54
|
-
fromWeakLogin={fromWeakLogin}
|
|
55
|
-
/>
|
|
56
|
-
</Box>
|
|
57
|
-
)}
|
|
58
|
-
|
|
59
56
|
{tab === 'account' && (
|
|
60
57
|
<Box sx={{ mt: 1 }}>
|
|
61
58
|
<ProfileComponent
|
|
@@ -68,6 +65,21 @@ export function AccountPage() {
|
|
|
68
65
|
/>
|
|
69
66
|
</Box>
|
|
70
67
|
)}
|
|
68
|
+
|
|
69
|
+
{tab === 'security' && (
|
|
70
|
+
<Box sx={{ mt: 1 }}>
|
|
71
|
+
<SecurityComponent
|
|
72
|
+
fromRecovery={fromRecovery}
|
|
73
|
+
fromWeakLogin={fromWeakLogin}
|
|
74
|
+
/>
|
|
75
|
+
</Box>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{tab === 'support' && (
|
|
79
|
+
<Box sx={{ mt: 1 }}>
|
|
80
|
+
<SupportRecoveryRequestsTab />
|
|
81
|
+
</Box>
|
|
82
|
+
)}
|
|
71
83
|
</WidePage>
|
|
72
84
|
);
|
|
73
85
|
}
|
package/src/pages/LoginPage.jsx
CHANGED
|
@@ -30,6 +30,7 @@ export function LoginPage() {
|
|
|
30
30
|
if (result.needsMfa) {
|
|
31
31
|
setMfaState({
|
|
32
32
|
availableTypes: result.availableTypes || [],
|
|
33
|
+
identifier,
|
|
33
34
|
});
|
|
34
35
|
setStep('mfa');
|
|
35
36
|
} else {
|
|
@@ -126,6 +127,7 @@ export function LoginPage() {
|
|
|
126
127
|
<Box>
|
|
127
128
|
<MfaLoginComponent
|
|
128
129
|
availableTypes={mfaState.availableTypes}
|
|
130
|
+
identifier={mfaState.identifier}
|
|
129
131
|
onSuccess={handleMfaSuccess}
|
|
130
132
|
onCancel={handleMfaCancel}
|
|
131
133
|
//onNeedHelp={handleMfaNeedHelp}
|