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

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.
@@ -9,6 +9,12 @@ export function UserInviteComponent() { // FIX: Removed apiUrl prop
9
9
  const [message, setMessage] = useState('');
10
10
  const [error, setError] = useState('');
11
11
  const [loading, setLoading] = useState(false);
12
+ const actionButtonSx = {
13
+ minWidth: 120,
14
+ height: 40,
15
+ textTransform: 'none',
16
+ whiteSpace: 'nowrap',
17
+ };
12
18
 
13
19
  const inviteUser = async () => {
14
20
  setMessage('');
@@ -34,7 +40,7 @@ export function UserInviteComponent() { // FIX: Removed apiUrl prop
34
40
  };
35
41
 
36
42
  return (
37
- <Box sx={{ maxWidth: 600, mt: 2 }}>
43
+ <Box sx={{ maxWidth: 600 }}>
38
44
  <Typography variant="h6" gutterBottom>
39
45
  {t('Auth.INVITE_TITLE', 'Invite a new user')}
40
46
  </Typography>
@@ -42,7 +48,7 @@ export function UserInviteComponent() { // FIX: Removed apiUrl prop
42
48
  {message && <Alert severity="success" sx={{ mb: 2 }}>{message}</Alert>}
43
49
  {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
44
50
 
45
- <Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
51
+ <Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
46
52
  <TextField
47
53
  label={t('Auth.EMAIL_LABEL', 'Email address')}
48
54
  type="email"
@@ -58,13 +64,14 @@ export function UserInviteComponent() { // FIX: Removed apiUrl prop
58
64
  />
59
65
  <Button
60
66
  variant="contained"
67
+ size="small"
61
68
  onClick={inviteUser}
62
69
  disabled={loading || !inviteEmail}
63
- sx={{ minWidth: 100, height: 40 }}
70
+ sx={actionButtonSx}
64
71
  >
65
72
  {loading ? <CircularProgress size={24} color="inherit" /> : t('Auth.INVITE_BUTTON', 'Invite')}
66
73
  </Button>
67
74
  </Box>
68
75
  </Box>
69
76
  );
70
- }
77
+ }
@@ -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,18 +7,14 @@ 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
 
14
- // Internal Library Components
15
- // Stellen Sie sicher, dass diese Pfade korrekt auf Ihre Library zeigen!
16
- // Da Sie '@micha.bigler/ui-core-micha' nutzen, sollten die Imports ggf. so aussehen:
17
- import {
18
- AuthContext,
19
- // ... andere Komponenten aus der Lib importieren, falls sie dort exportiert sind
20
- // Wenn sie lokal sind, lassen Sie die relativen Pfade.
21
- } from '@micha.bigler/ui-core-micha';
16
+ // Internal context
17
+ import { AuthContext } from '../auth/AuthContext';
22
18
 
23
19
  // Falls die Komponenten noch lokal sind:
24
20
  import { WidePage } from '../layout/PageLayout';
@@ -46,7 +42,10 @@ export function AccountPage({
46
42
  const [searchParams, setSearchParams] = useSearchParams();
47
43
 
48
44
  // 1. URL State Management
49
- const currentTab = searchParams.get('tab') || 'profile';
45
+ const currentTabRaw = searchParams.get('tab') || 'profile';
46
+ const currentTab = ['invite', 'bulk-invite-csv', 'access'].includes(currentTabRaw)
47
+ ? 'invite'
48
+ : currentTabRaw;
50
49
  const fromRecovery = searchParams.get('from') === 'recovery';
51
50
  const fromWeakLogin = searchParams.get('from') === 'weak_login';
52
51
 
@@ -82,18 +81,8 @@ export function AccountPage({
82
81
  list.push({ value: 'users', label: t('Account.TAB_USERS', 'Users') });
83
82
  }
84
83
 
85
- if (isSuperUser || perms.can_invite) {
84
+ if (isSuperUser || perms.can_invite || perms.can_manage_access_codes) {
86
85
  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
86
  }
98
87
 
99
88
  if (isSuperUser || perms.can_view_support) {
@@ -112,7 +101,7 @@ export function AccountPage({
112
101
  });
113
102
 
114
103
  return list;
115
- }, [user, perms, t, isSuperUser, showBulkInviteCsvTab, extraTabs]);
104
+ }, [user, perms, t, isSuperUser, extraTabs]);
116
105
 
117
106
  // 4. Loading & Auth Checks
118
107
  if (loading) {
@@ -137,7 +126,7 @@ export function AccountPage({
137
126
  const activeTabExists = tabs.some(t => t.value === currentTab);
138
127
  // Falls der Tab nicht erlaubt ist (z.B. manuell in URL eingegeben), Fallback auf 'profile'
139
128
  const safeTab = activeTabExists ? currentTab : 'profile';
140
- const builtInTabValues = new Set(['profile', 'security', 'users', 'invite', 'bulk-invite-csv', 'access', 'support']);
129
+ const builtInTabValues = new Set(['profile', 'security', 'users', 'invite', 'support']);
141
130
  const activeExtraTab = builtInTabValues.has(safeTab)
142
131
  ? null
143
132
  : extraTabs.find((tab) => tab.value === safeTab);
@@ -198,22 +187,28 @@ export function AccountPage({
198
187
 
199
188
  {safeTab === 'invite' && (
200
189
  <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 />
190
+ <Stack spacing={2.5}>
191
+ {(isSuperUser || perms.can_invite) && (
192
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
193
+ <UserInviteComponent />
194
+ </Paper>
195
+ )}
196
+
197
+ {(isSuperUser || perms.can_manage_access_codes) && (
198
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
199
+ <Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
200
+ {t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.')}
201
+ </Typography>
202
+ <AccessCodeManager />
203
+ </Paper>
204
+ )}
205
+
206
+ {(isSuperUser || perms.can_invite) && showBulkInviteCsvTab && (
207
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
208
+ <BulkInviteCsvTab {...bulkInviteCsvProps} />
209
+ </Paper>
210
+ )}
211
+ </Stack>
217
212
  </Box>
218
213
  )}
219
214
 
@@ -19,7 +19,7 @@ import { MfaLoginComponent } from '../components/MfaLoginComponent';
19
19
  export function LoginPage() {
20
20
  const navigate = useNavigate();
21
21
  const location = useLocation();
22
- const { login } = useContext(AuthContext);
22
+ const { login, authMethods } = useContext(AuthContext);
23
23
  const { t } = useTranslation();
24
24
 
25
25
  // State
@@ -40,6 +40,13 @@ export function LoginPage() {
40
40
  // Backward-compatible fallback for legacy links using query parameters.
41
41
  const recoveryEmail = hashParams.get('email') || params.get('email') || '';
42
42
 
43
+ useEffect(() => {
44
+ const socialError = params.get('error') || params.get('social');
45
+ if (socialError) {
46
+ setErrorKey('Auth.SOCIAL_LOGIN_FAILED');
47
+ }
48
+ }, [location.search]);
49
+
43
50
  // --- Helper: Central Success Logic ---
44
51
  const handleLoginSuccess = (user) => {
45
52
  login(user); // Update Context
@@ -127,6 +134,15 @@ export function LoginPage() {
127
134
  setErrorKey(null);
128
135
  };
129
136
 
137
+ const socialProviders = Array.isArray(authMethods?.social_providers)
138
+ ? authMethods.social_providers
139
+ : [];
140
+ const passwordLoginEnabled = Boolean(authMethods?.password_login) || Boolean(recoveryToken);
141
+ const socialLoginEnabled = Boolean(authMethods?.social_login) && socialProviders.length > 0;
142
+ const passkeyLoginEnabled = Boolean(authMethods?.passkey_login);
143
+ const signupEnabled = Boolean(authMethods?.signup);
144
+ const passwordResetEnabled = Boolean(authMethods?.password_reset);
145
+
130
146
  // --- Render ---
131
147
 
132
148
  return (
@@ -152,11 +168,14 @@ export function LoginPage() {
152
168
 
153
169
  {step === 'credentials' && (
154
170
  <LoginForm
155
- onSubmit={handleSubmitCredentials}
156
- onForgotPassword={() => navigate('/reset-request-password')}
157
- onSocialLogin={(provider) => startSocialLogin(provider)}
158
- onPasskeyLogin={handlePasskeyLoginInitial}
159
- onSignUp={() => navigate('/signup')}
171
+ onSubmit={passwordLoginEnabled ? handleSubmitCredentials : null}
172
+ onForgotPassword={
173
+ passwordResetEnabled ? () => navigate('/reset-request-password') : null
174
+ }
175
+ onSocialLogin={socialLoginEnabled ? (provider) => startSocialLogin(provider) : null}
176
+ socialProviders={socialProviders}
177
+ onPasskeyLogin={passkeyLoginEnabled ? handlePasskeyLoginInitial : null}
178
+ onSignUp={signupEnabled ? () => navigate('/signup') : null}
160
179
  disabled={submitting}
161
180
  initialIdentifier={recoveryEmail}
162
181
  />