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