@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.1.20",
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",
@@ -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 }) => {
@@ -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 requestInviteWithCode(email, accessCode) {
325
- const payload = { email };
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}/invite/`, payload);
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 CloseIcon from '@mui/icons-material/Close';
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 direction="row" flexWrap="wrap" gap={1}>
170
+ <Stack spacing={1}>
150
171
  {codes.map((code) => (
151
- <Chip
172
+ <Box
152
173
  key={code.id}
153
- label={code.code}
154
- onDelete={() => handleDelete(code.id)}
155
- deleteIcon={<CloseIcon />}
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 { requestInviteWithCode } from '../auth/authApi';
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) => requestInviteWithCode(email, null),
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 & Recovery */}
122
-
121
+ {/* Account actions */}
123
122
  {(onSignUp || onForgotPassword) && (
124
123
  <Box>
125
- <Typography variant="subtitle2" sx={{ mb: 1 }}>
126
- {t('Auth.LOGIN_ACCOUNT_RECOVERY_TITLE')}
127
- </Typography>
124
+ <Divider sx={{ my: 2 }}>
125
+ {t('Auth.LOGIN_OR')}
126
+ </Divider>
128
127
 
129
- <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
128
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
130
129
  {onSignUp && (
131
130
  <Button
132
131
  type="button"
133
132
  variant="outlined"
134
133
  onClick={onSignUp}
135
134
  disabled={disabled}
135
+ fullWidth
136
136
  >
137
137
  {t('Auth.LOGIN_SIGNUP_BUTTON')}
138
138
  </Button>
@@ -144,6 +144,7 @@ export function LoginForm({
144
144
  variant="outlined"
145
145
  onClick={onForgotPassword}
146
146
  disabled={disabled}
147
+ fullWidth
147
148
  >
148
149
  {t('Auth.LOGIN_FORGOT_PASSWORD_BUTTON')}
149
150
  </Button>
@@ -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 { requestInviteWithCode } from '../auth/authApi';
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
- // FIX: 2nd parameter is accessCode. For admin invites without code, we pass null.
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.'));