@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.
- package/dist/components/AllowedEmailDomainsManager.js +43 -0
- package/dist/components/QrSignupManager.js +195 -11
- package/dist/components/QrSignupValidityManager.js +48 -0
- package/dist/components/RegistrationMethodsManager.js +21 -69
- package/dist/i18n/authTranslations.js +234 -0
- package/dist/pages/AccountPage.js +34 -3
- package/package.json +1 -1
- package/src/components/AllowedEmailDomainsManager.jsx +87 -0
- package/src/components/QrSignupManager.jsx +244 -45
- package/src/components/QrSignupValidityManager.jsx +100 -0
- package/src/components/RegistrationMethodsManager.jsx +35 -97
- package/src/i18n/authTranslations.ts +234 -0
- package/src/pages/AccountPage.jsx +73 -8
|
@@ -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
|
-
|
|
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
|
-
|
|
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, '&')
|
|
26
|
+
.replace(/</g, '<')
|
|
27
|
+
.replace(/>/g, '>')
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/'/g, ''');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function QrSignupManager({
|
|
33
|
+
enabled = false,
|
|
34
|
+
expiryDays = DEFAULT_EXPIRY_DAYS,
|
|
35
|
+
}) {
|
|
14
36
|
const { t } = useTranslation();
|
|
15
|
-
const
|
|
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
|
|
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
|
-
|
|
74
|
+
expires_minutes: nextDays * 24 * 60,
|
|
33
75
|
});
|
|
34
76
|
setResult(data);
|
|
35
77
|
hasGeneratedRef.current = true;
|
|
36
|
-
setSuccess(t('Auth.
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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', '
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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:
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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 {
|
|
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({
|
|
25
|
+
export function RegistrationMethodsManager({
|
|
26
|
+
policy: authPolicy,
|
|
27
|
+
error = '',
|
|
28
|
+
onPolicyChange,
|
|
29
|
+
}) {
|
|
27
30
|
const { t } = useTranslation();
|
|
28
|
-
const [
|
|
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 [
|
|
33
|
+
const [saveError, setSaveError] = useState('');
|
|
34
34
|
const [success, setSuccess] = useState('');
|
|
35
35
|
|
|
36
36
|
useEffect(() => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 =
|
|
59
|
-
|
|
44
|
+
const previous = policyState[field];
|
|
45
|
+
setPolicyState((prev) => ({ ...prev, [field]: checked }));
|
|
60
46
|
setBusyField(field);
|
|
61
|
-
|
|
47
|
+
setSaveError('');
|
|
62
48
|
setSuccess('');
|
|
63
49
|
try {
|
|
64
50
|
const next = await updateAuthPolicy({ [field]: checked });
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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
|
}
|