@micha.bigler/ui-core-micha 1.4.20 → 1.4.22
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/AuthContext.js +0 -1
- package/dist/auth/authApi.js +137 -378
- package/dist/components/MFAComponent.js +7 -7
- package/dist/components/MfaLoginComponent.js +6 -5
- package/dist/components/PasskeysComponent.js +5 -5
- package/dist/components/SecurityComponent.js +4 -3
- package/dist/components/SupportRecoveryRequestsTab.js +4 -4
- package/dist/components/UserInviteComponent.js +38 -0
- package/dist/components/UserListComponent.js +83 -0
- package/dist/pages/AccountPage.js +67 -23
- package/dist/pages/LoginPage.js +6 -5
- package/dist/pages/PasswordInvitePage.js +3 -3
- package/dist/pages/SignUpPage.js +3 -3
- package/dist/utils/authService.js +53 -0
- package/dist/utils/errors.js +33 -0
- package/dist/utils/webauthn.js +44 -0
- package/package.json +1 -1
- package/src/auth/AuthContext.jsx +0 -1
- package/src/auth/authApi.jsx +143 -478
- package/src/components/MFAComponent.jsx +7 -7
- package/src/components/MfaLoginComponent.jsx +6 -5
- package/src/components/PasskeysComponent.jsx +5 -5
- package/src/components/SecurityComponent.jsx +4 -3
- package/src/components/SupportRecoveryRequestsTab.jsx +4 -4
- package/src/components/UserInviteComponent.jsx +69 -0
- package/src/components/UserListComponent.jsx +167 -0
- package/src/pages/AccountPage.jsx +140 -47
- package/src/pages/LoginPage.jsx +6 -5
- package/src/pages/PasswordInvitePage.jsx +3 -3
- package/src/pages/SignUpPage.jsx +3 -3
- package/src/utils/authService.js +68 -0
- package/src/utils/errors.js +39 -0
- package/src/utils/webauthn.js +51 -0
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
19
19
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
20
20
|
import { useTranslation } from 'react-i18next';
|
|
21
|
-
import {
|
|
21
|
+
import { fetchAuthenticators, requestTotpKey, activateTotp, deactivateTotp, fetchRecoveryCodes, generateRecoveryCodes } from '../auth/authApi';
|
|
22
22
|
|
|
23
23
|
const MFAComponent = () => {
|
|
24
24
|
const { t } = useTranslation();
|
|
@@ -41,7 +41,7 @@ const MFAComponent = () => {
|
|
|
41
41
|
setLoading(true);
|
|
42
42
|
setErrorKey(null);
|
|
43
43
|
try {
|
|
44
|
-
const data = await
|
|
44
|
+
const data = await fetchAuthenticators();
|
|
45
45
|
setAuthenticators(Array.isArray(data) ? data : []);
|
|
46
46
|
} catch (err) {
|
|
47
47
|
// 404 / leer einfach schlucken
|
|
@@ -64,7 +64,7 @@ const MFAComponent = () => {
|
|
|
64
64
|
setIsSettingUp(true);
|
|
65
65
|
|
|
66
66
|
try {
|
|
67
|
-
const result = await
|
|
67
|
+
const result = await requestTotpKey();
|
|
68
68
|
|
|
69
69
|
if (result.exists) {
|
|
70
70
|
setIsSettingUp(false);
|
|
@@ -90,7 +90,7 @@ const MFAComponent = () => {
|
|
|
90
90
|
setSubmitting(true);
|
|
91
91
|
setErrorKey(null);
|
|
92
92
|
try {
|
|
93
|
-
await
|
|
93
|
+
await activateTotp(verifyCode);
|
|
94
94
|
setIsSettingUp(false);
|
|
95
95
|
setVerifyCode('');
|
|
96
96
|
setTotpData(null);
|
|
@@ -112,7 +112,7 @@ const MFAComponent = () => {
|
|
|
112
112
|
|
|
113
113
|
setErrorKey(null);
|
|
114
114
|
try {
|
|
115
|
-
await
|
|
115
|
+
await deactivateTotp();
|
|
116
116
|
await loadData();
|
|
117
117
|
} catch (err) {
|
|
118
118
|
// eslint-disable-next-line no-console
|
|
@@ -125,7 +125,7 @@ const MFAComponent = () => {
|
|
|
125
125
|
const handleShowRecoveryCodes = async () => {
|
|
126
126
|
setErrorKey(null);
|
|
127
127
|
try {
|
|
128
|
-
const data = await
|
|
128
|
+
const data = await fetchRecoveryCodes();
|
|
129
129
|
const codes = data.codes || data.unused_codes || [];
|
|
130
130
|
setRecoveryCodes(Array.isArray(codes) ? codes : []);
|
|
131
131
|
setShowRecovery(true);
|
|
@@ -139,7 +139,7 @@ const MFAComponent = () => {
|
|
|
139
139
|
const handleGenerateNewRecoveryCodes = async () => {
|
|
140
140
|
setErrorKey(null);
|
|
141
141
|
try {
|
|
142
|
-
const data = await
|
|
142
|
+
const data = await generateRecoveryCodes();
|
|
143
143
|
const codes = data.codes || data.unused_codes || [];
|
|
144
144
|
setRecoveryCodes(Array.isArray(codes) ? codes : []);
|
|
145
145
|
} catch (err) {
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
DialogActions,
|
|
15
15
|
} from '@mui/material';
|
|
16
16
|
import { useTranslation } from 'react-i18next';
|
|
17
|
-
import {
|
|
17
|
+
import { authenticateWithMFA, fetchCurrentUser, requestMfaSupportHelp } from '../auth/authApi';
|
|
18
|
+
import { loginWithPasskey } from '../utils/authService';
|
|
18
19
|
|
|
19
20
|
const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel }) => {
|
|
20
21
|
const { t } = useTranslation();
|
|
@@ -41,9 +42,9 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
|
|
|
41
42
|
const trimmed = code.trim();
|
|
42
43
|
const isRecovery = trimmed.length > 6;
|
|
43
44
|
|
|
44
|
-
await
|
|
45
|
+
await authenticateWithMFA({ code: trimmed });
|
|
45
46
|
|
|
46
|
-
const user = await
|
|
47
|
+
const user = await fetchCurrentUser();
|
|
47
48
|
|
|
48
49
|
onSuccess({
|
|
49
50
|
user,
|
|
@@ -61,7 +62,7 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
|
|
|
61
62
|
setInfoKey(null);
|
|
62
63
|
setSubmitting(true);
|
|
63
64
|
try {
|
|
64
|
-
const { user } = await
|
|
65
|
+
const { user } = await loginWithPasskey();
|
|
65
66
|
onSuccess({ user, method: 'webauthn' });
|
|
66
67
|
} catch (err) {
|
|
67
68
|
setErrorKey(err.code || 'Auth.PASSKEY_FAILED');
|
|
@@ -81,7 +82,7 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
|
|
|
81
82
|
setInfoKey(null);
|
|
82
83
|
setSubmitting(true);
|
|
83
84
|
try {
|
|
84
|
-
await
|
|
85
|
+
await requestMfaSupportHelp(identifier || '', helpMessage || '');
|
|
85
86
|
setHelpRequested(true);
|
|
86
87
|
setInfoKey('Auth.MFA_HELP_REQUESTED');
|
|
87
88
|
setHelpDialogOpen(false);
|
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
} from '@mui/material';
|
|
19
19
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
20
20
|
import { useTranslation } from 'react-i18next';
|
|
21
|
-
import {
|
|
21
|
+
import { fetchPasskeys, registerPasskey, deletePasskey } from '../auth/authApi';
|
|
22
|
+
import { registerPasskey } from '../utils/authService';
|
|
22
23
|
import { FEATURES } from '../auth/authConfig';
|
|
23
24
|
|
|
24
25
|
const PasskeysComponent = () => {
|
|
@@ -41,10 +42,9 @@ const PasskeysComponent = () => {
|
|
|
41
42
|
const loadPasskeys = async () => {
|
|
42
43
|
setErrorKey(null);
|
|
43
44
|
try {
|
|
44
|
-
const data = await
|
|
45
|
+
const data = await fetchPasskeys();
|
|
45
46
|
setPasskeys(Array.isArray(data) ? data : []);
|
|
46
47
|
} catch (err) {
|
|
47
|
-
// authApi.fetchPasskeys wirft bereits normalisierte Errors mit .code
|
|
48
48
|
setErrorKey(err.code || 'Auth.PASSKEY_LIST_FAILED');
|
|
49
49
|
} finally {
|
|
50
50
|
setLoading(false);
|
|
@@ -74,7 +74,7 @@ const PasskeysComponent = () => {
|
|
|
74
74
|
const fallbackName =
|
|
75
75
|
name?.trim() || `Passkey on ${navigator.platform || 'this device'}`;
|
|
76
76
|
|
|
77
|
-
await
|
|
77
|
+
await registerPasskey(fallbackName);
|
|
78
78
|
setMessageKey('Auth.PASSKEY_CREATE_SUCCESS');
|
|
79
79
|
setName('');
|
|
80
80
|
|
|
@@ -94,7 +94,7 @@ const PasskeysComponent = () => {
|
|
|
94
94
|
|
|
95
95
|
setDeletingIds((prev) => new Set(prev).add(id));
|
|
96
96
|
try {
|
|
97
|
-
await
|
|
97
|
+
await deletePasskey(id);
|
|
98
98
|
setPasskeys((prev) => prev.filter((pk) => pk.id !== id));
|
|
99
99
|
setMessageKey('Auth.PASSKEY_DELETE_SUCCESS');
|
|
100
100
|
} catch (err) {
|
|
@@ -11,7 +11,8 @@ import PasswordChangeForm from './PasswordChangeForm';
|
|
|
11
11
|
import SocialLoginButtons from './SocialLoginButtons';
|
|
12
12
|
import PasskeysComponent from './PasskeysComponent';
|
|
13
13
|
import MFAComponent from './MFAComponent';
|
|
14
|
-
import {
|
|
14
|
+
import { changePassword } from '../auth/authApi';
|
|
15
|
+
import { startSocialLogin } from '../utils/authService';
|
|
15
16
|
|
|
16
17
|
const SecurityComponent = ({
|
|
17
18
|
fromRecovery = false,
|
|
@@ -26,7 +27,7 @@ const SecurityComponent = ({
|
|
|
26
27
|
setMessageKey(null);
|
|
27
28
|
setErrorKey(null);
|
|
28
29
|
try {
|
|
29
|
-
await
|
|
30
|
+
await startSocialLogin(provider);
|
|
30
31
|
// Redirect läuft über den Provider-Flow, hier kein extra Text nötig
|
|
31
32
|
} catch (err) {
|
|
32
33
|
setErrorKey(err.code || 'Auth.SOCIAL_LOGIN_FAILED');
|
|
@@ -37,7 +38,7 @@ const SecurityComponent = ({
|
|
|
37
38
|
setMessageKey(null);
|
|
38
39
|
setErrorKey(null);
|
|
39
40
|
try {
|
|
40
|
-
await
|
|
41
|
+
await changePassword(currentPassword, newPassword);
|
|
41
42
|
setMessageKey('Auth.RESET_PASSWORD_SUCCESS');
|
|
42
43
|
} catch (err) {
|
|
43
44
|
setErrorKey(err.code || 'Auth.PASSWORD_CHANGE_FAILED');
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
TextField,
|
|
22
22
|
} from '@mui/material';
|
|
23
23
|
import { useTranslation } from 'react-i18next';
|
|
24
|
-
import {
|
|
24
|
+
import { fetchRecoveryRequests, approveRecoveryRequest, rejectRecoveryRequest } from '../auth/authApi';
|
|
25
25
|
|
|
26
26
|
const SupportRecoveryRequestsTab = () => {
|
|
27
27
|
const { t } = useTranslation();
|
|
@@ -39,7 +39,7 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
39
39
|
setLoading(true);
|
|
40
40
|
setErrorKey(null);
|
|
41
41
|
try {
|
|
42
|
-
const data = await
|
|
42
|
+
const data = await fetchRecoveryRequests(statusFilter);
|
|
43
43
|
setRequests(Array.isArray(data) ? data : []);
|
|
44
44
|
} catch (err) {
|
|
45
45
|
setErrorKey(err.code || 'Support.RECOVERY_REQUESTS_LOAD_FAILED');
|
|
@@ -69,7 +69,7 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
69
69
|
if (!selectedRequest) return;
|
|
70
70
|
setErrorKey(null);
|
|
71
71
|
try {
|
|
72
|
-
await
|
|
72
|
+
await approveRecoveryRequest(selectedRequest.id, dialogNote);
|
|
73
73
|
await loadRequests();
|
|
74
74
|
closeDialog();
|
|
75
75
|
} catch (err) {
|
|
@@ -81,7 +81,7 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
81
81
|
if (!selectedRequest) return;
|
|
82
82
|
setErrorKey(null);
|
|
83
83
|
try {
|
|
84
|
-
await
|
|
84
|
+
await rejectRecoveryRequest(selectedRequest.id, dialogNote);
|
|
85
85
|
await loadRequests();
|
|
86
86
|
closeDialog();
|
|
87
87
|
} catch (err) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, TextField, Button, Typography, Alert, CircularProgress } from '@mui/material';
|
|
3
|
+
import { inviteUserByAdmin } from '../auth/authApi';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
|
|
6
|
+
export function UserInviteComponent({ apiUrl = '/api/users/' }) {
|
|
7
|
+
const { t } = useTranslation();
|
|
8
|
+
const [inviteEmail, setInviteEmail] = useState('');
|
|
9
|
+
const [message, setMessage] = useState('');
|
|
10
|
+
const [error, setError] = useState('');
|
|
11
|
+
const [loading, setLoading] = useState(false);
|
|
12
|
+
|
|
13
|
+
const inviteUser = async () => {
|
|
14
|
+
setMessage('');
|
|
15
|
+
setError('');
|
|
16
|
+
if (!inviteEmail) return;
|
|
17
|
+
|
|
18
|
+
setLoading(true);
|
|
19
|
+
try {
|
|
20
|
+
// API Call via authApi
|
|
21
|
+
const data = await inviteUserByAdmin(inviteEmail, apiUrl);
|
|
22
|
+
|
|
23
|
+
setInviteEmail('');
|
|
24
|
+
setMessage(data.detail || t('Auth.INVITE_SENT_SUCCESS', 'Invitation sent.'));
|
|
25
|
+
} catch (err) {
|
|
26
|
+
// err.message enthält dank authApi bereits den normalisierten Text oder Code
|
|
27
|
+
console.error('Error inviting user:', err);
|
|
28
|
+
// Fallback Text, falls der Key nicht übersetzt ist
|
|
29
|
+
setError(t(err.code) || err.message || t('Auth.INVITE_FAILED'));
|
|
30
|
+
} finally {
|
|
31
|
+
setLoading(false);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Box sx={{ maxWidth: 600, mt: 2 }}>
|
|
37
|
+
<Typography variant="h6" gutterBottom>
|
|
38
|
+
{t('Auth.INVITE_TITLE', 'Invite a new user')}
|
|
39
|
+
</Typography>
|
|
40
|
+
|
|
41
|
+
{message && <Alert severity="success" sx={{ mb: 2 }}>{message}</Alert>}
|
|
42
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
43
|
+
|
|
44
|
+
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
|
45
|
+
<TextField
|
|
46
|
+
label={t('Auth.EMAIL_LABEL', 'Email address')}
|
|
47
|
+
type="email"
|
|
48
|
+
variant="outlined"
|
|
49
|
+
fullWidth
|
|
50
|
+
size="small"
|
|
51
|
+
value={inviteEmail}
|
|
52
|
+
onChange={(e) => setInviteEmail(e.target.value)}
|
|
53
|
+
disabled={loading}
|
|
54
|
+
onKeyPress={(e) => {
|
|
55
|
+
if (e.key === 'Enter') inviteUser();
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
<Button
|
|
59
|
+
variant="contained"
|
|
60
|
+
onClick={inviteUser}
|
|
61
|
+
disabled={loading || !inviteEmail}
|
|
62
|
+
sx={{ minWidth: 100, height: 40 }}
|
|
63
|
+
>
|
|
64
|
+
{loading ? <CircularProgress size={24} color="inherit" /> : t('Auth.INVITE_BUTTON', 'Invite')}
|
|
65
|
+
</Button>
|
|
66
|
+
</Box>
|
|
67
|
+
</Box>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Box, Typography, Table, TableBody, TableCell, TableContainer,
|
|
4
|
+
TableHead, TableRow, Paper, FormControl, InputLabel, Select,
|
|
5
|
+
MenuItem, Button, IconButton, Tooltip, CircularProgress, Alert
|
|
6
|
+
} from '@mui/material';
|
|
7
|
+
import DeleteIcon from '@mui/icons-material/Delete';
|
|
8
|
+
import { useTranslation } from 'react-i18next';
|
|
9
|
+
import { fetchUsersList, deleteUser, updateUserRole, updateUserSupportStatus } from '../auth/authApi'; // Nur noch authApi!
|
|
10
|
+
|
|
11
|
+
const DEFAULT_ROLES = ['none', 'student', 'teacher', 'admin'];
|
|
12
|
+
|
|
13
|
+
export function UserListComponent({
|
|
14
|
+
roles = DEFAULT_ROLES,
|
|
15
|
+
apiUrl = '/api/users/',
|
|
16
|
+
currentUser
|
|
17
|
+
}) {
|
|
18
|
+
const { t } = useTranslation();
|
|
19
|
+
const [users, setUsers] = useState([]);
|
|
20
|
+
const [loading, setLoading] = useState(true);
|
|
21
|
+
const [error, setError] = useState(null);
|
|
22
|
+
|
|
23
|
+
const loadUsers = async () => {
|
|
24
|
+
setLoading(true);
|
|
25
|
+
setError(null);
|
|
26
|
+
try {
|
|
27
|
+
const data = await fetchUsersList(apiUrl);
|
|
28
|
+
setUsers(data);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
// err.code ist dank normaliseApiError verfügbar
|
|
31
|
+
setError(err.code || 'Auth.USER_LIST_FAILED');
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
loadUsers();
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
+
}, [apiUrl]);
|
|
41
|
+
|
|
42
|
+
const handleDelete = async (userId) => {
|
|
43
|
+
if (!window.confirm(t('UserList.DELETE_CONFIRM', 'Are you sure you want to delete this user?'))) return;
|
|
44
|
+
try {
|
|
45
|
+
await deleteUser(userId, apiUrl);
|
|
46
|
+
// Optimistic update oder reload:
|
|
47
|
+
setUsers(prev => prev.filter(u => u.id !== userId));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
alert(t(err.code || 'Auth.USER_DELETE_FAILED'));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handleChangeRole = async (userId, newRole) => {
|
|
54
|
+
try {
|
|
55
|
+
await updateUserRole(userId, newRole, apiUrl);
|
|
56
|
+
// Reload list to ensure consistency or update local state
|
|
57
|
+
loadUsers();
|
|
58
|
+
} catch (err) {
|
|
59
|
+
alert(t(err.code || 'Auth.USER_ROLE_UPDATE_FAILED'));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const handleToggleSupporter = async (userId, newValue) => {
|
|
64
|
+
try {
|
|
65
|
+
await updateUserSupportStatus(userId, newValue, apiUrl);
|
|
66
|
+
loadUsers();
|
|
67
|
+
} catch (err) {
|
|
68
|
+
alert(t(err.code || 'Auth.USER_UPDATE_FAILED'));
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const canEdit = (targetUser) => {
|
|
73
|
+
if (!currentUser) return false;
|
|
74
|
+
if (currentUser.is_superuser) return true;
|
|
75
|
+
|
|
76
|
+
const myRole = currentUser.role || 'none';
|
|
77
|
+
const targetRole = targetUser.role || 'none';
|
|
78
|
+
|
|
79
|
+
if (myRole === 'admin') return true;
|
|
80
|
+
|
|
81
|
+
// Beispiel Logik: Lehrer dürfen Schüler bearbeiten
|
|
82
|
+
if (myRole === 'teacher') {
|
|
83
|
+
if (targetUser.id === currentUser.id) return false;
|
|
84
|
+
if (['teacher', 'admin', 'supervisor'].includes(targetRole)) return false;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Box>
|
|
92
|
+
<Typography variant="h6" gutterBottom>{t('UserList.TITLE', 'All Users')}</Typography>
|
|
93
|
+
|
|
94
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{t(error)}</Alert>}
|
|
95
|
+
|
|
96
|
+
{loading ? (
|
|
97
|
+
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
|
98
|
+
<CircularProgress />
|
|
99
|
+
</Box>
|
|
100
|
+
) : (
|
|
101
|
+
<TableContainer component={Paper}>
|
|
102
|
+
<Table size="small">
|
|
103
|
+
<TableHead>
|
|
104
|
+
<TableRow>
|
|
105
|
+
<TableCell>{t('Auth.EMAIL_LABEL', 'Email')}</TableCell>
|
|
106
|
+
<TableCell>{t('Profile.NAME_LABEL', 'Name')}</TableCell>
|
|
107
|
+
<TableCell>{t('UserList.ROLE', 'Role')}</TableCell>
|
|
108
|
+
<TableCell>{t('UserList.SUPPORTER', 'Support Agent')}</TableCell>
|
|
109
|
+
<TableCell>{t('Common.ACTIONS', 'Actions')}</TableCell>
|
|
110
|
+
</TableRow>
|
|
111
|
+
</TableHead>
|
|
112
|
+
<TableBody>
|
|
113
|
+
{users.map((u) => (
|
|
114
|
+
<TableRow key={u.id}>
|
|
115
|
+
<TableCell>{u.email}</TableCell>
|
|
116
|
+
<TableCell>
|
|
117
|
+
{u.first_name || u.last_name ? `${u.first_name} ${u.last_name}` : u.username}
|
|
118
|
+
</TableCell>
|
|
119
|
+
<TableCell>
|
|
120
|
+
<FormControl size="small" fullWidth disabled={!canEdit(u)}>
|
|
121
|
+
<Select
|
|
122
|
+
value={u.role || 'none'}
|
|
123
|
+
onChange={(e) => handleChangeRole(u.id, e.target.value)}
|
|
124
|
+
variant="standard"
|
|
125
|
+
disableUnderline
|
|
126
|
+
>
|
|
127
|
+
{roles.map(r => <MenuItem key={r} value={r}>{r}</MenuItem>)}
|
|
128
|
+
</Select>
|
|
129
|
+
</FormControl>
|
|
130
|
+
</TableCell>
|
|
131
|
+
<TableCell>
|
|
132
|
+
<Button
|
|
133
|
+
size="small"
|
|
134
|
+
variant={u.is_support_agent ? 'contained' : 'outlined'}
|
|
135
|
+
color={u.is_support_agent ? 'primary' : 'inherit'}
|
|
136
|
+
onClick={() => handleToggleSupporter(u.id, !u.is_support_agent)}
|
|
137
|
+
disabled={!canEdit(u)}
|
|
138
|
+
sx={{ textTransform: 'none' }}
|
|
139
|
+
>
|
|
140
|
+
{u.is_support_agent ? t('Common.YES') : t('Common.NO')}
|
|
141
|
+
</Button>
|
|
142
|
+
</TableCell>
|
|
143
|
+
<TableCell>
|
|
144
|
+
<Tooltip title={t('Common.DELETE')}>
|
|
145
|
+
<span>
|
|
146
|
+
<IconButton onClick={() => handleDelete(u.id)} color="error" disabled={!canEdit(u)}>
|
|
147
|
+
<DeleteIcon />
|
|
148
|
+
</IconButton>
|
|
149
|
+
</span>
|
|
150
|
+
</Tooltip>
|
|
151
|
+
</TableCell>
|
|
152
|
+
</TableRow>
|
|
153
|
+
))}
|
|
154
|
+
{users.length === 0 && (
|
|
155
|
+
<TableRow>
|
|
156
|
+
<TableCell colSpan={5} align="center">
|
|
157
|
+
{t('UserList.NO_USERS', 'No users found.')}
|
|
158
|
+
</TableCell>
|
|
159
|
+
</TableRow>
|
|
160
|
+
)}
|
|
161
|
+
</TableBody>
|
|
162
|
+
</Table>
|
|
163
|
+
</TableContainer>
|
|
164
|
+
)}
|
|
165
|
+
</Box>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -1,35 +1,53 @@
|
|
|
1
|
-
|
|
2
|
-
import React, { useState, useContext } from 'react';
|
|
1
|
+
import React, { useContext, useMemo } from 'react';
|
|
3
2
|
import { Helmet } from 'react-helmet';
|
|
4
|
-
import {
|
|
3
|
+
import { useSearchParams } from 'react-router-dom';
|
|
4
|
+
import {
|
|
5
|
+
Tabs,
|
|
6
|
+
Tab,
|
|
7
|
+
Box,
|
|
8
|
+
Typography,
|
|
9
|
+
Alert,
|
|
10
|
+
CircularProgress
|
|
11
|
+
} from '@mui/material';
|
|
12
|
+
import { useTranslation } from 'react-i18next';
|
|
13
|
+
|
|
14
|
+
// Interne Komponenten der Library
|
|
5
15
|
import { WidePage } from '../layout/PageLayout';
|
|
6
|
-
import ProfileComponent from '../components/ProfileComponent';
|
|
7
|
-
import SecurityComponent from '../components/SecurityComponent';
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
16
|
+
import { ProfileComponent } from '../components/ProfileComponent';
|
|
17
|
+
import { SecurityComponent } from '../components/SecurityComponent';
|
|
18
|
+
import { UserListComponent } from '../components/UserListComponent';
|
|
19
|
+
import { UserInviteComponent } from '../components/UserInviteComponent';
|
|
20
|
+
import { AccessCodeManager } from '../components/AccessCodeManager';
|
|
21
|
+
import { SupportRecoveryRequestsTab } from '../components/SupportRecoveryRequestsTab';
|
|
10
22
|
import { AuthContext } from '../auth/AuthContext';
|
|
11
|
-
import {
|
|
23
|
+
import { authApi } from '../auth/authApi';
|
|
12
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Vollständige, selbst-konfigurierende Account-Seite.
|
|
27
|
+
* * Architektur:
|
|
28
|
+
* - Permissions: Werden aus user.ui_permissions gelesen (vom Backend).
|
|
29
|
+
* - Rollen: Werden aus user.available_roles gelesen (vom Backend).
|
|
30
|
+
* - Keine Props mehr nötig -> Plug & Play in der App.js.
|
|
31
|
+
*/
|
|
13
32
|
export function AccountPage() {
|
|
14
|
-
const {
|
|
15
|
-
const
|
|
33
|
+
const { t } = useTranslation();
|
|
34
|
+
const { user, login, loading } = useContext(AuthContext);
|
|
35
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
16
36
|
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
: initialTabParam === 'support'
|
|
22
|
-
? 'support'
|
|
23
|
-
: 'account';
|
|
37
|
+
// 1. URL State Management
|
|
38
|
+
const currentTab = searchParams.get('tab') || 'profile';
|
|
39
|
+
const fromRecovery = searchParams.get('from') === 'recovery';
|
|
40
|
+
const fromWeakLogin = searchParams.get('from') === 'weak_login';
|
|
24
41
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
// 2. Daten & Permissions extrahieren (Single Source of Truth)
|
|
43
|
+
// Backend liefert die Liste der Rollen (Keys), z.B. ['student', 'teacher']
|
|
44
|
+
const activeRoles = user?.available_roles || [];
|
|
45
|
+
|
|
46
|
+
// Backend liefert Permissions basierend auf settings.py
|
|
47
|
+
const perms = user?.ui_permissions || {};
|
|
30
48
|
|
|
31
49
|
const handleTabChange = (_event, newValue) => {
|
|
32
|
-
|
|
50
|
+
setSearchParams({ tab: newValue });
|
|
33
51
|
};
|
|
34
52
|
|
|
35
53
|
const handleProfileSubmit = async (payload) => {
|
|
@@ -37,24 +55,92 @@ export function AccountPage() {
|
|
|
37
55
|
login(updatedUser);
|
|
38
56
|
};
|
|
39
57
|
|
|
58
|
+
// 3. Dynamische Tabs bauen
|
|
59
|
+
const tabs = useMemo(() => {
|
|
60
|
+
// Wenn User noch nicht da ist, leere Liste (Loading State fängt das ab)
|
|
61
|
+
if (!user) return [];
|
|
62
|
+
|
|
63
|
+
const list = [
|
|
64
|
+
{ value: 'profile', label: t('Account.TAB_PROFILE', 'Profile') },
|
|
65
|
+
{ value: 'security', label: t('Account.TAB_SECURITY', 'Security') },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
if (perms.can_view_users) {
|
|
69
|
+
list.push({ value: 'users', label: t('Account.TAB_USERS', 'Users') });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (perms.can_invite) {
|
|
73
|
+
list.push({ value: 'invite', label: t('Account.TAB_INVITE', 'Invite') });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (perms.can_manage_access_codes) {
|
|
77
|
+
list.push({ value: 'access', label: t('Account.TAB_ACCESS_CODES', 'Access Codes') });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (perms.can_view_support) {
|
|
81
|
+
list.push({ value: 'support', label: t('Account.TAB_SUPPORT', 'Support') });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return list;
|
|
85
|
+
}, [user, perms, t]);
|
|
86
|
+
|
|
87
|
+
// 4. Loading & Auth Checks
|
|
88
|
+
if (loading) {
|
|
89
|
+
return (
|
|
90
|
+
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 10 }}>
|
|
91
|
+
<CircularProgress />
|
|
92
|
+
</Box>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!user) {
|
|
97
|
+
return (
|
|
98
|
+
<WidePage>
|
|
99
|
+
<Alert severity="warning">
|
|
100
|
+
{t('Auth.NOT_LOGGED_IN', 'User not logged in.')}
|
|
101
|
+
</Alert>
|
|
102
|
+
</WidePage>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 5. Sicherheits-Check: Ist der aktuelle Tab überhaupt erlaubt?
|
|
107
|
+
// Verhindert Zugriff durch URL-Manipulation (z.B. ?tab=users eingeben ohne Admin-Rechte)
|
|
108
|
+
const activeTabExists = tabs.some(t => t.value === currentTab);
|
|
109
|
+
const safeTab = activeTabExists ? currentTab : 'profile';
|
|
110
|
+
|
|
40
111
|
return (
|
|
41
|
-
<WidePage title=
|
|
112
|
+
<WidePage title={t('Account.TITLE', 'Account & Administration')}>
|
|
42
113
|
<Helmet>
|
|
43
|
-
<title>
|
|
114
|
+
<title>{t('Account.PAGE_TITLE', 'Account')} – {user.email}</title>
|
|
44
115
|
</Helmet>
|
|
45
116
|
|
|
46
117
|
<Tabs
|
|
47
|
-
value={
|
|
118
|
+
value={safeTab}
|
|
48
119
|
onChange={handleTabChange}
|
|
49
|
-
|
|
120
|
+
variant="scrollable"
|
|
121
|
+
scrollButtons="auto"
|
|
122
|
+
sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
|
|
50
123
|
>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
124
|
+
{tabs.map((tab) => (
|
|
125
|
+
<Tab key={tab.value} label={tab.label} value={tab.value} />
|
|
126
|
+
))}
|
|
54
127
|
</Tabs>
|
|
55
128
|
|
|
56
|
-
{
|
|
57
|
-
|
|
129
|
+
{/* --- TAB CONTENT --- */}
|
|
130
|
+
|
|
131
|
+
{safeTab === 'profile' && (
|
|
132
|
+
<Box sx={{ mt: 2 }}>
|
|
133
|
+
<ProfileComponent
|
|
134
|
+
onSubmit={handleProfileSubmit}
|
|
135
|
+
showName
|
|
136
|
+
showPrivacy
|
|
137
|
+
showCookies
|
|
138
|
+
/>
|
|
139
|
+
</Box>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{safeTab === 'security' && (
|
|
143
|
+
<Box sx={{ mt: 2 }}>
|
|
58
144
|
<SecurityComponent
|
|
59
145
|
fromRecovery={fromRecovery}
|
|
60
146
|
fromWeakLogin={fromWeakLogin}
|
|
@@ -62,28 +148,35 @@ export function AccountPage() {
|
|
|
62
148
|
</Box>
|
|
63
149
|
)}
|
|
64
150
|
|
|
65
|
-
{
|
|
66
|
-
<Box sx={{ mt:
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
submitText="Save"
|
|
71
|
-
showName
|
|
72
|
-
showPrivacy
|
|
73
|
-
showCookies
|
|
151
|
+
{safeTab === 'users' && (
|
|
152
|
+
<Box sx={{ mt: 2 }}>
|
|
153
|
+
<UserListComponent
|
|
154
|
+
roles={activeRoles}
|
|
155
|
+
currentUser={user}
|
|
74
156
|
/>
|
|
75
157
|
</Box>
|
|
76
158
|
)}
|
|
77
159
|
|
|
78
|
-
|
|
160
|
+
{safeTab === 'invite' && (
|
|
161
|
+
<Box sx={{ mt: 2 }}>
|
|
162
|
+
<UserInviteComponent />
|
|
163
|
+
</Box>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{safeTab === 'access' && (
|
|
167
|
+
<Box sx={{ mt: 2 }}>
|
|
168
|
+
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
|
169
|
+
{t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.')}
|
|
170
|
+
</Typography>
|
|
171
|
+
<AccessCodeManager />
|
|
172
|
+
</Box>
|
|
173
|
+
)}
|
|
79
174
|
|
|
80
|
-
{
|
|
81
|
-
<Box sx={{ mt:
|
|
175
|
+
{safeTab === 'support' && (
|
|
176
|
+
<Box sx={{ mt: 2 }}>
|
|
82
177
|
<SupportRecoveryRequestsTab />
|
|
83
178
|
</Box>
|
|
84
179
|
)}
|
|
85
180
|
</WidePage>
|
|
86
181
|
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export default AccountPage;
|
|
182
|
+
}
|