@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.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useMemo, useState, useEffect } from 'react';
3
- import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, FormControl, Select, MenuItem, Button, IconButton, Tooltip, CircularProgress, Alert } from '@mui/material';
3
+ import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, FormControl, Select, MenuItem, Button, Tooltip, CircularProgress, Alert, TableSortLabel, TextField } from '@mui/material';
4
4
  import DeleteIcon from '@mui/icons-material/Delete';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { fetchUsersList, deleteUser, updateUserRole, updateUserSupportStatus } from '../auth/authApi';
@@ -11,6 +11,11 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
11
11
  const [loading, setLoading] = useState(true);
12
12
  const [error, setError] = useState(null);
13
13
  const [rowActionLoading, setRowActionLoading] = useState({});
14
+ const [searchQuery, setSearchQuery] = useState('');
15
+ const [sortConfig, setSortConfig] = useState({ key: 'id', direction: 'asc' });
16
+ const cellSx = { verticalAlign: 'middle' };
17
+ const controlSx = { minWidth: 140 };
18
+ const actionButtonSx = { textTransform: 'none', minWidth: 90 };
14
19
  const loadUsers = async () => {
15
20
  setLoading(true);
16
21
  setError(null);
@@ -18,7 +23,15 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
18
23
  // FIX: Removed apiUrl parameter.
19
24
  // fetchUsersList uses USERS_BASE from authConfig internally.
20
25
  const data = await fetchUsersList();
21
- setUsers(data);
26
+ const list = Array.isArray(data) ? data : (Array.isArray(data === null || data === void 0 ? void 0 : data.results) ? data.results : []);
27
+ // Keep row identity stable even if backend returns unordered results.
28
+ list.sort((a, b) => {
29
+ var _a, _b;
30
+ const aId = Number((_a = a === null || a === void 0 ? void 0 : a.id) !== null && _a !== void 0 ? _a : 0);
31
+ const bId = Number((_b = b === null || b === void 0 ? void 0 : b.id) !== null && _b !== void 0 ? _b : 0);
32
+ return aId - bId;
33
+ });
34
+ setUsers(list);
22
35
  }
23
36
  catch (err) {
24
37
  setError(err.code || 'Auth.USER_LIST_FAILED');
@@ -47,7 +60,7 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
47
60
  try {
48
61
  // FIX: Removed apiUrl parameter.
49
62
  await updateUserRole(userId, newRole);
50
- loadUsers();
63
+ await loadUsers();
51
64
  }
52
65
  catch (err) {
53
66
  alert(t(err.code || 'Auth.USER_ROLE_UPDATE_FAILED'));
@@ -57,7 +70,7 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
57
70
  try {
58
71
  // FIX: Removed apiUrl parameter.
59
72
  await updateUserSupportStatus(userId, newValue);
60
- loadUsers();
73
+ await loadUsers();
61
74
  }
62
75
  catch (err) {
63
76
  alert(t(err.code || 'Auth.USER_UPDATE_FAILED'));
@@ -95,6 +108,135 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
95
108
  }), [currentUser, extraContext, t]);
96
109
  const visibleExtraColumns = useMemo(() => extraColumns.filter((column) => typeof column.visible === 'function' ? column.visible(listContext) : true), [extraColumns, listContext]);
97
110
  const visibleRowActions = useMemo(() => extraRowActions.filter((action) => typeof action.visible === 'function' ? action.visible(listContext) : true), [extraRowActions, listContext]);
111
+ const getUserDisplayName = (user) => {
112
+ if ((user === null || user === void 0 ? void 0 : user.first_name) || (user === null || user === void 0 ? void 0 : user.last_name)) {
113
+ return `${user.first_name || ''} ${user.last_name || ''}`.trim();
114
+ }
115
+ return (user === null || user === void 0 ? void 0 : user.username) || '';
116
+ };
117
+ const getExtraColumnSortValue = (column, user) => {
118
+ var _a;
119
+ const valueContext = { user, currentUser, extraContext, t };
120
+ if (typeof column.getSortValue === 'function') {
121
+ return column.getSortValue(valueContext);
122
+ }
123
+ if (typeof column.sortValue === 'function') {
124
+ return column.sortValue(valueContext);
125
+ }
126
+ if (typeof column.getSearchValue === 'function') {
127
+ return column.getSearchValue(valueContext);
128
+ }
129
+ if ((user === null || user === void 0 ? void 0 : user[column.key]) !== undefined)
130
+ return user[column.key];
131
+ if (((_a = user === null || user === void 0 ? void 0 : user.profile) === null || _a === void 0 ? void 0 : _a[column.key]) !== undefined)
132
+ return user.profile[column.key];
133
+ return '';
134
+ };
135
+ const getExtraColumnSearchValue = (column, user) => {
136
+ const valueContext = { user, currentUser, extraContext, t };
137
+ if (typeof column.getSearchValue === 'function') {
138
+ return column.getSearchValue(valueContext);
139
+ }
140
+ return getExtraColumnSortValue(column, user);
141
+ };
142
+ const normalizeSortValue = (value) => {
143
+ if (value === null || value === undefined)
144
+ return '';
145
+ if (typeof value === 'boolean')
146
+ return value ? 1 : 0;
147
+ if (typeof value === 'number')
148
+ return value;
149
+ if (value instanceof Date)
150
+ return value.getTime();
151
+ return String(value).toLocaleLowerCase();
152
+ };
153
+ const compareSortValues = (a, b) => {
154
+ const av = normalizeSortValue(a);
155
+ const bv = normalizeSortValue(b);
156
+ if (typeof av === 'number' && typeof bv === 'number') {
157
+ return av - bv;
158
+ }
159
+ return String(av).localeCompare(String(bv), undefined, {
160
+ numeric: true,
161
+ sensitivity: 'base',
162
+ });
163
+ };
164
+ const getSortValueByKey = (user, sortKey) => {
165
+ var _a, _b;
166
+ if (sortKey === 'id')
167
+ return Number((_a = user === null || user === void 0 ? void 0 : user.id) !== null && _a !== void 0 ? _a : 0);
168
+ if (sortKey === 'email')
169
+ return (user === null || user === void 0 ? void 0 : user.email) || '';
170
+ if (sortKey === 'name')
171
+ return getUserDisplayName(user);
172
+ if (sortKey === 'role')
173
+ return (user === null || user === void 0 ? void 0 : user.role) || '';
174
+ if (sortKey === 'is_support_agent')
175
+ return Boolean(user === null || user === void 0 ? void 0 : user.is_support_agent);
176
+ if (sortKey.startsWith('extra:')) {
177
+ const extraKey = sortKey.slice('extra:'.length);
178
+ const column = visibleExtraColumns.find((col) => String(col.key) === extraKey);
179
+ if (!column)
180
+ return '';
181
+ return getExtraColumnSortValue(column, user);
182
+ }
183
+ return (_b = user === null || user === void 0 ? void 0 : user[sortKey]) !== null && _b !== void 0 ? _b : '';
184
+ };
185
+ const toSearchText = (value) => {
186
+ if (value === null || value === undefined)
187
+ return '';
188
+ if (Array.isArray(value))
189
+ return value.map((item) => toSearchText(item)).join(' ');
190
+ if (typeof value === 'object')
191
+ return Object.values(value).map((item) => toSearchText(item)).join(' ');
192
+ return String(value).toLocaleLowerCase();
193
+ };
194
+ const getSearchBucketForUser = (user) => {
195
+ const baseValues = [
196
+ user === null || user === void 0 ? void 0 : user.id,
197
+ user === null || user === void 0 ? void 0 : user.email,
198
+ user === null || user === void 0 ? void 0 : user.username,
199
+ user === null || user === void 0 ? void 0 : user.first_name,
200
+ user === null || user === void 0 ? void 0 : user.last_name,
201
+ getUserDisplayName(user),
202
+ user === null || user === void 0 ? void 0 : user.role,
203
+ user === null || user === void 0 ? void 0 : user.language,
204
+ (user === null || user === void 0 ? void 0 : user.is_support_agent) ? t('Common.YES', 'Yes') : t('Common.NO', 'No'),
205
+ ];
206
+ const extraValues = visibleExtraColumns.map((column) => getExtraColumnSearchValue(column, user));
207
+ return [...baseValues, ...extraValues].map((value) => toSearchText(value)).filter(Boolean);
208
+ };
209
+ const normalizedSearch = searchQuery.trim().toLocaleLowerCase();
210
+ const filteredAndSortedUsers = useMemo(() => {
211
+ const filtered = users.filter((user) => {
212
+ if (!normalizedSearch)
213
+ return true;
214
+ const bucket = getSearchBucketForUser(user);
215
+ return bucket.some((entry) => entry.includes(normalizedSearch));
216
+ });
217
+ const sorted = [...filtered].sort((a, b) => {
218
+ var _a, _b;
219
+ const aValue = getSortValueByKey(a, sortConfig.key);
220
+ const bValue = getSortValueByKey(b, sortConfig.key);
221
+ const cmp = compareSortValues(aValue, bValue);
222
+ if (cmp !== 0)
223
+ return sortConfig.direction === 'asc' ? cmp : -cmp;
224
+ // Stable fallback
225
+ return Number((_a = a === null || a === void 0 ? void 0 : a.id) !== null && _a !== void 0 ? _a : 0) - Number((_b = b === null || b === void 0 ? void 0 : b.id) !== null && _b !== void 0 ? _b : 0);
226
+ });
227
+ return sorted;
228
+ }, [users, normalizedSearch, sortConfig, visibleExtraColumns, currentUser, extraContext, t]);
229
+ const handleSort = (columnKey) => {
230
+ setSortConfig((prev) => {
231
+ if (prev.key === columnKey) {
232
+ return {
233
+ key: columnKey,
234
+ direction: prev.direction === 'asc' ? 'desc' : 'asc',
235
+ };
236
+ }
237
+ return { key: columnKey, direction: 'asc' };
238
+ });
239
+ };
98
240
  const runRowAction = async (action, user) => {
99
241
  const actionId = `${action.key}:${user.id}`;
100
242
  setRowActionLoading((prev) => (Object.assign(Object.assign({}, prev), { [actionId]: true })));
@@ -116,27 +258,27 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
116
258
  setRowActionLoading((prev) => (Object.assign(Object.assign({}, prev), { [actionId]: false })));
117
259
  }
118
260
  };
119
- return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('UserList.TITLE', 'All Users') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(error) }), loading ? (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', p: 3 }, children: _jsx(CircularProgress, {}) })) : (_jsx(TableContainer, { component: Paper, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('Auth.EMAIL_LABEL', 'Email') }), _jsx(TableCell, { children: t('Profile.NAME_LABEL', 'Name') }), _jsx(TableCell, { children: t('UserList.ROLE', 'Role') }), _jsx(TableCell, { children: t('UserList.SUPPORTER', 'Support Agent') }), visibleExtraColumns.map((column) => (_jsx(TableCell, { align: column.align || 'left', children: typeof column.label === 'function' ? column.label(listContext) : column.label }, column.key))), _jsx(TableCell, { children: t('Common.ACTIONS', 'Actions') })] }) }), _jsxs(TableBody, { children: [users.map((u) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: u.email }), _jsx(TableCell, { children: u.first_name || u.last_name ? `${u.first_name} ${u.last_name}` : u.username }), _jsx(TableCell, { children: _jsx(FormControl, { size: "small", fullWidth: true, disabled: !canEdit(u), children: _jsx(Select, { value: u.role || 'none', onChange: (e) => handleChangeRole(u.id, e.target.value), variant: "standard", disableUnderline: true, children: roles.map(r => _jsx(MenuItem, { value: r, children: r }, r)) }) }) }), _jsx(TableCell, { children: _jsx(Button, { size: "small", variant: u.is_support_agent ? 'contained' : 'outlined', color: u.is_support_agent ? 'primary' : 'inherit', onClick: () => handleToggleSupporter(u.id, !u.is_support_agent), disabled: !canEdit(u), sx: { textTransform: 'none' }, children: u.is_support_agent ? t('Common.YES') : t('Common.NO') }) }), visibleExtraColumns.map((column) => (_jsx(TableCell, { align: column.align || 'left', children: column.renderCell({
261
+ return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('UserList.TITLE', 'All Users') }), _jsx(Box, { sx: { mb: 2, maxWidth: 420 }, children: _jsx(TextField, { fullWidth: true, size: "small", label: t('Common.SEARCH', 'Search'), placeholder: t('UserList.SEARCH_PLACEHOLDER', 'Search users...'), value: searchQuery, onChange: (event) => setSearchQuery(event.target.value) }) }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(error) }), loading ? (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', p: 3 }, children: _jsx(CircularProgress, {}) })) : (_jsx(TableContainer, { component: Paper, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { sx: cellSx, sortDirection: sortConfig.key === 'email' ? sortConfig.direction : false, children: _jsx(TableSortLabel, { active: sortConfig.key === 'email', direction: sortConfig.key === 'email' ? sortConfig.direction : 'asc', onClick: () => handleSort('email'), children: t('Auth.EMAIL_LABEL', 'Email') }) }), _jsx(TableCell, { sx: cellSx, sortDirection: sortConfig.key === 'name' ? sortConfig.direction : false, children: _jsx(TableSortLabel, { active: sortConfig.key === 'name', direction: sortConfig.key === 'name' ? sortConfig.direction : 'asc', onClick: () => handleSort('name'), children: t('Profile.NAME_LABEL', 'Name') }) }), _jsx(TableCell, { sx: cellSx, sortDirection: sortConfig.key === 'role' ? sortConfig.direction : false, children: _jsx(TableSortLabel, { active: sortConfig.key === 'role', direction: sortConfig.key === 'role' ? sortConfig.direction : 'asc', onClick: () => handleSort('role'), children: t('UserList.ROLE', 'Role') }) }), _jsx(TableCell, { sx: cellSx, sortDirection: sortConfig.key === 'is_support_agent' ? sortConfig.direction : false, children: _jsx(TableSortLabel, { active: sortConfig.key === 'is_support_agent', direction: sortConfig.key === 'is_support_agent' ? sortConfig.direction : 'asc', onClick: () => handleSort('is_support_agent'), children: t('UserList.SUPPORTER', 'Support Agent') }) }), visibleExtraColumns.map((column) => (_jsx(TableCell, { sx: cellSx, align: column.align || 'left', sortDirection: sortConfig.key === `extra:${column.key}` ? sortConfig.direction : false, children: _jsx(TableSortLabel, { active: sortConfig.key === `extra:${column.key}`, direction: sortConfig.key === `extra:${column.key}` ? sortConfig.direction : 'asc', onClick: () => handleSort(`extra:${column.key}`), children: typeof column.label === 'function' ? column.label(listContext) : column.label }) }, column.key))), _jsx(TableCell, { sx: cellSx, children: t('Common.ACTIONS', 'Actions') })] }) }), _jsxs(TableBody, { children: [filteredAndSortedUsers.map((u) => (_jsxs(TableRow, { children: [_jsx(TableCell, { sx: cellSx, children: u.email }), _jsx(TableCell, { sx: cellSx, children: getUserDisplayName(u) }), _jsx(TableCell, { sx: cellSx, children: _jsx(FormControl, { size: "small", fullWidth: true, sx: controlSx, disabled: !canEdit(u), children: _jsx(Select, { value: u.role || 'none', onChange: (e) => handleChangeRole(u.id, e.target.value), variant: "outlined", children: roles.map(r => _jsx(MenuItem, { value: r, children: r }, r)) }) }) }), _jsx(TableCell, { sx: cellSx, children: _jsx(FormControl, { size: "small", fullWidth: true, sx: controlSx, disabled: !canEdit(u), children: _jsxs(Select, { value: u.is_support_agent ? 'yes' : 'no', onChange: (e) => handleToggleSupporter(u.id, e.target.value === 'yes'), variant: "outlined", children: [_jsx(MenuItem, { value: "yes", children: t('Common.YES', 'Yes') }), _jsx(MenuItem, { value: "no", children: t('Common.NO', 'No') })] }) }) }), visibleExtraColumns.map((column) => (_jsx(TableCell, { sx: cellSx, align: column.align || 'left', children: column.renderCell({
120
262
  user: u,
121
263
  canEdit: canEdit(u),
122
264
  currentUser,
123
265
  extraContext,
124
266
  t,
125
267
  reloadUsers: loadUsers,
126
- }) }, `${column.key}-${u.id}`))), _jsxs(TableCell, { children: [visibleRowActions.map((action) => {
127
- const actionId = `${action.key}:${u.id}`;
128
- const isBusy = Boolean(rowActionLoading[actionId]);
129
- const isDisabled = typeof action.disabled === 'function'
130
- ? action.disabled({
131
- user: u,
132
- canEdit: canEdit(u),
133
- currentUser,
134
- extraContext,
135
- t,
136
- })
137
- : false;
138
- return (_jsx(Button, { size: "small", onClick: () => runRowAction(action, u), disabled: isBusy || isDisabled, sx: { mr: 1, mb: 0.5, textTransform: 'none' }, children: typeof action.label === 'function'
139
- ? action.label({ user: u, t, currentUser, canEdit: canEdit(u) })
140
- : action.label }, `${action.key}-${u.id}`));
141
- }), _jsx(Tooltip, { title: t('Common.DELETE'), children: _jsx("span", { children: _jsx(IconButton, { onClick: () => handleDelete(u.id), color: "error", disabled: !canEdit(u), children: _jsx(DeleteIcon, {}) }) }) })] })] }, u.id))), users.length === 0 && (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 5 + visibleExtraColumns.length, align: "center", children: t('UserList.NO_USERS', 'No users found.') }) }))] })] }) }))] }));
268
+ }) }, `${column.key}-${u.id}`))), _jsx(TableCell, { sx: cellSx, children: _jsxs(Box, { sx: { display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }, children: [visibleRowActions.map((action) => {
269
+ const actionId = `${action.key}:${u.id}`;
270
+ const isBusy = Boolean(rowActionLoading[actionId]);
271
+ const isDisabled = typeof action.disabled === 'function'
272
+ ? action.disabled({
273
+ user: u,
274
+ canEdit: canEdit(u),
275
+ currentUser,
276
+ extraContext,
277
+ t,
278
+ })
279
+ : false;
280
+ return (_jsx(Button, { size: "small", variant: "outlined", onClick: () => runRowAction(action, u), disabled: isBusy || isDisabled, sx: actionButtonSx, children: typeof action.label === 'function'
281
+ ? action.label({ user: u, t, currentUser, canEdit: canEdit(u) })
282
+ : action.label }, `${action.key}-${u.id}`));
283
+ }), _jsx(Tooltip, { title: t('Common.DELETE'), children: _jsx("span", { children: _jsx(Button, { size: "small", variant: "outlined", color: "error", startIcon: _jsx(DeleteIcon, {}), onClick: () => handleDelete(u.id), disabled: !canEdit(u), sx: actionButtonSx, children: t('Common.DELETE', 'Delete') }) }) })] }) })] }, u.id))), filteredAndSortedUsers.length === 0 && (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 5 + visibleExtraColumns.length, align: "center", children: t('UserList.NO_USERS', 'No users found.') }) }))] })] }) }))] }));
142
284
  }
@@ -810,6 +810,36 @@ export const authTranslations = {
810
810
  "en": "I allow convenience cookies.",
811
811
  "sw": "Ninaruhusu vidakuzi vya urahisi."
812
812
  },
813
+ "Profile.PRIVACY_STATEMENT_TITLE": {
814
+ "de": "Datenschutzerklärung",
815
+ "fr": "Déclaration de confidentialité",
816
+ "en": "Privacy statement",
817
+ "sw": "Taarifa ya faragha"
818
+ },
819
+ "Profile.COOKIES_STATEMENT_TITLE": {
820
+ "de": "Cookie-Richtlinie",
821
+ "fr": "Politique relative aux cookies",
822
+ "en": "Cookie statement",
823
+ "sw": "Taarifa ya vidakuzi"
824
+ },
825
+ "Profile.STATEMENT_LOADING": {
826
+ "de": "Statement wird geladen...",
827
+ "fr": "Chargement du document...",
828
+ "en": "Loading statement...",
829
+ "sw": "Inapakia taarifa..."
830
+ },
831
+ "Profile.PRIVACY_STATEMENT_EMPTY": {
832
+ "de": "Die Datenschutzerklärung ist derzeit nicht verfügbar.",
833
+ "fr": "La déclaration de confidentialité n'est actuellement pas disponible.",
834
+ "en": "Privacy statement is currently unavailable.",
835
+ "sw": "Taarifa ya faragha haipatikani kwa sasa."
836
+ },
837
+ "Profile.COOKIES_STATEMENT_EMPTY": {
838
+ "de": "Die Cookie-Richtlinie ist derzeit nicht verfügbar.",
839
+ "fr": "La politique relative aux cookies n'est actuellement pas disponible.",
840
+ "en": "Cookie statement is currently unavailable.",
841
+ "sw": "Taarifa ya vidakuzi haipatikani kwa sasa."
842
+ },
813
843
  "Profile.SAVE_BUTTON": {
814
844
  "de": "Speichern",
815
845
  "fr": "Enregistrer",
@@ -2,15 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useContext, useMemo } from 'react';
3
3
  import { Helmet } from 'react-helmet';
4
4
  import { useSearchParams } from 'react-router-dom';
5
- import { Tabs, Tab, Box, Typography, Alert, CircularProgress } from '@mui/material';
5
+ import { Tabs, Tab, Box, Typography, Alert, CircularProgress, Paper, Stack, } from '@mui/material';
6
6
  import { useTranslation } from 'react-i18next';
7
- // Internal Library Components
8
- // Stellen Sie sicher, dass diese Pfade korrekt auf Ihre Library zeigen!
9
- // Da Sie '@micha.bigler/ui-core-micha' nutzen, sollten die Imports ggf. so aussehen:
10
- import { AuthContext,
11
- // ... andere Komponenten aus der Lib importieren, falls sie dort exportiert sind
12
- // Wenn sie lokal sind, lassen Sie die relativen Pfade.
13
- } from '@micha.bigler/ui-core-micha';
7
+ // Internal context
8
+ import { AuthContext } from '../auth/AuthContext';
14
9
  // Falls die Komponenten noch lokal sind:
15
10
  import { WidePage } from '../layout/PageLayout';
16
11
  import { ProfileComponent } from '../components/ProfileComponent';
@@ -27,7 +22,10 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
27
22
  const { user, login, loading } = useContext(AuthContext);
28
23
  const [searchParams, setSearchParams] = useSearchParams();
29
24
  // 1. URL State Management
30
- const currentTab = searchParams.get('tab') || 'profile';
25
+ const currentTabRaw = searchParams.get('tab') || 'profile';
26
+ const currentTab = ['invite', 'bulk-invite-csv', 'access'].includes(currentTabRaw)
27
+ ? 'invite'
28
+ : currentTabRaw;
31
29
  const fromRecovery = searchParams.get('from') === 'recovery';
32
30
  const fromWeakLogin = searchParams.get('from') === 'weak_login';
33
31
  // 2. Data & Permissions
@@ -55,17 +53,8 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
55
53
  if (isSuperUser || perms.can_view_users) {
56
54
  list.push({ value: 'users', label: t('Account.TAB_USERS', 'Users') });
57
55
  }
58
- if (isSuperUser || perms.can_invite) {
56
+ if (isSuperUser || perms.can_invite || perms.can_manage_access_codes) {
59
57
  list.push({ value: 'invite', label: t('Account.TAB_INVITE', 'Invite') });
60
- if (showBulkInviteCsvTab) {
61
- list.push({
62
- value: 'bulk-invite-csv',
63
- label: t('Account.TAB_BULK_INVITE_CSV', 'Bulk Invite CSV'),
64
- });
65
- }
66
- }
67
- if (isSuperUser || perms.can_manage_access_codes) {
68
- list.push({ value: 'access', label: t('Account.TAB_ACCESS_CODES', 'Access Codes') });
69
58
  }
70
59
  if (isSuperUser || perms.can_view_support) {
71
60
  list.push({ value: 'support', label: t('Account.TAB_SUPPORT', 'Support') });
@@ -82,7 +71,7 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
82
71
  }
83
72
  });
84
73
  return list;
85
- }, [user, perms, t, isSuperUser, showBulkInviteCsvTab, extraTabs]);
74
+ }, [user, perms, t, isSuperUser, extraTabs]);
86
75
  // 4. Loading & Auth Checks
87
76
  if (loading) {
88
77
  return (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', mt: 10 }, children: _jsx(CircularProgress, {}) }));
@@ -94,9 +83,9 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
94
83
  const activeTabExists = tabs.some(t => t.value === currentTab);
95
84
  // Falls der Tab nicht erlaubt ist (z.B. manuell in URL eingegeben), Fallback auf 'profile'
96
85
  const safeTab = activeTabExists ? currentTab : 'profile';
97
- const builtInTabValues = new Set(['profile', 'security', 'users', 'invite', 'bulk-invite-csv', 'access', 'support']);
86
+ const builtInTabValues = new Set(['profile', 'security', 'users', 'invite', 'support']);
98
87
  const activeExtraTab = builtInTabValues.has(safeTab)
99
88
  ? null
100
89
  : extraTabs.find((tab) => tab.value === safeTab);
101
- return (_jsxs(WidePage, { title: t('Account.TITLE', 'Account & Administration'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('Account.PAGE_TITLE', 'Account'), " \u2013 ", user.email] }) }), _jsx(Tabs, { value: safeTab, onChange: handleTabChange, variant: "scrollable", scrollButtons: "auto", sx: { mb: 3, borderBottom: 1, borderColor: 'divider' }, children: tabs.map((tab) => (_jsx(Tab, { label: tab.label, value: tab.value }, tab.value))) }), safeTab === 'profile' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(ProfileComponent, { onSubmit: handleProfileSubmit, showName: true, showPrivacy: true, showCookies: true }) })), safeTab === 'security' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SecurityComponent, { fromRecovery: fromRecovery, fromWeakLogin: fromWeakLogin }) })), safeTab === 'users' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(UserListComponent, { roles: activeRoles, currentUser: user, extraColumns: userListExtraColumns, extraRowActions: userListExtraRowActions, extraContext: userListExtraContext, refreshTrigger: userListRefreshTrigger, canEditUser: userListCanEditUser }) })), safeTab === 'invite' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(UserInviteComponent, {}) })), safeTab === 'bulk-invite-csv' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(BulkInviteCsvTab, Object.assign({}, bulkInviteCsvProps)) })), safeTab === 'access' && (_jsxs(Box, { sx: { mt: 2 }, children: [_jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.') }), _jsx(AccessCodeManager, {})] })), safeTab === 'support' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SupportRecoveryRequestsTab, {}) })), activeExtraTab && (_jsx(Box, { sx: { mt: 2 }, children: (_a = activeExtraTab.render) === null || _a === void 0 ? void 0 : _a.call(activeExtraTab, { user, perms, isSuperUser, t }) }))] }));
90
+ return (_jsxs(WidePage, { title: t('Account.TITLE', 'Account & Administration'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('Account.PAGE_TITLE', 'Account'), " \u2013 ", user.email] }) }), _jsx(Tabs, { value: safeTab, onChange: handleTabChange, variant: "scrollable", scrollButtons: "auto", sx: { mb: 3, borderBottom: 1, borderColor: 'divider' }, children: tabs.map((tab) => (_jsx(Tab, { label: tab.label, value: tab.value }, tab.value))) }), safeTab === 'profile' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(ProfileComponent, { onSubmit: handleProfileSubmit, showName: true, showPrivacy: true, showCookies: true }) })), safeTab === 'security' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SecurityComponent, { fromRecovery: fromRecovery, fromWeakLogin: fromWeakLogin }) })), safeTab === 'users' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(UserListComponent, { roles: activeRoles, currentUser: user, extraColumns: userListExtraColumns, extraRowActions: userListExtraRowActions, extraContext: userListExtraContext, refreshTrigger: userListRefreshTrigger, canEditUser: userListCanEditUser }) })), safeTab === 'invite' && (_jsx(Box, { sx: { mt: 2 }, children: _jsxs(Stack, { spacing: 2.5, children: [(isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(UserInviteComponent, {}) })), (isSuperUser || perms.can_manage_access_codes) && (_jsxs(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: [_jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.') }), _jsx(AccessCodeManager, {})] })), (isSuperUser || perms.can_invite) && showBulkInviteCsvTab && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(BulkInviteCsvTab, Object.assign({}, bulkInviteCsvProps)) }))] }) })), safeTab === 'support' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SupportRecoveryRequestsTab, {}) })), activeExtraTab && (_jsx(Box, { sx: { mt: 2 }, children: (_a = activeExtraTab.render) === null || _a === void 0 ? void 0 : _a.call(activeExtraTab, { user, perms, isSuperUser, t }) }))] }));
102
91
  }
@@ -16,7 +16,7 @@ import { MfaLoginComponent } from '../components/MfaLoginComponent';
16
16
  export function LoginPage() {
17
17
  const navigate = useNavigate();
18
18
  const location = useLocation();
19
- const { login } = useContext(AuthContext);
19
+ const { login, authMethods } = useContext(AuthContext);
20
20
  const { t } = useTranslation();
21
21
  // State
22
22
  const [step, setStep] = useState('credentials'); // 'credentials' | 'mfa'
@@ -32,6 +32,12 @@ export function LoginPage() {
32
32
  : recoveryTokenRaw;
33
33
  // Backward-compatible fallback for legacy links using query parameters.
34
34
  const recoveryEmail = hashParams.get('email') || params.get('email') || '';
35
+ useEffect(() => {
36
+ const socialError = params.get('error') || params.get('social');
37
+ if (socialError) {
38
+ setErrorKey('Auth.SOCIAL_LOGIN_FAILED');
39
+ }
40
+ }, [location.search]);
35
41
  // --- Helper: Central Success Logic ---
36
42
  const handleLoginSuccess = (user) => {
37
43
  var _a;
@@ -113,6 +119,14 @@ export function LoginPage() {
113
119
  setMfaState(null);
114
120
  setErrorKey(null);
115
121
  };
122
+ const socialProviders = Array.isArray(authMethods === null || authMethods === void 0 ? void 0 : authMethods.social_providers)
123
+ ? authMethods.social_providers
124
+ : [];
125
+ const passwordLoginEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.password_login) || Boolean(recoveryToken);
126
+ const socialLoginEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.social_login) && socialProviders.length > 0;
127
+ const passkeyLoginEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.passkey_login);
128
+ const signupEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.signup);
129
+ const passwordResetEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.password_reset);
116
130
  // --- Render ---
117
- return (_jsxs(NarrowPage, { title: t('Auth.PAGE_LOGIN_TITLE'), subtitle: t('Auth.PAGE_LOGIN_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_LOGIN_TITLE')] }) }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), recoveryToken && !errorKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.RECOVERY_LOGIN_WARNING', 'Recovery link validated. Please enter your password.') })), step === 'credentials' && (_jsx(LoginForm, { onSubmit: handleSubmitCredentials, onForgotPassword: () => navigate('/reset-request-password'), onSocialLogin: (provider) => startSocialLogin(provider), onPasskeyLogin: handlePasskeyLoginInitial, onSignUp: () => navigate('/signup'), disabled: submitting, initialIdentifier: recoveryEmail })), step === 'mfa' && mfaState && (_jsx(Box, { children: _jsx(MfaLoginComponent, { availableTypes: mfaState.availableTypes, identifier: mfaState.identifier, onSuccess: handleMfaSuccess, onCancel: handleMfaCancel }) }))] }));
131
+ return (_jsxs(NarrowPage, { title: t('Auth.PAGE_LOGIN_TITLE'), subtitle: t('Auth.PAGE_LOGIN_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_LOGIN_TITLE')] }) }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), recoveryToken && !errorKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.RECOVERY_LOGIN_WARNING', 'Recovery link validated. Please enter your password.') })), step === 'credentials' && (_jsx(LoginForm, { onSubmit: passwordLoginEnabled ? handleSubmitCredentials : null, onForgotPassword: passwordResetEnabled ? () => navigate('/reset-request-password') : null, onSocialLogin: socialLoginEnabled ? (provider) => startSocialLogin(provider) : null, socialProviders: socialProviders, onPasskeyLogin: passkeyLoginEnabled ? handlePasskeyLoginInitial : null, onSignUp: signupEnabled ? () => navigate('/signup') : null, disabled: submitting, initialIdentifier: recoveryEmail })), step === 'mfa' && mfaState && (_jsx(Box, { children: _jsx(MfaLoginComponent, { availableTypes: mfaState.availableTypes, identifier: mfaState.identifier, onSuccess: handleMfaSuccess, onCancel: handleMfaCancel }) }))] }));
118
132
  }
@@ -101,9 +101,48 @@ export async function authenticateMfaWithPasskey() {
101
101
  const credentialJson = serializeCredential(assertion);
102
102
  return authenticateWithMFA({ credential: credentialJson });
103
103
  }
104
- export function startSocialLogin(provider) {
104
+ function getCsrfTokenFromCookie() {
105
+ if (typeof document === 'undefined' || !document.cookie)
106
+ return null;
107
+ const match = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
108
+ return match ? decodeURIComponent(match[1]) : null;
109
+ }
110
+ function submitSocialRedirectForm({ provider, callbackUrl, csrfToken }) {
111
+ const form = document.createElement('form');
112
+ form.method = 'POST';
113
+ form.action = `${HEADLESS_BASE}/auth/provider/redirect`;
114
+ form.style.display = 'none';
115
+ const fields = {
116
+ provider,
117
+ process: 'login',
118
+ callback_url: callbackUrl,
119
+ csrfmiddlewaretoken: csrfToken,
120
+ };
121
+ Object.entries(fields).forEach(([name, value]) => {
122
+ const input = document.createElement('input');
123
+ input.type = 'hidden';
124
+ input.name = name;
125
+ input.value = String(value);
126
+ form.appendChild(input);
127
+ });
128
+ document.body.appendChild(form);
129
+ form.submit();
130
+ }
131
+ export async function startSocialLogin(provider) {
105
132
  if (typeof window === 'undefined') {
106
133
  throw normaliseApiError(new Error('Auth.SOCIAL_LOGIN_NOT_IN_BROWSER'), 'Auth.SOCIAL_LOGIN_NOT_IN_BROWSER');
107
134
  }
108
- window.location.href = `/accounts/${provider}/login/?process=login`;
135
+ try {
136
+ // Ensures csrftoken cookie exists before form POST.
137
+ await apiClient.get('/api/csrf/');
138
+ }
139
+ catch (_a) {
140
+ // Continue; token might already be present.
141
+ }
142
+ const csrfToken = getCsrfTokenFromCookie();
143
+ if (!csrfToken) {
144
+ throw normaliseApiError(new Error('Auth.SOCIAL_LOGIN_FAILED'), 'Auth.SOCIAL_LOGIN_FAILED');
145
+ }
146
+ const callbackUrl = `${window.location.origin}/login`;
147
+ submitSocialRedirectForm({ provider, callbackUrl, csrfToken });
109
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.1.16",
3
+ "version": "2.1.18",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "private": false,
@@ -24,3 +24,4 @@
24
24
  "react-i18next": "^16.3.5"
25
25
  }
26
26
  }
27
+
@@ -6,16 +6,65 @@ import React, {
6
6
  } from 'react';
7
7
  import { ensureCsrfToken } from './apiClient'; // <--- IMPORT ADDED
8
8
  import {
9
+ fetchAuthMethods,
9
10
  fetchCurrentUser,
10
11
  logoutSession,
11
12
  } from './authApi';
12
13
 
13
14
  export const AuthContext = createContext(null);
14
15
 
16
+ const DEFAULT_AUTH_METHODS = {
17
+ password_login: true,
18
+ password_reset: true,
19
+ signup: true,
20
+ password_change: true,
21
+ social_login: true,
22
+ social_providers: ['google', 'microsoft'],
23
+ passkey_login: true,
24
+ passkeys_manage: true,
25
+ mfa_totp: true,
26
+ mfa_recovery_codes: true,
27
+ mfa_enabled: true,
28
+ };
29
+
15
30
  export const AuthProvider = ({ children }) => {
16
31
  const [user, setUser] = useState(null);
32
+ const [authMethods, setAuthMethods] = useState(DEFAULT_AUTH_METHODS);
17
33
  const [loading, setLoading] = useState(true);
18
34
 
35
+ const mapUserFromApi = (data) => {
36
+ const profile = data?.profile || {};
37
+ return {
38
+ ...data,
39
+ id: data?.id,
40
+ username: data?.username,
41
+ email: data?.email,
42
+ first_name: data?.first_name,
43
+ last_name: data?.last_name,
44
+ role: data?.role ?? profile?.role ?? null,
45
+ language: data?.language ?? profile?.language ?? 'en',
46
+ is_superuser: Boolean(data?.is_superuser),
47
+ is_new: Boolean(data?.is_new ?? profile?.is_new),
48
+ is_invited: Boolean(data?.is_invited ?? profile?.is_invited),
49
+ accepted_privacy_statement: Boolean(
50
+ data?.accepted_privacy_statement ?? profile?.accepted_privacy_statement
51
+ ),
52
+ accepted_convenience_cookies: Boolean(
53
+ data?.accepted_convenience_cookies ?? profile?.accepted_convenience_cookies
54
+ ),
55
+ is_support_agent: Boolean(data?.is_support_agent ?? profile?.is_support_agent),
56
+ support_contact_id: data?.support_contact_id ?? profile?.support_contact_id ?? null,
57
+ security_state: data?.security_state,
58
+ available_roles: data?.available_roles || [],
59
+ ui_permissions: data?.ui_permissions || {},
60
+ can_manage_support_agents: Boolean(data?.can_manage_support_agents),
61
+ can_manage: Boolean(data?.can_manage),
62
+ is_active: data?.is_active,
63
+ last_login: data?.last_login,
64
+ date_joined: data?.date_joined,
65
+ };
66
+ };
67
+
19
68
  useEffect(() => {
20
69
  let isMounted = true;
21
70
 
@@ -24,23 +73,21 @@ export const AuthProvider = ({ children }) => {
24
73
  // 1) Ensure CSRF cookie exists using the specific client
25
74
  await ensureCsrfToken();
26
75
 
27
- // 2) Load user
76
+ // 2) Load auth methods (public)
77
+ try {
78
+ const methods = await fetchAuthMethods();
79
+ if (isMounted && methods && typeof methods === 'object') {
80
+ setAuthMethods((prev) => ({ ...prev, ...methods }));
81
+ }
82
+ } catch {
83
+ // Keep defaults; login UI remains usable.
84
+ }
85
+
86
+ // 3) Load user
28
87
  const data = await fetchCurrentUser();
29
88
 
30
89
  if (isMounted) {
31
- // Map data to ensure consistent structure
32
- setUser({
33
- id: data.id,
34
- username: data.username,
35
- email: data.email,
36
- first_name: data.first_name,
37
- last_name: data.last_name,
38
- role: data.role,
39
- is_superuser: data.is_superuser,
40
- security_state: data.security_state,
41
- available_roles: data.available_roles,
42
- ui_permissions: data.ui_permissions,
43
- });
90
+ setUser(mapUserFromApi(data));
44
91
  }
45
92
  } catch (err) {
46
93
  // Silent failure on 401/403 is expected (user not logged in)
@@ -58,7 +105,7 @@ export const AuthProvider = ({ children }) => {
58
105
  const login = (userData) => {
59
106
  setUser((prev) => ({
60
107
  ...prev,
61
- ...userData,
108
+ ...mapUserFromApi(userData),
62
109
  }));
63
110
  };
64
111
 
@@ -77,6 +124,7 @@ export const AuthProvider = ({ children }) => {
77
124
  <AuthContext.Provider
78
125
  value={{
79
126
  user,
127
+ authMethods,
80
128
  loading,
81
129
  login,
82
130
  logout,
@@ -85,4 +133,4 @@ export const AuthProvider = ({ children }) => {
85
133
  {children}
86
134
  </AuthContext.Provider>
87
135
  );
88
- };
136
+ };
@@ -18,6 +18,11 @@ export async function fetchCurrentUser() {
18
18
  return res.data;
19
19
  }
20
20
 
21
+ export async function fetchAuthMethods() {
22
+ const res = await apiClient.get('/api/auth-methods/');
23
+ return res.data || {};
24
+ }
25
+
21
26
  export async function updateUserProfile(data) {
22
27
  try {
23
28
  const res = await apiClient.patch(`${USERS_BASE}/current/`, data);
@@ -27,6 +32,22 @@ export async function updateUserProfile(data) {
27
32
  }
28
33
  }
29
34
 
35
+ function normalizeStatementText(data) {
36
+ if (typeof data === 'string') return data;
37
+ if (data && typeof data.content === 'string') return data.content;
38
+ return '';
39
+ }
40
+
41
+ export async function fetchPrivacyStatement() {
42
+ const res = await apiClient.get('/api/utils/privacy/');
43
+ return normalizeStatementText(res.data);
44
+ }
45
+
46
+ export async function fetchCookieStatement() {
47
+ const res = await apiClient.get('/api/utils/cookie/');
48
+ return normalizeStatementText(res.data);
49
+ }
50
+
30
51
  export async function fetchHeadlessSession() {
31
52
  const res = await apiClient.get(`${HEADLESS_BASE}/auth/session`);
32
53
  return res.data;
@@ -411,4 +432,4 @@ export async function updateUserSupportStatus(userId, isSupportAgent) {
411
432
  } catch (error) {
412
433
  throw normaliseApiError(error, 'Auth.USER_SUPPORT_UPDATE_FAILED');
413
434
  }
414
- }
435
+ }