@micha.bigler/ui-core-micha 2.1.20 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/AuthContext.js +4 -0
- package/dist/auth/authApi.js +45 -3
- package/dist/components/AccessCodeManager.js +39 -3
- package/dist/components/AuthFactorRequirementCard.js +49 -0
- package/dist/components/BulkInviteCsvTab.js +2 -2
- package/dist/components/LoginForm.js +1 -1
- package/dist/components/QrSignupManager.js +81 -0
- package/dist/components/RegistrationMethodsManager.js +91 -0
- package/dist/components/UserInviteComponent.js +2 -4
- package/dist/components/UserListComponent.js +130 -105
- package/dist/index.js +3 -0
- package/dist/pages/AccountPage.js +6 -2
- package/dist/pages/LoginPage.js +31 -25
- package/dist/pages/PasswordInvitePage.js +6 -1
- package/dist/pages/SignUpPage.js +76 -16
- package/package.json +2 -1
- package/src/auth/AuthContext.jsx +4 -0
- package/src/auth/authApi.jsx +51 -3
- package/src/components/AccessCodeManager.jsx +71 -8
- package/src/components/AuthFactorRequirementCard.jsx +74 -0
- package/src/components/BulkInviteCsvTab.jsx +2 -2
- package/src/components/LoginForm.jsx +7 -6
- package/src/components/QrSignupManager.jsx +128 -0
- package/src/components/RegistrationMethodsManager.jsx +184 -0
- package/src/components/UserInviteComponent.jsx +2 -4
- package/src/components/UserListComponent.jsx +216 -246
- package/src/index.js +3 -0
- package/src/pages/AccountPage.jsx +23 -1
- package/src/pages/LoginPage.jsx +43 -23
- package/src/pages/PasswordInvitePage.jsx +6 -1
- package/src/pages/SignUpPage.jsx +145 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@micha.bigler/ui-core-micha",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"module": "dist/index.js",
|
|
6
6
|
"private": false,
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"@emotion/react": "^11.0.0",
|
|
9
9
|
"@emotion/styled": "^11.0.0",
|
|
10
10
|
"@mui/material": "^7.3.5",
|
|
11
|
+
"@mui/x-data-grid": "^8.4.0",
|
|
11
12
|
"axios": "^1.0.0",
|
|
12
13
|
"qrcode.react": "^4.2.0",
|
|
13
14
|
"react": "^19.2.1",
|
package/src/auth/AuthContext.jsx
CHANGED
|
@@ -17,6 +17,7 @@ const DEFAULT_AUTH_METHODS = {
|
|
|
17
17
|
password_login: true,
|
|
18
18
|
password_reset: true,
|
|
19
19
|
signup: true,
|
|
20
|
+
signup_modes: ['self_signup_access_code'],
|
|
20
21
|
password_change: true,
|
|
21
22
|
social_login: true,
|
|
22
23
|
social_providers: ['google', 'microsoft'],
|
|
@@ -25,6 +26,9 @@ const DEFAULT_AUTH_METHODS = {
|
|
|
25
26
|
mfa_totp: true,
|
|
26
27
|
mfa_recovery_codes: true,
|
|
27
28
|
mfa_enabled: true,
|
|
29
|
+
required_auth_factor_count: 1,
|
|
30
|
+
two_factor_required: false,
|
|
31
|
+
qr_signup_enabled: false,
|
|
28
32
|
};
|
|
29
33
|
|
|
30
34
|
export const AuthProvider = ({ children }) => {
|
package/src/auth/authApi.jsx
CHANGED
|
@@ -23,6 +23,24 @@ export async function fetchAuthMethods() {
|
|
|
23
23
|
return res.data || {};
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export async function fetchAuthPolicy() {
|
|
27
|
+
try {
|
|
28
|
+
const res = await apiClient.get(`${USERS_BASE}/auth-policy/`);
|
|
29
|
+
return res.data || {};
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw normaliseApiError(error, 'Auth.AUTH_POLICY_FETCH_FAILED');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function updateAuthPolicy(payload) {
|
|
36
|
+
try {
|
|
37
|
+
const res = await apiClient.patch(`${USERS_BASE}/auth-policy/`, payload);
|
|
38
|
+
return res.data || {};
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw normaliseApiError(error, 'Auth.AUTH_POLICY_UPDATE_FAILED');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
export async function updateUserProfile(data) {
|
|
27
45
|
try {
|
|
28
46
|
const res = await apiClient.patch(`${USERS_BASE}/current/`, data);
|
|
@@ -321,18 +339,48 @@ export async function validateAccessCode(code) {
|
|
|
321
339
|
}
|
|
322
340
|
}
|
|
323
341
|
|
|
324
|
-
export async function
|
|
325
|
-
|
|
342
|
+
export async function sendAdminInvite(email) {
|
|
343
|
+
try {
|
|
344
|
+
const res = await apiClient.post(`${USERS_BASE}/invite/`, { email });
|
|
345
|
+
return res.data;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
throw normaliseApiError(error, 'Auth.INVITE_FAILED');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function submitRegistrationRequest({
|
|
352
|
+
email,
|
|
353
|
+
mode,
|
|
354
|
+
accessCode,
|
|
355
|
+
registrationContextToken,
|
|
356
|
+
registrationContext,
|
|
357
|
+
}) {
|
|
358
|
+
const payload = { email, mode };
|
|
326
359
|
if (accessCode) payload.access_code = accessCode;
|
|
360
|
+
if (registrationContextToken) {
|
|
361
|
+
payload.registration_context_token = registrationContextToken;
|
|
362
|
+
}
|
|
363
|
+
if (registrationContext) {
|
|
364
|
+
payload.registration_context = registrationContext;
|
|
365
|
+
}
|
|
327
366
|
|
|
328
367
|
try {
|
|
329
|
-
const res = await apiClient.post(`${USERS_BASE}/
|
|
368
|
+
const res = await apiClient.post(`${USERS_BASE}/register-request/`, payload);
|
|
330
369
|
return res.data;
|
|
331
370
|
} catch (error) {
|
|
332
371
|
throw normaliseApiError(error, 'Auth.INVITE_FAILED');
|
|
333
372
|
}
|
|
334
373
|
}
|
|
335
374
|
|
|
375
|
+
export async function createSignupQr(payload = {}) {
|
|
376
|
+
try {
|
|
377
|
+
const res = await apiClient.post(`${USERS_BASE}/signup-qr/`, payload);
|
|
378
|
+
return res.data || {};
|
|
379
|
+
} catch (error) {
|
|
380
|
+
throw normaliseApiError(error, 'Auth.SIGNUP_QR_CREATE_FAILED');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
336
384
|
// -----------------------------
|
|
337
385
|
// Recovery Support (Admin/Support Side)
|
|
338
386
|
// -----------------------------
|
|
@@ -7,11 +7,11 @@ import {
|
|
|
7
7
|
Slider,
|
|
8
8
|
Button,
|
|
9
9
|
TextField,
|
|
10
|
-
Chip,
|
|
11
10
|
Alert,
|
|
12
11
|
CircularProgress,
|
|
13
12
|
} from '@mui/material';
|
|
14
|
-
import
|
|
13
|
+
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
14
|
+
import DeleteIcon from '@mui/icons-material/Delete';
|
|
15
15
|
import { useTranslation } from 'react-i18next';
|
|
16
16
|
|
|
17
17
|
// Pfad ggf. anpassen je nach Struktur: von src/auth/components zu src/auth/authApi
|
|
@@ -39,6 +39,7 @@ export function AccessCodeManager() {
|
|
|
39
39
|
|
|
40
40
|
const [errorKey, setErrorKey] = useState(null);
|
|
41
41
|
const [successKey, setSuccessKey] = useState(null);
|
|
42
|
+
const [copyNotice, setCopyNotice] = useState('');
|
|
42
43
|
|
|
43
44
|
// Helper that prefers backend error code if available
|
|
44
45
|
const setErrorFromErrorObject = (err, fallbackCode) => {
|
|
@@ -115,6 +116,21 @@ export function AccessCodeManager() {
|
|
|
115
116
|
}
|
|
116
117
|
};
|
|
117
118
|
|
|
119
|
+
const handleCopyCode = async (codeValue) => {
|
|
120
|
+
try {
|
|
121
|
+
if (navigator?.clipboard?.writeText) {
|
|
122
|
+
await navigator.clipboard.writeText(codeValue);
|
|
123
|
+
} else {
|
|
124
|
+
throw new Error('Clipboard API unavailable');
|
|
125
|
+
}
|
|
126
|
+
setCopyNotice(t('Auth.ACCESS_CODE_COPY_SUCCESS', 'Code kopiert.'));
|
|
127
|
+
window.setTimeout(() => setCopyNotice(''), 1800);
|
|
128
|
+
} catch (_err) {
|
|
129
|
+
setCopyNotice(t('Auth.ACCESS_CODE_COPY_FALLBACK', 'Code markieren und mit Ctrl+C kopieren.'));
|
|
130
|
+
window.setTimeout(() => setCopyNotice(''), 2200);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
118
134
|
if (loading) {
|
|
119
135
|
return (
|
|
120
136
|
<Box sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
|
|
@@ -135,6 +151,11 @@ export function AccessCodeManager() {
|
|
|
135
151
|
{t(successKey)}
|
|
136
152
|
</Alert>
|
|
137
153
|
)}
|
|
154
|
+
{copyNotice && (
|
|
155
|
+
<Alert severity="info" sx={{ mb: 2 }}>
|
|
156
|
+
{copyNotice}
|
|
157
|
+
</Alert>
|
|
158
|
+
)}
|
|
138
159
|
|
|
139
160
|
{/* Active codes list */}
|
|
140
161
|
<Box sx={{ mb: 3 }}>
|
|
@@ -146,14 +167,56 @@ export function AccessCodeManager() {
|
|
|
146
167
|
{t('Auth.ACCESS_CODE_NONE')}
|
|
147
168
|
</Typography>
|
|
148
169
|
) : (
|
|
149
|
-
<Stack
|
|
170
|
+
<Stack spacing={1}>
|
|
150
171
|
{codes.map((code) => (
|
|
151
|
-
<
|
|
172
|
+
<Box
|
|
152
173
|
key={code.id}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
174
|
+
sx={{
|
|
175
|
+
display: 'grid',
|
|
176
|
+
gridTemplateColumns: '1fr auto auto',
|
|
177
|
+
gap: 1,
|
|
178
|
+
alignItems: 'center',
|
|
179
|
+
width: '100%',
|
|
180
|
+
maxWidth: 560,
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
<TextField
|
|
184
|
+
value={code.code}
|
|
185
|
+
size="small"
|
|
186
|
+
fullWidth
|
|
187
|
+
slotProps={{
|
|
188
|
+
input: {
|
|
189
|
+
readOnly: true,
|
|
190
|
+
onFocus: (event) => event.target.select(),
|
|
191
|
+
},
|
|
192
|
+
}}
|
|
193
|
+
sx={{
|
|
194
|
+
'& .MuiInputBase-input': {
|
|
195
|
+
fontFamily: 'monospace',
|
|
196
|
+
letterSpacing: '0.04em',
|
|
197
|
+
},
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
<Button
|
|
201
|
+
variant="outlined"
|
|
202
|
+
size="small"
|
|
203
|
+
onClick={() => handleCopyCode(code.code)}
|
|
204
|
+
startIcon={<ContentCopyIcon fontSize="small" />}
|
|
205
|
+
sx={actionButtonSx}
|
|
206
|
+
>
|
|
207
|
+
{t('Auth.ACCESS_CODE_COPY_BUTTON', t('Auth.MFA_RECOVERY_COPY_TOOLTIP', 'Kopieren'))}
|
|
208
|
+
</Button>
|
|
209
|
+
<Button
|
|
210
|
+
variant="outlined"
|
|
211
|
+
color="error"
|
|
212
|
+
size="small"
|
|
213
|
+
onClick={() => handleDelete(code.id)}
|
|
214
|
+
startIcon={<DeleteIcon fontSize="small" />}
|
|
215
|
+
sx={actionButtonSx}
|
|
216
|
+
>
|
|
217
|
+
{t('Common.DELETE', 'Löschen')}
|
|
218
|
+
</Button>
|
|
219
|
+
</Box>
|
|
157
220
|
))}
|
|
158
221
|
</Stack>
|
|
159
222
|
)}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
Box,
|
|
5
|
+
FormControl,
|
|
6
|
+
FormControlLabel,
|
|
7
|
+
Radio,
|
|
8
|
+
RadioGroup,
|
|
9
|
+
Typography,
|
|
10
|
+
} from '@mui/material';
|
|
11
|
+
import { useTranslation } from 'react-i18next';
|
|
12
|
+
import { fetchAuthPolicy, updateAuthPolicy } from '../auth/authApi';
|
|
13
|
+
|
|
14
|
+
export function AuthFactorRequirementCard() {
|
|
15
|
+
const { t } = useTranslation();
|
|
16
|
+
const [value, setValue] = useState('1');
|
|
17
|
+
const [busy, setBusy] = useState(false);
|
|
18
|
+
const [error, setError] = useState('');
|
|
19
|
+
const [success, setSuccess] = useState('');
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
let active = true;
|
|
23
|
+
(async () => {
|
|
24
|
+
try {
|
|
25
|
+
const data = await fetchAuthPolicy();
|
|
26
|
+
if (active) {
|
|
27
|
+
setValue(String(data?.required_auth_factor_count || 1));
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Keep defaults when policy is unavailable.
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
return () => {
|
|
34
|
+
active = false;
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const handleChange = async (event) => {
|
|
39
|
+
const nextValue = event.target.value;
|
|
40
|
+
const previous = value;
|
|
41
|
+
setValue(nextValue);
|
|
42
|
+
setBusy(true);
|
|
43
|
+
setError('');
|
|
44
|
+
setSuccess('');
|
|
45
|
+
try {
|
|
46
|
+
await updateAuthPolicy({ required_auth_factor_count: Number(nextValue) });
|
|
47
|
+
setSuccess(t('Auth.AUTH_FACTOR_SAVE_SUCCESS', 'Factor requirement saved.'));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
setValue(previous);
|
|
50
|
+
setError(t(err?.code || 'Auth.AUTH_POLICY_UPDATE_FAILED', 'Could not save factor requirement.'));
|
|
51
|
+
} finally {
|
|
52
|
+
setBusy(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Box>
|
|
58
|
+
<Typography variant="h6" gutterBottom>
|
|
59
|
+
{t('Auth.AUTH_FACTOR_TITLE', 'Authentication Factors')}
|
|
60
|
+
</Typography>
|
|
61
|
+
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
|
62
|
+
{t('Auth.AUTH_FACTOR_HINT', 'Choose whether one factor is enough or two factors are required.')}
|
|
63
|
+
</Typography>
|
|
64
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
65
|
+
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
|
|
66
|
+
<FormControl>
|
|
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')} />
|
|
70
|
+
</RadioGroup>
|
|
71
|
+
</FormControl>
|
|
72
|
+
</Box>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
Paper,
|
|
15
15
|
} from '@mui/material';
|
|
16
16
|
import { useTranslation } from 'react-i18next';
|
|
17
|
-
import {
|
|
17
|
+
import { sendAdminInvite } from '../auth/authApi';
|
|
18
18
|
|
|
19
19
|
function parseEmailsFromCsv(text) {
|
|
20
20
|
if (!text) return [];
|
|
@@ -41,7 +41,7 @@ function parseEmailsFromCsv(text) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export function BulkInviteCsvTab({
|
|
44
|
-
inviteFn = (email) =>
|
|
44
|
+
inviteFn = (email) => sendAdminInvite(email),
|
|
45
45
|
onCompleted,
|
|
46
46
|
}) {
|
|
47
47
|
const { t } = useTranslation();
|
|
@@ -118,21 +118,21 @@ export function LoginForm({
|
|
|
118
118
|
/>
|
|
119
119
|
</Box>
|
|
120
120
|
)}
|
|
121
|
-
{/* Account
|
|
122
|
-
|
|
121
|
+
{/* Account actions */}
|
|
123
122
|
{(onSignUp || onForgotPassword) && (
|
|
124
123
|
<Box>
|
|
125
|
-
<
|
|
126
|
-
{t('Auth.
|
|
127
|
-
</
|
|
124
|
+
<Divider sx={{ my: 2 }}>
|
|
125
|
+
{t('Auth.LOGIN_OR')}
|
|
126
|
+
</Divider>
|
|
128
127
|
|
|
129
|
-
<Box sx={{ display: 'flex',
|
|
128
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
130
129
|
{onSignUp && (
|
|
131
130
|
<Button
|
|
132
131
|
type="button"
|
|
133
132
|
variant="outlined"
|
|
134
133
|
onClick={onSignUp}
|
|
135
134
|
disabled={disabled}
|
|
135
|
+
fullWidth
|
|
136
136
|
>
|
|
137
137
|
{t('Auth.LOGIN_SIGNUP_BUTTON')}
|
|
138
138
|
</Button>
|
|
@@ -144,6 +144,7 @@ export function LoginForm({
|
|
|
144
144
|
variant="outlined"
|
|
145
145
|
onClick={onForgotPassword}
|
|
146
146
|
disabled={disabled}
|
|
147
|
+
fullWidth
|
|
147
148
|
>
|
|
148
149
|
{t('Auth.LOGIN_FORGOT_PASSWORD_BUTTON')}
|
|
149
150
|
</Button>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React, { useEffect, useRef, 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 { QRCodeSVG } from 'qrcode.react';
|
|
11
|
+
import { createSignupQr } from '../auth/authApi';
|
|
12
|
+
|
|
13
|
+
export function QrSignupManager({ enabled = false }) {
|
|
14
|
+
const { t } = useTranslation();
|
|
15
|
+
const [label, setLabel] = useState('');
|
|
16
|
+
const [busy, setBusy] = useState(false);
|
|
17
|
+
const [error, setError] = useState('');
|
|
18
|
+
const [success, setSuccess] = useState('');
|
|
19
|
+
const [result, setResult] = useState(null);
|
|
20
|
+
const hasGeneratedRef = useRef(false);
|
|
21
|
+
|
|
22
|
+
const generate = async () => {
|
|
23
|
+
if (!enabled) {
|
|
24
|
+
setResult(null);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
setBusy(true);
|
|
28
|
+
setError('');
|
|
29
|
+
setSuccess('');
|
|
30
|
+
try {
|
|
31
|
+
const data = await createSignupQr({
|
|
32
|
+
label,
|
|
33
|
+
});
|
|
34
|
+
setResult(data);
|
|
35
|
+
hasGeneratedRef.current = true;
|
|
36
|
+
setSuccess(t('Auth.SIGNUP_QR_SAVE_SUCCESS', 'QR signup link updated.'));
|
|
37
|
+
} catch (err) {
|
|
38
|
+
setError(t(err?.code || 'Auth.SIGNUP_QR_CREATE_FAILED', 'Could not create signup QR.'));
|
|
39
|
+
} finally {
|
|
40
|
+
setBusy(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
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
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Box>
|
|
85
|
+
<Typography variant="h6" gutterBottom>
|
|
86
|
+
{t('Auth.SIGNUP_QR_MANAGER_TITLE', 'QR Signup')}
|
|
87
|
+
</Typography>
|
|
88
|
+
<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
|
+
{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
|
+
)}
|
|
99
|
+
|
|
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
|
+
/>
|
|
107
|
+
|
|
108
|
+
<Button variant="contained" sx={{ mt: 2 }} onClick={generate} disabled={!enabled || busy}>
|
|
109
|
+
{t('Common.SAVE', 'Save')}
|
|
110
|
+
</Button>
|
|
111
|
+
|
|
112
|
+
{result?.signup_url && (
|
|
113
|
+
<Box sx={{ mt: 3 }}>
|
|
114
|
+
<QRCodeSVG value={result.signup_url} size={180} includeMargin />
|
|
115
|
+
<TextField
|
|
116
|
+
label={t('Auth.SIGNUP_QR_LINK_LABEL', 'Signup link')}
|
|
117
|
+
value={result.signup_url}
|
|
118
|
+
fullWidth
|
|
119
|
+
multiline
|
|
120
|
+
minRows={2}
|
|
121
|
+
sx={{ mt: 2 }}
|
|
122
|
+
InputProps={{ readOnly: true }}
|
|
123
|
+
/>
|
|
124
|
+
</Box>
|
|
125
|
+
)}
|
|
126
|
+
</Box>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
Box,
|
|
5
|
+
Button,
|
|
6
|
+
FormControlLabel,
|
|
7
|
+
Stack,
|
|
8
|
+
Switch,
|
|
9
|
+
TextField,
|
|
10
|
+
Typography,
|
|
11
|
+
} from '@mui/material';
|
|
12
|
+
import { useTranslation } from 'react-i18next';
|
|
13
|
+
import { fetchAuthPolicy, updateAuthPolicy } from '../auth/authApi';
|
|
14
|
+
|
|
15
|
+
const EMPTY_POLICY = {
|
|
16
|
+
allow_admin_invite: true,
|
|
17
|
+
allow_self_signup_access_code: false,
|
|
18
|
+
allow_self_signup_open: false,
|
|
19
|
+
allow_self_signup_email_domain: false,
|
|
20
|
+
allow_self_signup_qr: false,
|
|
21
|
+
allowed_email_domains: [],
|
|
22
|
+
required_auth_factor_count: 1,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function RegistrationMethodsManager({ onPolicyChange }) {
|
|
26
|
+
const { t } = useTranslation();
|
|
27
|
+
const [policy, setPolicy] = useState(EMPTY_POLICY);
|
|
28
|
+
const [domainsText, setDomainsText] = useState('');
|
|
29
|
+
const [busy, setBusy] = useState(false);
|
|
30
|
+
const [busyField, setBusyField] = useState('');
|
|
31
|
+
const [error, setError] = useState('');
|
|
32
|
+
const [success, setSuccess] = useState('');
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let active = true;
|
|
36
|
+
(async () => {
|
|
37
|
+
try {
|
|
38
|
+
const data = await fetchAuthPolicy();
|
|
39
|
+
if (!active) return;
|
|
40
|
+
setPolicy((prev) => ({ ...prev, ...data }));
|
|
41
|
+
setDomainsText((data?.allowed_email_domains || []).join('\n'));
|
|
42
|
+
if (onPolicyChange) onPolicyChange(data);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (active) {
|
|
45
|
+
setError(t(err?.code || 'Auth.AUTH_POLICY_FETCH_FAILED', 'Could not load authentication policy.'));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
return () => {
|
|
50
|
+
active = false;
|
|
51
|
+
};
|
|
52
|
+
}, [onPolicyChange, t]);
|
|
53
|
+
|
|
54
|
+
const toggle = (field) => async (_event, checked) => {
|
|
55
|
+
const previous = policy[field];
|
|
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
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
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
|
+
return (
|
|
96
|
+
<Box>
|
|
97
|
+
<Typography variant="h6" gutterBottom>
|
|
98
|
+
{t('Auth.REGISTRATION_METHODS_TITLE', 'Registration Methods')}
|
|
99
|
+
</Typography>
|
|
100
|
+
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
|
101
|
+
{t('Auth.REGISTRATION_METHODS_HINT', 'Choose which signup and invite flows are active for this app.')}
|
|
102
|
+
</Typography>
|
|
103
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
104
|
+
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
|
|
105
|
+
|
|
106
|
+
<Stack spacing={1}>
|
|
107
|
+
<FormControlLabel
|
|
108
|
+
control={(
|
|
109
|
+
<Switch
|
|
110
|
+
checked={Boolean(policy.allow_admin_invite)}
|
|
111
|
+
onChange={toggle('allow_admin_invite')}
|
|
112
|
+
disabled={Boolean(busyField)}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
label={t('Auth.ADMIN_INVITE_LABEL', 'Admin invite')}
|
|
116
|
+
/>
|
|
117
|
+
<FormControlLabel
|
|
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
|
+
)}
|
|
125
|
+
label={t('Auth.SIGNUP_ACCESS_CODE_LABEL', 'Self-signup with access code')}
|
|
126
|
+
/>
|
|
127
|
+
<FormControlLabel
|
|
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
|
+
)}
|
|
135
|
+
label={t('Auth.SIGNUP_OPEN_LABEL', 'Open self-signup')}
|
|
136
|
+
/>
|
|
137
|
+
<FormControlLabel
|
|
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
|
+
)}
|
|
145
|
+
label={t('Auth.SIGNUP_EMAIL_DOMAIN_LABEL', 'Self-signup by email domain')}
|
|
146
|
+
/>
|
|
147
|
+
<FormControlLabel
|
|
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
|
+
)}
|
|
155
|
+
label={t('Auth.SIGNUP_QR_LABEL', 'Self-signup by QR')}
|
|
156
|
+
/>
|
|
157
|
+
</Stack>
|
|
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
|
+
|
|
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
|
+
</Box>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
2
|
import { Box, TextField, Button, Typography, Alert, CircularProgress } from '@mui/material';
|
|
3
|
-
import {
|
|
3
|
+
import { sendAdminInvite } from '../auth/authApi';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
|
|
6
6
|
export function UserInviteComponent() { // FIX: Removed apiUrl prop
|
|
@@ -23,9 +23,7 @@ export function UserInviteComponent() { // FIX: Removed apiUrl prop
|
|
|
23
23
|
|
|
24
24
|
setLoading(true);
|
|
25
25
|
try {
|
|
26
|
-
|
|
27
|
-
// Previously, 'apiUrl' was passed here incorrectly.
|
|
28
|
-
const data = await requestInviteWithCode(inviteEmail, null);
|
|
26
|
+
const data = await sendAdminInvite(inviteEmail);
|
|
29
27
|
|
|
30
28
|
setInviteEmail('');
|
|
31
29
|
setMessage(data.detail || t('Auth.INVITE_SENT_SUCCESS', 'Invitation sent.'));
|