@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.
Files changed (33) 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 +4 -4
  8. package/dist/components/UserInviteComponent.js +38 -0
  9. package/dist/components/UserListComponent.js +83 -0
  10. package/dist/pages/AccountPage.js +67 -23
  11. package/dist/pages/LoginPage.js +6 -5
  12. package/dist/pages/PasswordInvitePage.js +3 -3
  13. package/dist/pages/SignUpPage.js +3 -3
  14. package/dist/utils/authService.js +53 -0
  15. package/dist/utils/errors.js +33 -0
  16. package/dist/utils/webauthn.js +44 -0
  17. package/package.json +1 -1
  18. package/src/auth/AuthContext.jsx +0 -1
  19. package/src/auth/authApi.jsx +143 -478
  20. package/src/components/MFAComponent.jsx +7 -7
  21. package/src/components/MfaLoginComponent.jsx +6 -5
  22. package/src/components/PasskeysComponent.jsx +5 -5
  23. package/src/components/SecurityComponent.jsx +4 -3
  24. package/src/components/SupportRecoveryRequestsTab.jsx +4 -4
  25. package/src/components/UserInviteComponent.jsx +69 -0
  26. package/src/components/UserListComponent.jsx +167 -0
  27. package/src/pages/AccountPage.jsx +140 -47
  28. package/src/pages/LoginPage.jsx +6 -5
  29. package/src/pages/PasswordInvitePage.jsx +3 -3
  30. package/src/pages/SignUpPage.jsx +3 -3
  31. package/src/utils/authService.js +68 -0
  32. package/src/utils/errors.js +39 -0
  33. 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) {
@@ -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
- // src/pages/AccountPage.jsx
2
- import React, { useState, useContext } from 'react';
1
+ import React, { useContext, useMemo } from 'react';
3
2
  import { Helmet } from 'react-helmet';
4
- import { Tabs, Tab, Box } from '@mui/material';
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 SupportRecoveryRequestsTab from '../components/SupportRecoveryRequestsTab';
9
- import { authApi } from '../auth/authApi';
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 { useSearchParams } from 'react-router-dom';
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 { login } = useContext(AuthContext);
15
- const [searchParams] = useSearchParams();
33
+ const { t } = useTranslation();
34
+ const { user, login, loading } = useContext(AuthContext);
35
+ const [searchParams, setSearchParams] = useSearchParams();
16
36
 
17
- const initialTabParam = searchParams.get('tab');
18
- const initialTab =
19
- initialTabParam === 'security'
20
- ? 'security'
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
- const fromParam = searchParams.get('from');
26
- const fromRecovery = fromParam === 'recovery';
27
- const fromWeakLogin = fromParam === 'weak_login';
28
-
29
- const [tab, setTab] = useState(initialTab);
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
- setTab(newValue);
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="Account">
112
+ <WidePage title={t('Account.TITLE', 'Account & Administration')}>
42
113
  <Helmet>
43
- <title>PROJECT_NAMEAccount</title>
114
+ <title>{t('Account.PAGE_TITLE', 'Account')} {user.email}</title>
44
115
  </Helmet>
45
116
 
46
117
  <Tabs
47
- value={tab}
118
+ value={safeTab}
48
119
  onChange={handleTabChange}
49
- sx={{ mb: 3 }}
120
+ variant="scrollable"
121
+ scrollButtons="auto"
122
+ sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}
50
123
  >
51
- <Tab label="Security" value="security" />
52
- <Tab label="Account" value="account" />
53
- <Tab label="Support" value="support" />
124
+ {tabs.map((tab) => (
125
+ <Tab key={tab.value} label={tab.label} value={tab.value} />
126
+ ))}
54
127
  </Tabs>
55
128
 
56
- {tab === 'security' && (
57
- <Box sx={{ mt: 1 }}>
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
- {tab === 'account' && (
66
- <Box sx={{ mt: 1 }}>
67
- <ProfileComponent
68
- onLoad={() => {}}
69
- onSubmit={handleProfileSubmit}
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
- {tab === 'support' && (
81
- <Box sx={{ mt: 1 }}>
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
+ }