@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.
@@ -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
- const requiresExtra = user.security_state?.requires_additional_security === true;
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('/account?tab=security&from=recovery');
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('/account?tab=security&from=recovery');
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 signupEnabled = Boolean(authMethods?.signup);
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
- navigate('/login');
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 {
@@ -1,27 +1,104 @@
1
- // src/pages/SignUpPage.jsx
2
- import React, { useState } from 'react';
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 { validateAccessCode, requestInviteWithCode } from '../auth/authApi';
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
- if (!accessCode) {
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
- setSubmitting(true);
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
- // 1) Access-Code prüfen
44
- const res = await validateAccessCode(accessCode);
45
- if (!res?.valid) {
46
- setErrorKey('Auth.ACCESS_CODE_INVALID_OR_INACTIVE');
47
- return;
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
- <TextField
105
- label={t('Auth.ACCESS_CODE_LABEL')}
106
- type="text"
107
- required
108
- fullWidth
109
- value={accessCode}
110
- onChange={(e) => setAccessCode(e.target.value)}
111
- disabled={submitting}
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')