@micha.bigler/ui-core-micha 1.4.11 → 1.4.12

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.
@@ -609,13 +609,12 @@ export async function fetchRecoveryRequests(status = 'pending') {
609
609
  });
610
610
  return res.data;
611
611
  }
612
- export async function approveRecoveryRequest(id, note = '') {
613
- const res = await axios.post(`/api/support/recovery-requests/${id}/approve/`, { note }, // 👉 Note mitsenden
614
- { withCredentials: true });
612
+ export async function approveRecoveryRequest(id, supportNote) {
613
+ const res = await axios.post(`/api/support/recovery-requests/${id}/approve/`, { support_note: supportNote || '' }, { withCredentials: true });
615
614
  return res.data;
616
615
  }
617
- export async function rejectRecoveryRequest(id) {
618
- const res = await axios.post(`/api/support/recovery-requests/${id}/reject/`, {}, { withCredentials: true });
616
+ export async function rejectRecoveryRequest(id, supportNote) {
617
+ const res = await axios.post(`/api/support/recovery-requests/${id}/reject/`, { support_note: supportNote || '' }, { withCredentials: true });
619
618
  return res.data;
620
619
  }
621
620
  export async function loginWithRecoveryPassword(email, password, token) {
@@ -1,7 +1,7 @@
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, Divider, Dialog, DialogTitle, DialogContent, DialogActions, } from '@mui/material';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { authApi } from '../auth/authApi';
7
7
  const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel }) => {
@@ -11,6 +11,8 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
11
11
  const [errorKey, setErrorKey] = useState(null);
12
12
  const [infoKey, setInfoKey] = useState(null);
13
13
  const [helpRequested, setHelpRequested] = useState(false);
14
+ const [helpDialogOpen, setHelpDialogOpen] = useState(false);
15
+ const [helpMessage, setHelpMessage] = useState('');
14
16
  const types = Array.isArray(availableTypes) ? availableTypes : [];
15
17
  const supportsTotpOrRecovery = types.includes('totp') || types.includes('recovery_codes');
16
18
  const supportsWebauthn = types.includes('webauthn');
@@ -51,14 +53,21 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
51
53
  setSubmitting(false);
52
54
  }
53
55
  };
56
+ const openHelpDialog = () => {
57
+ setErrorKey(null);
58
+ setInfoKey(null);
59
+ setHelpDialogOpen(true);
60
+ };
54
61
  const handleNeedHelp = async () => {
55
62
  setErrorKey(null);
56
63
  setInfoKey(null);
57
64
  setSubmitting(true);
58
65
  try {
59
- await authApi.requestMfaSupportHelp(identifier || '');
66
+ await authApi.requestMfaSupportHelp(identifier || '', helpMessage || '');
60
67
  setHelpRequested(true);
61
68
  setInfoKey('Auth.MFA_HELP_REQUESTED');
69
+ setHelpDialogOpen(false);
70
+ setHelpMessage('');
62
71
  }
63
72
  catch (err) {
64
73
  setErrorKey(err.code || 'Auth.MFA_HELP_REQUEST_FAILED');
@@ -67,6 +76,6 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
67
76
  setSubmitting(false);
68
77
  }
69
78
  };
70
- return (_jsxs(Box, { children: [_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: [supportsWebauthn && (_jsx(Button, { variant: "contained", fullWidth: true, onClick: handlePasskey, disabled: submitting || helpRequested, children: t('Auth.LOGIN_USE_PASSKEY_BUTTON', 'Use passkey / security key') })), _jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_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 || 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') })] })), _jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') }), _jsxs(Stack, { direction: "row", spacing: 1, sx: { mt: 1 }, children: [_jsx(Button, { fullWidth: true, size: "small", variant: "outlined", onClick: onCancel, disabled: submitting, children: t('Auth.MFA_BACK_TO_LOGIN', 'Back to login') }), _jsx(Button, { fullWidth: true, size: "small", variant: "outlined", color: "secondary", onClick: handleNeedHelp, disabled: submitting || helpRequested, children: t('Auth.MFA_NEED_HELP', "I can't use any of these methods") })] })] })] }));
79
+ return (_jsxs(Box, { children: [_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: [supportsWebauthn && (_jsx(Button, { variant: "contained", fullWidth: true, onClick: handlePasskey, disabled: submitting || helpRequested, children: t('Auth.LOGIN_USE_PASSKEY_BUTTON', 'Use passkey / security key') })), _jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_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 || 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') })] })), _jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') }), _jsxs(Stack, { direction: "row", spacing: 1, sx: { mt: 1 }, children: [_jsx(Button, { fullWidth: true, size: "small", variant: "outlined", onClick: onCancel, disabled: submitting, children: t('Auth.MFA_BACK_TO_LOGIN', 'Back to login') }), _jsx(Button, { fullWidth: true, size: "small", variant: "outlined", color: "secondary", onClick: openHelpDialog, disabled: submitting || helpRequested, children: t('Auth.MFA_NEED_HELP', "I can't use any of these methods") })] })] }), _jsxs(Dialog, { open: helpDialogOpen, onClose: () => setHelpDialogOpen(false), fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('Auth.MFA_HELP_DIALOG_TITLE', 'Need help with sign-in') }), _jsxs(DialogContent, { dividers: true, children: [_jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: t('Auth.MFA_HELP_DIALOG_DESCRIPTION', 'Describe briefly why you cannot use the available methods. A support person will review your request.') }), _jsx(TextField, { label: t('Auth.MFA_HELP_MESSAGE_LABEL', 'Your message to support'), helperText: t('Auth.MFA_HELP_MESSAGE_HELP', 'Optional, but helps support verify your identity.'), multiline: true, minRows: 3, fullWidth: true, value: helpMessage, onChange: (e) => setHelpMessage(e.target.value), disabled: submitting })] }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setHelpDialogOpen(false), disabled: submitting, children: t('Common.CANCEL', 'Cancel') }), _jsx(Button, { onClick: handleNeedHelp, disabled: submitting, variant: "contained", children: t('Auth.MFA_HELP_SUBMIT', 'Send request') })] })] })] }));
71
80
  };
72
81
  export default MfaLoginComponent;
@@ -9,10 +9,11 @@ const SupportRecoveryRequestsTab = () => {
9
9
  const [requests, setRequests] = useState([]);
10
10
  const [loading, setLoading] = useState(true);
11
11
  const [errorKey, setErrorKey] = useState(null);
12
- const [statusFilter, setStatusFilter] = useState('pending'); // optional erweiterbar
13
- const [approveDialogOpen, setApproveDialogOpen] = useState(false);
14
- const [approveDialogNote, setApproveDialogNote] = useState('');
15
- const [approveDialogRequestId, setApproveDialogRequestId] = useState(null);
12
+ const [statusFilter, setStatusFilter] = useState('pending');
13
+ const [dialogOpen, setDialogOpen] = useState(false);
14
+ const [dialogNote, setDialogNote] = useState('');
15
+ const [dialogRequestId, setDialogRequestId] = useState(null);
16
+ const [dialogMode, setDialogMode] = useState('approve');
16
17
  const loadRequests = async () => {
17
18
  setLoading(true);
18
19
  setErrorKey(null);
@@ -31,37 +32,36 @@ const SupportRecoveryRequestsTab = () => {
31
32
  loadRequests();
32
33
  // eslint-disable-next-line react-hooks/exhaustive-deps
33
34
  }, [statusFilter]);
34
- const handleOpenApproveDialog = (id) => {
35
- setApproveDialogRequestId(id);
36
- setApproveDialogNote('');
37
- setApproveDialogOpen(true);
35
+ const openDialog = (id, mode) => {
36
+ setDialogRequestId(id);
37
+ setDialogMode(mode);
38
+ setDialogNote('');
39
+ setDialogOpen(true);
38
40
  };
39
- const handleCloseApproveDialog = () => {
40
- setApproveDialogOpen(false);
41
- setApproveDialogRequestId(null);
42
- setApproveDialogNote('');
41
+ const closeDialog = () => {
42
+ setDialogOpen(false);
43
+ setDialogRequestId(null);
44
+ setDialogNote('');
43
45
  };
44
- const handleConfirmApprove = async () => {
45
- if (!approveDialogRequestId)
46
+ const handleConfirm = async () => {
47
+ if (!dialogRequestId)
46
48
  return;
47
49
  setErrorKey(null);
48
50
  try {
49
- await authApi.approveRecoveryRequest(approveDialogRequestId, approveDialogNote);
51
+ if (dialogMode === 'approve') {
52
+ await authApi.approveRecoveryRequest(dialogRequestId, dialogNote);
53
+ }
54
+ else {
55
+ await authApi.rejectRecoveryRequest(dialogRequestId, dialogNote);
56
+ }
50
57
  await loadRequests();
51
- handleCloseApproveDialog();
58
+ closeDialog();
52
59
  }
53
60
  catch (err) {
54
- setErrorKey(err.code || 'Support.RECOVERY_REQUEST_APPROVE_FAILED');
55
- }
56
- };
57
- const handleReject = async (id) => {
58
- setErrorKey(null);
59
- try {
60
- await authApi.rejectRecoveryRequest(id);
61
- await loadRequests();
62
- }
63
- catch (err) {
64
- setErrorKey(err.code || 'Support.RECOVERY_REQUEST_REJECT_FAILED');
61
+ setErrorKey(err.code ||
62
+ (dialogMode === 'approve'
63
+ ? 'Support.RECOVERY_REQUEST_APPROVE_FAILED'
64
+ : 'Support.RECOVERY_REQUEST_REJECT_FAILED'));
65
65
  }
66
66
  };
67
67
  if (loading) {
@@ -69,6 +69,12 @@ const SupportRecoveryRequestsTab = () => {
69
69
  }
70
70
  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_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
71
71
  ? new Date(req.created_at).toLocaleString()
72
- : '-' }), _jsx(TableCell, { children: req.user_email || req.user }), _jsx(TableCell, { children: req.status }), _jsx(TableCell, { children: _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { variant: "contained", size: "small", onClick: () => handleOpenApproveDialog(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))) })] }) })), _jsxs(Dialog, { open: approveDialogOpen, onClose: handleCloseApproveDialog, fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('Support.RECOVERY_APPROVE_DIALOG_TITLE', 'Confirm account recovery') }), _jsxs(DialogContent, { dividers: true, children: [_jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: t('Support.RECOVERY_APPROVE_CONFIRM_QUESTION', 'Are you sure you want to approve this recovery request?') }), _jsx(TextField, { label: t('Support.RECOVERY_APPROVE_NOTE_LABEL', 'Reason for this decision'), helperText: t('Support.RECOVERY_APPROVE_NOTE_HELP', 'Describe briefly why you are approving this request.'), multiline: true, minRows: 3, fullWidth: true, value: approveDialogNote, onChange: (e) => setApproveDialogNote(e.target.value) })] }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: handleCloseApproveDialog, children: t('Common.CANCEL', 'Cancel') }), _jsx(Button, { onClick: handleConfirmApprove, variant: "contained", disabled: !approveDialogNote.trim(), children: t('Support.RECOVERY_APPROVE_SUBMIT', 'Send link') })] })] })] }));
72
+ : '-' }), _jsx(TableCell, { children: req.user_email || req.user }), _jsx(TableCell, { children: req.status }), _jsx(TableCell, { children: _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { variant: "contained", size: "small", onClick: () => openDialog(req.id, 'approve'), disabled: req.status !== 'pending', children: t('Support.RECOVERY_ACTION_SEND_LINK', 'Send recovery link') }), _jsx(Button, { variant: "outlined", size: "small", color: "error", onClick: () => openDialog(req.id, 'reject'), disabled: req.status !== 'pending', children: t('Support.RECOVERY_ACTION_REJECT', 'Reject') })] }) })] }, req.id))) })] }) })), _jsxs(Dialog, { open: dialogOpen, onClose: closeDialog, fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: dialogMode === 'approve'
73
+ ? t('Support.RECOVERY_APPROVE_DIALOG_TITLE', 'Confirm account recovery')
74
+ : t('Support.RECOVERY_REJECT_DIALOG_TITLE', 'Reject account recovery') }), _jsxs(DialogContent, { dividers: true, children: [_jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: dialogMode === 'approve'
75
+ ? t('Support.RECOVERY_APPROVE_CONFIRM_QUESTION', 'Are you sure you want to approve this recovery request?')
76
+ : t('Support.RECOVERY_REJECT_CONFIRM_QUESTION', 'Are you sure you want to reject this recovery request?') }), _jsx(TextField, { label: t('Support.RECOVERY_NOTE_LABEL', 'Reason for this decision'), helperText: t('Support.RECOVERY_NOTE_HELP', 'Describe briefly why you are approving or rejecting this request.'), multiline: true, minRows: 3, fullWidth: true, value: dialogNote, onChange: (e) => setDialogNote(e.target.value) })] }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: closeDialog, children: t('Common.CANCEL', 'Cancel') }), _jsx(Button, { onClick: handleConfirm, variant: "contained", disabled: !dialogNote.trim(), color: dialogMode === 'approve' ? 'primary' : 'error', children: dialogMode === 'approve'
77
+ ? t('Support.RECOVERY_APPROVE_SUBMIT', 'Send link')
78
+ : t('Support.RECOVERY_REJECT_SUBMIT', 'Reject request') })] })] })] }));
73
79
  };
74
80
  export default SupportRecoveryRequestsTab;
@@ -855,6 +855,56 @@ export const authTranslations = {
855
855
  "de": "Link senden",
856
856
  "fr": "Envoyer le lien",
857
857
  "en": "Send link"
858
+ },
859
+ "Auth.MFA_HELP_DIALOG_TITLE": {
860
+ "de": "Hilfe bei der Anmeldung",
861
+ "fr": "Aide pour la connexion",
862
+ "en": "Need help with sign-in"
863
+ },
864
+ "Auth.MFA_HELP_DIALOG_DESCRIPTION": {
865
+ "de": "Beschreiben Sie kurz, warum Sie keine der verfügbaren Methoden verwenden können. Eine Supportperson wird Ihre Anfrage prüfen.",
866
+ "fr": "Décrivez brièvement pourquoi vous ne pouvez utiliser aucune des méthodes disponibles. Un membre du support examinera votre demande.",
867
+ "en": "Briefly describe why you cannot use any of the available methods. A support person will review your request."
868
+ },
869
+ "Auth.MFA_HELP_MESSAGE_LABEL": {
870
+ "de": "Ihre Nachricht an den Support",
871
+ "fr": "Votre message au support",
872
+ "en": "Your message to support"
873
+ },
874
+ "Auth.MFA_HELP_MESSAGE_HELP": {
875
+ "de": "Optional, hilft dem Support bei der Überprüfung Ihrer Identität.",
876
+ "fr": "Facultatif, mais aide le support à vérifier votre identité.",
877
+ "en": "Optional, but helps support verify your identity."
878
+ },
879
+ "Auth.MFA_HELP_SUBMIT": {
880
+ "de": "Anfrage senden",
881
+ "fr": "Envoyer la demande",
882
+ "en": "Send request"
883
+ },
884
+ "Support.RECOVERY_NOTE_LABEL": {
885
+ "de": "Begründung für diese Entscheidung",
886
+ "fr": "Raison de cette décision",
887
+ "en": "Reason for this decision"
888
+ },
889
+ "Support.RECOVERY_NOTE_HELP": {
890
+ "de": "Beschreiben Sie kurz, warum Sie diese Anfrage genehmigen oder ablehnen.",
891
+ "fr": "Décrivez brièvement pourquoi vous approuvez ou rejetez cette demande.",
892
+ "en": "Briefly describe why you are approving or rejecting this request."
893
+ },
894
+ "Support.RECOVERY_REJECT_DIALOG_TITLE": {
895
+ "de": "Konto-Wiederherstellung ablehnen",
896
+ "fr": "Refuser la récupération de compte",
897
+ "en": "Reject account recovery"
898
+ },
899
+ "Support.RECOVERY_REJECT_CONFIRM_QUESTION": {
900
+ "de": "Sind Sie sicher, dass Sie diese Wiederherstellungsanfrage ablehnen möchten?",
901
+ "fr": "Êtes-vous sûr de vouloir refuser cette demande de récupération ?",
902
+ "en": "Are you sure you want to reject this recovery request?"
903
+ },
904
+ "Support.RECOVERY_REJECT_SUBMIT": {
905
+ "de": "Anfrage ablehnen",
906
+ "fr": "Refuser la demande",
907
+ "en": "Reject request"
858
908
  }
859
909
  // ...
860
910
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "1.4.11",
3
+ "version": "1.4.12",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "private": false,
@@ -800,19 +800,19 @@ export async function fetchRecoveryRequests(status = 'pending') {
800
800
  return res.data;
801
801
  }
802
802
 
803
- export async function approveRecoveryRequest(id, note = '') {
803
+ export async function approveRecoveryRequest(id, supportNote) {
804
804
  const res = await axios.post(
805
805
  `/api/support/recovery-requests/${id}/approve/`,
806
- { note }, // 👉 Note mitsenden
806
+ { support_note: supportNote || '' },
807
807
  { withCredentials: true },
808
808
  );
809
809
  return res.data;
810
810
  }
811
811
 
812
- export async function rejectRecoveryRequest(id) {
812
+ export async function rejectRecoveryRequest(id, supportNote) {
813
813
  const res = await axios.post(
814
814
  `/api/support/recovery-requests/${id}/reject/`,
815
- {},
815
+ { support_note: supportNote || '' },
816
816
  { withCredentials: true },
817
817
  );
818
818
  return res.data;
@@ -8,6 +8,10 @@ import {
8
8
  Stack,
9
9
  Alert,
10
10
  Divider,
11
+ Dialog,
12
+ DialogTitle,
13
+ DialogContent,
14
+ DialogActions,
11
15
  } from '@mui/material';
12
16
  import { useTranslation } from 'react-i18next';
13
17
  import { authApi } from '../auth/authApi';
@@ -20,6 +24,9 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
20
24
  const [infoKey, setInfoKey] = useState(null);
21
25
  const [helpRequested, setHelpRequested] = useState(false);
22
26
 
27
+ const [helpDialogOpen, setHelpDialogOpen] = useState(false);
28
+ const [helpMessage, setHelpMessage] = useState('');
29
+
23
30
  const types = Array.isArray(availableTypes) ? availableTypes : [];
24
31
  const supportsTotpOrRecovery =
25
32
  types.includes('totp') || types.includes('recovery_codes');
@@ -63,14 +70,22 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
63
70
  }
64
71
  };
65
72
 
73
+ const openHelpDialog = () => {
74
+ setErrorKey(null);
75
+ setInfoKey(null);
76
+ setHelpDialogOpen(true);
77
+ };
78
+
66
79
  const handleNeedHelp = async () => {
67
80
  setErrorKey(null);
68
81
  setInfoKey(null);
69
82
  setSubmitting(true);
70
83
  try {
71
- await authApi.requestMfaSupportHelp(identifier || '');
84
+ await authApi.requestMfaSupportHelp(identifier || '', helpMessage || '');
72
85
  setHelpRequested(true);
73
86
  setInfoKey('Auth.MFA_HELP_REQUESTED');
87
+ setHelpDialogOpen(false);
88
+ setHelpMessage('');
74
89
  } catch (err) {
75
90
  setErrorKey(err.code || 'Auth.MFA_HELP_REQUEST_FAILED');
76
91
  } finally {
@@ -162,7 +177,7 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
162
177
  size="small"
163
178
  variant="outlined"
164
179
  color="secondary"
165
- onClick={handleNeedHelp}
180
+ onClick={openHelpDialog}
166
181
  disabled={submitting || helpRequested}
167
182
  >
168
183
  {t(
@@ -172,8 +187,57 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
172
187
  </Button>
173
188
  </Stack>
174
189
  </Stack>
190
+ <Dialog
191
+ open={helpDialogOpen}
192
+ onClose={() => setHelpDialogOpen(false)}
193
+ fullWidth
194
+ maxWidth="sm"
195
+ >
196
+ <DialogTitle>
197
+ {t('Auth.MFA_HELP_DIALOG_TITLE', 'Need help with sign-in')}
198
+ </DialogTitle>
199
+ <DialogContent dividers>
200
+ <Typography variant="body2" sx={{ mb: 2 }}>
201
+ {t(
202
+ 'Auth.MFA_HELP_DIALOG_DESCRIPTION',
203
+ 'Describe briefly why you cannot use the available methods. A support person will review your request.',
204
+ )}
205
+ </Typography>
206
+ <TextField
207
+ label={t('Auth.MFA_HELP_MESSAGE_LABEL', 'Your message to support')}
208
+ helperText={t(
209
+ 'Auth.MFA_HELP_MESSAGE_HELP',
210
+ 'Optional, but helps support verify your identity.',
211
+ )}
212
+ multiline
213
+ minRows={3}
214
+ fullWidth
215
+ value={helpMessage}
216
+ onChange={(e) => setHelpMessage(e.target.value)}
217
+ disabled={submitting}
218
+ />
219
+ </DialogContent>
220
+ <DialogActions>
221
+ <Button
222
+ onClick={() => setHelpDialogOpen(false)}
223
+ disabled={submitting}
224
+ >
225
+ {t('Common.CANCEL', 'Cancel')}
226
+ </Button>
227
+ <Button
228
+ onClick={handleNeedHelp}
229
+ disabled={submitting}
230
+ variant="contained"
231
+ >
232
+ {t('Auth.MFA_HELP_SUBMIT', 'Send request')}
233
+ </Button>
234
+ </DialogActions>
235
+ </Dialog>
175
236
  </Box>
237
+
176
238
  );
239
+
177
240
  };
178
241
 
242
+
179
243
  export default MfaLoginComponent;
@@ -29,11 +29,12 @@ const SupportRecoveryRequestsTab = () => {
29
29
  const [requests, setRequests] = useState([]);
30
30
  const [loading, setLoading] = useState(true);
31
31
  const [errorKey, setErrorKey] = useState(null);
32
- const [statusFilter, setStatusFilter] = useState('pending'); // optional erweiterbar
32
+ const [statusFilter, setStatusFilter] = useState('pending');
33
33
 
34
- const [approveDialogOpen, setApproveDialogOpen] = useState(false);
35
- const [approveDialogNote, setApproveDialogNote] = useState('');
36
- const [approveDialogRequestId, setApproveDialogRequestId] = useState(null);
34
+ const [dialogOpen, setDialogOpen] = useState(false);
35
+ const [dialogNote, setDialogNote] = useState('');
36
+ const [dialogRequestId, setDialogRequestId] = useState(null);
37
+ const [dialogMode, setDialogMode] = useState('approve');
37
38
 
38
39
  const loadRequests = async () => {
39
40
  setLoading(true);
@@ -53,37 +54,37 @@ const SupportRecoveryRequestsTab = () => {
53
54
  // eslint-disable-next-line react-hooks/exhaustive-deps
54
55
  }, [statusFilter]);
55
56
 
56
- const handleOpenApproveDialog = (id) => {
57
- setApproveDialogRequestId(id);
58
- setApproveDialogNote('');
59
- setApproveDialogOpen(true);
57
+ const openDialog = (id, mode) => {
58
+ setDialogRequestId(id);
59
+ setDialogMode(mode);
60
+ setDialogNote('');
61
+ setDialogOpen(true);
60
62
  };
61
63
 
62
- const handleCloseApproveDialog = () => {
63
- setApproveDialogOpen(false);
64
- setApproveDialogRequestId(null);
65
- setApproveDialogNote('');
64
+ const closeDialog = () => {
65
+ setDialogOpen(false);
66
+ setDialogRequestId(null);
67
+ setDialogNote('');
66
68
  };
67
69
 
68
- const handleConfirmApprove = async () => {
69
- if (!approveDialogRequestId) return;
70
+ const handleConfirm = async () => {
71
+ if (!dialogRequestId) return;
70
72
  setErrorKey(null);
71
73
  try {
72
- await authApi.approveRecoveryRequest(approveDialogRequestId, approveDialogNote);
74
+ if (dialogMode === 'approve') {
75
+ await authApi.approveRecoveryRequest(dialogRequestId, dialogNote);
76
+ } else {
77
+ await authApi.rejectRecoveryRequest(dialogRequestId, dialogNote);
78
+ }
73
79
  await loadRequests();
74
- handleCloseApproveDialog();
80
+ closeDialog();
75
81
  } catch (err) {
76
- setErrorKey(err.code || 'Support.RECOVERY_REQUEST_APPROVE_FAILED');
77
- }
78
- };
79
-
80
- const handleReject = async (id) => {
81
- setErrorKey(null);
82
- try {
83
- await authApi.rejectRecoveryRequest(id);
84
- await loadRequests();
85
- } catch (err) {
86
- setErrorKey(err.code || 'Support.RECOVERY_REQUEST_REJECT_FAILED');
82
+ setErrorKey(
83
+ err.code ||
84
+ (dialogMode === 'approve'
85
+ ? 'Support.RECOVERY_REQUEST_APPROVE_FAILED'
86
+ : 'Support.RECOVERY_REQUEST_REJECT_FAILED'),
87
+ );
87
88
  }
88
89
  };
89
90
 
@@ -140,7 +141,10 @@ const SupportRecoveryRequestsTab = () => {
140
141
 
141
142
  {requests.length === 0 ? (
142
143
  <Typography variant="body2">
143
- {t('Support.RECOVERY_REQUESTS_EMPTY', 'No recovery requests for this filter.')}
144
+ {t(
145
+ 'Support.RECOVERY_REQUESTS_EMPTY',
146
+ 'No recovery requests for this filter.',
147
+ )}
144
148
  </Typography>
145
149
  ) : (
146
150
  <TableContainer component={Paper}>
@@ -176,7 +180,7 @@ const SupportRecoveryRequestsTab = () => {
176
180
  <Button
177
181
  variant="contained"
178
182
  size="small"
179
- onClick={() => handleOpenApproveDialog(req.id)}
183
+ onClick={() => openDialog(req.id, 'approve')}
180
184
  disabled={req.status !== 'pending'}
181
185
  >
182
186
  {t(
@@ -188,7 +192,7 @@ const SupportRecoveryRequestsTab = () => {
188
192
  variant="outlined"
189
193
  size="small"
190
194
  color="error"
191
- onClick={() => handleReject(req.id)}
195
+ onClick={() => openDialog(req.id, 'reject')}
192
196
  disabled={req.status !== 'pending'}
193
197
  >
194
198
  {t('Support.RECOVERY_ACTION_REJECT', 'Reject')}
@@ -202,55 +206,60 @@ const SupportRecoveryRequestsTab = () => {
202
206
  </TableContainer>
203
207
  )}
204
208
 
205
- <Dialog
206
- open={approveDialogOpen}
207
- onClose={handleCloseApproveDialog}
208
- fullWidth
209
- maxWidth="sm"
210
- >
209
+ <Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
211
210
  <DialogTitle>
212
- {t(
213
- 'Support.RECOVERY_APPROVE_DIALOG_TITLE',
214
- 'Confirm account recovery',
215
- )}
211
+ {dialogMode === 'approve'
212
+ ? t(
213
+ 'Support.RECOVERY_APPROVE_DIALOG_TITLE',
214
+ 'Confirm account recovery',
215
+ )
216
+ : t(
217
+ 'Support.RECOVERY_REJECT_DIALOG_TITLE',
218
+ 'Reject account recovery',
219
+ )}
216
220
  </DialogTitle>
217
221
  <DialogContent dividers>
218
222
  <Typography variant="body2" sx={{ mb: 2 }}>
219
- {t(
220
- 'Support.RECOVERY_APPROVE_CONFIRM_QUESTION',
221
- 'Are you sure you want to approve this recovery request?',
222
- )}
223
+ {dialogMode === 'approve'
224
+ ? t(
225
+ 'Support.RECOVERY_APPROVE_CONFIRM_QUESTION',
226
+ 'Are you sure you want to approve this recovery request?',
227
+ )
228
+ : t(
229
+ 'Support.RECOVERY_REJECT_CONFIRM_QUESTION',
230
+ 'Are you sure you want to reject this recovery request?',
231
+ )}
223
232
  </Typography>
224
233
 
225
234
  <TextField
226
235
  label={t(
227
- 'Support.RECOVERY_APPROVE_NOTE_LABEL',
236
+ 'Support.RECOVERY_NOTE_LABEL',
228
237
  'Reason for this decision',
229
238
  )}
230
239
  helperText={t(
231
- 'Support.RECOVERY_APPROVE_NOTE_HELP',
232
- 'Describe briefly why you are approving this request.',
240
+ 'Support.RECOVERY_NOTE_HELP',
241
+ 'Describe briefly why you are approving or rejecting this request.',
233
242
  )}
234
243
  multiline
235
244
  minRows={3}
236
245
  fullWidth
237
- value={approveDialogNote}
238
- onChange={(e) => setApproveDialogNote(e.target.value)}
246
+ value={dialogNote}
247
+ onChange={(e) => setDialogNote(e.target.value)}
239
248
  />
240
249
  </DialogContent>
241
250
  <DialogActions>
242
- <Button onClick={handleCloseApproveDialog}>
251
+ <Button onClick={closeDialog}>
243
252
  {t('Common.CANCEL', 'Cancel')}
244
253
  </Button>
245
254
  <Button
246
- onClick={handleConfirmApprove}
255
+ onClick={handleConfirm}
247
256
  variant="contained"
248
- disabled={!approveDialogNote.trim()}
257
+ disabled={!dialogNote.trim()}
258
+ color={dialogMode === 'approve' ? 'primary' : 'error'}
249
259
  >
250
- {t(
251
- 'Support.RECOVERY_APPROVE_SUBMIT',
252
- 'Send link',
253
- )}
260
+ {dialogMode === 'approve'
261
+ ? t('Support.RECOVERY_APPROVE_SUBMIT', 'Send link')
262
+ : t('Support.RECOVERY_REJECT_SUBMIT', 'Reject request')}
254
263
  </Button>
255
264
  </DialogActions>
256
265
  </Dialog>
@@ -902,6 +902,57 @@ export const authTranslations = {
902
902
  "de": "Link senden",
903
903
  "fr": "Envoyer le lien",
904
904
  "en": "Send link"
905
+ },
906
+ "Auth.MFA_HELP_DIALOG_TITLE": {
907
+ "de": "Hilfe bei der Anmeldung",
908
+ "fr": "Aide pour la connexion",
909
+ "en": "Need help with sign-in"
910
+ },
911
+ "Auth.MFA_HELP_DIALOG_DESCRIPTION": {
912
+ "de": "Beschreiben Sie kurz, warum Sie keine der verfügbaren Methoden verwenden können. Eine Supportperson wird Ihre Anfrage prüfen.",
913
+ "fr": "Décrivez brièvement pourquoi vous ne pouvez utiliser aucune des méthodes disponibles. Un membre du support examinera votre demande.",
914
+ "en": "Briefly describe why you cannot use any of the available methods. A support person will review your request."
915
+ },
916
+ "Auth.MFA_HELP_MESSAGE_LABEL": {
917
+ "de": "Ihre Nachricht an den Support",
918
+ "fr": "Votre message au support",
919
+ "en": "Your message to support"
920
+ },
921
+ "Auth.MFA_HELP_MESSAGE_HELP": {
922
+ "de": "Optional, hilft dem Support bei der Überprüfung Ihrer Identität.",
923
+ "fr": "Facultatif, mais aide le support à vérifier votre identité.",
924
+ "en": "Optional, but helps support verify your identity."
925
+ },
926
+ "Auth.MFA_HELP_SUBMIT": {
927
+ "de": "Anfrage senden",
928
+ "fr": "Envoyer la demande",
929
+ "en": "Send request"
930
+ },
931
+
932
+ "Support.RECOVERY_NOTE_LABEL": {
933
+ "de": "Begründung für diese Entscheidung",
934
+ "fr": "Raison de cette décision",
935
+ "en": "Reason for this decision"
936
+ },
937
+ "Support.RECOVERY_NOTE_HELP": {
938
+ "de": "Beschreiben Sie kurz, warum Sie diese Anfrage genehmigen oder ablehnen.",
939
+ "fr": "Décrivez brièvement pourquoi vous approuvez ou rejetez cette demande.",
940
+ "en": "Briefly describe why you are approving or rejecting this request."
941
+ },
942
+ "Support.RECOVERY_REJECT_DIALOG_TITLE": {
943
+ "de": "Konto-Wiederherstellung ablehnen",
944
+ "fr": "Refuser la récupération de compte",
945
+ "en": "Reject account recovery"
946
+ },
947
+ "Support.RECOVERY_REJECT_CONFIRM_QUESTION": {
948
+ "de": "Sind Sie sicher, dass Sie diese Wiederherstellungsanfrage ablehnen möchten?",
949
+ "fr": "Êtes-vous sûr de vouloir refuser cette demande de récupération ?",
950
+ "en": "Are you sure you want to reject this recovery request?"
951
+ },
952
+ "Support.RECOVERY_REJECT_SUBMIT": {
953
+ "de": "Anfrage ablehnen",
954
+ "fr": "Refuser la demande",
955
+ "en": "Reject request"
905
956
  }
906
957
 
907
958