@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
@@ -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 { authApi } from '../auth/authApi';
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 authApi.fetchAuthenticators();
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 authApi.requestTotpKey();
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 authApi.activateTotp(verifyCode);
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 authApi.deactivateTotp();
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 authApi.fetchRecoveryCodes();
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 authApi.generateRecoveryCodes();
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 { authApi } from '../auth/authApi';
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 authApi.authenticateWithMFA({ code: trimmed });
28
- const user = await authApi.fetchCurrentUser();
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 authApi.loginWithPasskey();
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 authApi.requestMfaSupportHelp(identifier || '', helpMessage || '');
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 { authApi } from '../auth/authApi';
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 authApi.fetchPasskeys();
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 authApi.registerPasskey(fallbackName);
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 authApi.deletePasskey(id);
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 { authApi } from '../auth/authApi';
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 authApi.startSocialLogin(provider);
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 authApi.changePassword(currentPassword, newPassword);
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 { authApi } from '../auth/authApi';
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 authApi.fetchRecoveryRequests(statusFilter);
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 authApi.approveRecoveryRequest(selectedRequest.id, dialogNote);
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 authApi.rejectRecoveryRequest(selectedRequest.id, dialogNote);
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
- // src/pages/AccountPage.jsx
3
- import React, { useState, useContext } from 'react';
2
+ import React, { useContext, useMemo } from 'react';
4
3
  import { Helmet } from 'react-helmet';
5
- import { Tabs, Tab, Box } from '@mui/material';
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 SupportRecoveryRequestsTab from '../components/SupportRecoveryRequestsTab';
10
- import { authApi } from '../auth/authApi';
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 { useSearchParams } from 'react-router-dom';
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 { login } = useContext(AuthContext);
15
- const [searchParams] = useSearchParams();
16
- const initialTabParam = searchParams.get('tab');
17
- const initialTab = initialTabParam === 'security'
18
- ? 'security'
19
- : initialTabParam === 'support'
20
- ? 'support'
21
- : 'account';
22
- const fromParam = searchParams.get('from');
23
- const fromRecovery = fromParam === 'recovery';
24
- const fromWeakLogin = fromParam === 'weak_login';
25
- const [tab, setTab] = useState(initialTab);
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
- setTab(newValue);
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
- return (_jsxs(WidePage, { title: "Account", children: [_jsx(Helmet, { children: _jsx("title", { children: "PROJECT_NAME \u2013 Account" }) }), _jsxs(Tabs, { value: tab, onChange: handleTabChange, sx: { mb: 3 }, children: [_jsx(Tab, { label: "Security", value: "security" }), _jsx(Tab, { label: "Account", value: "account" }), _jsx(Tab, { label: "Support", value: "support" })] }), tab === 'security' && (_jsx(Box, { sx: { mt: 1 }, children: _jsx(SecurityComponent, { fromRecovery: fromRecovery, fromWeakLogin: fromWeakLogin }) })), tab === 'account' && (_jsx(Box, { sx: { mt: 1 }, children: _jsx(ProfileComponent, { onLoad: () => { }, onSubmit: handleProfileSubmit, submitText: "Save", showName: true, showPrivacy: true, showCookies: true }) })), tab === 'support' && (_jsx(Box, { sx: { mt: 1 }, children: _jsx(SupportRecoveryRequestsTab, {}) }))] }));
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;
@@ -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 { authApi } from '../auth/authApi';
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 authApi.loginWithRecoveryPassword(identifier, password, recoveryToken);
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 authApi.loginWithPassword(identifier, password);
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 authApi.loginWithPasskey();
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) => authApi.startSocialLogin(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 { authApi } from '../auth/authApi';
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 authApi.verifyResetToken(uid, token);
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 authApi.setNewPassword(uid, token, newPassword);
56
+ await setNewPassword(uid, token, newPassword);
57
57
  setSuccessKey(isInvite
58
58
  ? 'Auth.RESET_PASSWORD_SUCCESS_INVITE'
59
59
  : 'Auth.RESET_PASSWORD_SUCCESS_RESET');
@@ -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 { authApi } from '../auth/authApi';
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 authApi.validateAccessCode(accessCode);
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 authApi.requestInviteWithCode(email, accessCode);
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
+ }