@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
|
@@ -6,7 +6,7 @@ import { Box, Typography, Button, TextField, Card, CardContent, Alert, CircularP
|
|
|
6
6
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
7
7
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
8
8
|
import { useTranslation } from 'react-i18next';
|
|
9
|
-
import {
|
|
9
|
+
import { fetchAuthenticators, requestTotpKey, activateTotp, deactivateTotp, fetchRecoveryCodes, generateRecoveryCodes } from '../auth/authApi';
|
|
10
10
|
const MFAComponent = () => {
|
|
11
11
|
const { t } = useTranslation();
|
|
12
12
|
const [authenticators, setAuthenticators] = useState([]);
|
|
@@ -24,7 +24,7 @@ const MFAComponent = () => {
|
|
|
24
24
|
setLoading(true);
|
|
25
25
|
setErrorKey(null);
|
|
26
26
|
try {
|
|
27
|
-
const data = await
|
|
27
|
+
const data = await fetchAuthenticators();
|
|
28
28
|
setAuthenticators(Array.isArray(data) ? data : []);
|
|
29
29
|
}
|
|
30
30
|
catch (err) {
|
|
@@ -45,7 +45,7 @@ const MFAComponent = () => {
|
|
|
45
45
|
setErrorKey(null);
|
|
46
46
|
setIsSettingUp(true);
|
|
47
47
|
try {
|
|
48
|
-
const result = await
|
|
48
|
+
const result = await requestTotpKey();
|
|
49
49
|
if (result.exists) {
|
|
50
50
|
setIsSettingUp(false);
|
|
51
51
|
setErrorKey('Auth.MFA_TOTP_ALREADY_ACTIVE');
|
|
@@ -69,7 +69,7 @@ const MFAComponent = () => {
|
|
|
69
69
|
setSubmitting(true);
|
|
70
70
|
setErrorKey(null);
|
|
71
71
|
try {
|
|
72
|
-
await
|
|
72
|
+
await activateTotp(verifyCode);
|
|
73
73
|
setIsSettingUp(false);
|
|
74
74
|
setVerifyCode('');
|
|
75
75
|
setTotpData(null);
|
|
@@ -92,7 +92,7 @@ const MFAComponent = () => {
|
|
|
92
92
|
return;
|
|
93
93
|
setErrorKey(null);
|
|
94
94
|
try {
|
|
95
|
-
await
|
|
95
|
+
await deactivateTotp();
|
|
96
96
|
await loadData();
|
|
97
97
|
}
|
|
98
98
|
catch (err) {
|
|
@@ -105,7 +105,7 @@ const MFAComponent = () => {
|
|
|
105
105
|
const handleShowRecoveryCodes = async () => {
|
|
106
106
|
setErrorKey(null);
|
|
107
107
|
try {
|
|
108
|
-
const data = await
|
|
108
|
+
const data = await fetchRecoveryCodes();
|
|
109
109
|
const codes = data.codes || data.unused_codes || [];
|
|
110
110
|
setRecoveryCodes(Array.isArray(codes) ? codes : []);
|
|
111
111
|
setShowRecovery(true);
|
|
@@ -119,7 +119,7 @@ const MFAComponent = () => {
|
|
|
119
119
|
const handleGenerateNewRecoveryCodes = async () => {
|
|
120
120
|
setErrorKey(null);
|
|
121
121
|
try {
|
|
122
|
-
const data = await
|
|
122
|
+
const data = await generateRecoveryCodes();
|
|
123
123
|
const codes = data.codes || data.unused_codes || [];
|
|
124
124
|
setRecoveryCodes(Array.isArray(codes) ? codes : []);
|
|
125
125
|
}
|
|
@@ -3,7 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import React, { useState } from 'react';
|
|
4
4
|
import { Box, Typography, TextField, Button, Stack, Alert, Divider, Dialog, DialogTitle, DialogContent, DialogActions, } from '@mui/material';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
|
-
import {
|
|
6
|
+
import { authenticateWithMFA, fetchCurrentUser, requestMfaSupportHelp } from '../auth/authApi';
|
|
7
|
+
import { loginWithPasskey } from '../utils/authService';
|
|
7
8
|
const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel }) => {
|
|
8
9
|
const { t } = useTranslation();
|
|
9
10
|
const [code, setCode] = useState('');
|
|
@@ -24,8 +25,8 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
|
|
|
24
25
|
try {
|
|
25
26
|
const trimmed = code.trim();
|
|
26
27
|
const isRecovery = trimmed.length > 6;
|
|
27
|
-
await
|
|
28
|
-
const user = await
|
|
28
|
+
await authenticateWithMFA({ code: trimmed });
|
|
29
|
+
const user = await fetchCurrentUser();
|
|
29
30
|
onSuccess({
|
|
30
31
|
user,
|
|
31
32
|
method: isRecovery ? 'recovery_code' : 'totp',
|
|
@@ -43,7 +44,7 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
|
|
|
43
44
|
setInfoKey(null);
|
|
44
45
|
setSubmitting(true);
|
|
45
46
|
try {
|
|
46
|
-
const { user } = await
|
|
47
|
+
const { user } = await loginWithPasskey();
|
|
47
48
|
onSuccess({ user, method: 'webauthn' });
|
|
48
49
|
}
|
|
49
50
|
catch (err) {
|
|
@@ -63,7 +64,7 @@ const MfaLoginComponent = ({ availableTypes, identifier, onSuccess, onCancel })
|
|
|
63
64
|
setInfoKey(null);
|
|
64
65
|
setSubmitting(true);
|
|
65
66
|
try {
|
|
66
|
-
await
|
|
67
|
+
await requestMfaSupportHelp(identifier || '', helpMessage || '');
|
|
67
68
|
setHelpRequested(true);
|
|
68
69
|
setInfoKey('Auth.MFA_HELP_REQUESTED');
|
|
69
70
|
setHelpDialogOpen(false);
|
|
@@ -4,7 +4,8 @@ import React, { useEffect, useState } from 'react';
|
|
|
4
4
|
import { Box, Typography, Stack, Button, TextField, IconButton, CircularProgress, Alert, List, ListItem, ListItemText, ListItemSecondaryAction, Tooltip, Divider, } from '@mui/material';
|
|
5
5
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
|
-
import {
|
|
7
|
+
import { fetchPasskeys, registerPasskey, deletePasskey } from '../auth/authApi';
|
|
8
|
+
import { registerPasskey } from '../utils/authService';
|
|
8
9
|
import { FEATURES } from '../auth/authConfig';
|
|
9
10
|
const PasskeysComponent = () => {
|
|
10
11
|
const { t } = useTranslation();
|
|
@@ -20,11 +21,10 @@ const PasskeysComponent = () => {
|
|
|
20
21
|
const loadPasskeys = async () => {
|
|
21
22
|
setErrorKey(null);
|
|
22
23
|
try {
|
|
23
|
-
const data = await
|
|
24
|
+
const data = await fetchPasskeys();
|
|
24
25
|
setPasskeys(Array.isArray(data) ? data : []);
|
|
25
26
|
}
|
|
26
27
|
catch (err) {
|
|
27
|
-
// authApi.fetchPasskeys wirft bereits normalisierte Errors mit .code
|
|
28
28
|
setErrorKey(err.code || 'Auth.PASSKEY_LIST_FAILED');
|
|
29
29
|
}
|
|
30
30
|
finally {
|
|
@@ -49,7 +49,7 @@ const PasskeysComponent = () => {
|
|
|
49
49
|
setCreating(true);
|
|
50
50
|
try {
|
|
51
51
|
const fallbackName = (name === null || name === void 0 ? void 0 : name.trim()) || `Passkey on ${navigator.platform || 'this device'}`;
|
|
52
|
-
await
|
|
52
|
+
await registerPasskey(fallbackName);
|
|
53
53
|
setMessageKey('Auth.PASSKEY_CREATE_SUCCESS');
|
|
54
54
|
setName('');
|
|
55
55
|
setReloading(true);
|
|
@@ -68,7 +68,7 @@ const PasskeysComponent = () => {
|
|
|
68
68
|
setErrorKey(null);
|
|
69
69
|
setDeletingIds((prev) => new Set(prev).add(id));
|
|
70
70
|
try {
|
|
71
|
-
await
|
|
71
|
+
await deletePasskey(id);
|
|
72
72
|
setPasskeys((prev) => prev.filter((pk) => pk.id !== id));
|
|
73
73
|
setMessageKey('Auth.PASSKEY_DELETE_SUCCESS');
|
|
74
74
|
}
|
|
@@ -7,7 +7,8 @@ import PasswordChangeForm from './PasswordChangeForm';
|
|
|
7
7
|
import SocialLoginButtons from './SocialLoginButtons';
|
|
8
8
|
import PasskeysComponent from './PasskeysComponent';
|
|
9
9
|
import MFAComponent from './MFAComponent';
|
|
10
|
-
import {
|
|
10
|
+
import { changePassword } from '../auth/authApi';
|
|
11
|
+
import { startSocialLogin } from '../utils/authService';
|
|
11
12
|
const SecurityComponent = ({ fromRecovery = false, fromWeakLogin = false, // optional: wenn du später weak-login-Redirect nutzt
|
|
12
13
|
}) => {
|
|
13
14
|
const { t } = useTranslation();
|
|
@@ -17,7 +18,7 @@ const SecurityComponent = ({ fromRecovery = false, fromWeakLogin = false, // opt
|
|
|
17
18
|
setMessageKey(null);
|
|
18
19
|
setErrorKey(null);
|
|
19
20
|
try {
|
|
20
|
-
await
|
|
21
|
+
await startSocialLogin(provider);
|
|
21
22
|
// Redirect läuft über den Provider-Flow, hier kein extra Text nötig
|
|
22
23
|
}
|
|
23
24
|
catch (err) {
|
|
@@ -28,7 +29,7 @@ const SecurityComponent = ({ fromRecovery = false, fromWeakLogin = false, // opt
|
|
|
28
29
|
setMessageKey(null);
|
|
29
30
|
setErrorKey(null);
|
|
30
31
|
try {
|
|
31
|
-
await
|
|
32
|
+
await changePassword(currentPassword, newPassword);
|
|
32
33
|
setMessageKey('Auth.RESET_PASSWORD_SUCCESS');
|
|
33
34
|
}
|
|
34
35
|
catch (err) {
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import React, { useEffect, useState } from 'react';
|
|
4
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
|
-
import {
|
|
6
|
+
import { fetchRecoveryRequests, approveRecoveryRequest, rejectRecoveryRequest } from '../auth/authApi';
|
|
7
7
|
const SupportRecoveryRequestsTab = () => {
|
|
8
8
|
const { t } = useTranslation();
|
|
9
9
|
const [requests, setRequests] = useState([]);
|
|
@@ -17,7 +17,7 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
17
17
|
setLoading(true);
|
|
18
18
|
setErrorKey(null);
|
|
19
19
|
try {
|
|
20
|
-
const data = await
|
|
20
|
+
const data = await fetchRecoveryRequests(statusFilter);
|
|
21
21
|
setRequests(Array.isArray(data) ? data : []);
|
|
22
22
|
}
|
|
23
23
|
catch (err) {
|
|
@@ -46,7 +46,7 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
46
46
|
return;
|
|
47
47
|
setErrorKey(null);
|
|
48
48
|
try {
|
|
49
|
-
await
|
|
49
|
+
await approveRecoveryRequest(selectedRequest.id, dialogNote);
|
|
50
50
|
await loadRequests();
|
|
51
51
|
closeDialog();
|
|
52
52
|
}
|
|
@@ -59,7 +59,7 @@ const SupportRecoveryRequestsTab = () => {
|
|
|
59
59
|
return;
|
|
60
60
|
setErrorKey(null);
|
|
61
61
|
try {
|
|
62
|
-
await
|
|
62
|
+
await rejectRecoveryRequest(selectedRequest.id, dialogNote);
|
|
63
63
|
await loadRequests();
|
|
64
64
|
closeDialog();
|
|
65
65
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { Box, TextField, Button, Typography, Alert, CircularProgress } from '@mui/material';
|
|
4
|
+
import { inviteUserByAdmin } from '../auth/authApi';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
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
|
+
const inviteUser = async () => {
|
|
13
|
+
setMessage('');
|
|
14
|
+
setError('');
|
|
15
|
+
if (!inviteEmail)
|
|
16
|
+
return;
|
|
17
|
+
setLoading(true);
|
|
18
|
+
try {
|
|
19
|
+
// API Call via authApi
|
|
20
|
+
const data = await inviteUserByAdmin(inviteEmail, apiUrl);
|
|
21
|
+
setInviteEmail('');
|
|
22
|
+
setMessage(data.detail || t('Auth.INVITE_SENT_SUCCESS', 'Invitation sent.'));
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
// err.message enthält dank authApi bereits den normalisierten Text oder Code
|
|
26
|
+
console.error('Error inviting user:', err);
|
|
27
|
+
// Fallback Text, falls der Key nicht übersetzt ist
|
|
28
|
+
setError(t(err.code) || err.message || t('Auth.INVITE_FAILED'));
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
setLoading(false);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
return (_jsxs(Box, { sx: { maxWidth: 600, mt: 2 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.INVITE_TITLE', 'Invite a new user') }), message && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: message }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), _jsxs(Box, { sx: { display: 'flex', gap: 2, alignItems: 'flex-start' }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL', 'Email address'), type: "email", variant: "outlined", fullWidth: true, size: "small", value: inviteEmail, onChange: (e) => setInviteEmail(e.target.value), disabled: loading, onKeyPress: (e) => {
|
|
35
|
+
if (e.key === 'Enter')
|
|
36
|
+
inviteUser();
|
|
37
|
+
} }), _jsx(Button, { variant: "contained", onClick: inviteUser, disabled: loading || !inviteEmail, sx: { minWidth: 100, height: 40 }, children: loading ? _jsx(CircularProgress, { size: 24, color: "inherit" }) : t('Auth.INVITE_BUTTON', 'Invite') })] })] }));
|
|
38
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, FormControl, InputLabel, Select, MenuItem, Button, IconButton, Tooltip, CircularProgress, Alert } from '@mui/material';
|
|
4
|
+
import DeleteIcon from '@mui/icons-material/Delete';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { fetchUsersList, deleteUser, updateUserRole, updateUserSupportStatus } from '../auth/authApi'; // Nur noch authApi!
|
|
7
|
+
const DEFAULT_ROLES = ['none', 'student', 'teacher', 'admin'];
|
|
8
|
+
export function UserListComponent({ roles = DEFAULT_ROLES, apiUrl = '/api/users/', currentUser }) {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const [users, setUsers] = useState([]);
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const loadUsers = async () => {
|
|
14
|
+
setLoading(true);
|
|
15
|
+
setError(null);
|
|
16
|
+
try {
|
|
17
|
+
const data = await fetchUsersList(apiUrl);
|
|
18
|
+
setUsers(data);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
// err.code ist dank normaliseApiError verfügbar
|
|
22
|
+
setError(err.code || 'Auth.USER_LIST_FAILED');
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
setLoading(false);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
loadUsers();
|
|
30
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
|
+
}, [apiUrl]);
|
|
32
|
+
const handleDelete = async (userId) => {
|
|
33
|
+
if (!window.confirm(t('UserList.DELETE_CONFIRM', 'Are you sure you want to delete this user?')))
|
|
34
|
+
return;
|
|
35
|
+
try {
|
|
36
|
+
await deleteUser(userId, apiUrl);
|
|
37
|
+
// Optimistic update oder reload:
|
|
38
|
+
setUsers(prev => prev.filter(u => u.id !== userId));
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
alert(t(err.code || 'Auth.USER_DELETE_FAILED'));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const handleChangeRole = async (userId, newRole) => {
|
|
45
|
+
try {
|
|
46
|
+
await updateUserRole(userId, newRole, apiUrl);
|
|
47
|
+
// Reload list to ensure consistency or update local state
|
|
48
|
+
loadUsers();
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
alert(t(err.code || 'Auth.USER_ROLE_UPDATE_FAILED'));
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const handleToggleSupporter = async (userId, newValue) => {
|
|
55
|
+
try {
|
|
56
|
+
await updateUserSupportStatus(userId, newValue, apiUrl);
|
|
57
|
+
loadUsers();
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
alert(t(err.code || 'Auth.USER_UPDATE_FAILED'));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const canEdit = (targetUser) => {
|
|
64
|
+
if (!currentUser)
|
|
65
|
+
return false;
|
|
66
|
+
if (currentUser.is_superuser)
|
|
67
|
+
return true;
|
|
68
|
+
const myRole = currentUser.role || 'none';
|
|
69
|
+
const targetRole = targetUser.role || 'none';
|
|
70
|
+
if (myRole === 'admin')
|
|
71
|
+
return true;
|
|
72
|
+
// Beispiel Logik: Lehrer dürfen Schüler bearbeiten
|
|
73
|
+
if (myRole === 'teacher') {
|
|
74
|
+
if (targetUser.id === currentUser.id)
|
|
75
|
+
return false;
|
|
76
|
+
if (['teacher', 'admin', 'supervisor'].includes(targetRole))
|
|
77
|
+
return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
};
|
|
82
|
+
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('UserList.TITLE', 'All Users') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(error) }), loading ? (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', p: 3 }, children: _jsx(CircularProgress, {}) })) : (_jsx(TableContainer, { component: Paper, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('Auth.EMAIL_LABEL', 'Email') }), _jsx(TableCell, { children: t('Profile.NAME_LABEL', 'Name') }), _jsx(TableCell, { children: t('UserList.ROLE', 'Role') }), _jsx(TableCell, { children: t('UserList.SUPPORTER', 'Support Agent') }), _jsx(TableCell, { children: t('Common.ACTIONS', 'Actions') })] }) }), _jsxs(TableBody, { children: [users.map((u) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: u.email }), _jsx(TableCell, { children: u.first_name || u.last_name ? `${u.first_name} ${u.last_name}` : u.username }), _jsx(TableCell, { children: _jsx(FormControl, { size: "small", fullWidth: true, disabled: !canEdit(u), children: _jsx(Select, { value: u.role || 'none', onChange: (e) => handleChangeRole(u.id, e.target.value), variant: "standard", disableUnderline: true, children: roles.map(r => _jsx(MenuItem, { value: r, children: r }, r)) }) }) }), _jsx(TableCell, { children: _jsx(Button, { size: "small", variant: u.is_support_agent ? 'contained' : 'outlined', color: u.is_support_agent ? 'primary' : 'inherit', onClick: () => handleToggleSupporter(u.id, !u.is_support_agent), disabled: !canEdit(u), sx: { textTransform: 'none' }, children: u.is_support_agent ? t('Common.YES') : t('Common.NO') }) }), _jsx(TableCell, { children: _jsx(Tooltip, { title: t('Common.DELETE'), children: _jsx("span", { children: _jsx(IconButton, { onClick: () => handleDelete(u.id), color: "error", disabled: !canEdit(u), children: _jsx(DeleteIcon, {}) }) }) }) })] }, u.id))), users.length === 0 && (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 5, align: "center", children: t('UserList.NO_USERS', 'No users found.') }) }))] })] }) }))] }));
|
|
83
|
+
}
|
|
@@ -1,35 +1,79 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
|
|
3
|
-
import React, { useState, useContext } from 'react';
|
|
2
|
+
import React, { useContext, useMemo } from 'react';
|
|
4
3
|
import { Helmet } from 'react-helmet';
|
|
5
|
-
import {
|
|
4
|
+
import { useSearchParams } from 'react-router-dom';
|
|
5
|
+
import { Tabs, Tab, Box, Typography, Alert, CircularProgress } from '@mui/material';
|
|
6
|
+
import { useTranslation } from 'react-i18next';
|
|
7
|
+
// Interne Komponenten der Library
|
|
6
8
|
import { WidePage } from '../layout/PageLayout';
|
|
7
|
-
import ProfileComponent from '../components/ProfileComponent';
|
|
8
|
-
import SecurityComponent from '../components/SecurityComponent';
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
9
|
+
import { ProfileComponent } from '../components/ProfileComponent';
|
|
10
|
+
import { SecurityComponent } from '../components/SecurityComponent';
|
|
11
|
+
import { UserListComponent } from '../components/UserListComponent';
|
|
12
|
+
import { UserInviteComponent } from '../components/UserInviteComponent';
|
|
13
|
+
import { AccessCodeManager } from '../components/AccessCodeManager';
|
|
14
|
+
import { SupportRecoveryRequestsTab } from '../components/SupportRecoveryRequestsTab';
|
|
11
15
|
import { AuthContext } from '../auth/AuthContext';
|
|
12
|
-
import {
|
|
16
|
+
import { authApi } from '../auth/authApi';
|
|
17
|
+
/**
|
|
18
|
+
* Vollständige, selbst-konfigurierende Account-Seite.
|
|
19
|
+
* * Architektur:
|
|
20
|
+
* - Permissions: Werden aus user.ui_permissions gelesen (vom Backend).
|
|
21
|
+
* - Rollen: Werden aus user.available_roles gelesen (vom Backend).
|
|
22
|
+
* - Keine Props mehr nötig -> Plug & Play in der App.js.
|
|
23
|
+
*/
|
|
13
24
|
export function AccountPage() {
|
|
14
|
-
const {
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const
|
|
25
|
+
const { t } = useTranslation();
|
|
26
|
+
const { user, login, loading } = useContext(AuthContext);
|
|
27
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
28
|
+
// 1. URL State Management
|
|
29
|
+
const currentTab = searchParams.get('tab') || 'profile';
|
|
30
|
+
const fromRecovery = searchParams.get('from') === 'recovery';
|
|
31
|
+
const fromWeakLogin = searchParams.get('from') === 'weak_login';
|
|
32
|
+
// 2. Daten & Permissions extrahieren (Single Source of Truth)
|
|
33
|
+
// Backend liefert die Liste der Rollen (Keys), z.B. ['student', 'teacher']
|
|
34
|
+
const activeRoles = (user === null || user === void 0 ? void 0 : user.available_roles) || [];
|
|
35
|
+
// Backend liefert Permissions basierend auf settings.py
|
|
36
|
+
const perms = (user === null || user === void 0 ? void 0 : user.ui_permissions) || {};
|
|
26
37
|
const handleTabChange = (_event, newValue) => {
|
|
27
|
-
|
|
38
|
+
setSearchParams({ tab: newValue });
|
|
28
39
|
};
|
|
29
40
|
const handleProfileSubmit = async (payload) => {
|
|
30
41
|
const updatedUser = await authApi.updateUserProfile(payload);
|
|
31
42
|
login(updatedUser);
|
|
32
43
|
};
|
|
33
|
-
|
|
44
|
+
// 3. Dynamische Tabs bauen
|
|
45
|
+
const tabs = useMemo(() => {
|
|
46
|
+
// Wenn User noch nicht da ist, leere Liste (Loading State fängt das ab)
|
|
47
|
+
if (!user)
|
|
48
|
+
return [];
|
|
49
|
+
const list = [
|
|
50
|
+
{ value: 'profile', label: t('Account.TAB_PROFILE', 'Profile') },
|
|
51
|
+
{ value: 'security', label: t('Account.TAB_SECURITY', 'Security') },
|
|
52
|
+
];
|
|
53
|
+
if (perms.can_view_users) {
|
|
54
|
+
list.push({ value: 'users', label: t('Account.TAB_USERS', 'Users') });
|
|
55
|
+
}
|
|
56
|
+
if (perms.can_invite) {
|
|
57
|
+
list.push({ value: 'invite', label: t('Account.TAB_INVITE', 'Invite') });
|
|
58
|
+
}
|
|
59
|
+
if (perms.can_manage_access_codes) {
|
|
60
|
+
list.push({ value: 'access', label: t('Account.TAB_ACCESS_CODES', 'Access Codes') });
|
|
61
|
+
}
|
|
62
|
+
if (perms.can_view_support) {
|
|
63
|
+
list.push({ value: 'support', label: t('Account.TAB_SUPPORT', 'Support') });
|
|
64
|
+
}
|
|
65
|
+
return list;
|
|
66
|
+
}, [user, perms, t]);
|
|
67
|
+
// 4. Loading & Auth Checks
|
|
68
|
+
if (loading) {
|
|
69
|
+
return (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', mt: 10 }, children: _jsx(CircularProgress, {}) }));
|
|
70
|
+
}
|
|
71
|
+
if (!user) {
|
|
72
|
+
return (_jsx(WidePage, { children: _jsx(Alert, { severity: "warning", children: t('Auth.NOT_LOGGED_IN', 'User not logged in.') }) }));
|
|
73
|
+
}
|
|
74
|
+
// 5. Sicherheits-Check: Ist der aktuelle Tab überhaupt erlaubt?
|
|
75
|
+
// Verhindert Zugriff durch URL-Manipulation (z.B. ?tab=users eingeben ohne Admin-Rechte)
|
|
76
|
+
const activeTabExists = tabs.some(t => t.value === currentTab);
|
|
77
|
+
const safeTab = activeTabExists ? currentTab : 'profile';
|
|
78
|
+
return (_jsxs(WidePage, { title: t('Account.TITLE', 'Account & Administration'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('Account.PAGE_TITLE', 'Account'), " \u2013 ", user.email] }) }), _jsx(Tabs, { value: safeTab, onChange: handleTabChange, variant: "scrollable", scrollButtons: "auto", sx: { mb: 3, borderBottom: 1, borderColor: 'divider' }, children: tabs.map((tab) => (_jsx(Tab, { label: tab.label, value: tab.value }, tab.value))) }), safeTab === 'profile' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(ProfileComponent, { onSubmit: handleProfileSubmit, showName: true, showPrivacy: true, showCookies: true }) })), safeTab === 'security' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SecurityComponent, { fromRecovery: fromRecovery, fromWeakLogin: fromWeakLogin }) })), safeTab === 'users' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(UserListComponent, { roles: activeRoles, currentUser: user }) })), safeTab === 'invite' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(UserInviteComponent, {}) })), safeTab === 'access' && (_jsxs(Box, { sx: { mt: 2 }, children: [_jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.') }), _jsx(AccessCodeManager, {})] })), safeTab === 'support' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SupportRecoveryRequestsTab, {}) }))] }));
|
|
34
79
|
}
|
|
35
|
-
export default AccountPage;
|
package/dist/pages/LoginPage.js
CHANGED
|
@@ -5,7 +5,8 @@ import { Helmet } from 'react-helmet';
|
|
|
5
5
|
import { Typography, Box, Alert } from '@mui/material';
|
|
6
6
|
import { NarrowPage } from '../layout/PageLayout';
|
|
7
7
|
import { AuthContext } from '../auth/AuthContext';
|
|
8
|
-
import {
|
|
8
|
+
import { loginWithRecoveryPassword, loginWithPassword } from '../auth/authApi';
|
|
9
|
+
import { loginWithPasskey, startSocialLogin } from '../utils/authService';
|
|
9
10
|
import LoginForm from '../components/LoginForm';
|
|
10
11
|
import MfaLoginComponent from '../components/MfaLoginComponent';
|
|
11
12
|
import { useTranslation } from 'react-i18next';
|
|
@@ -29,14 +30,14 @@ export function LoginPage() {
|
|
|
29
30
|
try {
|
|
30
31
|
// Recovery flow: password login via special endpoint, no MFA
|
|
31
32
|
if (recoveryToken) {
|
|
32
|
-
const result = await
|
|
33
|
+
const result = await loginWithRecoveryPassword(identifier, password, recoveryToken);
|
|
33
34
|
const user = result.user;
|
|
34
35
|
login(user);
|
|
35
36
|
navigate('/account?tab=security&from=recovery');
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
38
39
|
// Normal login flow via headless allauth
|
|
39
|
-
const result = await
|
|
40
|
+
const result = await loginWithPassword(identifier, password);
|
|
40
41
|
if (result.needsMfa) {
|
|
41
42
|
setMfaState({
|
|
42
43
|
availableTypes: result.availableTypes || [],
|
|
@@ -68,7 +69,7 @@ export function LoginPage() {
|
|
|
68
69
|
setErrorKey(null);
|
|
69
70
|
setSubmitting(true);
|
|
70
71
|
try {
|
|
71
|
-
const { user } = await
|
|
72
|
+
const { user } = await loginWithPasskey();
|
|
72
73
|
login(user);
|
|
73
74
|
const requiresExtra = ((_a = user.security_state) === null || _a === void 0 ? void 0 : _a.requires_additional_security) === true;
|
|
74
75
|
if (requiresExtra) {
|
|
@@ -85,7 +86,7 @@ export function LoginPage() {
|
|
|
85
86
|
setSubmitting(false);
|
|
86
87
|
}
|
|
87
88
|
};
|
|
88
|
-
const handleSocialLogin = (provider) =>
|
|
89
|
+
const handleSocialLogin = (provider) => startSocialLogin(provider);
|
|
89
90
|
const handleSignUp = () => navigate('/signup');
|
|
90
91
|
const handleForgotPassword = () => navigate('/reset-request-password');
|
|
91
92
|
const handleMfaSuccess = ({ user, method }) => {
|
|
@@ -7,7 +7,7 @@ import { Typography } from '@mui/material';
|
|
|
7
7
|
import { useTranslation } from 'react-i18next';
|
|
8
8
|
import { NarrowPage } from '../layout/PageLayout';
|
|
9
9
|
import PasswordSetForm from '../components/PasswordSetForm';
|
|
10
|
-
import {
|
|
10
|
+
import { verifyResetToken, setNewPassword } from '../auth/authApi';
|
|
11
11
|
export function PasswordInvitePage() {
|
|
12
12
|
const { uid, token } = useParams();
|
|
13
13
|
const location = useLocation();
|
|
@@ -34,7 +34,7 @@ export function PasswordInvitePage() {
|
|
|
34
34
|
}
|
|
35
35
|
const check = async () => {
|
|
36
36
|
try {
|
|
37
|
-
await
|
|
37
|
+
await verifyResetToken(uid, token);
|
|
38
38
|
setChecked(true);
|
|
39
39
|
}
|
|
40
40
|
catch (err) {
|
|
@@ -53,7 +53,7 @@ export function PasswordInvitePage() {
|
|
|
53
53
|
setErrorKey(null);
|
|
54
54
|
setSuccessKey(null);
|
|
55
55
|
try {
|
|
56
|
-
await
|
|
56
|
+
await setNewPassword(uid, token, newPassword);
|
|
57
57
|
setSuccessKey(isInvite
|
|
58
58
|
? 'Auth.RESET_PASSWORD_SUCCESS_INVITE'
|
|
59
59
|
: 'Auth.RESET_PASSWORD_SUCCESS_RESET');
|
package/dist/pages/SignUpPage.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Box, TextField, Button, Typography, Alert, } from '@mui/material';
|
|
|
6
6
|
import { Helmet } from 'react-helmet';
|
|
7
7
|
import { useTranslation } from 'react-i18next';
|
|
8
8
|
import { NarrowPage } from '../layout/PageLayout';
|
|
9
|
-
import {
|
|
9
|
+
import { validateAccessCode, requestInviteWithCode } from '../auth/authApi';
|
|
10
10
|
export function SignUpPage() {
|
|
11
11
|
const navigate = useNavigate();
|
|
12
12
|
const { t } = useTranslation();
|
|
@@ -30,13 +30,13 @@ export function SignUpPage() {
|
|
|
30
30
|
setSubmitting(true);
|
|
31
31
|
try {
|
|
32
32
|
// 1) Access-Code prüfen
|
|
33
|
-
const res = await
|
|
33
|
+
const res = await validateAccessCode(accessCode);
|
|
34
34
|
if (!(res === null || res === void 0 ? void 0 : res.valid)) {
|
|
35
35
|
setErrorKey('Auth.ACCESS_CODE_INVALID_OR_INACTIVE');
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
38
|
// 2) Invite anfordern
|
|
39
|
-
await
|
|
39
|
+
await requestInviteWithCode(email, accessCode);
|
|
40
40
|
setSuccessKey('Auth.INVITE_REQUEST_SUCCESS');
|
|
41
41
|
}
|
|
42
42
|
catch (err) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/utils/authService.js
|
|
2
|
+
import { getPasskeyRegistrationOptions, completePasskeyRegistration, getPasskeyLoginOptions, completePasskeyLogin, fetchCurrentUser } from '../auth/authApi';
|
|
3
|
+
import { ensureWebAuthnSupport, serializeCredential } from '../utils/webauthn-helpers';
|
|
4
|
+
import { normaliseApiError } from '../utils/auth-errors';
|
|
5
|
+
export async function registerPasskey(name = 'Passkey') {
|
|
6
|
+
ensureWebAuthnSupport();
|
|
7
|
+
// 1. Get Options from Server
|
|
8
|
+
const creationOptions = await getPasskeyRegistrationOptions();
|
|
9
|
+
// 2. Call Browser API
|
|
10
|
+
let credential;
|
|
11
|
+
try {
|
|
12
|
+
// Note: Parse JSON to Options needs a helper if creationOptions is raw JSON strings
|
|
13
|
+
// modern browsers/allauth usually provide correct types, but check your library version
|
|
14
|
+
// For standard Allauth headless, you might need window.PublicKeyCredential.parseCreationOptionsFromJSON
|
|
15
|
+
const pubKey = window.PublicKeyCredential.parseCreationOptionsFromJSON(creationOptions);
|
|
16
|
+
credential = await navigator.credentials.create({ publicKey: pubKey });
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err.name === 'NotAllowedError') {
|
|
20
|
+
throw normaliseApiError(new Error('Auth.PASSKEY_CANCELLED'), 'Auth.PASSKEY_CANCELLED');
|
|
21
|
+
}
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
// 3. Send back to Server
|
|
25
|
+
const credentialJson = serializeCredential(credential);
|
|
26
|
+
return completePasskeyRegistration(credentialJson, name);
|
|
27
|
+
}
|
|
28
|
+
export async function loginWithPasskey() {
|
|
29
|
+
ensureWebAuthnSupport();
|
|
30
|
+
// 1. Get Challenge
|
|
31
|
+
const requestOptions = await getPasskeyLoginOptions();
|
|
32
|
+
// 2. Browser Sign
|
|
33
|
+
let assertion;
|
|
34
|
+
try {
|
|
35
|
+
const pubKey = window.PublicKeyCredential.parseRequestOptionsFromJSON(requestOptions);
|
|
36
|
+
assertion = await navigator.credentials.get({ publicKey: pubKey });
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw normaliseApiError(new Error('Auth.PASSKEY_CANCELLED'), 'Auth.PASSKEY_CANCELLED');
|
|
40
|
+
}
|
|
41
|
+
// 3. Complete
|
|
42
|
+
const credentialJson = serializeCredential(assertion);
|
|
43
|
+
await completePasskeyLogin(credentialJson);
|
|
44
|
+
// 4. Reload User
|
|
45
|
+
return fetchCurrentUser();
|
|
46
|
+
}
|
|
47
|
+
export function startSocialLogin(provider) {
|
|
48
|
+
if (typeof window === 'undefined') {
|
|
49
|
+
throw normaliseApiError(new Error('Auth.SOCIAL_LOGIN_NOT_IN_BROWSER'), 'Auth.SOCIAL_LOGIN_NOT_IN_BROWSER');
|
|
50
|
+
}
|
|
51
|
+
// Browser-Redirect ist ein Side-Effect -> Service Layer
|
|
52
|
+
window.location.href = `/accounts/${provider}/login/?process=login`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// src/utils/errors.js
|
|
2
|
+
export function extractErrorInfo(error) {
|
|
3
|
+
var _a, _b, _c, _d;
|
|
4
|
+
const status = (_b = (_a = error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : null;
|
|
5
|
+
const data = (_d = (_c = error.response) === null || _c === void 0 ? void 0 : _c.data) !== null && _d !== void 0 ? _d : null;
|
|
6
|
+
if (!data) {
|
|
7
|
+
return { status, code: null, message: error.message || null, raw: null };
|
|
8
|
+
}
|
|
9
|
+
// Allauth Headless structure often nests errors in "errors" array or "status" key
|
|
10
|
+
if (Array.isArray(data.errors) && data.errors.length > 0) {
|
|
11
|
+
// Pick the first error code
|
|
12
|
+
const first = data.errors[0];
|
|
13
|
+
return { status, code: first.code, message: first.message, raw: data };
|
|
14
|
+
}
|
|
15
|
+
if (typeof data.code === 'string') {
|
|
16
|
+
return { status, code: data.code, message: null, raw: data };
|
|
17
|
+
}
|
|
18
|
+
// Fallback for generic Django errors
|
|
19
|
+
if (typeof data.detail === 'string') {
|
|
20
|
+
return { status, code: 'GENERIC', message: data.detail, raw: data };
|
|
21
|
+
}
|
|
22
|
+
return { status, code: null, message: null, raw: data };
|
|
23
|
+
}
|
|
24
|
+
export function normaliseApiError(error, defaultCode = 'Auth.GENERIC_ERROR') {
|
|
25
|
+
const info = extractErrorInfo(error);
|
|
26
|
+
const code = info.code || defaultCode;
|
|
27
|
+
const message = info.message || code || defaultCode;
|
|
28
|
+
const err = new Error(message);
|
|
29
|
+
err.code = code;
|
|
30
|
+
err.status = info.status;
|
|
31
|
+
err.raw = info.raw;
|
|
32
|
+
return err;
|
|
33
|
+
}
|