@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
@@ -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
+ }
@@ -4,7 +4,8 @@ import { Helmet } from 'react-helmet';
4
4
  import { Typography, Box, Alert } from '@mui/material';
5
5
  import { NarrowPage } from '../layout/PageLayout';
6
6
  import { AuthContext } from '../auth/AuthContext';
7
- import { authApi } from '../auth/authApi';
7
+ import { loginWithRecoveryPassword, loginWithPassword } from '../auth/authApi';
8
+ import { loginWithPasskey, startSocialLogin } from '../utils/authService';
8
9
  import LoginForm from '../components/LoginForm';
9
10
  import MfaLoginComponent from '../components/MfaLoginComponent';
10
11
  import { useTranslation } from 'react-i18next';
@@ -32,7 +33,7 @@ export function LoginPage() {
32
33
  try {
33
34
  // Recovery flow: password login via special endpoint, no MFA
34
35
  if (recoveryToken) {
35
- const result = await authApi.loginWithRecoveryPassword(
36
+ const result = await loginWithRecoveryPassword(
36
37
  identifier,
37
38
  password,
38
39
  recoveryToken,
@@ -45,7 +46,7 @@ export function LoginPage() {
45
46
  }
46
47
 
47
48
  // Normal login flow via headless allauth
48
- const result = await authApi.loginWithPassword(identifier, password);
49
+ const result = await loginWithPassword(identifier, password);
49
50
 
50
51
  if (result.needsMfa) {
51
52
  setMfaState({
@@ -77,7 +78,7 @@ export function LoginPage() {
77
78
  setErrorKey(null);
78
79
  setSubmitting(true);
79
80
  try {
80
- const { user } = await authApi.loginWithPasskey();
81
+ const { user } = await loginWithPasskey();
81
82
  login(user);
82
83
 
83
84
  const requiresExtra =
@@ -95,7 +96,7 @@ export function LoginPage() {
95
96
  }
96
97
  };
97
98
 
98
- const handleSocialLogin = (provider) => authApi.startSocialLogin(provider);
99
+ const handleSocialLogin = (provider) => startSocialLogin(provider);
99
100
  const handleSignUp = () => navigate('/signup');
100
101
  const handleForgotPassword = () => navigate('/reset-request-password');
101
102
 
@@ -6,7 +6,7 @@ import { Typography } from '@mui/material';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import { NarrowPage } from '../layout/PageLayout';
8
8
  import PasswordSetForm from '../components/PasswordSetForm';
9
- import { authApi } from '../auth/authApi';
9
+ import { verifyResetToken, setNewPassword } from '../auth/authApi';
10
10
 
11
11
  export function PasswordInvitePage() {
12
12
  const { uid, token } = useParams();
@@ -40,7 +40,7 @@ export function PasswordInvitePage() {
40
40
 
41
41
  const check = async () => {
42
42
  try {
43
- await authApi.verifyResetToken(uid, token);
43
+ await verifyResetToken(uid, token);
44
44
  setChecked(true);
45
45
  } catch (err) {
46
46
  setErrorKey(err.code || 'Auth.RESET_LINK_INVALID');
@@ -62,7 +62,7 @@ export function PasswordInvitePage() {
62
62
  setSuccessKey(null);
63
63
 
64
64
  try {
65
- await authApi.setNewPassword(uid, token, newPassword);
65
+ await setNewPassword(uid, token, newPassword);
66
66
  setSuccessKey(
67
67
  isInvite
68
68
  ? 'Auth.RESET_PASSWORD_SUCCESS_INVITE'
@@ -11,7 +11,7 @@ import {
11
11
  import { Helmet } from 'react-helmet';
12
12
  import { useTranslation } from 'react-i18next';
13
13
  import { NarrowPage } from '../layout/PageLayout';
14
- import { authApi } from '../auth/authApi';
14
+ import { validateAccessCode, requestInviteWithCode } from '../auth/authApi';
15
15
 
16
16
  export function SignUpPage() {
17
17
  const navigate = useNavigate();
@@ -41,14 +41,14 @@ export function SignUpPage() {
41
41
 
42
42
  try {
43
43
  // 1) Access-Code prüfen
44
- const res = await authApi.validateAccessCode(accessCode);
44
+ const res = await validateAccessCode(accessCode);
45
45
  if (!res?.valid) {
46
46
  setErrorKey('Auth.ACCESS_CODE_INVALID_OR_INACTIVE');
47
47
  return;
48
48
  }
49
49
 
50
50
  // 2) Invite anfordern
51
- await authApi.requestInviteWithCode(email, accessCode);
51
+ await requestInviteWithCode(email, accessCode);
52
52
 
53
53
  setSuccessKey('Auth.INVITE_REQUEST_SUCCESS');
54
54
  } catch (err) {
@@ -0,0 +1,68 @@
1
+ // src/utils/authService.js
2
+ import { getPasskeyRegistrationOptions, completePasskeyRegistration, getPasskeyLoginOptions, completePasskeyLogin, fetchCurrentUser } from '../auth/authApi';
3
+
4
+ import {
5
+ ensureWebAuthnSupport,
6
+ serializeCredential
7
+ } from '../utils/webauthn-helpers';
8
+ import { normaliseApiError } from '../utils/auth-errors';
9
+
10
+ export async function registerPasskey(name = 'Passkey') {
11
+ ensureWebAuthnSupport();
12
+
13
+ // 1. Get Options from Server
14
+ const creationOptions = await getPasskeyRegistrationOptions();
15
+
16
+ // 2. Call Browser API
17
+ let credential;
18
+ try {
19
+ // Note: Parse JSON to Options needs a helper if creationOptions is raw JSON strings
20
+ // modern browsers/allauth usually provide correct types, but check your library version
21
+ // For standard Allauth headless, you might need window.PublicKeyCredential.parseCreationOptionsFromJSON
22
+ const pubKey = window.PublicKeyCredential.parseCreationOptionsFromJSON(creationOptions);
23
+ credential = await navigator.credentials.create({ publicKey: pubKey });
24
+ } catch (err) {
25
+ if (err.name === 'NotAllowedError') {
26
+ throw normaliseApiError(new Error('Auth.PASSKEY_CANCELLED'), 'Auth.PASSKEY_CANCELLED');
27
+ }
28
+ throw err;
29
+ }
30
+
31
+ // 3. Send back to Server
32
+ const credentialJson = serializeCredential(credential);
33
+ return completePasskeyRegistration(credentialJson, name);
34
+ }
35
+
36
+ export async function loginWithPasskey() {
37
+ ensureWebAuthnSupport();
38
+
39
+ // 1. Get Challenge
40
+ const requestOptions = await getPasskeyLoginOptions();
41
+
42
+ // 2. Browser Sign
43
+ let assertion;
44
+ try {
45
+ const pubKey = window.PublicKeyCredential.parseRequestOptionsFromJSON(requestOptions);
46
+ assertion = await navigator.credentials.get({ publicKey: pubKey });
47
+ } catch (err) {
48
+ throw normaliseApiError(new Error('Auth.PASSKEY_CANCELLED'), 'Auth.PASSKEY_CANCELLED');
49
+ }
50
+
51
+ // 3. Complete
52
+ const credentialJson = serializeCredential(assertion);
53
+ await completePasskeyLogin(credentialJson);
54
+
55
+ // 4. Reload User
56
+ return fetchCurrentUser();
57
+ }
58
+
59
+ export function startSocialLogin(provider) {
60
+ if (typeof window === 'undefined') {
61
+ throw normaliseApiError(
62
+ new Error('Auth.SOCIAL_LOGIN_NOT_IN_BROWSER'),
63
+ 'Auth.SOCIAL_LOGIN_NOT_IN_BROWSER'
64
+ );
65
+ }
66
+ // Browser-Redirect ist ein Side-Effect -> Service Layer
67
+ window.location.href = `/accounts/${provider}/login/?process=login`;
68
+ }
@@ -0,0 +1,39 @@
1
+ // src/utils/errors.js
2
+ export function extractErrorInfo(error) {
3
+ const status = error.response?.status ?? null;
4
+ const data = error.response?.data ?? null;
5
+
6
+ if (!data) {
7
+ return { status, code: null, message: error.message || null, raw: null };
8
+ }
9
+
10
+ // Allauth Headless structure often nests errors in "errors" array or "status" key
11
+ if (Array.isArray(data.errors) && data.errors.length > 0) {
12
+ // Pick the first error code
13
+ const first = data.errors[0];
14
+ return { status, code: first.code, message: first.message, raw: data };
15
+ }
16
+
17
+ if (typeof data.code === 'string') {
18
+ return { status, code: data.code, message: null, raw: data };
19
+ }
20
+
21
+ // Fallback for generic Django errors
22
+ if (typeof data.detail === 'string') {
23
+ return { status, code: 'GENERIC', message: data.detail, raw: data };
24
+ }
25
+
26
+ return { status, code: null, message: null, raw: data };
27
+ }
28
+
29
+ export function normaliseApiError(error, defaultCode = 'Auth.GENERIC_ERROR') {
30
+ const info = extractErrorInfo(error);
31
+ const code = info.code || defaultCode;
32
+ const message = info.message || code || defaultCode;
33
+
34
+ const err = new Error(message);
35
+ err.code = code;
36
+ err.status = info.status;
37
+ err.raw = info.raw;
38
+ return err;
39
+ }
@@ -0,0 +1,51 @@
1
+ // src/utils/webauthn.js
2
+ export function bufferToBase64URL(buffer) {
3
+ const bytes = new Uint8Array(buffer);
4
+ let str = '';
5
+ for (const char of bytes) {
6
+ str += String.fromCharCode(char);
7
+ }
8
+ const base64 = btoa(str);
9
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
10
+ }
11
+
12
+ export function serializeCredential(credential) {
13
+ const p = {
14
+ id: credential.id,
15
+ rawId: bufferToBase64URL(credential.rawId),
16
+ type: credential.type,
17
+ response: {
18
+ clientDataJSON: bufferToBase64URL(credential.response.clientDataJSON),
19
+ },
20
+ };
21
+
22
+ if (credential.response.attestationObject) {
23
+ p.response.attestationObject = bufferToBase64URL(credential.response.attestationObject);
24
+ }
25
+ if (credential.response.authenticatorData) {
26
+ p.response.authenticatorData = bufferToBase64URL(credential.response.authenticatorData);
27
+ }
28
+ if (credential.response.signature) {
29
+ p.response.signature = bufferToBase64URL(credential.response.signature);
30
+ }
31
+ if (credential.response.userHandle) {
32
+ p.response.userHandle = bufferToBase64URL(credential.response.userHandle);
33
+ }
34
+
35
+ if (typeof credential.getClientExtensionResults === 'function') {
36
+ p.clientExtensionResults = credential.getClientExtensionResults();
37
+ }
38
+
39
+ return p;
40
+ }
41
+
42
+ export function ensureWebAuthnSupport() {
43
+ if (
44
+ typeof window === 'undefined' ||
45
+ typeof navigator === 'undefined' ||
46
+ !window.PublicKeyCredential ||
47
+ !navigator.credentials
48
+ ) {
49
+ throw new Error('Auth.PASSKEY_NOT_SUPPORTED');
50
+ }
51
+ }