@micha.bigler/ui-core-micha 2.2.0 → 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/authApi.js +0 -19
- package/dist/components/AuthFactorRequirementCard.js +8 -4
- package/dist/components/LoginForm.js +1 -1
- package/dist/components/QrSignupManager.js +55 -17
- package/dist/components/RegistrationMethodsManager.js +26 -4
- package/dist/pages/AccountPage.js +3 -2
- package/dist/pages/LoginPage.js +24 -23
- package/dist/pages/PasswordInvitePage.js +6 -1
- package/dist/pages/SignUpPage.js +23 -2
- package/package.json +1 -1
- package/src/auth/authApi.jsx +0 -20
- package/src/components/AuthFactorRequirementCard.jsx +9 -9
- package/src/components/LoginForm.jsx +7 -6
- package/src/components/QrSignupManager.jsx +67 -23
- package/src/components/RegistrationMethodsManager.jsx +66 -9
- package/src/pages/AccountPage.jsx +4 -3
- package/src/pages/LoginPage.jsx +31 -22
- package/src/pages/PasswordInvitePage.jsx +6 -1
- package/src/pages/SignUpPage.jsx +54 -29
package/dist/auth/authApi.js
CHANGED
|
@@ -19,15 +19,6 @@ export async function fetchAuthMethods() {
|
|
|
19
19
|
const res = await apiClient.get('/api/auth-methods/');
|
|
20
20
|
return res.data || {};
|
|
21
21
|
}
|
|
22
|
-
export async function fetchRegistrationOptions() {
|
|
23
|
-
try {
|
|
24
|
-
const res = await apiClient.get(`${USERS_BASE}/registration-options/`);
|
|
25
|
-
return res.data || {};
|
|
26
|
-
}
|
|
27
|
-
catch (error) {
|
|
28
|
-
throw normaliseApiError(error, 'Auth.REGISTRATION_OPTIONS_FAILED');
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
22
|
export async function fetchAuthPolicy() {
|
|
32
23
|
try {
|
|
33
24
|
const res = await apiClient.get(`${USERS_BASE}/auth-policy/`);
|
|
@@ -372,16 +363,6 @@ export async function createSignupQr(payload = {}) {
|
|
|
372
363
|
throw normaliseApiError(error, 'Auth.SIGNUP_QR_CREATE_FAILED');
|
|
373
364
|
}
|
|
374
365
|
}
|
|
375
|
-
export async function requestInviteWithCode(email, accessCode) {
|
|
376
|
-
if (accessCode) {
|
|
377
|
-
return submitRegistrationRequest({
|
|
378
|
-
email,
|
|
379
|
-
mode: 'self_signup_access_code',
|
|
380
|
-
accessCode,
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
return sendAdminInvite(email);
|
|
384
|
-
}
|
|
385
366
|
// -----------------------------
|
|
386
367
|
// Recovery Support (Admin/Support Side)
|
|
387
368
|
// -----------------------------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React, { useEffect, useState } from 'react';
|
|
3
|
-
import { Alert, Box,
|
|
3
|
+
import { Alert, Box, FormControl, FormControlLabel, Radio, RadioGroup, Typography, } from '@mui/material';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { fetchAuthPolicy, updateAuthPolicy } from '../auth/authApi';
|
|
6
6
|
export function AuthFactorRequirementCard() {
|
|
@@ -26,20 +26,24 @@ export function AuthFactorRequirementCard() {
|
|
|
26
26
|
active = false;
|
|
27
27
|
};
|
|
28
28
|
}, []);
|
|
29
|
-
const
|
|
29
|
+
const handleChange = async (event) => {
|
|
30
|
+
const nextValue = event.target.value;
|
|
31
|
+
const previous = value;
|
|
32
|
+
setValue(nextValue);
|
|
30
33
|
setBusy(true);
|
|
31
34
|
setError('');
|
|
32
35
|
setSuccess('');
|
|
33
36
|
try {
|
|
34
|
-
await updateAuthPolicy({ required_auth_factor_count: Number(
|
|
37
|
+
await updateAuthPolicy({ required_auth_factor_count: Number(nextValue) });
|
|
35
38
|
setSuccess(t('Auth.AUTH_FACTOR_SAVE_SUCCESS', 'Factor requirement saved.'));
|
|
36
39
|
}
|
|
37
40
|
catch (err) {
|
|
41
|
+
setValue(previous);
|
|
38
42
|
setError(t((err === null || err === void 0 ? void 0 : err.code) || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save factor requirement.'));
|
|
39
43
|
}
|
|
40
44
|
finally {
|
|
41
45
|
setBusy(false);
|
|
42
46
|
}
|
|
43
47
|
};
|
|
44
|
-
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.AUTH_FACTOR_TITLE', 'Authentication Factors') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.AUTH_FACTOR_HINT', 'Choose whether one factor is enough or two factors are required.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsx(FormControl, { children: _jsxs(RadioGroup, { value: value, onChange:
|
|
48
|
+
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.AUTH_FACTOR_TITLE', 'Authentication Factors') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.AUTH_FACTOR_HINT', 'Choose whether one factor is enough or two factors are required.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsx(FormControl, { children: _jsxs(RadioGroup, { value: value, onChange: handleChange, children: [_jsx(FormControlLabel, { value: "1", control: _jsx(Radio, { disabled: busy }), label: t('Auth.ONE_FACTOR_LABEL', 'One factor is enough') }), _jsx(FormControlLabel, { value: "2", control: _jsx(Radio, { disabled: busy }), label: t('Auth.TWO_FACTOR_LABEL', 'Two factors are required') })] }) })] }));
|
|
45
49
|
}
|
|
@@ -21,6 +21,6 @@ disabled = false, initialIdentifier = '', }) {
|
|
|
21
21
|
const supportsPasskey = !!onPasskeyLogin &&
|
|
22
22
|
typeof window !== 'undefined' &&
|
|
23
23
|
!!window.PublicKeyCredential;
|
|
24
|
-
return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 3 }, children: [error && (_jsx(Typography, { color: "error", gutterBottom: true, children: error })), supportsPasskey && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "contained", fullWidth: true, type: "button", onClick: onPasskeyLogin, disabled: disabled, children: t('Auth.LOGIN_USE_PASSKEY_BUTTON') }), _jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') })] })), onSubmit && (_jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: identifier, onChange: (e) => setIdentifier(e.target.value), disabled: disabled }), _jsx(TextField, { label: t('Auth.LOGIN_PASSWORD_LABEL'), type: "password", required: true, fullWidth: true, value: password, onChange: (e) => setPassword(e.target.value), disabled: disabled }), _jsx(Button, { type: "submit", variant: "contained", fullWidth: true, disabled: disabled, children: t('Auth.PAGE_LOGIN_TITLE') })] })), onSocialLogin && (_jsxs(Box, { children: [_jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') }), _jsx(SocialLoginButtons, { onProviderClick: onSocialLogin, providers: socialProviders })] })), (onSignUp || onForgotPassword) && (_jsxs(Box, { children: [_jsx(
|
|
24
|
+
return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 3 }, children: [error && (_jsx(Typography, { color: "error", gutterBottom: true, children: error })), supportsPasskey && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "contained", fullWidth: true, type: "button", onClick: onPasskeyLogin, disabled: disabled, children: t('Auth.LOGIN_USE_PASSKEY_BUTTON') }), _jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') })] })), onSubmit && (_jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: identifier, onChange: (e) => setIdentifier(e.target.value), disabled: disabled }), _jsx(TextField, { label: t('Auth.LOGIN_PASSWORD_LABEL'), type: "password", required: true, fullWidth: true, value: password, onChange: (e) => setPassword(e.target.value), disabled: disabled }), _jsx(Button, { type: "submit", variant: "contained", fullWidth: true, disabled: disabled, children: t('Auth.PAGE_LOGIN_TITLE') })] })), onSocialLogin && (_jsxs(Box, { children: [_jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') }), _jsx(SocialLoginButtons, { onProviderClick: onSocialLogin, providers: socialProviders })] })), (onSignUp || onForgotPassword) && (_jsxs(Box, { children: [_jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') }), _jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 1.5 }, children: [onSignUp && (_jsx(Button, { type: "button", variant: "outlined", onClick: onSignUp, disabled: disabled, fullWidth: true, children: t('Auth.LOGIN_SIGNUP_BUTTON') })), onForgotPassword && (_jsx(Button, { type: "button", variant: "outlined", onClick: onForgotPassword, disabled: disabled, fullWidth: true, children: t('Auth.LOGIN_FORGOT_PASSWORD_BUTTON') }))] })] }))] }));
|
|
25
25
|
}
|
|
26
26
|
;
|
|
@@ -1,36 +1,32 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useState } from 'react';
|
|
3
|
-
import { Alert, Box, Button,
|
|
2
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Alert, Box, Button, TextField, Typography, } from '@mui/material';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { QRCodeSVG } from 'qrcode.react';
|
|
6
6
|
import { createSignupQr } from '../auth/authApi';
|
|
7
|
-
export function QrSignupManager() {
|
|
7
|
+
export function QrSignupManager({ enabled = false }) {
|
|
8
8
|
const { t } = useTranslation();
|
|
9
9
|
const [label, setLabel] = useState('');
|
|
10
|
-
const [eventRef, setEventRef] = useState('');
|
|
11
|
-
const [courseRef, setCourseRef] = useState('');
|
|
12
|
-
const [groupRef, setGroupRef] = useState('');
|
|
13
10
|
const [busy, setBusy] = useState(false);
|
|
14
11
|
const [error, setError] = useState('');
|
|
12
|
+
const [success, setSuccess] = useState('');
|
|
15
13
|
const [result, setResult] = useState(null);
|
|
14
|
+
const hasGeneratedRef = useRef(false);
|
|
16
15
|
const generate = async () => {
|
|
16
|
+
if (!enabled) {
|
|
17
|
+
setResult(null);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
17
20
|
setBusy(true);
|
|
18
21
|
setError('');
|
|
22
|
+
setSuccess('');
|
|
19
23
|
try {
|
|
20
|
-
const registrationContext = {
|
|
21
|
-
schema_version: '1',
|
|
22
|
-
};
|
|
23
|
-
if (eventRef.trim())
|
|
24
|
-
registrationContext.event_ref = eventRef.trim();
|
|
25
|
-
if (courseRef.trim())
|
|
26
|
-
registrationContext.course_ref = courseRef.trim();
|
|
27
|
-
if (groupRef.trim())
|
|
28
|
-
registrationContext.group_ref = groupRef.trim();
|
|
29
24
|
const data = await createSignupQr({
|
|
30
25
|
label,
|
|
31
|
-
registration_context: registrationContext,
|
|
32
26
|
});
|
|
33
27
|
setResult(data);
|
|
28
|
+
hasGeneratedRef.current = true;
|
|
29
|
+
setSuccess(t('Auth.SIGNUP_QR_SAVE_SUCCESS', 'QR signup link updated.'));
|
|
34
30
|
}
|
|
35
31
|
catch (err) {
|
|
36
32
|
setError(t((err === null || err === void 0 ? void 0 : err.code) || 'Auth.SIGNUP_QR_CREATE_FAILED', 'Could not create signup QR.'));
|
|
@@ -39,5 +35,47 @@ export function QrSignupManager() {
|
|
|
39
35
|
setBusy(false);
|
|
40
36
|
}
|
|
41
37
|
};
|
|
42
|
-
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!enabled) {
|
|
40
|
+
setResult(null);
|
|
41
|
+
setError('');
|
|
42
|
+
setSuccess('');
|
|
43
|
+
hasGeneratedRef.current = false;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (hasGeneratedRef.current) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
let active = true;
|
|
50
|
+
const ensureInitialQr = async () => {
|
|
51
|
+
setBusy(true);
|
|
52
|
+
setError('');
|
|
53
|
+
setSuccess('');
|
|
54
|
+
try {
|
|
55
|
+
const data = await createSignupQr({
|
|
56
|
+
label,
|
|
57
|
+
});
|
|
58
|
+
if (!active)
|
|
59
|
+
return;
|
|
60
|
+
setResult(data);
|
|
61
|
+
hasGeneratedRef.current = true;
|
|
62
|
+
setSuccess(t('Auth.SIGNUP_QR_SAVE_SUCCESS', 'QR signup link updated.'));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (!active)
|
|
66
|
+
return;
|
|
67
|
+
setError(t((err === null || err === void 0 ? void 0 : err.code) || 'Auth.SIGNUP_QR_CREATE_FAILED', 'Could not create signup QR.'));
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
if (active) {
|
|
71
|
+
setBusy(false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
ensureInitialQr();
|
|
76
|
+
return () => {
|
|
77
|
+
active = false;
|
|
78
|
+
};
|
|
79
|
+
}, [enabled, label, t]);
|
|
80
|
+
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.SIGNUP_QR_MANAGER_TITLE', 'QR Signup') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.SIGNUP_QR_MANAGER_HINT', 'When QR signup is enabled, a signup QR code is shown here immediately.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), !enabled && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.SIGNUP_QR_DISABLED_HINT', 'Enable self-signup by QR above to show a QR code here.') })), _jsx(TextField, { label: t('Common.LABEL', 'Label'), value: label, onChange: (event) => setLabel(event.target.value), fullWidth: true, disabled: !enabled || busy }), _jsx(Button, { variant: "contained", sx: { mt: 2 }, onClick: generate, disabled: !enabled || busy, children: t('Common.SAVE', 'Save') }), (result === null || result === void 0 ? void 0 : result.signup_url) && (_jsxs(Box, { sx: { mt: 3 }, children: [_jsx(QRCodeSVG, { value: result.signup_url, size: 180, includeMargin: true }), _jsx(TextField, { label: t('Auth.SIGNUP_QR_LINK_LABEL', 'Signup link'), value: result.signup_url, fullWidth: true, multiline: true, minRows: 2, sx: { mt: 2 }, InputProps: { readOnly: true } })] }))] }));
|
|
43
81
|
}
|
|
@@ -17,6 +17,7 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
|
|
|
17
17
|
const [policy, setPolicy] = useState(EMPTY_POLICY);
|
|
18
18
|
const [domainsText, setDomainsText] = useState('');
|
|
19
19
|
const [busy, setBusy] = useState(false);
|
|
20
|
+
const [busyField, setBusyField] = useState('');
|
|
20
21
|
const [error, setError] = useState('');
|
|
21
22
|
const [success, setSuccess] = useState('');
|
|
22
23
|
useEffect(() => {
|
|
@@ -28,6 +29,8 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
|
|
|
28
29
|
return;
|
|
29
30
|
setPolicy((prev) => (Object.assign(Object.assign({}, prev), data)));
|
|
30
31
|
setDomainsText(((data === null || data === void 0 ? void 0 : data.allowed_email_domains) || []).join('\n'));
|
|
32
|
+
if (onPolicyChange)
|
|
33
|
+
onPolicyChange(data);
|
|
31
34
|
}
|
|
32
35
|
catch (err) {
|
|
33
36
|
if (active) {
|
|
@@ -38,9 +41,28 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
|
|
|
38
41
|
return () => {
|
|
39
42
|
active = false;
|
|
40
43
|
};
|
|
41
|
-
}, [t]);
|
|
42
|
-
const toggle = (field) => (_event, checked) => {
|
|
44
|
+
}, [onPolicyChange, t]);
|
|
45
|
+
const toggle = (field) => async (_event, checked) => {
|
|
46
|
+
const previous = policy[field];
|
|
43
47
|
setPolicy((prev) => (Object.assign(Object.assign({}, prev), { [field]: checked })));
|
|
48
|
+
setBusyField(field);
|
|
49
|
+
setError('');
|
|
50
|
+
setSuccess('');
|
|
51
|
+
try {
|
|
52
|
+
const next = await updateAuthPolicy({ [field]: checked });
|
|
53
|
+
setPolicy((prev) => (Object.assign(Object.assign({}, prev), next)));
|
|
54
|
+
setDomainsText(((next === null || next === void 0 ? void 0 : next.allowed_email_domains) || []).join('\n'));
|
|
55
|
+
setSuccess(t('Auth.AUTH_POLICY_SAVE_SUCCESS', 'Authentication settings saved.'));
|
|
56
|
+
if (onPolicyChange)
|
|
57
|
+
onPolicyChange(next);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
setPolicy((prev) => (Object.assign(Object.assign({}, prev), { [field]: previous })));
|
|
61
|
+
setError(t((err === null || err === void 0 ? void 0 : err.code) || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save authentication settings.'));
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
setBusyField('');
|
|
65
|
+
}
|
|
44
66
|
};
|
|
45
67
|
const save = async () => {
|
|
46
68
|
setBusy(true);
|
|
@@ -51,7 +73,7 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
|
|
|
51
73
|
.split(/\r?\n/)
|
|
52
74
|
.map((value) => value.trim())
|
|
53
75
|
.filter(Boolean);
|
|
54
|
-
const next = await updateAuthPolicy(
|
|
76
|
+
const next = await updateAuthPolicy({ allowed_email_domains });
|
|
55
77
|
setPolicy((prev) => (Object.assign(Object.assign({}, prev), next)));
|
|
56
78
|
setDomainsText(((next === null || next === void 0 ? void 0 : next.allowed_email_domains) || allowed_email_domains).join('\n'));
|
|
57
79
|
setSuccess(t('Auth.AUTH_POLICY_SAVE_SUCCESS', 'Authentication settings saved.'));
|
|
@@ -65,5 +87,5 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
|
|
|
65
87
|
setBusy(false);
|
|
66
88
|
}
|
|
67
89
|
};
|
|
68
|
-
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.REGISTRATION_METHODS_TITLE', 'Registration Methods') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.REGISTRATION_METHODS_HINT', 'Choose which signup and invite flows are active for this app.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsxs(Stack, { spacing: 1, children: [_jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(policy.allow_admin_invite), onChange: toggle('allow_admin_invite') }), label: t('Auth.ADMIN_INVITE_LABEL', 'Admin invite') }), _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(policy.allow_self_signup_access_code), onChange: toggle('allow_self_signup_access_code') }), label: t('Auth.SIGNUP_ACCESS_CODE_LABEL', 'Self-signup with access code') }), _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(policy.allow_self_signup_open), onChange: toggle('allow_self_signup_open') }), label: t('Auth.SIGNUP_OPEN_LABEL', 'Open self-signup') }), _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(policy.allow_self_signup_email_domain), onChange: toggle('allow_self_signup_email_domain') }), label: t('Auth.SIGNUP_EMAIL_DOMAIN_LABEL', 'Self-signup by email domain') }), _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(policy.allow_self_signup_qr), onChange: toggle('allow_self_signup_qr') }), label: t('Auth.SIGNUP_QR_LABEL', 'Self-signup by QR') })] }), _jsx(TextField, { label: t('Auth.ALLOWED_EMAIL_DOMAINS_LABEL', 'Allowed email domains'), helperText: t('Auth.ALLOWED_EMAIL_DOMAINS_HINT', 'One domain per line, e.g. example.org'), multiline: true, minRows: 3, fullWidth: true, sx: { mt: 2 }, value: domainsText, onChange: (event) => setDomainsText(event.target.value) }), _jsx(Button, { variant: "contained", sx: { mt: 2 }, onClick: save, disabled: busy, children: t('Common.SAVE', 'Save') })] }));
|
|
90
|
+
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.REGISTRATION_METHODS_TITLE', 'Registration Methods') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.REGISTRATION_METHODS_HINT', 'Choose which signup and invite flows are active for this app.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsxs(Stack, { spacing: 1, children: [_jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policy.allow_admin_invite), onChange: toggle('allow_admin_invite'), disabled: Boolean(busyField) })), label: t('Auth.ADMIN_INVITE_LABEL', 'Admin invite') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policy.allow_self_signup_access_code), onChange: toggle('allow_self_signup_access_code'), disabled: Boolean(busyField) })), label: t('Auth.SIGNUP_ACCESS_CODE_LABEL', 'Self-signup with access code') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policy.allow_self_signup_open), onChange: toggle('allow_self_signup_open'), disabled: Boolean(busyField) })), label: t('Auth.SIGNUP_OPEN_LABEL', 'Open self-signup') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policy.allow_self_signup_email_domain), onChange: toggle('allow_self_signup_email_domain'), disabled: Boolean(busyField) })), label: t('Auth.SIGNUP_EMAIL_DOMAIN_LABEL', 'Self-signup by email domain') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policy.allow_self_signup_qr), onChange: toggle('allow_self_signup_qr'), disabled: Boolean(busyField) })), label: t('Auth.SIGNUP_QR_LABEL', 'Self-signup by QR') })] }), policy.allow_self_signup_email_domain && !(policy.allowed_email_domains || []).length && (_jsx(Alert, { severity: "info", sx: { mt: 2 }, children: t('Auth.EMAIL_DOMAIN_CURRENTLY_BLOCKED_HINT', 'Email-domain signup is enabled, but it stays blocked until at least one allowed domain is saved.') })), _jsx(TextField, { label: t('Auth.ALLOWED_EMAIL_DOMAINS_LABEL', 'Allowed email domains'), helperText: t('Auth.ALLOWED_EMAIL_DOMAINS_HINT', 'One domain per line, e.g. example.org. You can leave this empty temporarily.'), multiline: true, minRows: 3, fullWidth: true, sx: { mt: 2 }, value: domainsText, onChange: (event) => setDomainsText(event.target.value) }), _jsx(Button, { variant: "contained", sx: { mt: 2 }, onClick: save, disabled: busy, children: t('Common.SAVE', 'Save') })] }));
|
|
69
91
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useContext, useMemo } from 'react';
|
|
2
|
+
import React, { useContext, useMemo, useState } from 'react';
|
|
3
3
|
import { Helmet } from 'react-helmet';
|
|
4
4
|
import { useSearchParams } from 'react-router-dom';
|
|
5
5
|
import { Tabs, Tab, Box, Typography, Alert, CircularProgress, Paper, Stack, } from '@mui/material';
|
|
@@ -24,6 +24,7 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
|
|
|
24
24
|
const { t } = useTranslation();
|
|
25
25
|
const { user, login, loading } = useContext(AuthContext);
|
|
26
26
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
27
|
+
const [authPolicy, setAuthPolicy] = useState(null);
|
|
27
28
|
// 1. URL State Management
|
|
28
29
|
const currentTabRaw = searchParams.get('tab') || 'profile';
|
|
29
30
|
const currentTab = ['invite', 'bulk-invite-csv', 'access'].includes(currentTabRaw)
|
|
@@ -90,5 +91,5 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
|
|
|
90
91
|
const activeExtraTab = builtInTabValues.has(safeTab)
|
|
91
92
|
? null
|
|
92
93
|
: extraTabs.find((tab) => tab.value === safeTab);
|
|
93
|
-
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, extraColumns: userListExtraColumns, extraRowActions: userListExtraRowActions, extraContext: userListExtraContext, refreshTrigger: userListRefreshTrigger, canEditUser: userListCanEditUser }) })), safeTab === 'invite' && (_jsx(Box, { sx: { mt: 2 }, children: _jsxs(Stack, { spacing: 2.5, children: [(isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(RegistrationMethodsManager, {}) })), (isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(AuthFactorRequirementCard, {}) })), (isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(UserInviteComponent, {}) })), (isSuperUser || perms.can_manage_access_codes) && (_jsxs(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 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, {})] })), (isSuperUser || perms.can_invite) && showBulkInviteCsvTab && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(BulkInviteCsvTab, Object.assign({}, bulkInviteCsvProps)) })), (isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(QrSignupManager, {}) }))] }) })), safeTab === 'support' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SupportRecoveryRequestsTab, {}) })), activeExtraTab && (_jsx(Box, { sx: { mt: 2 }, children: (_a = activeExtraTab.render) === null || _a === void 0 ? void 0 : _a.call(activeExtraTab, { user, perms, isSuperUser, t }) }))] }));
|
|
94
|
+
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, extraColumns: userListExtraColumns, extraRowActions: userListExtraRowActions, extraContext: userListExtraContext, refreshTrigger: userListRefreshTrigger, canEditUser: userListCanEditUser }) })), safeTab === 'invite' && (_jsx(Box, { sx: { mt: 2 }, children: _jsxs(Stack, { spacing: 2.5, children: [(isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(RegistrationMethodsManager, { onPolicyChange: setAuthPolicy }) })), (isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(AuthFactorRequirementCard, {}) })), (isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(UserInviteComponent, {}) })), (isSuperUser || perms.can_manage_access_codes) && (_jsxs(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 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, {})] })), (isSuperUser || perms.can_invite) && showBulkInviteCsvTab && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(BulkInviteCsvTab, Object.assign({}, bulkInviteCsvProps)) })), (isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(QrSignupManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr) }) }))] }) })), safeTab === 'support' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SupportRecoveryRequestsTab, {}) })), activeExtraTab && (_jsx(Box, { sx: { mt: 2 }, children: (_a = activeExtraTab.render) === null || _a === void 0 ? void 0 : _a.call(activeExtraTab, { user, perms, isSuperUser, t }) }))] }));
|
|
94
95
|
}
|
package/dist/pages/LoginPage.js
CHANGED
|
@@ -32,6 +32,21 @@ export function LoginPage() {
|
|
|
32
32
|
: recoveryTokenRaw;
|
|
33
33
|
// Backward-compatible fallback for legacy links using query parameters.
|
|
34
34
|
const recoveryEmail = hashParams.get('email') || params.get('email') || '';
|
|
35
|
+
const requestedNext = params.get('next');
|
|
36
|
+
const getRedirectTarget = (currentUser, options = {}) => {
|
|
37
|
+
var _a;
|
|
38
|
+
if (options.forceSecurityRedirect) {
|
|
39
|
+
return options.forceSecurityRedirect;
|
|
40
|
+
}
|
|
41
|
+
const requiresExtra = ((_a = currentUser === null || currentUser === void 0 ? void 0 : currentUser.security_state) === null || _a === void 0 ? void 0 : _a.requires_additional_security) === true;
|
|
42
|
+
if (requiresExtra) {
|
|
43
|
+
return '/account?tab=security&from=weak_login';
|
|
44
|
+
}
|
|
45
|
+
if (requestedNext && requestedNext.startsWith('/')) {
|
|
46
|
+
return requestedNext;
|
|
47
|
+
}
|
|
48
|
+
return '/';
|
|
49
|
+
};
|
|
35
50
|
useEffect(() => {
|
|
36
51
|
const socialError = params.get('error') || params.get('social');
|
|
37
52
|
if (socialError) {
|
|
@@ -39,30 +54,14 @@ export function LoginPage() {
|
|
|
39
54
|
}
|
|
40
55
|
}, [location.search]);
|
|
41
56
|
useEffect(() => {
|
|
42
|
-
var _a;
|
|
43
57
|
if (loading || !user)
|
|
44
58
|
return;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
navigate('/account?tab=security&from=weak_login', { replace: true });
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
navigate('/', { replace: true });
|
|
51
|
-
}
|
|
52
|
-
}, [loading, user, navigate]);
|
|
59
|
+
navigate(getRedirectTarget(user), { replace: true });
|
|
60
|
+
}, [loading, user, navigate, requestedNext]);
|
|
53
61
|
// --- Helper: Central Success Logic ---
|
|
54
62
|
const handleLoginSuccess = (user) => {
|
|
55
|
-
var _a;
|
|
56
63
|
login(user); // Update Context
|
|
57
|
-
|
|
58
|
-
const requiresExtra = ((_a = user.security_state) === null || _a === void 0 ? void 0 : _a.requires_additional_security) === true;
|
|
59
|
-
if (requiresExtra) {
|
|
60
|
-
navigate('/account?tab=security&from=weak_login');
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
// Standard Redirect (könnte man noch mit ?next=... erweitern)
|
|
64
|
-
navigate('/');
|
|
65
|
-
}
|
|
64
|
+
navigate(getRedirectTarget(user));
|
|
66
65
|
};
|
|
67
66
|
// --- Handlers ---
|
|
68
67
|
const handleSubmitCredentials = async ({ identifier, password }) => {
|
|
@@ -72,9 +71,10 @@ export function LoginPage() {
|
|
|
72
71
|
// A) Recovery Flow
|
|
73
72
|
if (recoveryToken) {
|
|
74
73
|
const result = await loginWithRecoveryPassword(identifier, password, recoveryToken);
|
|
75
|
-
// Recovery login implies a specific redirect usually, usually straight to security settings
|
|
76
74
|
login(result.user);
|
|
77
|
-
navigate(
|
|
75
|
+
navigate(getRedirectTarget(result.user, {
|
|
76
|
+
forceSecurityRedirect: '/account?tab=security&from=recovery',
|
|
77
|
+
}));
|
|
78
78
|
return;
|
|
79
79
|
}
|
|
80
80
|
// B) Standard Password Login
|
|
@@ -118,9 +118,10 @@ export function LoginPage() {
|
|
|
118
118
|
const handleMfaSuccess = ({ user, method }) => {
|
|
119
119
|
// MFA component should return the user object after verifying code
|
|
120
120
|
if (method === 'recovery_code') {
|
|
121
|
-
// Recovery codes often trigger a security check prompt
|
|
122
121
|
login(user);
|
|
123
|
-
navigate(
|
|
122
|
+
navigate(getRedirectTarget(user, {
|
|
123
|
+
forceSecurityRedirect: '/account?tab=security&from=recovery',
|
|
124
|
+
}));
|
|
124
125
|
}
|
|
125
126
|
else {
|
|
126
127
|
handleLoginSuccess(user);
|
|
@@ -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
|
const [submitting, setSubmitting] = useState(false);
|
|
17
19
|
const [errorKey, setErrorKey] = useState(null);
|
|
18
20
|
const [successKey, setSuccessKey] = useState(null);
|
|
@@ -57,7 +59,10 @@ export function PasswordInvitePage() {
|
|
|
57
59
|
setSuccessKey(isInvite
|
|
58
60
|
? 'Auth.RESET_PASSWORD_SUCCESS_INVITE'
|
|
59
61
|
: 'Auth.RESET_PASSWORD_SUCCESS_RESET');
|
|
60
|
-
|
|
62
|
+
const target = nextPath
|
|
63
|
+
? `/login?next=${encodeURIComponent(nextPath)}`
|
|
64
|
+
: '/login';
|
|
65
|
+
navigate(target);
|
|
61
66
|
}
|
|
62
67
|
catch (err) {
|
|
63
68
|
setErrorKey(err.code || 'Auth.RESET_PASSWORD_FAILED');
|
package/dist/pages/SignUpPage.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
|
3
3
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
4
|
-
import { Alert, Box, Button, Stack,
|
|
4
|
+
import { Alert, Box, Button, Stack, TextField, Typography, } from '@mui/material';
|
|
5
5
|
import { Helmet } from 'react-helmet';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
7
|
import { NarrowPage } from '../layout/PageLayout';
|
|
@@ -13,6 +13,12 @@ const MODE_LABELS = {
|
|
|
13
13
|
self_signup_email_domain: 'Auth.SIGNUP_EMAIL_DOMAIN_TAB',
|
|
14
14
|
self_signup_qr: 'Auth.SIGNUP_QR_TAB',
|
|
15
15
|
};
|
|
16
|
+
const MODE_HINTS = {
|
|
17
|
+
self_signup_access_code: 'Auth.SIGNUP_ACCESS_CODE_HINT',
|
|
18
|
+
self_signup_open: 'Auth.SIGNUP_OPEN_HINT',
|
|
19
|
+
self_signup_email_domain: 'Auth.SIGNUP_EMAIL_DOMAIN_HINT',
|
|
20
|
+
self_signup_qr: 'Auth.SIGNUP_QR_HINT',
|
|
21
|
+
};
|
|
16
22
|
export function SignUpPage() {
|
|
17
23
|
const navigate = useNavigate();
|
|
18
24
|
const location = useLocation();
|
|
@@ -42,6 +48,21 @@ export function SignUpPage() {
|
|
|
42
48
|
const [successKey, setSuccessKey] = useState(null);
|
|
43
49
|
const [errorKey, setErrorKey] = useState(null);
|
|
44
50
|
const [qrHint, setQrHint] = useState('');
|
|
51
|
+
const modeHint = useMemo(() => {
|
|
52
|
+
if (mode === 'self_signup_access_code') {
|
|
53
|
+
return t(MODE_HINTS[mode], 'Use this option only if you were given an access code for signup.');
|
|
54
|
+
}
|
|
55
|
+
if (mode === 'self_signup_open') {
|
|
56
|
+
return t(MODE_HINTS[mode], 'Use this option for direct signup without an access code.');
|
|
57
|
+
}
|
|
58
|
+
if (mode === 'self_signup_email_domain') {
|
|
59
|
+
return t(MODE_HINTS[mode], 'Use an email address from an allowed domain for this signup flow.');
|
|
60
|
+
}
|
|
61
|
+
if (mode === 'self_signup_qr') {
|
|
62
|
+
return qrHint || t(MODE_HINTS[mode], 'Use a valid QR signup link to continue.');
|
|
63
|
+
}
|
|
64
|
+
return '';
|
|
65
|
+
}, [mode, qrHint, t]);
|
|
45
66
|
useEffect(() => {
|
|
46
67
|
setMode(initialMode);
|
|
47
68
|
}, [initialMode]);
|
|
@@ -89,7 +110,7 @@ export function SignUpPage() {
|
|
|
89
110
|
const handleGoToLogin = () => {
|
|
90
111
|
navigate('/login');
|
|
91
112
|
};
|
|
92
|
-
return (_jsxs(NarrowPage, { title: t('Auth.LOGIN_SIGNUP_BUTTON'), subtitle: t('Auth.PAGE_SIGNUP_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.LOGIN_SIGNUP_BUTTON')] }) }), successKey && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: t(successKey, { email }) })), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.')) })), signupModes.length > 1 && (
|
|
113
|
+
return (_jsxs(NarrowPage, { title: t('Auth.LOGIN_SIGNUP_BUTTON'), subtitle: t('Auth.PAGE_SIGNUP_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.LOGIN_SIGNUP_BUTTON')] }) }), successKey && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: t(successKey, { email }) })), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.')) })), signupModes.length > 1 && (_jsxs(Stack, { spacing: 1, sx: { mb: 2 }, children: [_jsx(Alert, { severity: "info", children: t('Auth.SIGNUP_MODE_SELECTOR_HINT', 'Choose the signup option that matches how you want to register.') }), _jsx(Stack, { direction: { xs: 'column', sm: 'row' }, spacing: 1, flexWrap: "wrap", children: signupModes.map((entry) => (_jsx(Button, { variant: mode === entry ? 'contained' : 'outlined', onClick: () => setMode(entry), disabled: submitting, children: t(MODE_LABELS[entry] || entry, entry) }, entry))) })] })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [modeHint && _jsx(Alert, { severity: "info", children: modeHint }), _jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: email, onChange: (e) => setEmail(e.target.value), disabled: submitting }), mode === 'self_signup_access_code' && (_jsx(TextField, { label: t('Auth.ACCESS_CODE_LABEL'), type: "text", required: true, fullWidth: true, value: accessCode, onChange: (e) => setAccessCode(e.target.value), disabled: submitting })), mode === 'self_signup_qr' && (_jsx(Stack, { spacing: 1, children: _jsx(TextField, { label: t('Auth.SIGNUP_QR_TOKEN_LABEL', 'QR token'), value: tokenFromUrl, fullWidth: true, InputProps: { readOnly: true } }) })), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting || signupModes.length === 0, children: submitting
|
|
93
114
|
? t('Auth.SIGNUP_SUBMITTING')
|
|
94
115
|
: t('Auth.SIGNUP_SUBMIT') })] }), _jsx(Box, { sx: { mt: 3 }, children: _jsxs(Typography, { variant: "body2", children: [t('Auth.SIGNUP_ALREADY_HAVE_ACCOUNT'), ' ', _jsx(Button, { onClick: handleGoToLogin, variant: "text", size: "small", children: t('Auth.SIGNUP_GO_TO_LOGIN') })] }) })] }));
|
|
95
116
|
}
|
package/package.json
CHANGED
package/src/auth/authApi.jsx
CHANGED
|
@@ -23,15 +23,6 @@ export async function fetchAuthMethods() {
|
|
|
23
23
|
return res.data || {};
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export async function fetchRegistrationOptions() {
|
|
27
|
-
try {
|
|
28
|
-
const res = await apiClient.get(`${USERS_BASE}/registration-options/`);
|
|
29
|
-
return res.data || {};
|
|
30
|
-
} catch (error) {
|
|
31
|
-
throw normaliseApiError(error, 'Auth.REGISTRATION_OPTIONS_FAILED');
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
26
|
export async function fetchAuthPolicy() {
|
|
36
27
|
try {
|
|
37
28
|
const res = await apiClient.get(`${USERS_BASE}/auth-policy/`);
|
|
@@ -390,17 +381,6 @@ export async function createSignupQr(payload = {}) {
|
|
|
390
381
|
}
|
|
391
382
|
}
|
|
392
383
|
|
|
393
|
-
export async function requestInviteWithCode(email, accessCode) {
|
|
394
|
-
if (accessCode) {
|
|
395
|
-
return submitRegistrationRequest({
|
|
396
|
-
email,
|
|
397
|
-
mode: 'self_signup_access_code',
|
|
398
|
-
accessCode,
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
return sendAdminInvite(email);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
384
|
// -----------------------------
|
|
405
385
|
// Recovery Support (Admin/Support Side)
|
|
406
386
|
// -----------------------------
|
|
@@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react';
|
|
|
2
2
|
import {
|
|
3
3
|
Alert,
|
|
4
4
|
Box,
|
|
5
|
-
Button,
|
|
6
5
|
FormControl,
|
|
7
6
|
FormControlLabel,
|
|
8
7
|
Radio,
|
|
@@ -36,14 +35,18 @@ export function AuthFactorRequirementCard() {
|
|
|
36
35
|
};
|
|
37
36
|
}, []);
|
|
38
37
|
|
|
39
|
-
const
|
|
38
|
+
const handleChange = async (event) => {
|
|
39
|
+
const nextValue = event.target.value;
|
|
40
|
+
const previous = value;
|
|
41
|
+
setValue(nextValue);
|
|
40
42
|
setBusy(true);
|
|
41
43
|
setError('');
|
|
42
44
|
setSuccess('');
|
|
43
45
|
try {
|
|
44
|
-
await updateAuthPolicy({ required_auth_factor_count: Number(
|
|
46
|
+
await updateAuthPolicy({ required_auth_factor_count: Number(nextValue) });
|
|
45
47
|
setSuccess(t('Auth.AUTH_FACTOR_SAVE_SUCCESS', 'Factor requirement saved.'));
|
|
46
48
|
} catch (err) {
|
|
49
|
+
setValue(previous);
|
|
47
50
|
setError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save factor requirement.'));
|
|
48
51
|
} finally {
|
|
49
52
|
setBusy(false);
|
|
@@ -61,14 +64,11 @@ export function AuthFactorRequirementCard() {
|
|
|
61
64
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
62
65
|
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
|
|
63
66
|
<FormControl>
|
|
64
|
-
<RadioGroup value={value} onChange={
|
|
65
|
-
<FormControlLabel value="1" control={<Radio />} label={t('Auth.ONE_FACTOR_LABEL', 'One factor is enough')} />
|
|
66
|
-
<FormControlLabel value="2" control={<Radio />} label={t('Auth.TWO_FACTOR_LABEL', 'Two factors are required')} />
|
|
67
|
+
<RadioGroup value={value} onChange={handleChange}>
|
|
68
|
+
<FormControlLabel value="1" control={<Radio disabled={busy} />} label={t('Auth.ONE_FACTOR_LABEL', 'One factor is enough')} />
|
|
69
|
+
<FormControlLabel value="2" control={<Radio disabled={busy} />} label={t('Auth.TWO_FACTOR_LABEL', 'Two factors are required')} />
|
|
67
70
|
</RadioGroup>
|
|
68
71
|
</FormControl>
|
|
69
|
-
<Button variant="contained" sx={{ mt: 2 }} onClick={save} disabled={busy}>
|
|
70
|
-
{t('Common.SAVE', 'Save')}
|
|
71
|
-
</Button>
|
|
72
72
|
</Box>
|
|
73
73
|
);
|
|
74
74
|
}
|
|
@@ -118,21 +118,21 @@ export function LoginForm({
|
|
|
118
118
|
/>
|
|
119
119
|
</Box>
|
|
120
120
|
)}
|
|
121
|
-
{/* Account
|
|
122
|
-
|
|
121
|
+
{/* Account actions */}
|
|
123
122
|
{(onSignUp || onForgotPassword) && (
|
|
124
123
|
<Box>
|
|
125
|
-
<
|
|
126
|
-
{t('Auth.
|
|
127
|
-
</
|
|
124
|
+
<Divider sx={{ my: 2 }}>
|
|
125
|
+
{t('Auth.LOGIN_OR')}
|
|
126
|
+
</Divider>
|
|
128
127
|
|
|
129
|
-
<Box sx={{ display: 'flex',
|
|
128
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
130
129
|
{onSignUp && (
|
|
131
130
|
<Button
|
|
132
131
|
type="button"
|
|
133
132
|
variant="outlined"
|
|
134
133
|
onClick={onSignUp}
|
|
135
134
|
disabled={disabled}
|
|
135
|
+
fullWidth
|
|
136
136
|
>
|
|
137
137
|
{t('Auth.LOGIN_SIGNUP_BUTTON')}
|
|
138
138
|
</Button>
|
|
@@ -144,6 +144,7 @@ export function LoginForm({
|
|
|
144
144
|
variant="outlined"
|
|
145
145
|
onClick={onForgotPassword}
|
|
146
146
|
disabled={disabled}
|
|
147
|
+
fullWidth
|
|
147
148
|
>
|
|
148
149
|
{t('Auth.LOGIN_FORGOT_PASSWORD_BUTTON')}
|
|
149
150
|
</Button>
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Alert,
|
|
4
4
|
Box,
|
|
5
5
|
Button,
|
|
6
|
-
Stack,
|
|
7
6
|
TextField,
|
|
8
7
|
Typography,
|
|
9
8
|
} from '@mui/material';
|
|
@@ -11,32 +10,30 @@ import { useTranslation } from 'react-i18next';
|
|
|
11
10
|
import { QRCodeSVG } from 'qrcode.react';
|
|
12
11
|
import { createSignupQr } from '../auth/authApi';
|
|
13
12
|
|
|
14
|
-
export function QrSignupManager() {
|
|
13
|
+
export function QrSignupManager({ enabled = false }) {
|
|
15
14
|
const { t } = useTranslation();
|
|
16
15
|
const [label, setLabel] = useState('');
|
|
17
|
-
const [eventRef, setEventRef] = useState('');
|
|
18
|
-
const [courseRef, setCourseRef] = useState('');
|
|
19
|
-
const [groupRef, setGroupRef] = useState('');
|
|
20
16
|
const [busy, setBusy] = useState(false);
|
|
21
17
|
const [error, setError] = useState('');
|
|
18
|
+
const [success, setSuccess] = useState('');
|
|
22
19
|
const [result, setResult] = useState(null);
|
|
20
|
+
const hasGeneratedRef = useRef(false);
|
|
23
21
|
|
|
24
22
|
const generate = async () => {
|
|
23
|
+
if (!enabled) {
|
|
24
|
+
setResult(null);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
25
27
|
setBusy(true);
|
|
26
28
|
setError('');
|
|
29
|
+
setSuccess('');
|
|
27
30
|
try {
|
|
28
|
-
const registrationContext = {
|
|
29
|
-
schema_version: '1',
|
|
30
|
-
};
|
|
31
|
-
if (eventRef.trim()) registrationContext.event_ref = eventRef.trim();
|
|
32
|
-
if (courseRef.trim()) registrationContext.course_ref = courseRef.trim();
|
|
33
|
-
if (groupRef.trim()) registrationContext.group_ref = groupRef.trim();
|
|
34
|
-
|
|
35
31
|
const data = await createSignupQr({
|
|
36
32
|
label,
|
|
37
|
-
registration_context: registrationContext,
|
|
38
33
|
});
|
|
39
34
|
setResult(data);
|
|
35
|
+
hasGeneratedRef.current = true;
|
|
36
|
+
setSuccess(t('Auth.SIGNUP_QR_SAVE_SUCCESS', 'QR signup link updated.'));
|
|
40
37
|
} catch (err) {
|
|
41
38
|
setError(t(err?.code || 'Auth.SIGNUP_QR_CREATE_FAILED', 'Could not create signup QR.'));
|
|
42
39
|
} finally {
|
|
@@ -44,25 +41,72 @@ export function QrSignupManager() {
|
|
|
44
41
|
}
|
|
45
42
|
};
|
|
46
43
|
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!enabled) {
|
|
46
|
+
setResult(null);
|
|
47
|
+
setError('');
|
|
48
|
+
setSuccess('');
|
|
49
|
+
hasGeneratedRef.current = false;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (hasGeneratedRef.current) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
let active = true;
|
|
56
|
+
const ensureInitialQr = async () => {
|
|
57
|
+
setBusy(true);
|
|
58
|
+
setError('');
|
|
59
|
+
setSuccess('');
|
|
60
|
+
try {
|
|
61
|
+
const data = await createSignupQr({
|
|
62
|
+
label,
|
|
63
|
+
});
|
|
64
|
+
if (!active) return;
|
|
65
|
+
setResult(data);
|
|
66
|
+
hasGeneratedRef.current = true;
|
|
67
|
+
setSuccess(t('Auth.SIGNUP_QR_SAVE_SUCCESS', 'QR signup link updated.'));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (!active) return;
|
|
70
|
+
setError(t(err?.code || 'Auth.SIGNUP_QR_CREATE_FAILED', 'Could not create signup QR.'));
|
|
71
|
+
} finally {
|
|
72
|
+
if (active) {
|
|
73
|
+
setBusy(false);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
ensureInitialQr();
|
|
78
|
+
return () => {
|
|
79
|
+
active = false;
|
|
80
|
+
};
|
|
81
|
+
}, [enabled, label, t]);
|
|
82
|
+
|
|
47
83
|
return (
|
|
48
84
|
<Box>
|
|
49
85
|
<Typography variant="h6" gutterBottom>
|
|
50
86
|
{t('Auth.SIGNUP_QR_MANAGER_TITLE', 'QR Signup')}
|
|
51
87
|
</Typography>
|
|
52
88
|
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
|
53
|
-
{t('Auth.SIGNUP_QR_MANAGER_HINT', '
|
|
89
|
+
{t('Auth.SIGNUP_QR_MANAGER_HINT', 'When QR signup is enabled, a signup QR code is shown here immediately.')}
|
|
54
90
|
</Typography>
|
|
55
91
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
92
|
+
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
|
|
93
|
+
|
|
94
|
+
{!enabled && (
|
|
95
|
+
<Alert severity="info" sx={{ mb: 2 }}>
|
|
96
|
+
{t('Auth.SIGNUP_QR_DISABLED_HINT', 'Enable self-signup by QR above to show a QR code here.')}
|
|
97
|
+
</Alert>
|
|
98
|
+
)}
|
|
56
99
|
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
100
|
+
<TextField
|
|
101
|
+
label={t('Common.LABEL', 'Label')}
|
|
102
|
+
value={label}
|
|
103
|
+
onChange={(event) => setLabel(event.target.value)}
|
|
104
|
+
fullWidth
|
|
105
|
+
disabled={!enabled || busy}
|
|
106
|
+
/>
|
|
63
107
|
|
|
64
|
-
<Button variant="contained" sx={{ mt: 2 }} onClick={generate} disabled={busy}>
|
|
65
|
-
{t('
|
|
108
|
+
<Button variant="contained" sx={{ mt: 2 }} onClick={generate} disabled={!enabled || busy}>
|
|
109
|
+
{t('Common.SAVE', 'Save')}
|
|
66
110
|
</Button>
|
|
67
111
|
|
|
68
112
|
{result?.signup_url && (
|
|
@@ -27,6 +27,7 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
|
|
|
27
27
|
const [policy, setPolicy] = useState(EMPTY_POLICY);
|
|
28
28
|
const [domainsText, setDomainsText] = useState('');
|
|
29
29
|
const [busy, setBusy] = useState(false);
|
|
30
|
+
const [busyField, setBusyField] = useState('');
|
|
30
31
|
const [error, setError] = useState('');
|
|
31
32
|
const [success, setSuccess] = useState('');
|
|
32
33
|
|
|
@@ -38,6 +39,7 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
|
|
|
38
39
|
if (!active) return;
|
|
39
40
|
setPolicy((prev) => ({ ...prev, ...data }));
|
|
40
41
|
setDomainsText((data?.allowed_email_domains || []).join('\n'));
|
|
42
|
+
if (onPolicyChange) onPolicyChange(data);
|
|
41
43
|
} catch (err) {
|
|
42
44
|
if (active) {
|
|
43
45
|
setError(t(err?.code || 'Auth.AUTH_POLICY_FETCH_FAILED', 'Could not load authentication policy.'));
|
|
@@ -47,10 +49,26 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
|
|
|
47
49
|
return () => {
|
|
48
50
|
active = false;
|
|
49
51
|
};
|
|
50
|
-
}, [t]);
|
|
52
|
+
}, [onPolicyChange, t]);
|
|
51
53
|
|
|
52
|
-
const toggle = (field) => (_event, checked) => {
|
|
54
|
+
const toggle = (field) => async (_event, checked) => {
|
|
55
|
+
const previous = policy[field];
|
|
53
56
|
setPolicy((prev) => ({ ...prev, [field]: checked }));
|
|
57
|
+
setBusyField(field);
|
|
58
|
+
setError('');
|
|
59
|
+
setSuccess('');
|
|
60
|
+
try {
|
|
61
|
+
const next = await updateAuthPolicy({ [field]: checked });
|
|
62
|
+
setPolicy((prev) => ({ ...prev, ...next }));
|
|
63
|
+
setDomainsText((next?.allowed_email_domains || []).join('\n'));
|
|
64
|
+
setSuccess(t('Auth.AUTH_POLICY_SAVE_SUCCESS', 'Authentication settings saved.'));
|
|
65
|
+
if (onPolicyChange) onPolicyChange(next);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
setPolicy((prev) => ({ ...prev, [field]: previous }));
|
|
68
|
+
setError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save authentication settings.'));
|
|
69
|
+
} finally {
|
|
70
|
+
setBusyField('');
|
|
71
|
+
}
|
|
54
72
|
};
|
|
55
73
|
|
|
56
74
|
const save = async () => {
|
|
@@ -62,7 +80,7 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
|
|
|
62
80
|
.split(/\r?\n/)
|
|
63
81
|
.map((value) => value.trim())
|
|
64
82
|
.filter(Boolean);
|
|
65
|
-
const next = await updateAuthPolicy({
|
|
83
|
+
const next = await updateAuthPolicy({ allowed_email_domains });
|
|
66
84
|
setPolicy((prev) => ({ ...prev, ...next }));
|
|
67
85
|
setDomainsText((next?.allowed_email_domains || allowed_email_domains).join('\n'));
|
|
68
86
|
setSuccess(t('Auth.AUTH_POLICY_SAVE_SUCCESS', 'Authentication settings saved.'));
|
|
@@ -87,30 +105,69 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
|
|
|
87
105
|
|
|
88
106
|
<Stack spacing={1}>
|
|
89
107
|
<FormControlLabel
|
|
90
|
-
control={
|
|
108
|
+
control={(
|
|
109
|
+
<Switch
|
|
110
|
+
checked={Boolean(policy.allow_admin_invite)}
|
|
111
|
+
onChange={toggle('allow_admin_invite')}
|
|
112
|
+
disabled={Boolean(busyField)}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
91
115
|
label={t('Auth.ADMIN_INVITE_LABEL', 'Admin invite')}
|
|
92
116
|
/>
|
|
93
117
|
<FormControlLabel
|
|
94
|
-
control={
|
|
118
|
+
control={(
|
|
119
|
+
<Switch
|
|
120
|
+
checked={Boolean(policy.allow_self_signup_access_code)}
|
|
121
|
+
onChange={toggle('allow_self_signup_access_code')}
|
|
122
|
+
disabled={Boolean(busyField)}
|
|
123
|
+
/>
|
|
124
|
+
)}
|
|
95
125
|
label={t('Auth.SIGNUP_ACCESS_CODE_LABEL', 'Self-signup with access code')}
|
|
96
126
|
/>
|
|
97
127
|
<FormControlLabel
|
|
98
|
-
control={
|
|
128
|
+
control={(
|
|
129
|
+
<Switch
|
|
130
|
+
checked={Boolean(policy.allow_self_signup_open)}
|
|
131
|
+
onChange={toggle('allow_self_signup_open')}
|
|
132
|
+
disabled={Boolean(busyField)}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
99
135
|
label={t('Auth.SIGNUP_OPEN_LABEL', 'Open self-signup')}
|
|
100
136
|
/>
|
|
101
137
|
<FormControlLabel
|
|
102
|
-
control={
|
|
138
|
+
control={(
|
|
139
|
+
<Switch
|
|
140
|
+
checked={Boolean(policy.allow_self_signup_email_domain)}
|
|
141
|
+
onChange={toggle('allow_self_signup_email_domain')}
|
|
142
|
+
disabled={Boolean(busyField)}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
103
145
|
label={t('Auth.SIGNUP_EMAIL_DOMAIN_LABEL', 'Self-signup by email domain')}
|
|
104
146
|
/>
|
|
105
147
|
<FormControlLabel
|
|
106
|
-
control={
|
|
148
|
+
control={(
|
|
149
|
+
<Switch
|
|
150
|
+
checked={Boolean(policy.allow_self_signup_qr)}
|
|
151
|
+
onChange={toggle('allow_self_signup_qr')}
|
|
152
|
+
disabled={Boolean(busyField)}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
107
155
|
label={t('Auth.SIGNUP_QR_LABEL', 'Self-signup by QR')}
|
|
108
156
|
/>
|
|
109
157
|
</Stack>
|
|
110
158
|
|
|
159
|
+
{policy.allow_self_signup_email_domain && !(policy.allowed_email_domains || []).length && (
|
|
160
|
+
<Alert severity="info" sx={{ mt: 2 }}>
|
|
161
|
+
{t(
|
|
162
|
+
'Auth.EMAIL_DOMAIN_CURRENTLY_BLOCKED_HINT',
|
|
163
|
+
'Email-domain signup is enabled, but it stays blocked until at least one allowed domain is saved.',
|
|
164
|
+
)}
|
|
165
|
+
</Alert>
|
|
166
|
+
)}
|
|
167
|
+
|
|
111
168
|
<TextField
|
|
112
169
|
label={t('Auth.ALLOWED_EMAIL_DOMAINS_LABEL', 'Allowed email domains')}
|
|
113
|
-
helperText={t('Auth.ALLOWED_EMAIL_DOMAINS_HINT', 'One domain per line, e.g. example.org')}
|
|
170
|
+
helperText={t('Auth.ALLOWED_EMAIL_DOMAINS_HINT', 'One domain per line, e.g. example.org. You can leave this empty temporarily.')}
|
|
114
171
|
multiline
|
|
115
172
|
minRows={3}
|
|
116
173
|
fullWidth
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useContext, useMemo } from 'react';
|
|
1
|
+
import React, { useContext, useMemo, useState } from 'react';
|
|
2
2
|
import { Helmet } from 'react-helmet';
|
|
3
3
|
import { useSearchParams } from 'react-router-dom';
|
|
4
4
|
import {
|
|
@@ -43,6 +43,7 @@ export function AccountPage({
|
|
|
43
43
|
const { t } = useTranslation();
|
|
44
44
|
const { user, login, loading } = useContext(AuthContext);
|
|
45
45
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
46
|
+
const [authPolicy, setAuthPolicy] = useState(null);
|
|
46
47
|
|
|
47
48
|
// 1. URL State Management
|
|
48
49
|
const currentTabRaw = searchParams.get('tab') || 'profile';
|
|
@@ -193,7 +194,7 @@ export function AccountPage({
|
|
|
193
194
|
<Stack spacing={2.5}>
|
|
194
195
|
{(isSuperUser || perms.can_invite) && (
|
|
195
196
|
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
|
|
196
|
-
<RegistrationMethodsManager />
|
|
197
|
+
<RegistrationMethodsManager onPolicyChange={setAuthPolicy} />
|
|
197
198
|
</Paper>
|
|
198
199
|
)}
|
|
199
200
|
|
|
@@ -226,7 +227,7 @@ export function AccountPage({
|
|
|
226
227
|
|
|
227
228
|
{(isSuperUser || perms.can_invite) && (
|
|
228
229
|
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
|
|
229
|
-
<QrSignupManager />
|
|
230
|
+
<QrSignupManager enabled={Boolean(authPolicy?.allow_self_signup_qr)} />
|
|
230
231
|
</Paper>
|
|
231
232
|
)}
|
|
232
233
|
</Stack>
|
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
|
}
|
|
@@ -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
|
@@ -5,8 +5,6 @@ import {
|
|
|
5
5
|
Box,
|
|
6
6
|
Button,
|
|
7
7
|
Stack,
|
|
8
|
-
Tab,
|
|
9
|
-
Tabs,
|
|
10
8
|
TextField,
|
|
11
9
|
Typography,
|
|
12
10
|
} from '@mui/material';
|
|
@@ -23,6 +21,13 @@ const MODE_LABELS = {
|
|
|
23
21
|
self_signup_qr: 'Auth.SIGNUP_QR_TAB',
|
|
24
22
|
};
|
|
25
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
|
+
};
|
|
30
|
+
|
|
26
31
|
export function SignUpPage() {
|
|
27
32
|
const navigate = useNavigate();
|
|
28
33
|
const location = useLocation();
|
|
@@ -57,6 +62,31 @@ export function SignUpPage() {
|
|
|
57
62
|
const [errorKey, setErrorKey] = useState(null);
|
|
58
63
|
const [qrHint, setQrHint] = useState('');
|
|
59
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
|
+
|
|
60
90
|
useEffect(() => {
|
|
61
91
|
setMode(initialMode);
|
|
62
92
|
}, [initialMode]);
|
|
@@ -134,21 +164,26 @@ export function SignUpPage() {
|
|
|
134
164
|
)}
|
|
135
165
|
|
|
136
166
|
{signupModes.length > 1 && (
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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>
|
|
152
187
|
)}
|
|
153
188
|
|
|
154
189
|
<Box
|
|
@@ -156,6 +191,8 @@ export function SignUpPage() {
|
|
|
156
191
|
onSubmit={handleSubmit}
|
|
157
192
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
|
158
193
|
>
|
|
194
|
+
{modeHint && <Alert severity="info">{modeHint}</Alert>}
|
|
195
|
+
|
|
159
196
|
<TextField
|
|
160
197
|
label={t('Auth.EMAIL_LABEL')}
|
|
161
198
|
type="email"
|
|
@@ -178,20 +215,8 @@ export function SignUpPage() {
|
|
|
178
215
|
/>
|
|
179
216
|
)}
|
|
180
217
|
|
|
181
|
-
{mode === 'self_signup_email_domain' && (
|
|
182
|
-
<Alert severity="info">
|
|
183
|
-
{t(
|
|
184
|
-
'Auth.SIGNUP_EMAIL_DOMAIN_HINT',
|
|
185
|
-
'Only addresses from configured email domains are allowed for this signup flow.',
|
|
186
|
-
)}
|
|
187
|
-
</Alert>
|
|
188
|
-
)}
|
|
189
|
-
|
|
190
218
|
{mode === 'self_signup_qr' && (
|
|
191
219
|
<Stack spacing={1}>
|
|
192
|
-
<Alert severity="info">
|
|
193
|
-
{qrHint || t('Auth.SIGNUP_QR_HINT', 'Use a valid QR signup link to continue.')}
|
|
194
|
-
</Alert>
|
|
195
220
|
<TextField
|
|
196
221
|
label={t('Auth.SIGNUP_QR_TOKEN_LABEL', 'QR token')}
|
|
197
222
|
value={tokenFromUrl}
|