@micha.bigler/ui-core-micha 1.4.3 → 1.4.4

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.
@@ -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, Divider, } from '@mui/material';
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; // 6 = typische TOTP-Länge
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
- return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.MFA_TITLE', 'Additional verification') }), _jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: t('Auth.MFA_SUBTITLE_SHORT', 'Choose one of the available methods.') }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), _jsxs(Stack, { spacing: 2, children: [supportsWebauthn && (_jsx(Button, { variant: "outlined", fullWidth: true, onClick: handlePasskey, disabled: submitting, children: t('Auth.MFA_USE_PASSKEY', 'Use passkey / security key') })), supportsWebauthn && supportsTotpOrRecovery && (_jsx(Divider, { children: t('Auth.MFA_OR', 'or') })), 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, autoComplete: "one-time-code", sx: { mb: 2 } }), _jsx(Button, { type: "submit", variant: "contained", fullWidth: true, disabled: submitting || !code.trim(), children: t('Auth.MFA_VERIFY', 'Verify') })] })), _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: onNeedHelp, disabled: submitting, children: t('Auth.MFA_NEED_HELP', "I can't use any of these methods") })] })] }));
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
- // Tab aus URL bestimmen
16
- const initialTab = searchParams.get('tab') === 'security' ? 'security' : 'account';
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: "Account", value: "account" })] }), tab === 'security' && (_jsx(Box, { sx: { mt: 1 }, children: _jsx(SecurityComponent, { fromRecovery: fromRecovery, fromWeakLogin: fromWeakLogin }) })), tab === 'account' && (_jsx(Box, { sx: { mt: 1 }, children: _jsx(ProfileComponent, { onLoad: () => { }, onSubmit: handleProfileSubmit, submitText: "Save", showName: true, showPrivacy: true, showCookies: true }) }))] }));
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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "private": false,
@@ -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; // 6 = typische TOTP-Länge
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('Auth.MFA_SUBTITLE_SHORT', 'Choose one of the available methods.')}
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
- <Stack spacing={2}>
82
- {supportsWebauthn && (
83
- <Button
84
- variant="outlined"
85
- fullWidth
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={onNeedHelp}
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
- // Tab aus URL bestimmen
17
+ const initialTabParam = searchParams.get('tab');
17
18
  const initialTab =
18
- searchParams.get('tab') === 'security' ? 'security' : 'account';
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
  }
@@ -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}