@micha.bigler/ui-core-micha 1.4.10 → 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.
- package/dist/auth/authApi.js +15 -4
- package/dist/components/LoginForm.js +8 -5
- package/dist/components/MfaLoginComponent.js +12 -3
- package/dist/components/SupportRecoveryRequestsTab.js +38 -17
- package/dist/i18n/authTranslations.js +75 -0
- package/dist/pages/LoginPage.js +16 -3
- package/package.json +1 -1
- package/src/auth/authApi.jsx +21 -4
- package/src/components/LoginForm.jsx +10 -7
- package/src/components/MfaLoginComponent.jsx +66 -2
- package/src/components/SupportRecoveryRequestsTab.jsx +112 -21
- package/src/i18n/authTranslations.js +76 -0
- package/src/pages/LoginPage.jsx +33 -4
package/dist/auth/authApi.js
CHANGED
|
@@ -609,14 +609,24 @@ export async function fetchRecoveryRequests(status = 'pending') {
|
|
|
609
609
|
});
|
|
610
610
|
return res.data;
|
|
611
611
|
}
|
|
612
|
-
export async function approveRecoveryRequest(id) {
|
|
613
|
-
const res = await axios.post(`/api/support/recovery-requests/${id}/approve/`, {}, { 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 });
|
|
614
614
|
return res.data;
|
|
615
615
|
}
|
|
616
|
-
export async function rejectRecoveryRequest(id) {
|
|
617
|
-
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 });
|
|
618
618
|
return res.data;
|
|
619
619
|
}
|
|
620
|
+
export async function loginWithRecoveryPassword(email, password, token) {
|
|
621
|
+
try {
|
|
622
|
+
await axios.post(`/api/support/recovery-requests/recovery-login/${token}/`, { email, password }, { withCredentials: true });
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
throw normaliseApiError(error, 'Auth.RECOVERY_LOGIN_FAILED');
|
|
626
|
+
}
|
|
627
|
+
const user = await fetchCurrentUser();
|
|
628
|
+
return { user, needsMfa: false };
|
|
629
|
+
}
|
|
620
630
|
// -----------------------------
|
|
621
631
|
// Aggregated API object
|
|
622
632
|
// -----------------------------
|
|
@@ -650,5 +660,6 @@ export const authApi = {
|
|
|
650
660
|
fetchRecoveryRequests,
|
|
651
661
|
approveRecoveryRequest,
|
|
652
662
|
rejectRecoveryRequest,
|
|
663
|
+
loginWithRecoveryPassword,
|
|
653
664
|
};
|
|
654
665
|
export default authApi;
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
|
|
3
|
-
import React, { useState } from 'react';
|
|
2
|
+
import React, { useState, useEffect } from 'react';
|
|
4
3
|
import { Box, TextField, Button, Typography, Divider, } from '@mui/material';
|
|
5
4
|
import { useTranslation } from 'react-i18next';
|
|
6
5
|
import SocialLoginButtons from './SocialLoginButtons';
|
|
7
6
|
const LoginForm = ({ onSubmit, onForgotPassword, onSocialLogin, onPasskeyLogin, onSignUp, error, // bereits übersetzter Text oder t(errorKey) aus dem Parent
|
|
8
|
-
disabled = false, }) => {
|
|
7
|
+
disabled = false, initialIdentifier = '', }) => {
|
|
9
8
|
const { t } = useTranslation();
|
|
10
|
-
const [identifier, setIdentifier] = useState(
|
|
9
|
+
const [identifier, setIdentifier] = useState(initialIdentifier);
|
|
11
10
|
const [password, setPassword] = useState('');
|
|
11
|
+
// Keep identifier in sync if initialIdentifier changes (e.g. recovery link)
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
setIdentifier(initialIdentifier);
|
|
14
|
+
}, [initialIdentifier]);
|
|
12
15
|
const handleSubmit = (event) => {
|
|
13
16
|
event.preventDefault();
|
|
14
17
|
if (!onSubmit)
|
|
@@ -18,6 +21,6 @@ disabled = false, }) => {
|
|
|
18
21
|
const supportsPasskey = !!onPasskeyLogin &&
|
|
19
22
|
typeof window !== 'undefined' &&
|
|
20
23
|
!!window.PublicKeyCredential;
|
|
21
|
-
return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 3 }, children: [error && (_jsx(Typography, { color: "error", gutterBottom: true, children: error })), supportsPasskey && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "contained", fullWidth: true, type: "button", onClick: onPasskeyLogin, disabled: disabled, children: t('Auth.LOGIN_USE_PASSKEY_BUTTON') }), _jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') })] })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: identifier, onChange: (e) => setIdentifier(e.target.value), disabled: disabled }), _jsx(TextField, { label: t('Auth.LOGIN_PASSWORD_LABEL'), type: "password", required: true, fullWidth: true, value: password, onChange: (e) => setPassword(e.target.value), disabled: disabled }), _jsx(Button, { type: "submit", variant: "contained", fullWidth: true, disabled: disabled, children: t('Auth.LOGIN_SUBMIT') })] }), _jsxs(Box, {
|
|
24
|
+
return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 3 }, children: [error && (_jsx(Typography, { color: "error", gutterBottom: true, children: error })), supportsPasskey && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "contained", fullWidth: true, type: "button", onClick: onPasskeyLogin, disabled: disabled, children: t('Auth.LOGIN_USE_PASSKEY_BUTTON') }), _jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') })] })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: identifier, onChange: (e) => setIdentifier(e.target.value), disabled: disabled }), _jsx(TextField, { label: t('Auth.LOGIN_PASSWORD_LABEL'), type: "password", required: true, fullWidth: true, value: password, onChange: (e) => setPassword(e.target.value), disabled: disabled }), _jsx(Button, { type: "submit", variant: "contained", fullWidth: true, disabled: disabled, children: t('Auth.LOGIN_SUBMIT') })] }), _jsxs(Box, { children: [_jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') }), _jsx(SocialLoginButtons, { onProviderClick: onSocialLogin })] }), _jsxs(Box, { children: [_jsx(Typography, { variant: "subtitle2", sx: { mb: 1 }, children: t('Auth.LOGIN_ACCOUNT_RECOVERY_TITLE') }), _jsxs(Box, { sx: { display: 'flex', gap: 1, flexWrap: 'wrap' }, children: [onSignUp && (_jsx(Button, { type: "button", variant: "outlined", onClick: onSignUp, disabled: disabled, children: t('Auth.LOGIN_SIGNUP_BUTTON') })), _jsx(Button, { type: "button", variant: "outlined", onClick: onForgotPassword, disabled: disabled, children: t('Auth.LOGIN_FORGOT_PASSWORD_BUTTON') })] })] })] }));
|
|
22
25
|
};
|
|
23
26
|
export default LoginForm;
|
|
@@ -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:
|
|
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;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// src/components/SupportRecoveryRequestsTab.jsx
|
|
3
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';
|
|
4
|
+
import { Box, Typography, Table, TableHead, TableBody, TableRow, TableCell, TableContainer, Paper, Button, CircularProgress, Alert, Stack, Dialog, DialogTitle, DialogContent, DialogActions, TextField, } from '@mui/material';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import { authApi } from '../auth/authApi';
|
|
7
7
|
const SupportRecoveryRequestsTab = () => {
|
|
@@ -9,7 +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');
|
|
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');
|
|
13
17
|
const loadRequests = async () => {
|
|
14
18
|
setLoading(true);
|
|
15
19
|
setErrorKey(null);
|
|
@@ -28,32 +32,49 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
28
32
|
loadRequests();
|
|
29
33
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
30
34
|
}, [statusFilter]);
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
const openDialog = (id, mode) => {
|
|
36
|
+
setDialogRequestId(id);
|
|
37
|
+
setDialogMode(mode);
|
|
38
|
+
setDialogNote('');
|
|
39
|
+
setDialogOpen(true);
|
|
40
|
+
};
|
|
41
|
+
const closeDialog = () => {
|
|
42
|
+
setDialogOpen(false);
|
|
43
|
+
setDialogRequestId(null);
|
|
44
|
+
setDialogNote('');
|
|
41
45
|
};
|
|
42
|
-
const
|
|
46
|
+
const handleConfirm = async () => {
|
|
47
|
+
if (!dialogRequestId)
|
|
48
|
+
return;
|
|
43
49
|
setErrorKey(null);
|
|
44
50
|
try {
|
|
45
|
-
|
|
51
|
+
if (dialogMode === 'approve') {
|
|
52
|
+
await authApi.approveRecoveryRequest(dialogRequestId, dialogNote);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
await authApi.rejectRecoveryRequest(dialogRequestId, dialogNote);
|
|
56
|
+
}
|
|
46
57
|
await loadRequests();
|
|
58
|
+
closeDialog();
|
|
47
59
|
}
|
|
48
60
|
catch (err) {
|
|
49
|
-
setErrorKey(err.code ||
|
|
61
|
+
setErrorKey(err.code ||
|
|
62
|
+
(dialogMode === 'approve'
|
|
63
|
+
? 'Support.RECOVERY_REQUEST_APPROVE_FAILED'
|
|
64
|
+
: 'Support.RECOVERY_REQUEST_REJECT_FAILED'));
|
|
50
65
|
}
|
|
51
66
|
};
|
|
52
67
|
if (loading) {
|
|
53
68
|
return (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', mt: 4 }, children: _jsx(CircularProgress, {}) }));
|
|
54
69
|
}
|
|
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.
|
|
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
|
|
56
71
|
? new Date(req.created_at).toLocaleString()
|
|
57
|
-
: '-' }), _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: () =>
|
|
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') })] })] })] }));
|
|
58
79
|
};
|
|
59
80
|
export default SupportRecoveryRequestsTab;
|
|
@@ -831,5 +831,80 @@ export const authTranslations = {
|
|
|
831
831
|
"fr": "Si ce compte existe, votre demande a été transmise au support. Veuillez contacter le support pour obtenir de l’aide supplémentaire.",
|
|
832
832
|
"en": "If this account exists, your request has been forwarded to support. Please contact support for further assistance."
|
|
833
833
|
},
|
|
834
|
+
"Support.RECOVERY_APPROVE_DIALOG_TITLE": {
|
|
835
|
+
"de": "Kontowiederherstellung bestätigen",
|
|
836
|
+
"fr": "Confirmer la récupération du compte",
|
|
837
|
+
"en": "Confirm account recovery"
|
|
838
|
+
},
|
|
839
|
+
"Support.RECOVERY_APPROVE_CONFIRM_QUESTION": {
|
|
840
|
+
"de": "Sind Sie sicher, dass Sie diese Wiederherstellungsanfrage bewilligen möchten?",
|
|
841
|
+
"fr": "Êtes-vous sûr de vouloir approuver cette demande de récupération ?",
|
|
842
|
+
"en": "Are you sure you want to approve this recovery request?"
|
|
843
|
+
},
|
|
844
|
+
"Support.RECOVERY_APPROVE_NOTE_LABEL": {
|
|
845
|
+
"de": "Begründung für diese Entscheidung",
|
|
846
|
+
"fr": "Raison de cette décision",
|
|
847
|
+
"en": "Reason for this decision"
|
|
848
|
+
},
|
|
849
|
+
"Support.RECOVERY_APPROVE_NOTE_HELP": {
|
|
850
|
+
"de": "Beschreiben Sie kurz, warum Sie diese Anfrage bewilligen.",
|
|
851
|
+
"fr": "Décrivez brièvement pourquoi vous approuvez cette demande.",
|
|
852
|
+
"en": "Briefly describe why you are approving this request."
|
|
853
|
+
},
|
|
854
|
+
"Support.RECOVERY_APPROVE_SUBMIT": {
|
|
855
|
+
"de": "Link senden",
|
|
856
|
+
"fr": "Envoyer le lien",
|
|
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"
|
|
908
|
+
}
|
|
834
909
|
// ...
|
|
835
910
|
};
|
package/dist/pages/LoginPage.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
// src/pages/LoginPage.jsx
|
|
3
2
|
import React, { useState, useContext } from 'react';
|
|
4
|
-
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { useNavigate, useLocation } from 'react-router-dom';
|
|
5
4
|
import { Helmet } from 'react-helmet';
|
|
6
5
|
import { Typography, Box, Alert } from '@mui/material';
|
|
7
6
|
import { NarrowPage } from '../layout/PageLayout';
|
|
@@ -12,17 +11,31 @@ import MfaLoginComponent from '../components/MfaLoginComponent';
|
|
|
12
11
|
import { useTranslation } from 'react-i18next';
|
|
13
12
|
export function LoginPage() {
|
|
14
13
|
const navigate = useNavigate();
|
|
14
|
+
const location = useLocation();
|
|
15
15
|
const { login } = useContext(AuthContext);
|
|
16
16
|
const [step, setStep] = useState('credentials'); // 'credentials' | 'mfa'
|
|
17
17
|
const [submitting, setSubmitting] = useState(false);
|
|
18
18
|
const [errorKey, setErrorKey] = useState(null);
|
|
19
19
|
const [mfaState, setMfaState] = useState(null); // { availableTypes: [...] }
|
|
20
20
|
const { t } = useTranslation();
|
|
21
|
+
// Read recovery token + prefill email from query params
|
|
22
|
+
const params = new URLSearchParams(location.search);
|
|
23
|
+
const recoveryToken = params.get('recovery');
|
|
24
|
+
const recoveryEmail = params.get('email') || '';
|
|
21
25
|
const handleSubmitCredentials = async ({ identifier, password }) => {
|
|
22
26
|
var _a;
|
|
23
27
|
setErrorKey(null);
|
|
24
28
|
setSubmitting(true);
|
|
25
29
|
try {
|
|
30
|
+
// Recovery flow: password login via special endpoint, no MFA
|
|
31
|
+
if (recoveryToken) {
|
|
32
|
+
const result = await authApi.loginWithRecoveryPassword(identifier, password, recoveryToken);
|
|
33
|
+
const user = result.user;
|
|
34
|
+
login(user);
|
|
35
|
+
navigate('/account?tab=security&from=recovery');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
// Normal login flow via headless allauth
|
|
26
39
|
const result = await authApi.loginWithPassword(identifier, password);
|
|
27
40
|
if (result.needsMfa) {
|
|
28
41
|
setMfaState({
|
|
@@ -89,6 +102,6 @@ export function LoginPage() {
|
|
|
89
102
|
setMfaState(null);
|
|
90
103
|
setErrorKey(null);
|
|
91
104
|
};
|
|
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 }) }))] }));
|
|
105
|
+
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) })), recoveryToken && !errorKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.RECOVERY_LOGIN_INFO', 'Your recovery link was validated. Please sign in with your password to continue.') })), step === 'credentials' && (_jsx(LoginForm, { onSubmit: handleSubmitCredentials, onForgotPassword: handleForgotPassword, onSocialLogin: handleSocialLogin, onPasskeyLogin: handlePasskeyLoginInitial, onSignUp: handleSignUp, disabled: submitting, initialIdentifier: recoveryEmail })), step === 'mfa' && mfaState && (_jsx(Box, { children: _jsx(MfaLoginComponent, { availableTypes: mfaState.availableTypes, identifier: mfaState.identifier, onSuccess: handleMfaSuccess, onCancel: handleMfaCancel }) }))] }));
|
|
93
106
|
}
|
|
94
107
|
export default LoginPage;
|
package/package.json
CHANGED
package/src/auth/authApi.jsx
CHANGED
|
@@ -800,25 +800,41 @@ export async function fetchRecoveryRequests(status = 'pending') {
|
|
|
800
800
|
return res.data;
|
|
801
801
|
}
|
|
802
802
|
|
|
803
|
-
export async function approveRecoveryRequest(id) {
|
|
803
|
+
export async function approveRecoveryRequest(id, supportNote) {
|
|
804
804
|
const res = await axios.post(
|
|
805
805
|
`/api/support/recovery-requests/${id}/approve/`,
|
|
806
|
-
{},
|
|
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;
|
|
819
819
|
}
|
|
820
820
|
|
|
821
821
|
|
|
822
|
+
export async function loginWithRecoveryPassword(email, password, token) {
|
|
823
|
+
try {
|
|
824
|
+
await axios.post(
|
|
825
|
+
`/api/support/recovery-requests/recovery-login/${token}/`,
|
|
826
|
+
{ email, password },
|
|
827
|
+
{ withCredentials: true },
|
|
828
|
+
);
|
|
829
|
+
} catch (error) {
|
|
830
|
+
throw normaliseApiError(error, 'Auth.RECOVERY_LOGIN_FAILED');
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const user = await fetchCurrentUser();
|
|
834
|
+
return { user, needsMfa: false };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
|
|
822
838
|
|
|
823
839
|
// -----------------------------
|
|
824
840
|
// Aggregated API object
|
|
@@ -853,6 +869,7 @@ export const authApi = {
|
|
|
853
869
|
fetchRecoveryRequests,
|
|
854
870
|
approveRecoveryRequest,
|
|
855
871
|
rejectRecoveryRequest,
|
|
872
|
+
loginWithRecoveryPassword,
|
|
856
873
|
};
|
|
857
874
|
|
|
858
875
|
export default authApi;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
3
2
|
import {
|
|
4
3
|
Box,
|
|
5
4
|
TextField,
|
|
@@ -18,12 +17,18 @@ const LoginForm = ({
|
|
|
18
17
|
onSignUp,
|
|
19
18
|
error, // bereits übersetzter Text oder t(errorKey) aus dem Parent
|
|
20
19
|
disabled = false,
|
|
20
|
+
initialIdentifier = '',
|
|
21
21
|
}) => {
|
|
22
22
|
const { t } = useTranslation();
|
|
23
23
|
|
|
24
|
-
const [identifier, setIdentifier] = useState(
|
|
24
|
+
const [identifier, setIdentifier] = useState(initialIdentifier);
|
|
25
25
|
const [password, setPassword] = useState('');
|
|
26
26
|
|
|
27
|
+
// Keep identifier in sync if initialIdentifier changes (e.g. recovery link)
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setIdentifier(initialIdentifier);
|
|
30
|
+
}, [initialIdentifier]);
|
|
31
|
+
|
|
27
32
|
const handleSubmit = (event) => {
|
|
28
33
|
event.preventDefault();
|
|
29
34
|
if (!onSubmit) return;
|
|
@@ -96,13 +101,11 @@ const LoginForm = ({
|
|
|
96
101
|
>
|
|
97
102
|
{t('Auth.LOGIN_SUBMIT')}
|
|
98
103
|
</Button>
|
|
99
|
-
|
|
100
|
-
|
|
101
104
|
</Box>
|
|
102
105
|
|
|
103
106
|
{/* Other ways to sign in */}
|
|
104
|
-
|
|
105
|
-
<Divider>
|
|
107
|
+
<Box>
|
|
108
|
+
<Divider sx={{ my: 2 }}>
|
|
106
109
|
{t('Auth.LOGIN_OR')}
|
|
107
110
|
</Divider>
|
|
108
111
|
<SocialLoginButtons onProviderClick={onSocialLogin} />
|
|
@@ -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={
|
|
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;
|
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
CircularProgress,
|
|
15
15
|
Alert,
|
|
16
16
|
Stack,
|
|
17
|
+
Dialog,
|
|
18
|
+
DialogTitle,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogActions,
|
|
21
|
+
TextField,
|
|
17
22
|
} from '@mui/material';
|
|
18
23
|
import { useTranslation } from 'react-i18next';
|
|
19
24
|
import { authApi } from '../auth/authApi';
|
|
@@ -24,7 +29,12 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
24
29
|
const [requests, setRequests] = useState([]);
|
|
25
30
|
const [loading, setLoading] = useState(true);
|
|
26
31
|
const [errorKey, setErrorKey] = useState(null);
|
|
27
|
-
const [statusFilter, setStatusFilter] = useState('pending');
|
|
32
|
+
const [statusFilter, setStatusFilter] = useState('pending');
|
|
33
|
+
|
|
34
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
35
|
+
const [dialogNote, setDialogNote] = useState('');
|
|
36
|
+
const [dialogRequestId, setDialogRequestId] = useState(null);
|
|
37
|
+
const [dialogMode, setDialogMode] = useState('approve');
|
|
28
38
|
|
|
29
39
|
const loadRequests = async () => {
|
|
30
40
|
setLoading(true);
|
|
@@ -44,24 +54,37 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
44
54
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
55
|
}, [statusFilter]);
|
|
46
56
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
const openDialog = (id, mode) => {
|
|
58
|
+
setDialogRequestId(id);
|
|
59
|
+
setDialogMode(mode);
|
|
60
|
+
setDialogNote('');
|
|
61
|
+
setDialogOpen(true);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const closeDialog = () => {
|
|
65
|
+
setDialogOpen(false);
|
|
66
|
+
setDialogRequestId(null);
|
|
67
|
+
setDialogNote('');
|
|
56
68
|
};
|
|
57
69
|
|
|
58
|
-
const
|
|
70
|
+
const handleConfirm = async () => {
|
|
71
|
+
if (!dialogRequestId) return;
|
|
59
72
|
setErrorKey(null);
|
|
60
73
|
try {
|
|
61
|
-
|
|
74
|
+
if (dialogMode === 'approve') {
|
|
75
|
+
await authApi.approveRecoveryRequest(dialogRequestId, dialogNote);
|
|
76
|
+
} else {
|
|
77
|
+
await authApi.rejectRecoveryRequest(dialogRequestId, dialogNote);
|
|
78
|
+
}
|
|
62
79
|
await loadRequests();
|
|
80
|
+
closeDialog();
|
|
63
81
|
} catch (err) {
|
|
64
|
-
setErrorKey(
|
|
82
|
+
setErrorKey(
|
|
83
|
+
err.code ||
|
|
84
|
+
(dialogMode === 'approve'
|
|
85
|
+
? 'Support.RECOVERY_REQUEST_APPROVE_FAILED'
|
|
86
|
+
: 'Support.RECOVERY_REQUEST_REJECT_FAILED'),
|
|
87
|
+
);
|
|
65
88
|
}
|
|
66
89
|
};
|
|
67
90
|
|
|
@@ -118,18 +141,28 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
118
141
|
|
|
119
142
|
{requests.length === 0 ? (
|
|
120
143
|
<Typography variant="body2">
|
|
121
|
-
{t(
|
|
144
|
+
{t(
|
|
145
|
+
'Support.RECOVERY_REQUESTS_EMPTY',
|
|
146
|
+
'No recovery requests for this filter.',
|
|
147
|
+
)}
|
|
122
148
|
</Typography>
|
|
123
149
|
) : (
|
|
124
150
|
<TableContainer component={Paper}>
|
|
125
151
|
<Table size="small">
|
|
126
152
|
<TableHead>
|
|
127
153
|
<TableRow>
|
|
128
|
-
<TableCell>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<TableCell>
|
|
132
|
-
|
|
154
|
+
<TableCell>
|
|
155
|
+
{t('Support.RECOVERY_COL_CREATED', 'Created')}
|
|
156
|
+
</TableCell>
|
|
157
|
+
<TableCell>
|
|
158
|
+
{t('Support.RECOVERY_COL_USER', 'User')}
|
|
159
|
+
</TableCell>
|
|
160
|
+
<TableCell>
|
|
161
|
+
{t('Support.RECOVERY_COL_STATUS', 'Status')}
|
|
162
|
+
</TableCell>
|
|
163
|
+
<TableCell>
|
|
164
|
+
{t('Support.RECOVERY_COL_ACTIONS', 'Actions')}
|
|
165
|
+
</TableCell>
|
|
133
166
|
</TableRow>
|
|
134
167
|
</TableHead>
|
|
135
168
|
<TableBody>
|
|
@@ -147,7 +180,7 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
147
180
|
<Button
|
|
148
181
|
variant="contained"
|
|
149
182
|
size="small"
|
|
150
|
-
onClick={() =>
|
|
183
|
+
onClick={() => openDialog(req.id, 'approve')}
|
|
151
184
|
disabled={req.status !== 'pending'}
|
|
152
185
|
>
|
|
153
186
|
{t(
|
|
@@ -159,7 +192,7 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
159
192
|
variant="outlined"
|
|
160
193
|
size="small"
|
|
161
194
|
color="error"
|
|
162
|
-
onClick={() =>
|
|
195
|
+
onClick={() => openDialog(req.id, 'reject')}
|
|
163
196
|
disabled={req.status !== 'pending'}
|
|
164
197
|
>
|
|
165
198
|
{t('Support.RECOVERY_ACTION_REJECT', 'Reject')}
|
|
@@ -172,6 +205,64 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
172
205
|
</Table>
|
|
173
206
|
</TableContainer>
|
|
174
207
|
)}
|
|
208
|
+
|
|
209
|
+
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
|
|
210
|
+
<DialogTitle>
|
|
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
|
+
)}
|
|
220
|
+
</DialogTitle>
|
|
221
|
+
<DialogContent dividers>
|
|
222
|
+
<Typography variant="body2" sx={{ mb: 2 }}>
|
|
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
|
+
)}
|
|
232
|
+
</Typography>
|
|
233
|
+
|
|
234
|
+
<TextField
|
|
235
|
+
label={t(
|
|
236
|
+
'Support.RECOVERY_NOTE_LABEL',
|
|
237
|
+
'Reason for this decision',
|
|
238
|
+
)}
|
|
239
|
+
helperText={t(
|
|
240
|
+
'Support.RECOVERY_NOTE_HELP',
|
|
241
|
+
'Describe briefly why you are approving or rejecting this request.',
|
|
242
|
+
)}
|
|
243
|
+
multiline
|
|
244
|
+
minRows={3}
|
|
245
|
+
fullWidth
|
|
246
|
+
value={dialogNote}
|
|
247
|
+
onChange={(e) => setDialogNote(e.target.value)}
|
|
248
|
+
/>
|
|
249
|
+
</DialogContent>
|
|
250
|
+
<DialogActions>
|
|
251
|
+
<Button onClick={closeDialog}>
|
|
252
|
+
{t('Common.CANCEL', 'Cancel')}
|
|
253
|
+
</Button>
|
|
254
|
+
<Button
|
|
255
|
+
onClick={handleConfirm}
|
|
256
|
+
variant="contained"
|
|
257
|
+
disabled={!dialogNote.trim()}
|
|
258
|
+
color={dialogMode === 'approve' ? 'primary' : 'error'}
|
|
259
|
+
>
|
|
260
|
+
{dialogMode === 'approve'
|
|
261
|
+
? t('Support.RECOVERY_APPROVE_SUBMIT', 'Send link')
|
|
262
|
+
: t('Support.RECOVERY_REJECT_SUBMIT', 'Reject request')}
|
|
263
|
+
</Button>
|
|
264
|
+
</DialogActions>
|
|
265
|
+
</Dialog>
|
|
175
266
|
</Box>
|
|
176
267
|
);
|
|
177
268
|
};
|
|
@@ -878,6 +878,82 @@ export const authTranslations = {
|
|
|
878
878
|
"fr": "Si ce compte existe, votre demande a été transmise au support. Veuillez contacter le support pour obtenir de l’aide supplémentaire.",
|
|
879
879
|
"en": "If this account exists, your request has been forwarded to support. Please contact support for further assistance."
|
|
880
880
|
},
|
|
881
|
+
"Support.RECOVERY_APPROVE_DIALOG_TITLE": {
|
|
882
|
+
"de": "Kontowiederherstellung bestätigen",
|
|
883
|
+
"fr": "Confirmer la récupération du compte",
|
|
884
|
+
"en": "Confirm account recovery"
|
|
885
|
+
},
|
|
886
|
+
"Support.RECOVERY_APPROVE_CONFIRM_QUESTION": {
|
|
887
|
+
"de": "Sind Sie sicher, dass Sie diese Wiederherstellungsanfrage bewilligen möchten?",
|
|
888
|
+
"fr": "Êtes-vous sûr de vouloir approuver cette demande de récupération ?",
|
|
889
|
+
"en": "Are you sure you want to approve this recovery request?"
|
|
890
|
+
},
|
|
891
|
+
"Support.RECOVERY_APPROVE_NOTE_LABEL": {
|
|
892
|
+
"de": "Begründung für diese Entscheidung",
|
|
893
|
+
"fr": "Raison de cette décision",
|
|
894
|
+
"en": "Reason for this decision"
|
|
895
|
+
},
|
|
896
|
+
"Support.RECOVERY_APPROVE_NOTE_HELP": {
|
|
897
|
+
"de": "Beschreiben Sie kurz, warum Sie diese Anfrage bewilligen.",
|
|
898
|
+
"fr": "Décrivez brièvement pourquoi vous approuvez cette demande.",
|
|
899
|
+
"en": "Briefly describe why you are approving this request."
|
|
900
|
+
},
|
|
901
|
+
"Support.RECOVERY_APPROVE_SUBMIT": {
|
|
902
|
+
"de": "Link senden",
|
|
903
|
+
"fr": "Envoyer le lien",
|
|
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"
|
|
956
|
+
}
|
|
881
957
|
|
|
882
958
|
|
|
883
959
|
|
package/src/pages/LoginPage.jsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// src/pages/LoginPage.jsx
|
|
2
1
|
import React, { useState, useContext } from 'react';
|
|
3
|
-
import { useNavigate } from 'react-router-dom';
|
|
2
|
+
import { useNavigate, useLocation } from 'react-router-dom';
|
|
4
3
|
import { Helmet } from 'react-helmet';
|
|
5
4
|
import { Typography, Box, Alert } from '@mui/material';
|
|
6
5
|
import { NarrowPage } from '../layout/PageLayout';
|
|
@@ -12,6 +11,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
12
11
|
|
|
13
12
|
export function LoginPage() {
|
|
14
13
|
const navigate = useNavigate();
|
|
14
|
+
const location = useLocation();
|
|
15
15
|
const { login } = useContext(AuthContext);
|
|
16
16
|
|
|
17
17
|
const [step, setStep] = useState('credentials'); // 'credentials' | 'mfa'
|
|
@@ -21,10 +21,30 @@ export function LoginPage() {
|
|
|
21
21
|
|
|
22
22
|
const { t } = useTranslation();
|
|
23
23
|
|
|
24
|
+
// Read recovery token + prefill email from query params
|
|
25
|
+
const params = new URLSearchParams(location.search);
|
|
26
|
+
const recoveryToken = params.get('recovery');
|
|
27
|
+
const recoveryEmail = params.get('email') || '';
|
|
28
|
+
|
|
24
29
|
const handleSubmitCredentials = async ({ identifier, password }) => {
|
|
25
30
|
setErrorKey(null);
|
|
26
31
|
setSubmitting(true);
|
|
27
32
|
try {
|
|
33
|
+
// Recovery flow: password login via special endpoint, no MFA
|
|
34
|
+
if (recoveryToken) {
|
|
35
|
+
const result = await authApi.loginWithRecoveryPassword(
|
|
36
|
+
identifier,
|
|
37
|
+
password,
|
|
38
|
+
recoveryToken,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const user = result.user;
|
|
42
|
+
login(user);
|
|
43
|
+
navigate('/account?tab=security&from=recovery');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Normal login flow via headless allauth
|
|
28
48
|
const result = await authApi.loginWithPassword(identifier, password);
|
|
29
49
|
|
|
30
50
|
if (result.needsMfa) {
|
|
@@ -112,14 +132,24 @@ export function LoginPage() {
|
|
|
112
132
|
</Alert>
|
|
113
133
|
)}
|
|
114
134
|
|
|
135
|
+
{recoveryToken && !errorKey && (
|
|
136
|
+
<Alert severity="info" sx={{ mb: 2 }}>
|
|
137
|
+
{t(
|
|
138
|
+
'Auth.RECOVERY_LOGIN_INFO',
|
|
139
|
+
'Your recovery link was validated. Please sign in with your password to continue.',
|
|
140
|
+
)}
|
|
141
|
+
</Alert>
|
|
142
|
+
)}
|
|
143
|
+
|
|
115
144
|
{step === 'credentials' && (
|
|
116
145
|
<LoginForm
|
|
117
146
|
onSubmit={handleSubmitCredentials}
|
|
118
147
|
onForgotPassword={handleForgotPassword}
|
|
119
148
|
onSocialLogin={handleSocialLogin}
|
|
120
|
-
onPasskeyLogin={handlePasskeyLoginInitial} // Passkey
|
|
149
|
+
onPasskeyLogin={handlePasskeyLoginInitial} // Passkey as first factor
|
|
121
150
|
onSignUp={handleSignUp}
|
|
122
151
|
disabled={submitting}
|
|
152
|
+
initialIdentifier={recoveryEmail}
|
|
123
153
|
/>
|
|
124
154
|
)}
|
|
125
155
|
|
|
@@ -130,7 +160,6 @@ export function LoginPage() {
|
|
|
130
160
|
identifier={mfaState.identifier}
|
|
131
161
|
onSuccess={handleMfaSuccess}
|
|
132
162
|
onCancel={handleMfaCancel}
|
|
133
|
-
//onNeedHelp={handleMfaNeedHelp}
|
|
134
163
|
/>
|
|
135
164
|
</Box>
|
|
136
165
|
)}
|