@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.
@@ -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, Button, FormControl, FormControlLabel, Radio, RadioGroup, Typography, } from '@mui/material';
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 save = async () => {
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(value) });
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: (event) => setValue(event.target.value), children: [_jsx(FormControlLabel, { value: "1", control: _jsx(Radio, {}), label: t('Auth.ONE_FACTOR_LABEL', 'One factor is enough') }), _jsx(FormControlLabel, { value: "2", control: _jsx(Radio, {}), label: t('Auth.TWO_FACTOR_LABEL', 'Two factors are required') })] }) }), _jsx(Button, { variant: "contained", sx: { mt: 2 }, onClick: save, disabled: busy, children: t('Common.SAVE', 'Save') })] }));
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(Typography, { variant: "subtitle2", sx: { mb: 1 }, children: t('Auth.LOGIN_ACCOUNT_RECOVERY_TITLE') }), _jsxs(Box, { sx: { display: 'flex', gap: 1, flexWrap: 'wrap' }, children: [onSignUp && (_jsx(Button, { type: "button", variant: "outlined", onClick: onSignUp, disabled: disabled, children: t('Auth.LOGIN_SIGNUP_BUTTON') })), onForgotPassword && (_jsx(Button, { type: "button", variant: "outlined", onClick: onForgotPassword, disabled: disabled, children: t('Auth.LOGIN_FORGOT_PASSWORD_BUTTON') }))] })] }))] }));
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, Stack, TextField, Typography, } from '@mui/material';
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
- 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', 'Generate a QR code for self-signup and optionally prefill registration context references.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), _jsxs(Stack, { spacing: 2, children: [_jsx(TextField, { label: t('Common.LABEL', 'Label'), value: label, onChange: (event) => setLabel(event.target.value) }), _jsx(TextField, { label: t('Auth.EVENT_REF_LABEL', 'Event reference'), value: eventRef, onChange: (event) => setEventRef(event.target.value) }), _jsx(TextField, { label: t('Auth.COURSE_REF_LABEL', 'Course reference'), value: courseRef, onChange: (event) => setCourseRef(event.target.value) }), _jsx(TextField, { label: t('Auth.GROUP_REF_LABEL', 'Group reference'), value: groupRef, onChange: (event) => setGroupRef(event.target.value) })] }), _jsx(Button, { variant: "contained", sx: { mt: 2 }, onClick: generate, disabled: busy, children: t('Auth.SIGNUP_QR_CREATE_BUTTON', 'Generate QR') }), (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 } })] }))] }));
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(Object.assign(Object.assign({}, policy), { allowed_email_domains }));
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
  }
@@ -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
- const requiresExtra = ((_a = user.security_state) === null || _a === void 0 ? void 0 : _a.requires_additional_security) === true;
46
- if (requiresExtra) {
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
- // Check if "Strong Security" is enforced/required but not met
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('/account?tab=security&from=recovery');
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('/account?tab=security&from=recovery');
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
- navigate('/login');
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');
@@ -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, Tab, Tabs, TextField, Typography, } from '@mui/material';
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 && (_jsx(Tabs, { value: mode, onChange: (_event, next) => setMode(next), variant: "scrollable", scrollButtons: "auto", sx: { mb: 2 }, children: signupModes.map((entry) => (_jsx(Tab, { value: entry, label: t(MODE_LABELS[entry] || entry, entry) }, entry))) })), _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: 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_email_domain' && (_jsx(Alert, { severity: "info", children: t('Auth.SIGNUP_EMAIL_DOMAIN_HINT', 'Only addresses from configured email domains are allowed for this signup flow.') })), mode === 'self_signup_qr' && (_jsxs(Stack, { spacing: 1, children: [_jsx(Alert, { severity: "info", children: qrHint || t('Auth.SIGNUP_QR_HINT', 'Use a valid QR signup link to continue.') }), _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
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "private": false,
@@ -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 save = async () => {
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(value) });
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={(event) => setValue(event.target.value)}>
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 & Recovery */}
122
-
121
+ {/* Account actions */}
123
122
  {(onSignUp || onForgotPassword) && (
124
123
  <Box>
125
- <Typography variant="subtitle2" sx={{ mb: 1 }}>
126
- {t('Auth.LOGIN_ACCOUNT_RECOVERY_TITLE')}
127
- </Typography>
124
+ <Divider sx={{ my: 2 }}>
125
+ {t('Auth.LOGIN_OR')}
126
+ </Divider>
128
127
 
129
- <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
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', 'Generate a QR code for self-signup and optionally prefill registration context references.')}
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
- <Stack spacing={2}>
58
- <TextField label={t('Common.LABEL', 'Label')} value={label} onChange={(event) => setLabel(event.target.value)} />
59
- <TextField label={t('Auth.EVENT_REF_LABEL', 'Event reference')} value={eventRef} onChange={(event) => setEventRef(event.target.value)} />
60
- <TextField label={t('Auth.COURSE_REF_LABEL', 'Course reference')} value={courseRef} onChange={(event) => setCourseRef(event.target.value)} />
61
- <TextField label={t('Auth.GROUP_REF_LABEL', 'Group reference')} value={groupRef} onChange={(event) => setGroupRef(event.target.value)} />
62
- </Stack>
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('Auth.SIGNUP_QR_CREATE_BUTTON', 'Generate QR')}
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({ ...policy, allowed_email_domains });
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={<Switch checked={Boolean(policy.allow_admin_invite)} onChange={toggle('allow_admin_invite')} />}
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={<Switch checked={Boolean(policy.allow_self_signup_access_code)} onChange={toggle('allow_self_signup_access_code')} />}
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={<Switch checked={Boolean(policy.allow_self_signup_open)} onChange={toggle('allow_self_signup_open')} />}
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={<Switch checked={Boolean(policy.allow_self_signup_email_domain)} onChange={toggle('allow_self_signup_email_domain')} />}
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={<Switch checked={Boolean(policy.allow_self_signup_qr)} onChange={toggle('allow_self_signup_qr')} />}
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>
@@ -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
  }
@@ -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 {
@@ -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
- <Tabs
138
- value={mode}
139
- onChange={(_event, next) => setMode(next)}
140
- variant="scrollable"
141
- scrollButtons="auto"
142
- sx={{ mb: 2 }}
143
- >
144
- {signupModes.map((entry) => (
145
- <Tab
146
- key={entry}
147
- value={entry}
148
- label={t(MODE_LABELS[entry] || entry, entry)}
149
- />
150
- ))}
151
- </Tabs>
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}