@micha.bigler/ui-core-micha 2.1.20 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/AuthContext.js +4 -0
- package/dist/auth/authApi.js +45 -3
- package/dist/components/AccessCodeManager.js +39 -3
- package/dist/components/AuthFactorRequirementCard.js +49 -0
- package/dist/components/BulkInviteCsvTab.js +2 -2
- package/dist/components/LoginForm.js +1 -1
- package/dist/components/QrSignupManager.js +81 -0
- package/dist/components/RegistrationMethodsManager.js +91 -0
- package/dist/components/UserInviteComponent.js +2 -4
- package/dist/components/UserListComponent.js +130 -105
- package/dist/index.js +3 -0
- package/dist/pages/AccountPage.js +6 -2
- package/dist/pages/LoginPage.js +31 -25
- package/dist/pages/PasswordInvitePage.js +6 -1
- package/dist/pages/SignUpPage.js +76 -16
- package/package.json +2 -1
- package/src/auth/AuthContext.jsx +4 -0
- package/src/auth/authApi.jsx +51 -3
- package/src/components/AccessCodeManager.jsx +71 -8
- package/src/components/AuthFactorRequirementCard.jsx +74 -0
- package/src/components/BulkInviteCsvTab.jsx +2 -2
- package/src/components/LoginForm.jsx +7 -6
- package/src/components/QrSignupManager.jsx +128 -0
- package/src/components/RegistrationMethodsManager.jsx +184 -0
- package/src/components/UserInviteComponent.jsx +2 -4
- package/src/components/UserListComponent.jsx +216 -246
- package/src/index.js +3 -0
- package/src/pages/AccountPage.jsx +23 -1
- package/src/pages/LoginPage.jsx +43 -23
- package/src/pages/PasswordInvitePage.jsx +6 -1
- package/src/pages/SignUpPage.jsx +145 -30
package/src/pages/LoginPage.jsx
CHANGED
|
@@ -39,6 +39,24 @@ export function LoginPage() {
|
|
|
39
39
|
: recoveryTokenRaw;
|
|
40
40
|
// Backward-compatible fallback for legacy links using query parameters.
|
|
41
41
|
const recoveryEmail = hashParams.get('email') || params.get('email') || '';
|
|
42
|
+
const requestedNext = params.get('next');
|
|
43
|
+
|
|
44
|
+
const getRedirectTarget = (currentUser, options = {}) => {
|
|
45
|
+
if (options.forceSecurityRedirect) {
|
|
46
|
+
return options.forceSecurityRedirect;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const requiresExtra = currentUser?.security_state?.requires_additional_security === true;
|
|
50
|
+
if (requiresExtra) {
|
|
51
|
+
return '/account?tab=security&from=weak_login';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (requestedNext && requestedNext.startsWith('/')) {
|
|
55
|
+
return requestedNext;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return '/';
|
|
59
|
+
};
|
|
42
60
|
|
|
43
61
|
useEffect(() => {
|
|
44
62
|
const socialError = params.get('error') || params.get('social');
|
|
@@ -49,28 +67,13 @@ export function LoginPage() {
|
|
|
49
67
|
|
|
50
68
|
useEffect(() => {
|
|
51
69
|
if (loading || !user) return;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (requiresExtra) {
|
|
55
|
-
navigate('/account?tab=security&from=weak_login', { replace: true });
|
|
56
|
-
} else {
|
|
57
|
-
navigate('/', { replace: true });
|
|
58
|
-
}
|
|
59
|
-
}, [loading, user, navigate]);
|
|
70
|
+
navigate(getRedirectTarget(user), { replace: true });
|
|
71
|
+
}, [loading, user, navigate, requestedNext]);
|
|
60
72
|
|
|
61
73
|
// --- Helper: Central Success Logic ---
|
|
62
74
|
const handleLoginSuccess = (user) => {
|
|
63
75
|
login(user); // Update Context
|
|
64
|
-
|
|
65
|
-
// Check if "Strong Security" is enforced/required but not met
|
|
66
|
-
const requiresExtra = user.security_state?.requires_additional_security === true;
|
|
67
|
-
|
|
68
|
-
if (requiresExtra) {
|
|
69
|
-
navigate('/account?tab=security&from=weak_login');
|
|
70
|
-
} else {
|
|
71
|
-
// Standard Redirect (könnte man noch mit ?next=... erweitern)
|
|
72
|
-
navigate('/');
|
|
73
|
-
}
|
|
76
|
+
navigate(getRedirectTarget(user));
|
|
74
77
|
};
|
|
75
78
|
|
|
76
79
|
// --- Handlers ---
|
|
@@ -86,9 +89,12 @@ export function LoginPage() {
|
|
|
86
89
|
password,
|
|
87
90
|
recoveryToken
|
|
88
91
|
);
|
|
89
|
-
// Recovery login implies a specific redirect usually, usually straight to security settings
|
|
90
92
|
login(result.user);
|
|
91
|
-
navigate(
|
|
93
|
+
navigate(
|
|
94
|
+
getRedirectTarget(result.user, {
|
|
95
|
+
forceSecurityRedirect: '/account?tab=security&from=recovery',
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
92
98
|
return;
|
|
93
99
|
}
|
|
94
100
|
|
|
@@ -131,9 +137,12 @@ export function LoginPage() {
|
|
|
131
137
|
const handleMfaSuccess = ({ user, method }) => {
|
|
132
138
|
// MFA component should return the user object after verifying code
|
|
133
139
|
if (method === 'recovery_code') {
|
|
134
|
-
// Recovery codes often trigger a security check prompt
|
|
135
140
|
login(user);
|
|
136
|
-
navigate(
|
|
141
|
+
navigate(
|
|
142
|
+
getRedirectTarget(user, {
|
|
143
|
+
forceSecurityRedirect: '/account?tab=security&from=recovery',
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
137
146
|
} else {
|
|
138
147
|
handleLoginSuccess(user);
|
|
139
148
|
}
|
|
@@ -151,8 +160,13 @@ export function LoginPage() {
|
|
|
151
160
|
const passwordLoginEnabled = Boolean(authMethods?.password_login) || Boolean(recoveryToken);
|
|
152
161
|
const socialLoginEnabled = Boolean(authMethods?.social_login) && socialProviders.length > 0;
|
|
153
162
|
const passkeyLoginEnabled = Boolean(authMethods?.passkey_login);
|
|
154
|
-
const
|
|
163
|
+
const signupModes = Array.isArray(authMethods?.signup_modes)
|
|
164
|
+
? authMethods.signup_modes.filter(Boolean)
|
|
165
|
+
: [];
|
|
166
|
+
const signupEnabled = signupModes.length > 0 || Boolean(authMethods?.signup);
|
|
155
167
|
const passwordResetEnabled = Boolean(authMethods?.password_reset);
|
|
168
|
+
const twoFactorRequired = Boolean(authMethods?.two_factor_required)
|
|
169
|
+
|| Number(authMethods?.required_auth_factor_count || 1) >= 2;
|
|
156
170
|
|
|
157
171
|
// --- Render ---
|
|
158
172
|
|
|
@@ -177,6 +191,12 @@ export function LoginPage() {
|
|
|
177
191
|
</Alert>
|
|
178
192
|
)}
|
|
179
193
|
|
|
194
|
+
{twoFactorRequired && !recoveryToken && (
|
|
195
|
+
<Alert severity="info" sx={{ mb: 2 }}>
|
|
196
|
+
{t('Auth.TWO_FACTOR_REQUIRED_HINT', 'This app requires two authentication factors for full access.')}
|
|
197
|
+
</Alert>
|
|
198
|
+
)}
|
|
199
|
+
|
|
180
200
|
{step === 'credentials' && (
|
|
181
201
|
<LoginForm
|
|
182
202
|
onSubmit={passwordLoginEnabled ? handleSubmitCredentials : null}
|
|
@@ -13,6 +13,8 @@ export function PasswordInvitePage() {
|
|
|
13
13
|
const location = useLocation();
|
|
14
14
|
const navigate = useNavigate();
|
|
15
15
|
const { t } = useTranslation();
|
|
16
|
+
const searchParams = new URLSearchParams(location.search);
|
|
17
|
+
const nextPath = searchParams.get('next');
|
|
16
18
|
|
|
17
19
|
const [submitting, setSubmitting] = useState(false);
|
|
18
20
|
const [errorKey, setErrorKey] = useState(null);
|
|
@@ -68,7 +70,10 @@ export function PasswordInvitePage() {
|
|
|
68
70
|
? 'Auth.RESET_PASSWORD_SUCCESS_INVITE'
|
|
69
71
|
: 'Auth.RESET_PASSWORD_SUCCESS_RESET',
|
|
70
72
|
);
|
|
71
|
-
|
|
73
|
+
const target = nextPath
|
|
74
|
+
? `/login?next=${encodeURIComponent(nextPath)}`
|
|
75
|
+
: '/login';
|
|
76
|
+
navigate(target);
|
|
72
77
|
} catch (err) {
|
|
73
78
|
setErrorKey(err.code || 'Auth.RESET_PASSWORD_FAILED');
|
|
74
79
|
} finally {
|
package/src/pages/SignUpPage.jsx
CHANGED
|
@@ -1,27 +1,104 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
import { useNavigate } from 'react-router-dom';
|
|
1
|
+
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useNavigate, useLocation } from 'react-router-dom';
|
|
4
3
|
import {
|
|
4
|
+
Alert,
|
|
5
5
|
Box,
|
|
6
|
-
TextField,
|
|
7
6
|
Button,
|
|
7
|
+
Stack,
|
|
8
|
+
TextField,
|
|
8
9
|
Typography,
|
|
9
|
-
Alert,
|
|
10
10
|
} from '@mui/material';
|
|
11
11
|
import { Helmet } from 'react-helmet';
|
|
12
12
|
import { useTranslation } from 'react-i18next';
|
|
13
13
|
import { NarrowPage } from '../layout/PageLayout';
|
|
14
|
-
import {
|
|
14
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
15
|
+
import { submitRegistrationRequest } from '../auth/authApi';
|
|
16
|
+
|
|
17
|
+
const MODE_LABELS = {
|
|
18
|
+
self_signup_access_code: 'Auth.SIGNUP_ACCESS_CODE_TAB',
|
|
19
|
+
self_signup_open: 'Auth.SIGNUP_OPEN_TAB',
|
|
20
|
+
self_signup_email_domain: 'Auth.SIGNUP_EMAIL_DOMAIN_TAB',
|
|
21
|
+
self_signup_qr: 'Auth.SIGNUP_QR_TAB',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const MODE_HINTS = {
|
|
25
|
+
self_signup_access_code: 'Auth.SIGNUP_ACCESS_CODE_HINT',
|
|
26
|
+
self_signup_open: 'Auth.SIGNUP_OPEN_HINT',
|
|
27
|
+
self_signup_email_domain: 'Auth.SIGNUP_EMAIL_DOMAIN_HINT',
|
|
28
|
+
self_signup_qr: 'Auth.SIGNUP_QR_HINT',
|
|
29
|
+
};
|
|
15
30
|
|
|
16
31
|
export function SignUpPage() {
|
|
17
32
|
const navigate = useNavigate();
|
|
33
|
+
const location = useLocation();
|
|
18
34
|
const { t } = useTranslation();
|
|
35
|
+
const { authMethods } = useContext(AuthContext);
|
|
36
|
+
|
|
37
|
+
const signupModes = useMemo(() => {
|
|
38
|
+
const configured = Array.isArray(authMethods?.signup_modes)
|
|
39
|
+
? authMethods.signup_modes.filter(Boolean)
|
|
40
|
+
: [];
|
|
41
|
+
if (configured.length > 0) {
|
|
42
|
+
return configured;
|
|
43
|
+
}
|
|
44
|
+
return authMethods?.signup ? ['self_signup_access_code'] : [];
|
|
45
|
+
}, [authMethods]);
|
|
46
|
+
|
|
47
|
+
const query = new URLSearchParams(location.search);
|
|
48
|
+
const tokenFromUrl = query.get('rt') || '';
|
|
49
|
+
|
|
50
|
+
const initialMode = useMemo(() => {
|
|
51
|
+
if (tokenFromUrl && signupModes.includes('self_signup_qr')) {
|
|
52
|
+
return 'self_signup_qr';
|
|
53
|
+
}
|
|
54
|
+
return signupModes[0] || 'self_signup_access_code';
|
|
55
|
+
}, [signupModes, tokenFromUrl]);
|
|
19
56
|
|
|
57
|
+
const [mode, setMode] = useState(initialMode);
|
|
20
58
|
const [email, setEmail] = useState('');
|
|
21
59
|
const [accessCode, setAccessCode] = useState('');
|
|
22
60
|
const [submitting, setSubmitting] = useState(false);
|
|
23
61
|
const [successKey, setSuccessKey] = useState(null);
|
|
24
62
|
const [errorKey, setErrorKey] = useState(null);
|
|
63
|
+
const [qrHint, setQrHint] = useState('');
|
|
64
|
+
|
|
65
|
+
const modeHint = useMemo(() => {
|
|
66
|
+
if (mode === 'self_signup_access_code') {
|
|
67
|
+
return t(
|
|
68
|
+
MODE_HINTS[mode],
|
|
69
|
+
'Use this option only if you were given an access code for signup.',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (mode === 'self_signup_open') {
|
|
73
|
+
return t(
|
|
74
|
+
MODE_HINTS[mode],
|
|
75
|
+
'Use this option for direct signup without an access code.',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (mode === 'self_signup_email_domain') {
|
|
79
|
+
return t(
|
|
80
|
+
MODE_HINTS[mode],
|
|
81
|
+
'Use an email address from an allowed domain for this signup flow.',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (mode === 'self_signup_qr') {
|
|
85
|
+
return qrHint || t(MODE_HINTS[mode], 'Use a valid QR signup link to continue.');
|
|
86
|
+
}
|
|
87
|
+
return '';
|
|
88
|
+
}, [mode, qrHint, t]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
setMode(initialMode);
|
|
92
|
+
}, [initialMode]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!tokenFromUrl || mode !== 'self_signup_qr') {
|
|
96
|
+
setQrHint('');
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
setQrHint(t('Auth.SIGNUP_QR_READY', 'QR signup token detected. You can complete your signup now.'));
|
|
100
|
+
return undefined;
|
|
101
|
+
}, [mode, tokenFromUrl, t]);
|
|
25
102
|
|
|
26
103
|
const handleSubmit = async (event) => {
|
|
27
104
|
event.preventDefault();
|
|
@@ -32,27 +109,27 @@ export function SignUpPage() {
|
|
|
32
109
|
setErrorKey('Auth.EMAIL_REQUIRED');
|
|
33
110
|
return;
|
|
34
111
|
}
|
|
35
|
-
|
|
112
|
+
|
|
113
|
+
if (mode === 'self_signup_access_code' && !accessCode) {
|
|
36
114
|
setErrorKey('Auth.SIGNUP_ACCESS_CODE_REQUIRED');
|
|
37
115
|
return;
|
|
38
116
|
}
|
|
39
117
|
|
|
40
|
-
|
|
118
|
+
if (mode === 'self_signup_qr' && !tokenFromUrl) {
|
|
119
|
+
setErrorKey('Auth.SIGNUP_QR_INVALID');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
41
122
|
|
|
123
|
+
setSubmitting(true);
|
|
42
124
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// 2) Invite anfordern
|
|
51
|
-
await requestInviteWithCode(email, accessCode);
|
|
52
|
-
|
|
125
|
+
await submitRegistrationRequest({
|
|
126
|
+
email,
|
|
127
|
+
mode,
|
|
128
|
+
accessCode,
|
|
129
|
+
registrationContextToken: mode === 'self_signup_qr' ? tokenFromUrl : null,
|
|
130
|
+
});
|
|
53
131
|
setSuccessKey('Auth.INVITE_REQUEST_SUCCESS');
|
|
54
132
|
} catch (err) {
|
|
55
|
-
// validateAccessCode / requestInviteWithCode liefern normalisierte Errors
|
|
56
133
|
setErrorKey(err.code || 'Auth.INVITE_FAILED');
|
|
57
134
|
} finally {
|
|
58
135
|
setSubmitting(false);
|
|
@@ -82,15 +159,40 @@ export function SignUpPage() {
|
|
|
82
159
|
|
|
83
160
|
{errorKey && (
|
|
84
161
|
<Alert severity="error" sx={{ mb: 2 }}>
|
|
85
|
-
{t(errorKey)}
|
|
162
|
+
{t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.'))}
|
|
86
163
|
</Alert>
|
|
87
164
|
)}
|
|
88
165
|
|
|
166
|
+
{signupModes.length > 1 && (
|
|
167
|
+
<Stack spacing={1} sx={{ mb: 2 }}>
|
|
168
|
+
<Alert severity="info">
|
|
169
|
+
{t(
|
|
170
|
+
'Auth.SIGNUP_MODE_SELECTOR_HINT',
|
|
171
|
+
'Choose the signup option that matches how you want to register.',
|
|
172
|
+
)}
|
|
173
|
+
</Alert>
|
|
174
|
+
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} flexWrap="wrap">
|
|
175
|
+
{signupModes.map((entry) => (
|
|
176
|
+
<Button
|
|
177
|
+
key={entry}
|
|
178
|
+
variant={mode === entry ? 'contained' : 'outlined'}
|
|
179
|
+
onClick={() => setMode(entry)}
|
|
180
|
+
disabled={submitting}
|
|
181
|
+
>
|
|
182
|
+
{t(MODE_LABELS[entry] || entry, entry)}
|
|
183
|
+
</Button>
|
|
184
|
+
))}
|
|
185
|
+
</Stack>
|
|
186
|
+
</Stack>
|
|
187
|
+
)}
|
|
188
|
+
|
|
89
189
|
<Box
|
|
90
190
|
component="form"
|
|
91
191
|
onSubmit={handleSubmit}
|
|
92
192
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
|
93
193
|
>
|
|
194
|
+
{modeHint && <Alert severity="info">{modeHint}</Alert>}
|
|
195
|
+
|
|
94
196
|
<TextField
|
|
95
197
|
label={t('Auth.EMAIL_LABEL')}
|
|
96
198
|
type="email"
|
|
@@ -101,20 +203,33 @@ export function SignUpPage() {
|
|
|
101
203
|
disabled={submitting}
|
|
102
204
|
/>
|
|
103
205
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
206
|
+
{mode === 'self_signup_access_code' && (
|
|
207
|
+
<TextField
|
|
208
|
+
label={t('Auth.ACCESS_CODE_LABEL')}
|
|
209
|
+
type="text"
|
|
210
|
+
required
|
|
211
|
+
fullWidth
|
|
212
|
+
value={accessCode}
|
|
213
|
+
onChange={(e) => setAccessCode(e.target.value)}
|
|
214
|
+
disabled={submitting}
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
{mode === 'self_signup_qr' && (
|
|
219
|
+
<Stack spacing={1}>
|
|
220
|
+
<TextField
|
|
221
|
+
label={t('Auth.SIGNUP_QR_TOKEN_LABEL', 'QR token')}
|
|
222
|
+
value={tokenFromUrl}
|
|
223
|
+
fullWidth
|
|
224
|
+
InputProps={{ readOnly: true }}
|
|
225
|
+
/>
|
|
226
|
+
</Stack>
|
|
227
|
+
)}
|
|
113
228
|
|
|
114
229
|
<Button
|
|
115
230
|
type="submit"
|
|
116
231
|
variant="contained"
|
|
117
|
-
disabled={submitting}
|
|
232
|
+
disabled={submitting || signupModes.length === 0}
|
|
118
233
|
>
|
|
119
234
|
{submitting
|
|
120
235
|
? t('Auth.SIGNUP_SUBMITTING')
|