@micha.bigler/ui-core-micha 1.4.9 → 1.4.11
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 +14 -2
- package/dist/components/LoginForm.js +8 -5
- package/dist/components/SupportRecoveryRequestsTab.js +21 -6
- package/dist/i18n/authTranslations.js +35 -5
- package/dist/pages/LoginPage.js +16 -3
- package/package.json +1 -1
- package/src/auth/authApi.jsx +19 -2
- package/src/components/LoginForm.jsx +10 -7
- package/src/components/SupportRecoveryRequestsTab.jsx +91 -10
- package/src/i18n/authTranslations.js +35 -5
- package/src/pages/LoginPage.jsx +33 -4
package/dist/auth/authApi.js
CHANGED
|
@@ -609,14 +609,25 @@ 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/`, {},
|
|
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 });
|
|
614
615
|
return res.data;
|
|
615
616
|
}
|
|
616
617
|
export async function rejectRecoveryRequest(id) {
|
|
617
618
|
const res = await axios.post(`/api/support/recovery-requests/${id}/reject/`, {}, { withCredentials: true });
|
|
618
619
|
return res.data;
|
|
619
620
|
}
|
|
621
|
+
export async function loginWithRecoveryPassword(email, password, token) {
|
|
622
|
+
try {
|
|
623
|
+
await axios.post(`/api/support/recovery-requests/recovery-login/${token}/`, { email, password }, { withCredentials: true });
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
throw normaliseApiError(error, 'Auth.RECOVERY_LOGIN_FAILED');
|
|
627
|
+
}
|
|
628
|
+
const user = await fetchCurrentUser();
|
|
629
|
+
return { user, needsMfa: false };
|
|
630
|
+
}
|
|
620
631
|
// -----------------------------
|
|
621
632
|
// Aggregated API object
|
|
622
633
|
// -----------------------------
|
|
@@ -650,5 +661,6 @@ export const authApi = {
|
|
|
650
661
|
fetchRecoveryRequests,
|
|
651
662
|
approveRecoveryRequest,
|
|
652
663
|
rejectRecoveryRequest,
|
|
664
|
+
loginWithRecoveryPassword,
|
|
653
665
|
};
|
|
654
666
|
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/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 = () => {
|
|
@@ -10,6 +10,9 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
10
10
|
const [loading, setLoading] = useState(true);
|
|
11
11
|
const [errorKey, setErrorKey] = useState(null);
|
|
12
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);
|
|
13
16
|
const loadRequests = async () => {
|
|
14
17
|
setLoading(true);
|
|
15
18
|
setErrorKey(null);
|
|
@@ -28,12 +31,24 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
28
31
|
loadRequests();
|
|
29
32
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
30
33
|
}, [statusFilter]);
|
|
31
|
-
const
|
|
34
|
+
const handleOpenApproveDialog = (id) => {
|
|
35
|
+
setApproveDialogRequestId(id);
|
|
36
|
+
setApproveDialogNote('');
|
|
37
|
+
setApproveDialogOpen(true);
|
|
38
|
+
};
|
|
39
|
+
const handleCloseApproveDialog = () => {
|
|
40
|
+
setApproveDialogOpen(false);
|
|
41
|
+
setApproveDialogRequestId(null);
|
|
42
|
+
setApproveDialogNote('');
|
|
43
|
+
};
|
|
44
|
+
const handleConfirmApprove = async () => {
|
|
45
|
+
if (!approveDialogRequestId)
|
|
46
|
+
return;
|
|
32
47
|
setErrorKey(null);
|
|
33
48
|
try {
|
|
34
|
-
await authApi.approveRecoveryRequest(
|
|
35
|
-
// Nach Erfolg Liste neu laden
|
|
49
|
+
await authApi.approveRecoveryRequest(approveDialogRequestId, approveDialogNote);
|
|
36
50
|
await loadRequests();
|
|
51
|
+
handleCloseApproveDialog();
|
|
37
52
|
}
|
|
38
53
|
catch (err) {
|
|
39
54
|
setErrorKey(err.code || 'Support.RECOVERY_REQUEST_APPROVE_FAILED');
|
|
@@ -52,8 +67,8 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
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.
|
|
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') })] })] })] }));
|
|
58
73
|
};
|
|
59
74
|
export default SupportRecoveryRequestsTab;
|
|
@@ -806,11 +806,6 @@ export const authTranslations = {
|
|
|
806
806
|
"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.",
|
|
807
807
|
"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."
|
|
808
808
|
},
|
|
809
|
-
"Auth.MFA_HELP_REQUESTED": {
|
|
810
|
-
"de": "Wir haben deinen Support-Kontakt informiert. Du erhältst eine Rückmeldung, sobald deine Identität geprüft wurde.",
|
|
811
|
-
"fr": "Nous avons informé votre contact de support. Vous recevrez une réponse dès que votre identité aura été vérifiée.",
|
|
812
|
-
"en": "We have informed your support contact. You will hear back as soon as your identity has been verified."
|
|
813
|
-
},
|
|
814
809
|
"Auth.MFA_HELP_REQUEST_FAILED": {
|
|
815
810
|
"de": "Die Support-Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut oder kontaktiere den Support direkt.",
|
|
816
811
|
"fr": "La demande d'assistance n'a pas pu être envoyée. Veuillez réessayer plus tard ou contacter le support directement.",
|
|
@@ -826,5 +821,40 @@ export const authTranslations = {
|
|
|
826
821
|
"fr": "Récupération de compte",
|
|
827
822
|
"en": "Account recovery"
|
|
828
823
|
},
|
|
824
|
+
"Auth.MFA_IDENTIFIER_REQUIRED": {
|
|
825
|
+
"de": "E-Mail-Adresse oder Kennung ist erforderlich.",
|
|
826
|
+
"fr": "L’adresse e-mail ou l’identifiant est requis.",
|
|
827
|
+
"en": "Email address or identifier is required."
|
|
828
|
+
},
|
|
829
|
+
"Auth.MFA_HELP_REQUESTED": {
|
|
830
|
+
"de": "Falls dieses Konto existiert, wurde Ihre Anfrage dem Support weitergeleitet. Bitte kontaktiere den Support für weitere Hilfe.",
|
|
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
|
+
"en": "If this account exists, your request has been forwarded to support. Please contact support for further assistance."
|
|
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
|
+
}
|
|
829
859
|
// ...
|
|
830
860
|
};
|
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,10 +800,10 @@ 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, note = '') {
|
|
804
804
|
const res = await axios.post(
|
|
805
805
|
`/api/support/recovery-requests/${id}/approve/`,
|
|
806
|
-
{},
|
|
806
|
+
{ note }, // 👉 Note mitsenden
|
|
807
807
|
{ withCredentials: true },
|
|
808
808
|
);
|
|
809
809
|
return res.data;
|
|
@@ -819,6 +819,22 @@ export async function rejectRecoveryRequest(id) {
|
|
|
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} />
|
|
@@ -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';
|
|
@@ -26,6 +31,10 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
26
31
|
const [errorKey, setErrorKey] = useState(null);
|
|
27
32
|
const [statusFilter, setStatusFilter] = useState('pending'); // optional erweiterbar
|
|
28
33
|
|
|
34
|
+
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
|
|
35
|
+
const [approveDialogNote, setApproveDialogNote] = useState('');
|
|
36
|
+
const [approveDialogRequestId, setApproveDialogRequestId] = useState(null);
|
|
37
|
+
|
|
29
38
|
const loadRequests = async () => {
|
|
30
39
|
setLoading(true);
|
|
31
40
|
setErrorKey(null);
|
|
@@ -44,12 +53,25 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
44
53
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
54
|
}, [statusFilter]);
|
|
46
55
|
|
|
47
|
-
const
|
|
56
|
+
const handleOpenApproveDialog = (id) => {
|
|
57
|
+
setApproveDialogRequestId(id);
|
|
58
|
+
setApproveDialogNote('');
|
|
59
|
+
setApproveDialogOpen(true);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleCloseApproveDialog = () => {
|
|
63
|
+
setApproveDialogOpen(false);
|
|
64
|
+
setApproveDialogRequestId(null);
|
|
65
|
+
setApproveDialogNote('');
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const handleConfirmApprove = async () => {
|
|
69
|
+
if (!approveDialogRequestId) return;
|
|
48
70
|
setErrorKey(null);
|
|
49
71
|
try {
|
|
50
|
-
await authApi.approveRecoveryRequest(
|
|
51
|
-
// Nach Erfolg Liste neu laden
|
|
72
|
+
await authApi.approveRecoveryRequest(approveDialogRequestId, approveDialogNote);
|
|
52
73
|
await loadRequests();
|
|
74
|
+
handleCloseApproveDialog();
|
|
53
75
|
} catch (err) {
|
|
54
76
|
setErrorKey(err.code || 'Support.RECOVERY_REQUEST_APPROVE_FAILED');
|
|
55
77
|
}
|
|
@@ -125,11 +147,18 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
125
147
|
<Table size="small">
|
|
126
148
|
<TableHead>
|
|
127
149
|
<TableRow>
|
|
128
|
-
<TableCell>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<TableCell>
|
|
132
|
-
|
|
150
|
+
<TableCell>
|
|
151
|
+
{t('Support.RECOVERY_COL_CREATED', 'Created')}
|
|
152
|
+
</TableCell>
|
|
153
|
+
<TableCell>
|
|
154
|
+
{t('Support.RECOVERY_COL_USER', 'User')}
|
|
155
|
+
</TableCell>
|
|
156
|
+
<TableCell>
|
|
157
|
+
{t('Support.RECOVERY_COL_STATUS', 'Status')}
|
|
158
|
+
</TableCell>
|
|
159
|
+
<TableCell>
|
|
160
|
+
{t('Support.RECOVERY_COL_ACTIONS', 'Actions')}
|
|
161
|
+
</TableCell>
|
|
133
162
|
</TableRow>
|
|
134
163
|
</TableHead>
|
|
135
164
|
<TableBody>
|
|
@@ -141,14 +170,13 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
141
170
|
: '-'}
|
|
142
171
|
</TableCell>
|
|
143
172
|
<TableCell>{req.user_email || req.user}</TableCell>
|
|
144
|
-
<TableCell>{req.support_email || '–'}</TableCell>
|
|
145
173
|
<TableCell>{req.status}</TableCell>
|
|
146
174
|
<TableCell>
|
|
147
175
|
<Stack direction="row" spacing={1}>
|
|
148
176
|
<Button
|
|
149
177
|
variant="contained"
|
|
150
178
|
size="small"
|
|
151
|
-
onClick={() =>
|
|
179
|
+
onClick={() => handleOpenApproveDialog(req.id)}
|
|
152
180
|
disabled={req.status !== 'pending'}
|
|
153
181
|
>
|
|
154
182
|
{t(
|
|
@@ -173,6 +201,59 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
173
201
|
</Table>
|
|
174
202
|
</TableContainer>
|
|
175
203
|
)}
|
|
204
|
+
|
|
205
|
+
<Dialog
|
|
206
|
+
open={approveDialogOpen}
|
|
207
|
+
onClose={handleCloseApproveDialog}
|
|
208
|
+
fullWidth
|
|
209
|
+
maxWidth="sm"
|
|
210
|
+
>
|
|
211
|
+
<DialogTitle>
|
|
212
|
+
{t(
|
|
213
|
+
'Support.RECOVERY_APPROVE_DIALOG_TITLE',
|
|
214
|
+
'Confirm account recovery',
|
|
215
|
+
)}
|
|
216
|
+
</DialogTitle>
|
|
217
|
+
<DialogContent dividers>
|
|
218
|
+
<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
|
+
</Typography>
|
|
224
|
+
|
|
225
|
+
<TextField
|
|
226
|
+
label={t(
|
|
227
|
+
'Support.RECOVERY_APPROVE_NOTE_LABEL',
|
|
228
|
+
'Reason for this decision',
|
|
229
|
+
)}
|
|
230
|
+
helperText={t(
|
|
231
|
+
'Support.RECOVERY_APPROVE_NOTE_HELP',
|
|
232
|
+
'Describe briefly why you are approving this request.',
|
|
233
|
+
)}
|
|
234
|
+
multiline
|
|
235
|
+
minRows={3}
|
|
236
|
+
fullWidth
|
|
237
|
+
value={approveDialogNote}
|
|
238
|
+
onChange={(e) => setApproveDialogNote(e.target.value)}
|
|
239
|
+
/>
|
|
240
|
+
</DialogContent>
|
|
241
|
+
<DialogActions>
|
|
242
|
+
<Button onClick={handleCloseApproveDialog}>
|
|
243
|
+
{t('Common.CANCEL', 'Cancel')}
|
|
244
|
+
</Button>
|
|
245
|
+
<Button
|
|
246
|
+
onClick={handleConfirmApprove}
|
|
247
|
+
variant="contained"
|
|
248
|
+
disabled={!approveDialogNote.trim()}
|
|
249
|
+
>
|
|
250
|
+
{t(
|
|
251
|
+
'Support.RECOVERY_APPROVE_SUBMIT',
|
|
252
|
+
'Send link',
|
|
253
|
+
)}
|
|
254
|
+
</Button>
|
|
255
|
+
</DialogActions>
|
|
256
|
+
</Dialog>
|
|
176
257
|
</Box>
|
|
177
258
|
);
|
|
178
259
|
};
|
|
@@ -853,11 +853,6 @@ export const authTranslations = {
|
|
|
853
853
|
"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.",
|
|
854
854
|
"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."
|
|
855
855
|
},
|
|
856
|
-
"Auth.MFA_HELP_REQUESTED": {
|
|
857
|
-
"de": "Wir haben deinen Support-Kontakt informiert. Du erhältst eine Rückmeldung, sobald deine Identität geprüft wurde.",
|
|
858
|
-
"fr": "Nous avons informé votre contact de support. Vous recevrez une réponse dès que votre identité aura été vérifiée.",
|
|
859
|
-
"en": "We have informed your support contact. You will hear back as soon as your identity has been verified."
|
|
860
|
-
},
|
|
861
856
|
"Auth.MFA_HELP_REQUEST_FAILED": {
|
|
862
857
|
"de": "Die Support-Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut oder kontaktiere den Support direkt.",
|
|
863
858
|
"fr": "La demande d'assistance n'a pas pu être envoyée. Veuillez réessayer plus tard ou contacter le support directement.",
|
|
@@ -873,6 +868,41 @@ export const authTranslations = {
|
|
|
873
868
|
"fr": "Récupération de compte",
|
|
874
869
|
"en": "Account recovery"
|
|
875
870
|
},
|
|
871
|
+
"Auth.MFA_IDENTIFIER_REQUIRED": {
|
|
872
|
+
"de": "E-Mail-Adresse oder Kennung ist erforderlich.",
|
|
873
|
+
"fr": "L’adresse e-mail ou l’identifiant est requis.",
|
|
874
|
+
"en": "Email address or identifier is required."
|
|
875
|
+
},
|
|
876
|
+
"Auth.MFA_HELP_REQUESTED": {
|
|
877
|
+
"de": "Falls dieses Konto existiert, wurde Ihre Anfrage dem Support weitergeleitet. Bitte kontaktiere den Support für weitere Hilfe.",
|
|
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
|
+
"en": "If this account exists, your request has been forwarded to support. Please contact support for further assistance."
|
|
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
|
+
}
|
|
876
906
|
|
|
877
907
|
|
|
878
908
|
|
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
|
)}
|