@micha.bigler/ui-core-micha 2.2.3 → 2.2.5

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.
@@ -1,39 +1,81 @@
1
- import React, { useEffect, useRef, useState } from 'react';
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  Alert,
4
4
  Box,
5
5
  Button,
6
- TextField,
6
+ Stack,
7
7
  Typography,
8
8
  } from '@mui/material';
9
9
  import { useTranslation } from 'react-i18next';
10
10
  import { QRCodeSVG } from 'qrcode.react';
11
11
  import { createSignupQr } from '../auth/authApi';
12
12
 
13
- export function QrSignupManager({ enabled = false }) {
13
+ const DEFAULT_EXPIRY_DAYS = 90;
14
+
15
+ function clampExpiryDays(value) {
16
+ const parsed = parseInt(value, 10);
17
+ if (!Number.isFinite(parsed) || parsed < 1) {
18
+ return DEFAULT_EXPIRY_DAYS;
19
+ }
20
+ return parsed;
21
+ }
22
+
23
+ function escapeHtml(value) {
24
+ return String(value ?? '')
25
+ .replace(/&/g, '&amp;')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;')
29
+ .replace(/'/g, '&#39;');
30
+ }
31
+
32
+ export function QrSignupManager({
33
+ enabled = false,
34
+ expiryDays = DEFAULT_EXPIRY_DAYS,
35
+ }) {
14
36
  const { t } = useTranslation();
15
- const [label, setLabel] = useState('');
37
+ const qrWrapperRef = useRef(null);
16
38
  const [busy, setBusy] = useState(false);
17
39
  const [error, setError] = useState('');
18
40
  const [success, setSuccess] = useState('');
19
41
  const [result, setResult] = useState(null);
42
+ const [copyState, setCopyState] = useState('idle');
20
43
  const hasGeneratedRef = useRef(false);
21
44
 
22
- const generate = async () => {
45
+ const formattedExpiry = useMemo(() => {
46
+ if (!result?.expires_at) {
47
+ return '';
48
+ }
49
+ const parsed = new Date(result.expires_at);
50
+ if (Number.isNaN(parsed.getTime())) {
51
+ return result.expires_at;
52
+ }
53
+ return new Intl.DateTimeFormat(undefined, {
54
+ year: 'numeric',
55
+ month: '2-digit',
56
+ day: '2-digit',
57
+ hour: '2-digit',
58
+ minute: '2-digit',
59
+ }).format(parsed);
60
+ }, [result]);
61
+
62
+ const generate = async (daysOverride) => {
23
63
  if (!enabled) {
24
64
  setResult(null);
25
65
  return;
26
66
  }
67
+ const nextDays = clampExpiryDays(daysOverride ?? expiryDays);
27
68
  setBusy(true);
28
69
  setError('');
29
70
  setSuccess('');
71
+ setCopyState('idle');
30
72
  try {
31
73
  const data = await createSignupQr({
32
- label,
74
+ expires_minutes: nextDays * 24 * 60,
33
75
  });
34
76
  setResult(data);
35
77
  hasGeneratedRef.current = true;
36
- setSuccess(t('Auth.SIGNUP_QR_SAVE_SUCCESS', 'QR signup link updated.'));
78
+ setSuccess(t('Auth.SIGNUP_QR_CREATE_SUCCESS', 'New QR signup link created.'));
37
79
  } catch (err) {
38
80
  setError(t(err?.code || 'Auth.SIGNUP_QR_CREATE_FAILED', 'Could not create signup QR.'));
39
81
  } finally {
@@ -46,6 +88,7 @@ export function QrSignupManager({ enabled = false }) {
46
88
  setResult(null);
47
89
  setError('');
48
90
  setSuccess('');
91
+ setCopyState('idle');
49
92
  hasGeneratedRef.current = false;
50
93
  return;
51
94
  }
@@ -57,14 +100,16 @@ export function QrSignupManager({ enabled = false }) {
57
100
  setBusy(true);
58
101
  setError('');
59
102
  setSuccess('');
103
+ setCopyState('idle');
60
104
  try {
105
+ const days = clampExpiryDays(expiryDays);
61
106
  const data = await createSignupQr({
62
- label,
107
+ expires_minutes: days * 24 * 60,
63
108
  });
64
109
  if (!active) return;
65
110
  setResult(data);
66
111
  hasGeneratedRef.current = true;
67
- setSuccess(t('Auth.SIGNUP_QR_SAVE_SUCCESS', 'QR signup link updated.'));
112
+ setSuccess(t('Auth.SIGNUP_QR_CREATE_SUCCESS', 'New QR signup link created.'));
68
113
  } catch (err) {
69
114
  if (!active) return;
70
115
  setError(t(err?.code || 'Auth.SIGNUP_QR_CREATE_FAILED', 'Could not create signup QR.'));
@@ -78,7 +123,139 @@ export function QrSignupManager({ enabled = false }) {
78
123
  return () => {
79
124
  active = false;
80
125
  };
81
- }, [enabled, label, t]);
126
+ }, [enabled, expiryDays, t]);
127
+
128
+ const handleCopyLink = async () => {
129
+ const signupUrl = result?.signup_url;
130
+ if (!signupUrl || !navigator?.clipboard?.writeText) {
131
+ setCopyState('error');
132
+ return;
133
+ }
134
+ try {
135
+ await navigator.clipboard.writeText(signupUrl);
136
+ setCopyState('copied');
137
+ } catch (_error) {
138
+ setCopyState('error');
139
+ }
140
+ };
141
+
142
+ const handleSavePdf = () => {
143
+ if (!result?.signup_url) {
144
+ return;
145
+ }
146
+ const svgMarkup = qrWrapperRef.current?.innerHTML;
147
+ if (!svgMarkup) {
148
+ setError(t('Auth.SIGNUP_QR_PDF_NOT_READY', 'The QR image is not ready yet. Please try again.'));
149
+ return;
150
+ }
151
+
152
+ const printWindow = window.open('', '_blank', 'width=960,height=900');
153
+ if (!printWindow) {
154
+ setError(t('Auth.SIGNUP_QR_PDF_BLOCKED', 'Popup blocked. Please allow popups to save the QR card as PDF.'));
155
+ return;
156
+ }
157
+
158
+ const safeUrl = escapeHtml(result.signup_url);
159
+ const safeExpiresAt = escapeHtml(formattedExpiry || result.expires_at || '');
160
+
161
+ printWindow.document.write(`
162
+ <!DOCTYPE html>
163
+ <html>
164
+ <head>
165
+ <meta charset="utf-8" />
166
+ <title>${escapeHtml(t('Auth.SIGNUP_QR_MANAGER_TITLE', 'QR Signup'))}</title>
167
+ <style>
168
+ body {
169
+ margin: 0;
170
+ padding: 32px;
171
+ font-family: Arial, sans-serif;
172
+ background: #f5f7fb;
173
+ color: #122033;
174
+ }
175
+ .card {
176
+ max-width: 720px;
177
+ margin: 0 auto;
178
+ border: 1px solid #d9e2f2;
179
+ border-radius: 20px;
180
+ background: #ffffff;
181
+ padding: 32px;
182
+ box-sizing: border-box;
183
+ }
184
+ .eyebrow {
185
+ display: inline-block;
186
+ padding: 6px 10px;
187
+ border-radius: 999px;
188
+ background: #e8f0ff;
189
+ color: #23408e;
190
+ font-size: 12px;
191
+ font-weight: 700;
192
+ letter-spacing: 0.06em;
193
+ text-transform: uppercase;
194
+ }
195
+ h1 {
196
+ margin: 16px 0 8px;
197
+ font-size: 28px;
198
+ line-height: 1.2;
199
+ }
200
+ .qr-box {
201
+ display: flex;
202
+ justify-content: center;
203
+ align-items: center;
204
+ padding: 24px;
205
+ border-radius: 16px;
206
+ background: #ffffff;
207
+ border: 1px solid #d9e2f2;
208
+ }
209
+ .meta {
210
+ margin-top: 20px;
211
+ padding: 16px;
212
+ border-radius: 16px;
213
+ background: #f8faff;
214
+ border: 1px solid #d9e2f2;
215
+ word-break: break-word;
216
+ font-size: 14px;
217
+ line-height: 1.5;
218
+ }
219
+ @media print {
220
+ body {
221
+ background: #ffffff;
222
+ padding: 0;
223
+ }
224
+ .card {
225
+ border: 0;
226
+ border-radius: 0;
227
+ max-width: none;
228
+ padding: 0;
229
+ }
230
+ }
231
+ </style>
232
+ </head>
233
+ <body>
234
+ <div class="card">
235
+ <div class="eyebrow">${escapeHtml(t('Auth.SIGNUP_QR_MANAGER_TITLE', 'QR Signup'))}</div>
236
+ <h1>${escapeHtml(t('Auth.SIGNUP_QR_PRINT_TITLE', 'Sign-Up Access'))}</h1>
237
+ <div class="qr-box">${svgMarkup}</div>
238
+ <div class="meta">
239
+ <strong>${escapeHtml(t('Auth.SIGNUP_QR_LINK_LABEL', 'Signup link'))}</strong><br />
240
+ <a href="${safeUrl}">${safeUrl}</a><br /><br />
241
+ <strong>${escapeHtml(t('Auth.SIGNUP_QR_VALID_UNTIL', 'Valid until'))}</strong>: ${safeExpiresAt}
242
+ </div>
243
+ </div>
244
+ <script>
245
+ window.addEventListener('load', function () {
246
+ window.focus();
247
+ window.print();
248
+ });
249
+ </script>
250
+ </body>
251
+ </html>
252
+ `);
253
+ printWindow.document.close();
254
+ };
255
+
256
+ if (!enabled) {
257
+ return null;
258
+ }
82
259
 
83
260
  return (
84
261
  <Box>
@@ -86,49 +263,71 @@ export function QrSignupManager({ enabled = false }) {
86
263
  {t('Auth.SIGNUP_QR_MANAGER_TITLE', 'QR Signup')}
87
264
  </Typography>
88
265
  <Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
89
- {t('Auth.SIGNUP_QR_MANAGER_HINT', 'When QR signup is enabled, a signup QR code is shown here immediately.')}
90
- </Typography>
91
- <Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
92
- {t('Auth.SIGNUP_QR_POLICY_HINT', 'The default QR validity is configured in the authentication policy above.')}
266
+ {t('Auth.SIGNUP_QR_MANAGER_HINT', 'Generate and share QR signup links below.')}
93
267
  </Typography>
268
+
94
269
  {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
95
270
  {success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
96
-
97
- {!enabled && (
98
- <Alert severity="info" sx={{ mb: 2 }}>
99
- {t('Auth.SIGNUP_QR_DISABLED_HINT', 'Enable self-signup by QR above to show a QR code here.')}
271
+ {copyState === 'copied' && (
272
+ <Alert severity="success" sx={{ mb: 2 }}>
273
+ {t('Auth.SIGNUP_QR_LINK_COPIED', 'Signup link copied.')}
274
+ </Alert>
275
+ )}
276
+ {copyState === 'error' && (
277
+ <Alert severity="warning" sx={{ mb: 2 }}>
278
+ {t('Auth.SIGNUP_QR_COPY_UNAVAILABLE', 'Copying the link is not available in this browser.')}
100
279
  </Alert>
101
280
  )}
102
-
103
- <TextField
104
- label={t('Common.LABEL', 'Label')}
105
- value={label}
106
- onChange={(event) => setLabel(event.target.value)}
107
- fullWidth
108
- disabled={!enabled || busy}
109
- />
110
-
111
- <Button variant="contained" sx={{ mt: 2 }} onClick={generate} disabled={!enabled || busy}>
112
- {t('Common.SAVE', 'Save')}
113
- </Button>
114
281
 
115
282
  {result?.signup_url && (
116
- <Box sx={{ mt: 3 }}>
117
- <QRCodeSVG value={result.signup_url} size={180} includeMargin />
118
- <TextField
119
- label={t('Auth.SIGNUP_QR_LINK_LABEL', 'Signup link')}
120
- value={result.signup_url}
121
- fullWidth
122
- multiline
123
- minRows={2}
124
- sx={{ mt: 2 }}
125
- InputProps={{ readOnly: true }}
126
- />
127
- {result?.expires_at && (
128
- <Typography variant="body2" sx={{ mt: 1.5, color: 'text.secondary' }}>
129
- {t('Auth.SIGNUP_QR_VALID_UNTIL', 'Valid until')}: {result.expires_at}
283
+ <Box sx={{ mt: 1 }}>
284
+ <Box
285
+ sx={{
286
+ display: 'flex',
287
+ justifyContent: 'center',
288
+ alignItems: 'center',
289
+ borderRadius: 3,
290
+ border: '1px solid',
291
+ borderColor: 'divider',
292
+ bgcolor: '#ffffff',
293
+ py: 3,
294
+ px: 2,
295
+ }}
296
+ >
297
+ <Box ref={qrWrapperRef}>
298
+ <QRCodeSVG value={result.signup_url} size={220} includeMargin />
299
+ </Box>
300
+ </Box>
301
+
302
+ <Box
303
+ sx={{
304
+ mt: 2,
305
+ borderRadius: 3,
306
+ p: 2,
307
+ bgcolor: 'grey.50',
308
+ border: '1px solid',
309
+ borderColor: 'divider',
310
+ }}
311
+ >
312
+ <Typography variant="subtitle2" gutterBottom>
313
+ {t('Auth.SIGNUP_QR_ACCESS_TITLE', 'Signup Access')}
130
314
  </Typography>
131
- )}
315
+ <Typography variant="body2" color="text.secondary">
316
+ {t('Auth.SIGNUP_QR_VALID_UNTIL', 'Valid until')}: {formattedExpiry || result.expires_at}
317
+ </Typography>
318
+ </Box>
319
+
320
+ <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ mt: 2 }}>
321
+ <Button variant="outlined" onClick={() => generate()} disabled={busy}>
322
+ {t('Auth.SIGNUP_QR_NEW_BUTTON', 'New QR-Code')}
323
+ </Button>
324
+ <Button variant="outlined" onClick={handleCopyLink} disabled={!result?.signup_url || busy}>
325
+ {t('Auth.SIGNUP_QR_COPY_BUTTON', 'Copy Link')}
326
+ </Button>
327
+ <Button variant="outlined" onClick={handleSavePdf} disabled={!result?.signup_url || busy}>
328
+ {t('Auth.SIGNUP_QR_PDF_BUTTON', 'Save as PDF')}
329
+ </Button>
330
+ </Stack>
132
331
  </Box>
133
332
  )}
134
333
  </Box>
@@ -0,0 +1,100 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import {
3
+ Alert,
4
+ Box,
5
+ Button,
6
+ Stack,
7
+ TextField,
8
+ Typography,
9
+ } from '@mui/material';
10
+ import { useTranslation } from 'react-i18next';
11
+ import { updateAuthPolicy } from '../auth/authApi';
12
+
13
+ const DEFAULT_EXPIRY_DAYS = 90;
14
+
15
+ function clampExpiryDays(value) {
16
+ const parsed = parseInt(value, 10);
17
+ if (!Number.isFinite(parsed) || parsed < 1) {
18
+ return DEFAULT_EXPIRY_DAYS;
19
+ }
20
+ return parsed;
21
+ }
22
+
23
+ export function QrSignupValidityManager({
24
+ enabled = false,
25
+ expiryDays = DEFAULT_EXPIRY_DAYS,
26
+ onPolicyChange,
27
+ }) {
28
+ const { t } = useTranslation();
29
+ const [currentExpiryDays, setCurrentExpiryDays] = useState(String(expiryDays || DEFAULT_EXPIRY_DAYS));
30
+ const [busy, setBusy] = useState(false);
31
+ const [error, setError] = useState('');
32
+ const [success, setSuccess] = useState('');
33
+
34
+ useEffect(() => {
35
+ setCurrentExpiryDays(String(expiryDays || DEFAULT_EXPIRY_DAYS));
36
+ }, [expiryDays]);
37
+
38
+ const handleSave = async () => {
39
+ const nextDays = clampExpiryDays(currentExpiryDays);
40
+ setBusy(true);
41
+ setError('');
42
+ setSuccess('');
43
+ try {
44
+ const next = await updateAuthPolicy({
45
+ signup_qr_expiry_days: nextDays,
46
+ });
47
+ setCurrentExpiryDays(String(next?.signup_qr_expiry_days || nextDays));
48
+ setSuccess(t('Auth.AUTH_POLICY_SAVE_SUCCESS', 'Authentication settings saved.'));
49
+ if (onPolicyChange) onPolicyChange(next);
50
+ } catch (err) {
51
+ setError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save authentication settings.'));
52
+ } finally {
53
+ setBusy(false);
54
+ }
55
+ };
56
+
57
+ if (!enabled) {
58
+ return null;
59
+ }
60
+
61
+ return (
62
+ <Box>
63
+ <Typography variant="h6" gutterBottom>
64
+ {t('Auth.SIGNUP_QR_VALIDITY_TITLE', 'QR Signup Validity')}
65
+ </Typography>
66
+ <Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
67
+ {t(
68
+ 'Auth.SIGNUP_QR_VALIDITY_HINT',
69
+ 'Set the default validity for newly generated QR signup links.',
70
+ )}
71
+ </Typography>
72
+
73
+ {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
74
+ {success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
75
+
76
+ <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ sm: 'flex-start' }}>
77
+ <TextField
78
+ label={t('Auth.SIGNUP_QR_EXPIRY_DAYS_LABEL', 'QR signup validity (days)')}
79
+ helperText={t(
80
+ 'Auth.SIGNUP_QR_EXPIRY_DAYS_HINT',
81
+ 'Default validity for newly generated QR signup links.',
82
+ )}
83
+ type="number"
84
+ value={currentExpiryDays}
85
+ onChange={(event) => setCurrentExpiryDays(event.target.value)}
86
+ disabled={busy}
87
+ sx={{ flex: 1 }}
88
+ />
89
+ <Button
90
+ variant="contained"
91
+ onClick={handleSave}
92
+ disabled={busy}
93
+ sx={{ minWidth: 120, mt: { sm: '8px' } }}
94
+ >
95
+ {t('Common.SAVE', 'Save')}
96
+ </Button>
97
+ </Stack>
98
+ </Box>
99
+ );
100
+ }
@@ -2,15 +2,14 @@ import React, { useEffect, useState } from 'react';
2
2
  import {
3
3
  Alert,
4
4
  Box,
5
- Button,
5
+ CircularProgress,
6
6
  FormControlLabel,
7
7
  Stack,
8
8
  Switch,
9
- TextField,
10
9
  Typography,
11
10
  } from '@mui/material';
12
11
  import { useTranslation } from 'react-i18next';
13
- import { fetchAuthPolicy, updateAuthPolicy } from '../auth/authApi';
12
+ import { updateAuthPolicy } from '../auth/authApi';
14
13
 
15
14
  const EMPTY_POLICY = {
16
15
  allow_admin_invite: true,
@@ -19,88 +18,54 @@ const EMPTY_POLICY = {
19
18
  allow_self_signup_email_domain: false,
20
19
  allow_self_signup_qr: false,
21
20
  allowed_email_domains: [],
22
- required_auth_factor_count: 1,
23
21
  signup_qr_expiry_days: 90,
22
+ required_auth_factor_count: 1,
24
23
  };
25
24
 
26
- export function RegistrationMethodsManager({ onPolicyChange }) {
25
+ export function RegistrationMethodsManager({
26
+ policy: authPolicy,
27
+ error = '',
28
+ onPolicyChange,
29
+ }) {
27
30
  const { t } = useTranslation();
28
- const [policy, setPolicy] = useState(EMPTY_POLICY);
29
- const [domainsText, setDomainsText] = useState('');
30
- const [signupQrExpiryDays, setSignupQrExpiryDays] = useState(String(EMPTY_POLICY.signup_qr_expiry_days));
31
- const [busy, setBusy] = useState(false);
31
+ const [policyState, setPolicyState] = useState(EMPTY_POLICY);
32
32
  const [busyField, setBusyField] = useState('');
33
- const [error, setError] = useState('');
33
+ const [saveError, setSaveError] = useState('');
34
34
  const [success, setSuccess] = useState('');
35
35
 
36
36
  useEffect(() => {
37
- let active = true;
38
- (async () => {
39
- try {
40
- const data = await fetchAuthPolicy();
41
- if (!active) return;
42
- setPolicy((prev) => ({ ...prev, ...data }));
43
- setDomainsText((data?.allowed_email_domains || []).join('\n'));
44
- setSignupQrExpiryDays(String(data?.signup_qr_expiry_days || EMPTY_POLICY.signup_qr_expiry_days));
45
- if (onPolicyChange) onPolicyChange(data);
46
- } catch (err) {
47
- if (active) {
48
- setError(t(err?.code || 'Auth.AUTH_POLICY_FETCH_FAILED', 'Could not load authentication policy.'));
49
- }
50
- }
51
- })();
52
- return () => {
53
- active = false;
54
- };
55
- }, [onPolicyChange, t]);
37
+ if (!authPolicy) {
38
+ return;
39
+ }
40
+ setPolicyState({ ...EMPTY_POLICY, ...authPolicy });
41
+ }, [authPolicy]);
56
42
 
57
43
  const toggle = (field) => async (_event, checked) => {
58
- const previous = policy[field];
59
- setPolicy((prev) => ({ ...prev, [field]: checked }));
44
+ const previous = policyState[field];
45
+ setPolicyState((prev) => ({ ...prev, [field]: checked }));
60
46
  setBusyField(field);
61
- setError('');
47
+ setSaveError('');
62
48
  setSuccess('');
63
49
  try {
64
50
  const next = await updateAuthPolicy({ [field]: checked });
65
- setPolicy((prev) => ({ ...prev, ...next }));
66
- setDomainsText((next?.allowed_email_domains || []).join('\n'));
51
+ setPolicyState((prev) => ({ ...prev, ...next }));
67
52
  setSuccess(t('Auth.AUTH_POLICY_SAVE_SUCCESS', 'Authentication settings saved.'));
68
53
  if (onPolicyChange) onPolicyChange(next);
69
54
  } catch (err) {
70
- setPolicy((prev) => ({ ...prev, [field]: previous }));
71
- setError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save authentication settings.'));
55
+ setPolicyState((prev) => ({ ...prev, [field]: previous }));
56
+ setSaveError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save authentication settings.'));
72
57
  } finally {
73
58
  setBusyField('');
74
59
  }
75
60
  };
76
61
 
77
- const save = async () => {
78
- setBusy(true);
79
- setError('');
80
- setSuccess('');
81
- try {
82
- const allowed_email_domains = domainsText
83
- .split(/\r?\n/)
84
- .map((value) => value.trim())
85
- .filter(Boolean);
86
- const parsedExpiryDays = parseInt(signupQrExpiryDays, 10);
87
- const next = await updateAuthPolicy({
88
- allowed_email_domains,
89
- signup_qr_expiry_days: Number.isFinite(parsedExpiryDays) && parsedExpiryDays > 0
90
- ? parsedExpiryDays
91
- : EMPTY_POLICY.signup_qr_expiry_days,
92
- });
93
- setPolicy((prev) => ({ ...prev, ...next }));
94
- setDomainsText((next?.allowed_email_domains || allowed_email_domains).join('\n'));
95
- setSignupQrExpiryDays(String(next?.signup_qr_expiry_days || EMPTY_POLICY.signup_qr_expiry_days));
96
- setSuccess(t('Auth.AUTH_POLICY_SAVE_SUCCESS', 'Authentication settings saved.'));
97
- if (onPolicyChange) onPolicyChange(next);
98
- } catch (err) {
99
- setError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save authentication settings.'));
100
- } finally {
101
- setBusy(false);
102
- }
103
- };
62
+ if (!authPolicy && !error) {
63
+ return (
64
+ <Box sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
65
+ <CircularProgress size={28} />
66
+ </Box>
67
+ );
68
+ }
104
69
 
105
70
  return (
106
71
  <Box>
@@ -111,13 +76,14 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
111
76
  {t('Auth.REGISTRATION_METHODS_HINT', 'Choose which signup and invite flows are active for this app.')}
112
77
  </Typography>
113
78
  {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
79
+ {saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
114
80
  {success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
115
81
 
116
82
  <Stack spacing={1}>
117
83
  <FormControlLabel
118
84
  control={(
119
85
  <Switch
120
- checked={Boolean(policy.allow_admin_invite)}
86
+ checked={Boolean(policyState.allow_admin_invite)}
121
87
  onChange={toggle('allow_admin_invite')}
122
88
  disabled={Boolean(busyField)}
123
89
  />
@@ -127,7 +93,7 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
127
93
  <FormControlLabel
128
94
  control={(
129
95
  <Switch
130
- checked={Boolean(policy.allow_self_signup_access_code)}
96
+ checked={Boolean(policyState.allow_self_signup_access_code)}
131
97
  onChange={toggle('allow_self_signup_access_code')}
132
98
  disabled={Boolean(busyField)}
133
99
  />
@@ -137,7 +103,7 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
137
103
  <FormControlLabel
138
104
  control={(
139
105
  <Switch
140
- checked={Boolean(policy.allow_self_signup_open)}
106
+ checked={Boolean(policyState.allow_self_signup_open)}
141
107
  onChange={toggle('allow_self_signup_open')}
142
108
  disabled={Boolean(busyField)}
143
109
  />
@@ -147,7 +113,7 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
147
113
  <FormControlLabel
148
114
  control={(
149
115
  <Switch
150
- checked={Boolean(policy.allow_self_signup_email_domain)}
116
+ checked={Boolean(policyState.allow_self_signup_email_domain)}
151
117
  onChange={toggle('allow_self_signup_email_domain')}
152
118
  disabled={Boolean(busyField)}
153
119
  />
@@ -157,7 +123,7 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
157
123
  <FormControlLabel
158
124
  control={(
159
125
  <Switch
160
- checked={Boolean(policy.allow_self_signup_qr)}
126
+ checked={Boolean(policyState.allow_self_signup_qr)}
161
127
  onChange={toggle('allow_self_signup_qr')}
162
128
  disabled={Boolean(busyField)}
163
129
  />
@@ -166,7 +132,7 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
166
132
  />
167
133
  </Stack>
168
134
 
169
- {policy.allow_self_signup_email_domain && !(policy.allowed_email_domains || []).length && (
135
+ {policyState.allow_self_signup_email_domain && !(policyState.allowed_email_domains || []).length && (
170
136
  <Alert severity="info" sx={{ mt: 2 }}>
171
137
  {t(
172
138
  'Auth.EMAIL_DOMAIN_CURRENTLY_BLOCKED_HINT',
@@ -174,34 +140,6 @@ export function RegistrationMethodsManager({ onPolicyChange }) {
174
140
  )}
175
141
  </Alert>
176
142
  )}
177
-
178
- <TextField
179
- label={t('Auth.ALLOWED_EMAIL_DOMAINS_LABEL', 'Allowed email domains')}
180
- helperText={t('Auth.ALLOWED_EMAIL_DOMAINS_HINT', 'One domain per line, e.g. example.org. You can leave this empty temporarily.')}
181
- multiline
182
- minRows={3}
183
- fullWidth
184
- sx={{ mt: 2 }}
185
- value={domainsText}
186
- onChange={(event) => setDomainsText(event.target.value)}
187
- />
188
-
189
- <TextField
190
- label={t('Auth.SIGNUP_QR_EXPIRY_DAYS_LABEL', 'QR signup validity (days)')}
191
- helperText={t(
192
- 'Auth.SIGNUP_QR_EXPIRY_DAYS_HINT',
193
- 'Default validity for newly generated QR signup links.',
194
- )}
195
- type="number"
196
- fullWidth
197
- sx={{ mt: 2 }}
198
- value={signupQrExpiryDays}
199
- onChange={(event) => setSignupQrExpiryDays(event.target.value)}
200
- />
201
-
202
- <Button variant="contained" sx={{ mt: 2 }} onClick={save} disabled={busy}>
203
- {t('Common.SAVE', 'Save')}
204
- </Button>
205
143
  </Box>
206
144
  );
207
145
  }