@micha.bigler/ui-core-micha 1.4.19 → 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.
Files changed (35) hide show
  1. package/dist/auth/AuthContext.js +0 -1
  2. package/dist/auth/authApi.js +137 -378
  3. package/dist/components/MFAComponent.js +7 -7
  4. package/dist/components/MfaLoginComponent.js +6 -5
  5. package/dist/components/PasskeysComponent.js +5 -5
  6. package/dist/components/SecurityComponent.js +4 -3
  7. package/dist/components/SupportRecoveryRequestsTab.js +5 -5
  8. package/dist/components/UserInviteComponent.js +38 -0
  9. package/dist/components/UserListComponent.js +83 -0
  10. package/dist/i18n/authTranslations.js +25 -0
  11. package/dist/pages/AccountPage.js +67 -23
  12. package/dist/pages/LoginPage.js +6 -5
  13. package/dist/pages/PasswordInvitePage.js +3 -3
  14. package/dist/pages/SignUpPage.js +3 -3
  15. package/dist/utils/authService.js +53 -0
  16. package/dist/utils/errors.js +33 -0
  17. package/dist/utils/webauthn.js +44 -0
  18. package/package.json +1 -1
  19. package/src/auth/AuthContext.jsx +0 -1
  20. package/src/auth/authApi.jsx +143 -478
  21. package/src/components/MFAComponent.jsx +7 -7
  22. package/src/components/MfaLoginComponent.jsx +6 -5
  23. package/src/components/PasskeysComponent.jsx +5 -5
  24. package/src/components/SecurityComponent.jsx +4 -3
  25. package/src/components/SupportRecoveryRequestsTab.jsx +7 -5
  26. package/src/components/UserInviteComponent.jsx +69 -0
  27. package/src/components/UserListComponent.jsx +167 -0
  28. package/src/i18n/authTranslations.js +25 -0
  29. package/src/pages/AccountPage.jsx +140 -47
  30. package/src/pages/LoginPage.jsx +6 -5
  31. package/src/pages/PasswordInvitePage.jsx +3 -3
  32. package/src/pages/SignUpPage.jsx +3 -3
  33. package/src/utils/authService.js +68 -0
  34. package/src/utils/errors.js +39 -0
  35. 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 { authApi } from '../auth/authApi';
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 authApi.fetchAuthenticators();
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 authApi.requestTotpKey();
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 authApi.activateTotp(verifyCode);
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 authApi.deactivateTotp();
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 authApi.fetchRecoveryCodes();
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 authApi.generateRecoveryCodes();
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 { authApi } from '../auth/authApi';
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 authApi.authenticateWithMFA({ code: trimmed });
45
+ await authenticateWithMFA({ code: trimmed });
45
46
 
46
- const user = await authApi.fetchCurrentUser();
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 authApi.loginWithPasskey();
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 authApi.requestMfaSupportHelp(identifier || '', helpMessage || '');
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 { authApi } from '../auth/authApi';
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 authApi.fetchPasskeys();
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 authApi.registerPasskey(fallbackName);
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 authApi.deletePasskey(id);
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 { authApi } from '../auth/authApi';
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 authApi.startSocialLogin(provider);
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 authApi.changePassword(currentPassword, newPassword);
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 { authApi } from '../auth/authApi';
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 authApi.fetchRecoveryRequests(statusFilter);
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 authApi.approveRecoveryRequest(selectedRequest.id, dialogNote);
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 authApi.rejectRecoveryRequest(selectedRequest.id, dialogNote);
84
+ await rejectRecoveryRequest(selectedRequest.id, dialogNote);
85
85
  await loadRequests();
86
86
  closeDialog();
87
87
  } catch (err) {
@@ -175,7 +175,9 @@ const SupportRecoveryRequestsTab = () => {
175
175
  : '-'}
176
176
  </TableCell>
177
177
  <TableCell>{req.user_email || req.user}</TableCell>
178
- <TableCell>{req.status}</TableCell>
178
+ <TableCell>
179
+ {t(`Support.RECOVERY_STATUS_${req.status}`, req.status)}
180
+ </TableCell>
179
181
  <TableCell>
180
182
  <Button
181
183
  variant="contained"
@@ -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
+ }
@@ -978,4 +978,29 @@ export const authTranslations = {
978
978
  "fr": "Si un compte existe avec cette adresse e-mail, votre demande a été transmise au support.",
979
979
  "en": "If an account with this email exists, your request has been forwarded to support."
980
980
  },
981
+ "Support.RECOVERY_STATUS_pending": {
982
+ "de": "Offen",
983
+ "fr": "En attente",
984
+ "en": "Pending"
985
+ },
986
+ "Support.RECOVERY_STATUS_approved": {
987
+ "de": "Genehmigt",
988
+ "fr": "Approuvée",
989
+ "en": "Approved"
990
+ },
991
+ "Support.RECOVERY_STATUS_rejected": {
992
+ "de": "Abgelehnt",
993
+ "fr": "Refusée",
994
+ "en": "Rejected"
995
+ },
996
+ "Support.RECOVERY_STATUS_completed": {
997
+ "de": "Abgeschlossen",
998
+ "fr": "Terminée",
999
+ "en": "Completed"
1000
+ },
1001
+ "Support.RECOVERY_STATUS_expired": {
1002
+ "de": "Abgelaufen",
1003
+ "fr": "Expirée",
1004
+ "en": "Expired"
1005
+ }
981
1006
  };