@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.
@@ -1,10 +1,17 @@
1
- import React, { useMemo, useState, useEffect } from 'react';
1
+ import React, { useMemo, useState, useEffect, useCallback } from 'react';
2
2
  import {
3
- Box, Typography, Table, TableBody, TableCell, TableContainer,
4
- TableHead, TableRow, Paper, FormControl, Select,
5
- MenuItem, Button, Tooltip, CircularProgress, Alert,
6
- TableSortLabel, TextField
3
+ Box,
4
+ Typography,
5
+ FormControl,
6
+ Select,
7
+ MenuItem,
8
+ Button,
9
+ Tooltip,
10
+ CircularProgress,
11
+ Alert,
12
+ TextField,
7
13
  } from '@mui/material';
14
+ import { DataGrid } from '@mui/x-data-grid';
8
15
  import DeleteIcon from '@mui/icons-material/Delete';
9
16
  import { useTranslation } from 'react-i18next';
10
17
  import { fetchUsersList, deleteUser, updateUserRole, updateUserSupportStatus } from '../auth/authApi';
@@ -26,17 +33,13 @@ export function UserListComponent({
26
33
  const [error, setError] = useState(null);
27
34
  const [rowActionLoading, setRowActionLoading] = useState({});
28
35
  const [searchQuery, setSearchQuery] = useState('');
29
- const [sortConfig, setSortConfig] = useState({ key: 'id', direction: 'asc' });
30
- const cellSx = { verticalAlign: 'middle' };
31
36
  const controlSx = { minWidth: 140 };
32
37
  const actionButtonSx = { textTransform: 'none', minWidth: 90 };
33
38
 
34
- const loadUsers = async () => {
39
+ const loadUsers = useCallback(async () => {
35
40
  setLoading(true);
36
41
  setError(null);
37
42
  try {
38
- // FIX: Removed apiUrl parameter.
39
- // fetchUsersList uses USERS_BASE from authConfig internally.
40
43
  const data = await fetchUsersList();
41
44
  const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []);
42
45
  // Keep row identity stable even if backend returns unordered results.
@@ -51,17 +54,15 @@ export function UserListComponent({
51
54
  } finally {
52
55
  setLoading(false);
53
56
  }
54
- };
57
+ }, []);
55
58
 
56
59
  useEffect(() => {
57
60
  loadUsers();
58
- // eslint-disable-next-line react-hooks/exhaustive-deps
59
- }, [refreshTrigger]); // Dependency on apiUrl removed
61
+ }, [loadUsers, refreshTrigger]);
60
62
 
61
63
  const handleDelete = async (userId) => {
62
64
  if (!window.confirm(t('UserList.DELETE_CONFIRM', 'Are you sure you want to delete this user?'))) return;
63
65
  try {
64
- // FIX: Removed apiUrl parameter.
65
66
  await deleteUser(userId);
66
67
  setUsers(prev => prev.filter(u => u.id !== userId));
67
68
  } catch (err) {
@@ -71,7 +72,6 @@ export function UserListComponent({
71
72
 
72
73
  const handleChangeRole = async (userId, newRole) => {
73
74
  try {
74
- // FIX: Removed apiUrl parameter.
75
75
  await updateUserRole(userId, newRole);
76
76
  await loadUsers();
77
77
  } catch (err) {
@@ -81,7 +81,6 @@ export function UserListComponent({
81
81
 
82
82
  const handleToggleSupporter = async (userId, newValue) => {
83
83
  try {
84
- // FIX: Removed apiUrl parameter.
85
84
  await updateUserSupportStatus(userId, newValue);
86
85
  await loadUsers();
87
86
  } catch (err) {
@@ -117,7 +116,7 @@ export function UserListComponent({
117
116
  extraContext,
118
117
  t,
119
118
  reloadUsers: loadUsers,
120
- }), [currentUser, extraContext, t]);
119
+ }), [currentUser, extraContext, t, loadUsers]);
121
120
 
122
121
  const visibleExtraColumns = useMemo(
123
122
  () =>
@@ -172,45 +171,6 @@ export function UserListComponent({
172
171
  return getExtraColumnSortValue(column, user);
173
172
  };
174
173
 
175
- const normalizeSortValue = (value) => {
176
- if (value === null || value === undefined) return '';
177
- if (typeof value === 'boolean') return value ? 1 : 0;
178
- if (typeof value === 'number') return value;
179
- if (value instanceof Date) return value.getTime();
180
- return String(value).toLocaleLowerCase();
181
- };
182
-
183
- const compareSortValues = (a, b) => {
184
- const av = normalizeSortValue(a);
185
- const bv = normalizeSortValue(b);
186
-
187
- if (typeof av === 'number' && typeof bv === 'number') {
188
- return av - bv;
189
- }
190
-
191
- return String(av).localeCompare(String(bv), undefined, {
192
- numeric: true,
193
- sensitivity: 'base',
194
- });
195
- };
196
-
197
- const getSortValueByKey = (user, sortKey) => {
198
- if (sortKey === 'id') return Number(user?.id ?? 0);
199
- if (sortKey === 'email') return user?.email || '';
200
- if (sortKey === 'name') return getUserDisplayName(user);
201
- if (sortKey === 'role') return user?.role || '';
202
- if (sortKey === 'is_support_agent') return Boolean(user?.is_support_agent);
203
-
204
- if (sortKey.startsWith('extra:')) {
205
- const extraKey = sortKey.slice('extra:'.length);
206
- const column = visibleExtraColumns.find((col) => String(col.key) === extraKey);
207
- if (!column) return '';
208
- return getExtraColumnSortValue(column, user);
209
- }
210
-
211
- return user?.[sortKey] ?? '';
212
- };
213
-
214
174
  const toSearchText = (value) => {
215
175
  if (value === null || value === undefined) return '';
216
176
  if (Array.isArray(value)) return value.map((item) => toSearchText(item)).join(' ');
@@ -238,37 +198,13 @@ export function UserListComponent({
238
198
 
239
199
  const normalizedSearch = searchQuery.trim().toLocaleLowerCase();
240
200
 
241
- const filteredAndSortedUsers = useMemo(() => {
242
- const filtered = users.filter((user) => {
201
+ const filteredUsers = useMemo(() => {
202
+ return users.filter((user) => {
243
203
  if (!normalizedSearch) return true;
244
204
  const bucket = getSearchBucketForUser(user);
245
205
  return bucket.some((entry) => entry.includes(normalizedSearch));
246
206
  });
247
-
248
- const sorted = [...filtered].sort((a, b) => {
249
- const aValue = getSortValueByKey(a, sortConfig.key);
250
- const bValue = getSortValueByKey(b, sortConfig.key);
251
- const cmp = compareSortValues(aValue, bValue);
252
- if (cmp !== 0) return sortConfig.direction === 'asc' ? cmp : -cmp;
253
-
254
- // Stable fallback
255
- return Number(a?.id ?? 0) - Number(b?.id ?? 0);
256
- });
257
-
258
- return sorted;
259
- }, [users, normalizedSearch, sortConfig, visibleExtraColumns, currentUser, extraContext, t]);
260
-
261
- const handleSort = (columnKey) => {
262
- setSortConfig((prev) => {
263
- if (prev.key === columnKey) {
264
- return {
265
- key: columnKey,
266
- direction: prev.direction === 'asc' ? 'desc' : 'asc',
267
- };
268
- }
269
- return { key: columnKey, direction: 'asc' };
270
- });
271
- };
207
+ }, [users, normalizedSearch, visibleExtraColumns, currentUser, extraContext, t]);
272
208
 
273
209
  const runRowAction = async (action, user) => {
274
210
  const actionId = `${action.key}:${user.id}`;
@@ -290,6 +226,162 @@ export function UserListComponent({
290
226
  }
291
227
  };
292
228
 
229
+ const columns = useMemo(() => {
230
+ const baseColumns = [
231
+ {
232
+ field: 'email',
233
+ headerName: t('Auth.EMAIL_LABEL', 'Email'),
234
+ minWidth: 240,
235
+ flex: 1.2,
236
+ },
237
+ {
238
+ field: 'name',
239
+ headerName: t('Profile.NAME_LABEL', 'Name'),
240
+ minWidth: 220,
241
+ flex: 1,
242
+ valueGetter: (_value, row) => getUserDisplayName(row),
243
+ },
244
+ {
245
+ field: 'role',
246
+ headerName: t('UserList.ROLE', 'Role'),
247
+ minWidth: 180,
248
+ flex: 0.8,
249
+ valueGetter: (_value, row) => row?.role || 'none',
250
+ renderCell: (params) => {
251
+ const user = params.row;
252
+ return (
253
+ <FormControl size="small" fullWidth sx={controlSx} disabled={!canEdit(user)}>
254
+ <Select
255
+ value={user.role || 'none'}
256
+ onChange={(event) => handleChangeRole(user.id, event.target.value)}
257
+ variant="outlined"
258
+ >
259
+ {roles.map((role) => <MenuItem key={role} value={role}>{role}</MenuItem>)}
260
+ </Select>
261
+ </FormControl>
262
+ );
263
+ },
264
+ },
265
+ {
266
+ field: 'is_support_agent',
267
+ headerName: t('UserList.SUPPORTER', 'Support Agent'),
268
+ minWidth: 190,
269
+ flex: 0.8,
270
+ valueGetter: (_value, row) => Boolean(row?.is_support_agent),
271
+ renderCell: (params) => {
272
+ const user = params.row;
273
+ return (
274
+ <FormControl size="small" fullWidth sx={controlSx} disabled={!canEdit(user)}>
275
+ <Select
276
+ value={user.is_support_agent ? 'yes' : 'no'}
277
+ onChange={(event) => handleToggleSupporter(user.id, event.target.value === 'yes')}
278
+ variant="outlined"
279
+ >
280
+ <MenuItem value="yes">{t('Common.YES', 'Yes')}</MenuItem>
281
+ <MenuItem value="no">{t('Common.NO', 'No')}</MenuItem>
282
+ </Select>
283
+ </FormControl>
284
+ );
285
+ },
286
+ },
287
+ ];
288
+
289
+ const mappedExtraColumns = visibleExtraColumns.map((column) => ({
290
+ field: `extra:${column.key}`,
291
+ headerName: typeof column.label === 'function' ? column.label(listContext) : column.label,
292
+ minWidth: Number(column.minWidth) || 180,
293
+ flex: Number(column.flex) || 0.9,
294
+ sortable: column.sortable !== false,
295
+ align: column.align || 'left',
296
+ headerAlign: column.align || 'left',
297
+ valueGetter: (_value, row) => getExtraColumnSortValue(column, row),
298
+ renderCell: (params) =>
299
+ column.renderCell({
300
+ user: params.row,
301
+ canEdit: canEdit(params.row),
302
+ currentUser,
303
+ extraContext,
304
+ t,
305
+ reloadUsers: loadUsers,
306
+ }),
307
+ }));
308
+
309
+ const actionColumn = {
310
+ field: 'actions',
311
+ headerName: t('Common.ACTIONS', 'Actions'),
312
+ minWidth: Math.max(220, 110 + visibleRowActions.length * 110),
313
+ flex: 1.4,
314
+ sortable: false,
315
+ filterable: false,
316
+ disableColumnMenu: true,
317
+ renderCell: (params) => {
318
+ const user = params.row;
319
+
320
+ return (
321
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', py: 0.5 }}>
322
+ {visibleRowActions.map((action) => {
323
+ const actionId = `${action.key}:${user.id}`;
324
+ const isBusy = Boolean(rowActionLoading[actionId]);
325
+ const isDisabled = typeof action.disabled === 'function'
326
+ ? action.disabled({
327
+ user,
328
+ canEdit: canEdit(user),
329
+ currentUser,
330
+ extraContext,
331
+ t,
332
+ })
333
+ : false;
334
+
335
+ return (
336
+ <Button
337
+ key={`${action.key}-${user.id}`}
338
+ size="small"
339
+ variant="outlined"
340
+ onClick={() => runRowAction(action, user)}
341
+ disabled={isBusy || isDisabled}
342
+ sx={actionButtonSx}
343
+ >
344
+ {typeof action.label === 'function'
345
+ ? action.label({ user, t, currentUser, canEdit: canEdit(user) })
346
+ : action.label}
347
+ </Button>
348
+ );
349
+ })}
350
+
351
+ <Tooltip title={t('Common.DELETE', 'Delete')}>
352
+ <span>
353
+ <Button
354
+ size="small"
355
+ variant="outlined"
356
+ color="error"
357
+ startIcon={<DeleteIcon />}
358
+ onClick={() => handleDelete(user.id)}
359
+ disabled={!canEdit(user)}
360
+ sx={actionButtonSx}
361
+ >
362
+ {t('Common.DELETE', 'Delete')}
363
+ </Button>
364
+ </span>
365
+ </Tooltip>
366
+ </Box>
367
+ );
368
+ },
369
+ };
370
+
371
+ return [...baseColumns, ...mappedExtraColumns, actionColumn];
372
+ }, [
373
+ t,
374
+ roles,
375
+ visibleExtraColumns,
376
+ visibleRowActions,
377
+ listContext,
378
+ rowActionLoading,
379
+ currentUser,
380
+ extraContext,
381
+ loadUsers,
382
+ canEdit,
383
+ ]);
384
+
293
385
  return (
294
386
  <Box>
295
387
  <Typography variant="h6" gutterBottom>{t('UserList.TITLE', 'All Users')}</Typography>
@@ -307,171 +399,49 @@ export function UserListComponent({
307
399
 
308
400
  {error && <Alert severity="error" sx={{ mb: 2 }}>{t(error)}</Alert>}
309
401
 
310
- {loading ? (
402
+ {loading && (
311
403
  <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
312
- <CircularProgress />
404
+ <CircularProgress />
405
+ </Box>
406
+ )}
407
+
408
+ {!loading && (
409
+ <Box sx={{ width: '100%', minHeight: 520 }}>
410
+ <DataGrid
411
+ rows={filteredUsers}
412
+ columns={columns}
413
+ disableRowSelectionOnClick
414
+ showToolbar
415
+ getRowHeight={() => 'auto'}
416
+ pageSizeOptions={[10, 25, 50, 100]}
417
+ initialState={{
418
+ sorting: { sortModel: [{ field: 'email', sort: 'asc' }] },
419
+ pagination: { paginationModel: { pageSize: 25, page: 0 } },
420
+ }}
421
+ localeText={{
422
+ noRowsLabel: t('UserList.NO_USERS', 'No users found.'),
423
+ toolbarQuickFilterPlaceholder: t('UserList.SEARCH_PLACEHOLDER', 'Search users...'),
424
+ }}
425
+ slotProps={{
426
+ toolbar: {
427
+ showQuickFilter: true,
428
+ quickFilterProps: { debounceMs: 300 },
429
+ },
430
+ }}
431
+ sx={{
432
+ border: 1,
433
+ borderColor: 'divider',
434
+ '& .MuiDataGrid-cell': {
435
+ display: 'flex',
436
+ alignItems: 'flex-start',
437
+ py: 1,
438
+ },
439
+ '& .MuiDataGrid-columnHeaders': {
440
+ bgcolor: 'action.hover',
441
+ },
442
+ }}
443
+ />
313
444
  </Box>
314
- ) : (
315
- <TableContainer component={Paper}>
316
- <Table size="small">
317
- <TableHead>
318
- <TableRow>
319
- <TableCell sx={cellSx} sortDirection={sortConfig.key === 'email' ? sortConfig.direction : false}>
320
- <TableSortLabel
321
- active={sortConfig.key === 'email'}
322
- direction={sortConfig.key === 'email' ? sortConfig.direction : 'asc'}
323
- onClick={() => handleSort('email')}
324
- >
325
- {t('Auth.EMAIL_LABEL', 'Email')}
326
- </TableSortLabel>
327
- </TableCell>
328
- <TableCell sx={cellSx} sortDirection={sortConfig.key === 'name' ? sortConfig.direction : false}>
329
- <TableSortLabel
330
- active={sortConfig.key === 'name'}
331
- direction={sortConfig.key === 'name' ? sortConfig.direction : 'asc'}
332
- onClick={() => handleSort('name')}
333
- >
334
- {t('Profile.NAME_LABEL', 'Name')}
335
- </TableSortLabel>
336
- </TableCell>
337
- <TableCell sx={cellSx} sortDirection={sortConfig.key === 'role' ? sortConfig.direction : false}>
338
- <TableSortLabel
339
- active={sortConfig.key === 'role'}
340
- direction={sortConfig.key === 'role' ? sortConfig.direction : 'asc'}
341
- onClick={() => handleSort('role')}
342
- >
343
- {t('UserList.ROLE', 'Role')}
344
- </TableSortLabel>
345
- </TableCell>
346
- <TableCell sx={cellSx} sortDirection={sortConfig.key === 'is_support_agent' ? sortConfig.direction : false}>
347
- <TableSortLabel
348
- active={sortConfig.key === 'is_support_agent'}
349
- direction={sortConfig.key === 'is_support_agent' ? sortConfig.direction : 'asc'}
350
- onClick={() => handleSort('is_support_agent')}
351
- >
352
- {t('UserList.SUPPORTER', 'Support Agent')}
353
- </TableSortLabel>
354
- </TableCell>
355
- {visibleExtraColumns.map((column) => (
356
- <TableCell
357
- key={column.key}
358
- sx={cellSx}
359
- align={column.align || 'left'}
360
- sortDirection={sortConfig.key === `extra:${column.key}` ? sortConfig.direction : false}
361
- >
362
- <TableSortLabel
363
- active={sortConfig.key === `extra:${column.key}`}
364
- direction={sortConfig.key === `extra:${column.key}` ? sortConfig.direction : 'asc'}
365
- onClick={() => handleSort(`extra:${column.key}`)}
366
- >
367
- {typeof column.label === 'function' ? column.label(listContext) : column.label}
368
- </TableSortLabel>
369
- </TableCell>
370
- ))}
371
- <TableCell sx={cellSx}>{t('Common.ACTIONS', 'Actions')}</TableCell>
372
- </TableRow>
373
- </TableHead>
374
- <TableBody>
375
- {filteredAndSortedUsers.map((u) => (
376
- <TableRow key={u.id}>
377
- <TableCell sx={cellSx}>{u.email}</TableCell>
378
- <TableCell sx={cellSx}>
379
- {getUserDisplayName(u)}
380
- </TableCell>
381
- <TableCell sx={cellSx}>
382
- <FormControl size="small" fullWidth sx={controlSx} disabled={!canEdit(u)}>
383
- <Select
384
- value={u.role || 'none'}
385
- onChange={(e) => handleChangeRole(u.id, e.target.value)}
386
- variant="outlined"
387
- >
388
- {roles.map(r => <MenuItem key={r} value={r}>{r}</MenuItem>)}
389
- </Select>
390
- </FormControl>
391
- </TableCell>
392
- <TableCell sx={cellSx}>
393
- <FormControl size="small" fullWidth sx={controlSx} disabled={!canEdit(u)}>
394
- <Select
395
- value={u.is_support_agent ? 'yes' : 'no'}
396
- onChange={(e) => handleToggleSupporter(u.id, e.target.value === 'yes')}
397
- variant="outlined"
398
- >
399
- <MenuItem value="yes">{t('Common.YES', 'Yes')}</MenuItem>
400
- <MenuItem value="no">{t('Common.NO', 'No')}</MenuItem>
401
- </Select>
402
- </FormControl>
403
- </TableCell>
404
- {visibleExtraColumns.map((column) => (
405
- <TableCell key={`${column.key}-${u.id}`} sx={cellSx} align={column.align || 'left'}>
406
- {column.renderCell({
407
- user: u,
408
- canEdit: canEdit(u),
409
- currentUser,
410
- extraContext,
411
- t,
412
- reloadUsers: loadUsers,
413
- })}
414
- </TableCell>
415
- ))}
416
- <TableCell sx={cellSx}>
417
- <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>
418
- {visibleRowActions.map((action) => {
419
- const actionId = `${action.key}:${u.id}`;
420
- const isBusy = Boolean(rowActionLoading[actionId]);
421
- const isDisabled = typeof action.disabled === 'function'
422
- ? action.disabled({
423
- user: u,
424
- canEdit: canEdit(u),
425
- currentUser,
426
- extraContext,
427
- t,
428
- })
429
- : false;
430
-
431
- return (
432
- <Button
433
- key={`${action.key}-${u.id}`}
434
- size="small"
435
- variant="outlined"
436
- onClick={() => runRowAction(action, u)}
437
- disabled={isBusy || isDisabled}
438
- sx={actionButtonSx}
439
- >
440
- {typeof action.label === 'function'
441
- ? action.label({ user: u, t, currentUser, canEdit: canEdit(u) })
442
- : action.label}
443
- </Button>
444
- );
445
- })}
446
- <Tooltip title={t('Common.DELETE')}>
447
- <span>
448
- <Button
449
- size="small"
450
- variant="outlined"
451
- color="error"
452
- startIcon={<DeleteIcon />}
453
- onClick={() => handleDelete(u.id)}
454
- disabled={!canEdit(u)}
455
- sx={actionButtonSx}
456
- >
457
- {t('Common.DELETE', 'Delete')}
458
- </Button>
459
- </span>
460
- </Tooltip>
461
- </Box>
462
- </TableCell>
463
- </TableRow>
464
- ))}
465
- {filteredAndSortedUsers.length === 0 && (
466
- <TableRow>
467
- <TableCell colSpan={5 + visibleExtraColumns.length} align="center">
468
- {t('UserList.NO_USERS', 'No users found.')}
469
- </TableCell>
470
- </TableRow>
471
- )}
472
- </TableBody>
473
- </Table>
474
- </TableContainer>
475
445
  )}
476
446
  </Box>
477
447
  );
package/src/index.js CHANGED
@@ -28,6 +28,9 @@ export { AccessCodeManager } from './components/AccessCodeManager';
28
28
  export { UserListComponent } from './components/UserListComponent';
29
29
  export { UserInviteComponent } from './components/UserInviteComponent';
30
30
  export { BulkInviteCsvTab } from './components/BulkInviteCsvTab';
31
+ export { RegistrationMethodsManager } from './components/RegistrationMethodsManager';
32
+ export { AuthFactorRequirementCard } from './components/AuthFactorRequirementCard';
33
+ export { QrSignupManager } from './components/QrSignupManager';
31
34
 
32
35
  // --- 6. Translations ---
33
36
  export { authTranslations } from './i18n/authTranslations';
@@ -1,4 +1,4 @@
1
- import React, { useContext, useMemo } from 'react';
1
+ import React, { useContext, useMemo, useState } from 'react';
2
2
  import { Helmet } from 'react-helmet';
3
3
  import { useSearchParams } from 'react-router-dom';
4
4
  import {
@@ -23,6 +23,9 @@ import { SecurityComponent } from '../components/SecurityComponent';
23
23
  import { UserListComponent } from '../components/UserListComponent';
24
24
  import { UserInviteComponent } from '../components/UserInviteComponent';
25
25
  import { AccessCodeManager } from '../components/AccessCodeManager';
26
+ import { RegistrationMethodsManager } from '../components/RegistrationMethodsManager';
27
+ import { AuthFactorRequirementCard } from '../components/AuthFactorRequirementCard';
28
+ import { QrSignupManager } from '../components/QrSignupManager';
26
29
  import { SupportRecoveryRequestsTab } from '../components/SupportRecoveryRequestsTab';
27
30
  import { BulkInviteCsvTab } from '../components/BulkInviteCsvTab';
28
31
  import { updateUserProfile } from '../auth/authApi'; // Ggf. Pfad anpassen
@@ -40,6 +43,7 @@ export function AccountPage({
40
43
  const { t } = useTranslation();
41
44
  const { user, login, loading } = useContext(AuthContext);
42
45
  const [searchParams, setSearchParams] = useSearchParams();
46
+ const [authPolicy, setAuthPolicy] = useState(null);
43
47
 
44
48
  // 1. URL State Management
45
49
  const currentTabRaw = searchParams.get('tab') || 'profile';
@@ -188,6 +192,18 @@ export function AccountPage({
188
192
  {safeTab === 'invite' && (
189
193
  <Box sx={{ mt: 2 }}>
190
194
  <Stack spacing={2.5}>
195
+ {(isSuperUser || perms.can_invite) && (
196
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
197
+ <RegistrationMethodsManager onPolicyChange={setAuthPolicy} />
198
+ </Paper>
199
+ )}
200
+
201
+ {(isSuperUser || perms.can_invite) && (
202
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
203
+ <AuthFactorRequirementCard />
204
+ </Paper>
205
+ )}
206
+
191
207
  {(isSuperUser || perms.can_invite) && (
192
208
  <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
193
209
  <UserInviteComponent />
@@ -208,6 +224,12 @@ export function AccountPage({
208
224
  <BulkInviteCsvTab {...bulkInviteCsvProps} />
209
225
  </Paper>
210
226
  )}
227
+
228
+ {(isSuperUser || perms.can_invite) && (
229
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
230
+ <QrSignupManager enabled={Boolean(authPolicy?.allow_self_signup_qr)} />
231
+ </Paper>
232
+ )}
211
233
  </Stack>
212
234
  </Box>
213
235
  )}