@micha.bigler/ui-core-micha 1.4.2 → 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;
@@ -4,21 +4,24 @@ import React, { useState } from 'react';
4
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 }) => {
7
+ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel }) => {
8
8
  const { t } = useTranslation();
9
9
  const [code, setCode] = useState('');
10
10
  const [submitting, setSubmitting] = useState(false);
11
11
  const [errorKey, setErrorKey] = useState(null);
12
+ const [infoKey, setInfoKey] = useState(null);
13
+ const [helpRequested, setHelpRequested] = useState(false);
12
14
  const types = Array.isArray(availableTypes) ? availableTypes : [];
13
15
  const supportsTotpOrRecovery = types.includes('totp') || types.includes('recovery_codes');
14
16
  const supportsWebauthn = types.includes('webauthn');
15
17
  const handleSubmitCode = async (event) => {
16
18
  event.preventDefault();
17
19
  setErrorKey(null);
20
+ setInfoKey(null);
18
21
  setSubmitting(true);
19
22
  try {
20
23
  const trimmed = code.trim();
21
- const isRecovery = trimmed.length > 6; // 6 = typische TOTP-Länge, >6 = Recovery
24
+ const isRecovery = trimmed.length > 6;
22
25
  await authApi.authenticateWithMFA({ code: trimmed });
23
26
  const user = await authApi.fetchCurrentUser();
24
27
  onSuccess({
@@ -35,6 +38,7 @@ const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel }) => {
35
38
  };
36
39
  const handlePasskey = async () => {
37
40
  setErrorKey(null);
41
+ setInfoKey(null);
38
42
  setSubmitting(true);
39
43
  try {
40
44
  const { user } = await authApi.loginWithPasskey();
@@ -47,8 +51,22 @@ const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel }) => {
47
51
  setSubmitting(false);
48
52
  }
49
53
  };
50
- 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) })), _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') })), 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: () => {
51
- // später z.B. navigate('/help/account-recovery');
52
- }, 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;
@@ -781,6 +781,11 @@ export const authTranslations = {
781
781
  "fr": "Veuillez confirmer votre connexion à l’aide d’une des méthodes disponibles.",
782
782
  "en": "Please confirm your login using one of the available methods."
783
783
  },
784
+ "Auth.MFA_SUBTITLE_SHORT": {
785
+ "de": "Bitte bestätige deine Anmeldung mit einer der verfügbaren Methoden.",
786
+ "fr": "Veuillez confirmer votre connexion à l’aide d’une des méthodes disponibles.",
787
+ "en": "Please confirm your login using one of the available methods."
788
+ },
784
789
  "Auth.MFA_USE_PASSKEY": {
785
790
  "de": "Passkey / Sicherheitsschlüssel verwenden",
786
791
  "fr": "Utiliser une clé d’accès / clé de sécurité",
@@ -811,5 +816,15 @@ export const authTranslations = {
811
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.",
812
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."
813
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
+ }
814
829
  // ...
815
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.2",
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;
@@ -11,11 +11,13 @@ import {
11
11
  import { useTranslation } from 'react-i18next';
12
12
  import { authApi } from '../auth/authApi';
13
13
 
14
- const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel }) => {
14
+ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel }) => {
15
15
  const { t } = useTranslation();
16
16
  const [code, setCode] = useState('');
17
17
  const [submitting, setSubmitting] = useState(false);
18
18
  const [errorKey, setErrorKey] = useState(null);
19
+ const [infoKey, setInfoKey] = useState(null);
20
+ const [helpRequested, setHelpRequested] = useState(false);
19
21
 
20
22
  const types = Array.isArray(availableTypes) ? availableTypes : [];
21
23
  const supportsTotpOrRecovery =
@@ -25,10 +27,11 @@ const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel }) => {
25
27
  const handleSubmitCode = async (event) => {
26
28
  event.preventDefault();
27
29
  setErrorKey(null);
30
+ setInfoKey(null);
28
31
  setSubmitting(true);
29
32
  try {
30
33
  const trimmed = code.trim();
31
- const isRecovery = trimmed.length > 6; // 6 = typische TOTP-Länge, >6 = Recovery
34
+ const isRecovery = trimmed.length > 6;
32
35
 
33
36
  await authApi.authenticateWithMFA({ code: trimmed });
34
37
 
@@ -47,6 +50,7 @@ const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel }) => {
47
50
 
48
51
  const handlePasskey = async () => {
49
52
  setErrorKey(null);
53
+ setInfoKey(null);
50
54
  setSubmitting(true);
51
55
  try {
52
56
  const { user } = await authApi.loginWithPasskey();
@@ -58,6 +62,21 @@ const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel }) => {
58
62
  }
59
63
  };
60
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
+
61
80
  return (
62
81
  <Box>
63
82
  <Typography variant="h6" gutterBottom>
@@ -76,18 +95,13 @@ const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel }) => {
76
95
  </Alert>
77
96
  )}
78
97
 
79
- <Stack spacing={2}>
80
- {supportsWebauthn && (
81
- <Button
82
- variant="outlined"
83
- fullWidth
84
- onClick={handlePasskey}
85
- disabled={submitting}
86
- >
87
- {t('Auth.MFA_USE_PASSKEY', 'Use passkey / security key')}
88
- </Button>
89
- )}
98
+ {infoKey && (
99
+ <Alert severity="info" sx={{ mb: 2 }}>
100
+ {t(infoKey)}
101
+ </Alert>
102
+ )}
90
103
 
104
+ <Stack spacing={2}>
91
105
  {supportsTotpOrRecovery && (
92
106
  <Box component="form" onSubmit={handleSubmitCode}>
93
107
  <TextField
@@ -98,7 +112,7 @@ const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel }) => {
98
112
  value={code}
99
113
  onChange={(e) => setCode(e.target.value)}
100
114
  fullWidth
101
- disabled={submitting}
115
+ disabled={submitting || helpRequested}
102
116
  autoComplete="one-time-code"
103
117
  sx={{ mb: 2 }}
104
118
  />
@@ -106,13 +120,24 @@ const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel }) => {
106
120
  type="submit"
107
121
  variant="contained"
108
122
  fullWidth
109
- disabled={submitting || !code.trim()}
123
+ disabled={submitting || !code.trim() || helpRequested}
110
124
  >
111
125
  {t('Auth.MFA_VERIFY', 'Verify')}
112
126
  </Button>
113
127
  </Box>
114
128
  )}
115
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
+
116
141
  <Button
117
142
  size="small"
118
143
  onClick={onCancel}
@@ -124,9 +149,8 @@ const MfaLoginComponent = ({ availableTypes, onSuccess, onCancel }) => {
124
149
  <Button
125
150
  size="small"
126
151
  color="secondary"
127
- onClick={() => {
128
- // später z.B. navigate('/help/account-recovery');
129
- }}
152
+ onClick={handleNeedHelp}
153
+ disabled={submitting || helpRequested}
130
154
  >
131
155
  {t(
132
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;
@@ -828,6 +828,11 @@ export const authTranslations = {
828
828
  "fr": "Veuillez confirmer votre connexion à l’aide d’une des méthodes disponibles.",
829
829
  "en": "Please confirm your login using one of the available methods."
830
830
  },
831
+ "Auth.MFA_SUBTITLE_SHORT": {
832
+ "de": "Bitte bestätige deine Anmeldung mit einer der verfügbaren Methoden.",
833
+ "fr": "Veuillez confirmer votre connexion à l’aide d’une des méthodes disponibles.",
834
+ "en": "Please confirm your login using one of the available methods."
835
+ },
831
836
  "Auth.MFA_USE_PASSKEY": {
832
837
  "de": "Passkey / Sicherheitsschlüssel verwenden",
833
838
  "fr": "Utiliser une clé d’accès / clé de sécurité",
@@ -858,6 +863,16 @@ export const authTranslations = {
858
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.",
859
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."
860
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
+ }
861
876
 
862
877
 
863
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,8 +127,10 @@ 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}
133
+ //onNeedHelp={handleMfaNeedHelp}
131
134
  />
132
135
  </Box>
133
136
  )}