@micha.bigler/ui-core-micha 1.4.10 → 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.
@@ -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/`, {}, { withCredentials: true });
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
- // src/auth/components/LoginForm.jsx
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, { sx: { display: 'flex', flexDirection: 'column', gap: 3 }, children: [_jsx(Divider, { 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') })] })] })] }));
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 handleSendRecoveryLink = async (id) => {
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(id);
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.RECOVERY_COL_SUPPORT', 'Assigned support') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_STATUS', 'Status') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_ACTIONS', 'Actions') })] }) }), _jsx(TableBody, { children: requests.map((req) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: req.created_at
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: () => handleSendRecoveryLink(req.id), disabled: req.status !== 'pending', children: t('Support.RECOVERY_ACTION_SEND_LINK', 'Send recovery link') }), _jsx(Button, { variant: "outlined", size: "small", color: "error", onClick: () => handleReject(req.id), disabled: req.status !== 'pending', children: t('Support.RECOVERY_ACTION_REJECT', 'Reject') })] }) })] }, req.id))) })] }) }))] }));
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;
@@ -831,5 +831,30 @@ 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
+ }
834
859
  // ...
835
860
  };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "1.4.10",
3
+ "version": "1.4.11",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "private": false,
@@ -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
- // src/auth/components/LoginForm.jsx
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
- <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
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 handleSendRecoveryLink = async (id) => {
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(id);
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>{t('Support.RECOVERY_COL_CREATED', 'Created')}</TableCell>
129
- <TableCell>{t('Support.RECOVERY_COL_USER', 'User')}</TableCell>
130
- <TableCell>{t('Support.RECOVERY_COL_SUPPORT', 'Assigned support')}</TableCell>
131
- <TableCell>{t('Support.RECOVERY_COL_STATUS', 'Status')}</TableCell>
132
- <TableCell>{t('Support.RECOVERY_COL_ACTIONS', 'Actions')}</TableCell>
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>
@@ -147,7 +176,7 @@ const SupportRecoveryRequestsTab = () => {
147
176
  <Button
148
177
  variant="contained"
149
178
  size="small"
150
- onClick={() => handleSendRecoveryLink(req.id)}
179
+ onClick={() => handleOpenApproveDialog(req.id)}
151
180
  disabled={req.status !== 'pending'}
152
181
  >
153
182
  {t(
@@ -172,6 +201,59 @@ const SupportRecoveryRequestsTab = () => {
172
201
  </Table>
173
202
  </TableContainer>
174
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>
175
257
  </Box>
176
258
  );
177
259
  };
@@ -878,6 +878,31 @@ 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
+ }
881
906
 
882
907
 
883
908
 
@@ -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 als 1. Faktor
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
  )}