@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.
- package/dist/auth/AuthContext.js +34 -16
- package/dist/auth/authApi.js +19 -0
- package/dist/components/AccessCodeManager.js +8 -2
- package/dist/components/BulkInviteCsvTab.js +7 -1
- package/dist/components/LoginForm.js +2 -2
- package/dist/components/ProfileComponent.js +65 -5
- package/dist/components/SecurityComponent.js +23 -6
- package/dist/components/SocialLoginButtons.js +7 -4
- package/dist/components/UserInviteComponent.js +8 -2
- package/dist/components/UserListComponent.js +163 -21
- package/dist/i18n/authTranslations.js +30 -0
- package/dist/pages/AccountPage.js +11 -22
- package/dist/pages/LoginPage.js +16 -2
- package/dist/utils/authService.js +41 -2
- package/package.json +2 -1
- package/src/auth/AuthContext.jsx +64 -16
- package/src/auth/authApi.jsx +22 -1
- package/src/components/AccessCodeManager.jsx +14 -4
- package/src/components/BulkInviteCsvTab.jsx +9 -3
- package/src/components/LoginForm.jsx +58 -48
- package/src/components/ProfileComponent.jsx +117 -4
- package/src/components/SecurityComponent.jsx +58 -28
- package/src/components/SocialLoginButtons.jsx +57 -49
- package/src/components/UserInviteComponent.jsx +11 -4
- package/src/components/UserListComponent.jsx +252 -40
- package/src/i18n/authTranslations.ts +31 -1
- package/src/pages/AccountPage.jsx +34 -39
- package/src/pages/LoginPage.jsx +25 -6
- package/src/utils/authService.js +51 -3
|
@@ -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
|
|
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: '
|
|
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={
|
|
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,
|
|
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
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
166
|
-
{
|
|
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
|
-
{
|
|
375
|
+
{filteredAndSortedUsers.map((u) => (
|
|
174
376
|
<TableRow key={u.id}>
|
|
175
|
-
<TableCell>{u.email}</TableCell>
|
|
176
|
-
<TableCell>
|
|
177
|
-
{u
|
|
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="
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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={
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
<
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
{
|
|
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
|
|
15
|
-
|
|
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
|
|
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,
|
|
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', '
|
|
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
|
-
<
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
package/src/pages/LoginPage.jsx
CHANGED
|
@@ -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={
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
/>
|