@micha.bigler/ui-core-micha 2.1.16 → 2.1.17

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.
@@ -2,7 +2,8 @@ import React, { useMemo, useState, useEffect } from 'react';
2
2
  import {
3
3
  Box, Typography, Table, TableBody, TableCell, TableContainer,
4
4
  TableHead, TableRow, Paper, FormControl, Select,
5
- MenuItem, Button, IconButton, Tooltip, CircularProgress, Alert
5
+ MenuItem, Button, Tooltip, CircularProgress, Alert,
6
+ TableSortLabel, TextField
6
7
  } from '@mui/material';
7
8
  import DeleteIcon from '@mui/icons-material/Delete';
8
9
  import { useTranslation } from 'react-i18next';
@@ -24,6 +25,11 @@ export function UserListComponent({
24
25
  const [loading, setLoading] = useState(true);
25
26
  const [error, setError] = useState(null);
26
27
  const [rowActionLoading, setRowActionLoading] = useState({});
28
+ const [searchQuery, setSearchQuery] = useState('');
29
+ const [sortConfig, setSortConfig] = useState({ key: 'id', direction: 'asc' });
30
+ const cellSx = { verticalAlign: 'middle' };
31
+ const controlSx = { minWidth: 140 };
32
+ const actionButtonSx = { textTransform: 'none', minWidth: 90 };
27
33
 
28
34
  const loadUsers = async () => {
29
35
  setLoading(true);
@@ -32,7 +38,14 @@ export function UserListComponent({
32
38
  // FIX: Removed apiUrl parameter.
33
39
  // fetchUsersList uses USERS_BASE from authConfig internally.
34
40
  const data = await fetchUsersList();
35
- setUsers(data);
41
+ const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []);
42
+ // Keep row identity stable even if backend returns unordered results.
43
+ list.sort((a, b) => {
44
+ const aId = Number(a?.id ?? 0);
45
+ const bId = Number(b?.id ?? 0);
46
+ return aId - bId;
47
+ });
48
+ setUsers(list);
36
49
  } catch (err) {
37
50
  setError(err.code || 'Auth.USER_LIST_FAILED');
38
51
  } finally {
@@ -60,7 +73,7 @@ export function UserListComponent({
60
73
  try {
61
74
  // FIX: Removed apiUrl parameter.
62
75
  await updateUserRole(userId, newRole);
63
- loadUsers();
76
+ await loadUsers();
64
77
  } catch (err) {
65
78
  alert(t(err.code || 'Auth.USER_ROLE_UPDATE_FAILED'));
66
79
  }
@@ -70,7 +83,7 @@ export function UserListComponent({
70
83
  try {
71
84
  // FIX: Removed apiUrl parameter.
72
85
  await updateUserSupportStatus(userId, newValue);
73
- loadUsers();
86
+ await loadUsers();
74
87
  } catch (err) {
75
88
  alert(t(err.code || 'Auth.USER_UPDATE_FAILED'));
76
89
  }
@@ -122,6 +135,141 @@ export function UserListComponent({
122
135
  [extraRowActions, listContext],
123
136
  );
124
137
 
138
+ const getUserDisplayName = (user) => {
139
+ if (user?.first_name || user?.last_name) {
140
+ return `${user.first_name || ''} ${user.last_name || ''}`.trim();
141
+ }
142
+ return user?.username || '';
143
+ };
144
+
145
+ const getExtraColumnSortValue = (column, user) => {
146
+ const valueContext = { user, currentUser, extraContext, t };
147
+
148
+ if (typeof column.getSortValue === 'function') {
149
+ return column.getSortValue(valueContext);
150
+ }
151
+
152
+ if (typeof column.sortValue === 'function') {
153
+ return column.sortValue(valueContext);
154
+ }
155
+
156
+ if (typeof column.getSearchValue === 'function') {
157
+ return column.getSearchValue(valueContext);
158
+ }
159
+
160
+ if (user?.[column.key] !== undefined) return user[column.key];
161
+ if (user?.profile?.[column.key] !== undefined) return user.profile[column.key];
162
+ return '';
163
+ };
164
+
165
+ const getExtraColumnSearchValue = (column, user) => {
166
+ const valueContext = { user, currentUser, extraContext, t };
167
+
168
+ if (typeof column.getSearchValue === 'function') {
169
+ return column.getSearchValue(valueContext);
170
+ }
171
+
172
+ return getExtraColumnSortValue(column, user);
173
+ };
174
+
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
+ const toSearchText = (value) => {
215
+ if (value === null || value === undefined) return '';
216
+ if (Array.isArray(value)) return value.map((item) => toSearchText(item)).join(' ');
217
+ if (typeof value === 'object') return Object.values(value).map((item) => toSearchText(item)).join(' ');
218
+ return String(value).toLocaleLowerCase();
219
+ };
220
+
221
+ const getSearchBucketForUser = (user) => {
222
+ const baseValues = [
223
+ user?.id,
224
+ user?.email,
225
+ user?.username,
226
+ user?.first_name,
227
+ user?.last_name,
228
+ getUserDisplayName(user),
229
+ user?.role,
230
+ user?.language,
231
+ user?.is_support_agent ? t('Common.YES', 'Yes') : t('Common.NO', 'No'),
232
+ ];
233
+
234
+ const extraValues = visibleExtraColumns.map((column) => getExtraColumnSearchValue(column, user));
235
+
236
+ return [...baseValues, ...extraValues].map((value) => toSearchText(value)).filter(Boolean);
237
+ };
238
+
239
+ const normalizedSearch = searchQuery.trim().toLocaleLowerCase();
240
+
241
+ const filteredAndSortedUsers = useMemo(() => {
242
+ const filtered = users.filter((user) => {
243
+ if (!normalizedSearch) return true;
244
+ const bucket = getSearchBucketForUser(user);
245
+ return bucket.some((entry) => entry.includes(normalizedSearch));
246
+ });
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
+ };
272
+
125
273
  const runRowAction = async (action, user) => {
126
274
  const actionId = `${action.key}:${user.id}`;
127
275
  setRowActionLoading((prev) => ({ ...prev, [actionId]: true }));
@@ -145,6 +293,17 @@ export function UserListComponent({
145
293
  return (
146
294
  <Box>
147
295
  <Typography variant="h6" gutterBottom>{t('UserList.TITLE', 'All Users')}</Typography>
296
+
297
+ <Box sx={{ mb: 2, maxWidth: 420 }}>
298
+ <TextField
299
+ fullWidth
300
+ size="small"
301
+ label={t('Common.SEARCH', 'Search')}
302
+ placeholder={t('UserList.SEARCH_PLACEHOLDER', 'Search users...')}
303
+ value={searchQuery}
304
+ onChange={(event) => setSearchQuery(event.target.value)}
305
+ />
306
+ </Box>
148
307
 
149
308
  {error && <Alert severity="error" sx={{ mb: 2 }}>{t(error)}</Alert>}
150
309
 
@@ -157,51 +316,93 @@ export function UserListComponent({
157
316
  <Table size="small">
158
317
  <TableHead>
159
318
  <TableRow>
160
- <TableCell>{t('Auth.EMAIL_LABEL', 'Email')}</TableCell>
161
- <TableCell>{t('Profile.NAME_LABEL', 'Name')}</TableCell>
162
- <TableCell>{t('UserList.ROLE', 'Role')}</TableCell>
163
- <TableCell>{t('UserList.SUPPORTER', 'Support Agent')}</TableCell>
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>
164
355
  {visibleExtraColumns.map((column) => (
165
- <TableCell key={column.key} align={column.align || 'left'}>
166
- {typeof column.label === 'function' ? column.label(listContext) : column.label}
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>
167
369
  </TableCell>
168
370
  ))}
169
- <TableCell>{t('Common.ACTIONS', 'Actions')}</TableCell>
371
+ <TableCell sx={cellSx}>{t('Common.ACTIONS', 'Actions')}</TableCell>
170
372
  </TableRow>
171
373
  </TableHead>
172
374
  <TableBody>
173
- {users.map((u) => (
375
+ {filteredAndSortedUsers.map((u) => (
174
376
  <TableRow key={u.id}>
175
- <TableCell>{u.email}</TableCell>
176
- <TableCell>
177
- {u.first_name || u.last_name ? `${u.first_name} ${u.last_name}` : u.username}
377
+ <TableCell sx={cellSx}>{u.email}</TableCell>
378
+ <TableCell sx={cellSx}>
379
+ {getUserDisplayName(u)}
178
380
  </TableCell>
179
- <TableCell>
180
- <FormControl size="small" fullWidth disabled={!canEdit(u)}>
381
+ <TableCell sx={cellSx}>
382
+ <FormControl size="small" fullWidth sx={controlSx} disabled={!canEdit(u)}>
181
383
  <Select
182
384
  value={u.role || 'none'}
183
385
  onChange={(e) => handleChangeRole(u.id, e.target.value)}
184
- variant="standard"
185
- disableUnderline
386
+ variant="outlined"
186
387
  >
187
388
  {roles.map(r => <MenuItem key={r} value={r}>{r}</MenuItem>)}
188
389
  </Select>
189
390
  </FormControl>
190
391
  </TableCell>
191
- <TableCell>
192
- <Button
193
- size="small"
194
- variant={u.is_support_agent ? 'contained' : 'outlined'}
195
- color={u.is_support_agent ? 'primary' : 'inherit'}
196
- onClick={() => handleToggleSupporter(u.id, !u.is_support_agent)}
197
- disabled={!canEdit(u)}
198
- sx={{ textTransform: 'none' }}
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"
199
398
  >
200
- {u.is_support_agent ? t('Common.YES') : t('Common.NO')}
201
- </Button>
399
+ <MenuItem value="yes">{t('Common.YES', 'Yes')}</MenuItem>
400
+ <MenuItem value="no">{t('Common.NO', 'No')}</MenuItem>
401
+ </Select>
402
+ </FormControl>
202
403
  </TableCell>
203
404
  {visibleExtraColumns.map((column) => (
204
- <TableCell key={`${column.key}-${u.id}`} align={column.align || 'left'}>
405
+ <TableCell key={`${column.key}-${u.id}`} sx={cellSx} align={column.align || 'left'}>
205
406
  {column.renderCell({
206
407
  user: u,
207
408
  canEdit: canEdit(u),
@@ -212,7 +413,8 @@ export function UserListComponent({
212
413
  })}
213
414
  </TableCell>
214
415
  ))}
215
- <TableCell>
416
+ <TableCell sx={cellSx}>
417
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>
216
418
  {visibleRowActions.map((action) => {
217
419
  const actionId = `${action.key}:${u.id}`;
218
420
  const isBusy = Boolean(rowActionLoading[actionId]);
@@ -230,9 +432,10 @@ export function UserListComponent({
230
432
  <Button
231
433
  key={`${action.key}-${u.id}`}
232
434
  size="small"
435
+ variant="outlined"
233
436
  onClick={() => runRowAction(action, u)}
234
437
  disabled={isBusy || isDisabled}
235
- sx={{ mr: 1, mb: 0.5, textTransform: 'none' }}
438
+ sx={actionButtonSx}
236
439
  >
237
440
  {typeof action.label === 'function'
238
441
  ? action.label({ user: u, t, currentUser, canEdit: canEdit(u) })
@@ -240,17 +443,26 @@ export function UserListComponent({
240
443
  </Button>
241
444
  );
242
445
  })}
243
- <Tooltip title={t('Common.DELETE')}>
244
- <span>
245
- <IconButton onClick={() => handleDelete(u.id)} color="error" disabled={!canEdit(u)}>
246
- <DeleteIcon />
247
- </IconButton>
248
- </span>
249
- </Tooltip>
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>
250
462
  </TableCell>
251
463
  </TableRow>
252
464
  ))}
253
- {users.length === 0 && (
465
+ {filteredAndSortedUsers.length === 0 && (
254
466
  <TableRow>
255
467
  <TableCell colSpan={5 + visibleExtraColumns.length} align="center">
256
468
  {t('UserList.NO_USERS', 'No users found.')}
@@ -855,6 +855,36 @@ export const authTranslations = {
855
855
  "en": "I allow convenience cookies.",
856
856
  "sw": "Ninaruhusu vidakuzi vya urahisi."
857
857
  },
858
+ "Profile.PRIVACY_STATEMENT_TITLE": {
859
+ "de": "Datenschutzerklärung",
860
+ "fr": "Déclaration de confidentialité",
861
+ "en": "Privacy statement",
862
+ "sw": "Taarifa ya faragha"
863
+ },
864
+ "Profile.COOKIES_STATEMENT_TITLE": {
865
+ "de": "Cookie-Richtlinie",
866
+ "fr": "Politique relative aux cookies",
867
+ "en": "Cookie statement",
868
+ "sw": "Taarifa ya vidakuzi"
869
+ },
870
+ "Profile.STATEMENT_LOADING": {
871
+ "de": "Statement wird geladen...",
872
+ "fr": "Chargement du document...",
873
+ "en": "Loading statement...",
874
+ "sw": "Inapakia taarifa..."
875
+ },
876
+ "Profile.PRIVACY_STATEMENT_EMPTY": {
877
+ "de": "Die Datenschutzerklärung ist derzeit nicht verfügbar.",
878
+ "fr": "La déclaration de confidentialité n'est actuellement pas disponible.",
879
+ "en": "Privacy statement is currently unavailable.",
880
+ "sw": "Taarifa ya faragha haipatikani kwa sasa."
881
+ },
882
+ "Profile.COOKIES_STATEMENT_EMPTY": {
883
+ "de": "Die Cookie-Richtlinie ist derzeit nicht verfügbar.",
884
+ "fr": "La politique relative aux cookies n'est actuellement pas disponible.",
885
+ "en": "Cookie statement is currently unavailable.",
886
+ "sw": "Taarifa ya vidakuzi haipatikani kwa sasa."
887
+ },
858
888
  "Profile.SAVE_BUTTON": {
859
889
  "de": "Speichern",
860
890
  "fr": "Enregistrer",
@@ -1326,4 +1356,4 @@ export const authTranslations = {
1326
1356
  "en": "Loading…",
1327
1357
  "sw": "Inapakia..."
1328
1358
  }
1329
- };
1359
+ };
@@ -7,7 +7,9 @@ import {
7
7
  Box,
8
8
  Typography,
9
9
  Alert,
10
- CircularProgress
10
+ CircularProgress,
11
+ Paper,
12
+ Stack,
11
13
  } from '@mui/material';
12
14
  import { useTranslation } from 'react-i18next';
13
15
 
@@ -46,7 +48,10 @@ export function AccountPage({
46
48
  const [searchParams, setSearchParams] = useSearchParams();
47
49
 
48
50
  // 1. URL State Management
49
- const currentTab = searchParams.get('tab') || 'profile';
51
+ const currentTabRaw = searchParams.get('tab') || 'profile';
52
+ const currentTab = ['invite', 'bulk-invite-csv', 'access'].includes(currentTabRaw)
53
+ ? 'invite'
54
+ : currentTabRaw;
50
55
  const fromRecovery = searchParams.get('from') === 'recovery';
51
56
  const fromWeakLogin = searchParams.get('from') === 'weak_login';
52
57
 
@@ -82,18 +87,8 @@ export function AccountPage({
82
87
  list.push({ value: 'users', label: t('Account.TAB_USERS', 'Users') });
83
88
  }
84
89
 
85
- if (isSuperUser || perms.can_invite) {
90
+ if (isSuperUser || perms.can_invite || perms.can_manage_access_codes) {
86
91
  list.push({ value: 'invite', label: t('Account.TAB_INVITE', 'Invite') });
87
- if (showBulkInviteCsvTab) {
88
- list.push({
89
- value: 'bulk-invite-csv',
90
- label: t('Account.TAB_BULK_INVITE_CSV', 'Bulk Invite CSV'),
91
- });
92
- }
93
- }
94
-
95
- if (isSuperUser || perms.can_manage_access_codes) {
96
- list.push({ value: 'access', label: t('Account.TAB_ACCESS_CODES', 'Access Codes') });
97
92
  }
98
93
 
99
94
  if (isSuperUser || perms.can_view_support) {
@@ -112,7 +107,7 @@ export function AccountPage({
112
107
  });
113
108
 
114
109
  return list;
115
- }, [user, perms, t, isSuperUser, showBulkInviteCsvTab, extraTabs]);
110
+ }, [user, perms, t, isSuperUser, extraTabs]);
116
111
 
117
112
  // 4. Loading & Auth Checks
118
113
  if (loading) {
@@ -137,7 +132,7 @@ export function AccountPage({
137
132
  const activeTabExists = tabs.some(t => t.value === currentTab);
138
133
  // Falls der Tab nicht erlaubt ist (z.B. manuell in URL eingegeben), Fallback auf 'profile'
139
134
  const safeTab = activeTabExists ? currentTab : 'profile';
140
- const builtInTabValues = new Set(['profile', 'security', 'users', 'invite', 'bulk-invite-csv', 'access', 'support']);
135
+ const builtInTabValues = new Set(['profile', 'security', 'users', 'invite', 'support']);
141
136
  const activeExtraTab = builtInTabValues.has(safeTab)
142
137
  ? null
143
138
  : extraTabs.find((tab) => tab.value === safeTab);
@@ -198,22 +193,28 @@ export function AccountPage({
198
193
 
199
194
  {safeTab === 'invite' && (
200
195
  <Box sx={{ mt: 2 }}>
201
- <UserInviteComponent />
202
- </Box>
203
- )}
204
-
205
- {safeTab === 'bulk-invite-csv' && (
206
- <Box sx={{ mt: 2 }}>
207
- <BulkInviteCsvTab {...bulkInviteCsvProps} />
208
- </Box>
209
- )}
210
-
211
- {safeTab === 'access' && (
212
- <Box sx={{ mt: 2 }}>
213
- <Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
214
- {t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.')}
215
- </Typography>
216
- <AccessCodeManager />
196
+ <Stack spacing={2.5}>
197
+ {(isSuperUser || perms.can_invite) && (
198
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
199
+ <UserInviteComponent />
200
+ </Paper>
201
+ )}
202
+
203
+ {(isSuperUser || perms.can_invite) && showBulkInviteCsvTab && (
204
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
205
+ <BulkInviteCsvTab {...bulkInviteCsvProps} />
206
+ </Paper>
207
+ )}
208
+
209
+ {(isSuperUser || perms.can_manage_access_codes) && (
210
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
211
+ <Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
212
+ {t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.')}
213
+ </Typography>
214
+ <AccessCodeManager />
215
+ </Paper>
216
+ )}
217
+ </Stack>
217
218
  </Box>
218
219
  )}
219
220