@micha.bigler/ui-core-micha 2.1.13 → 2.1.15
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/components/BulkInviteCsvTab.js +96 -0
- package/dist/components/UserListComponent.js +63 -5
- package/dist/i18n/authTranslations.js +54 -0
- package/dist/index.js +3 -0
- package/dist/layout/PageLayout.js +1 -1
- package/dist/pages/AccountPage.js +27 -3
- package/package.json +1 -1
- package/src/components/BulkInviteCsvTab.jsx +190 -0
- package/src/components/UserListComponent.jsx +106 -7
- package/src/i18n/authTranslations.ts +54 -0
- package/src/index.js +4 -1
- package/src/layout/PageLayout.jsx +2 -2
- package/src/pages/AccountPage.jsx +52 -3
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useMemo, useState } from 'react';
|
|
3
|
+
import { Box, Button, Typography, Alert, LinearProgress, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, } from '@mui/material';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { requestInviteWithCode } from '../auth/authApi';
|
|
6
|
+
function parseEmailsFromCsv(text) {
|
|
7
|
+
if (!text)
|
|
8
|
+
return [];
|
|
9
|
+
const lines = text
|
|
10
|
+
.split(/\r?\n/)
|
|
11
|
+
.map((line) => line.trim())
|
|
12
|
+
.filter(Boolean);
|
|
13
|
+
if (lines.length === 0)
|
|
14
|
+
return [];
|
|
15
|
+
const first = lines[0].toLowerCase();
|
|
16
|
+
const hasHeader = first.includes('email');
|
|
17
|
+
const dataLines = hasHeader ? lines.slice(1) : lines;
|
|
18
|
+
const emails = [];
|
|
19
|
+
dataLines.forEach((line) => {
|
|
20
|
+
const cols = line.split(/[;,|\t]/).map((part) => part.trim());
|
|
21
|
+
const email = cols.find((part) => part.includes('@'));
|
|
22
|
+
if (email)
|
|
23
|
+
emails.push(email);
|
|
24
|
+
});
|
|
25
|
+
return Array.from(new Set(emails.map((e) => e.toLowerCase())));
|
|
26
|
+
}
|
|
27
|
+
export function BulkInviteCsvTab({ inviteFn = (email) => requestInviteWithCode(email, null), onCompleted, }) {
|
|
28
|
+
const { t } = useTranslation();
|
|
29
|
+
const [emails, setEmails] = useState([]);
|
|
30
|
+
const [results, setResults] = useState({});
|
|
31
|
+
const [busy, setBusy] = useState(false);
|
|
32
|
+
const [error, setError] = useState('');
|
|
33
|
+
const [success, setSuccess] = useState('');
|
|
34
|
+
const total = emails.length;
|
|
35
|
+
const done = Object.keys(results).length;
|
|
36
|
+
const progress = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
37
|
+
const successCount = useMemo(() => Object.values(results).filter((r) => r.ok).length, [results]);
|
|
38
|
+
const handleFile = async (event) => {
|
|
39
|
+
var _a;
|
|
40
|
+
setError('');
|
|
41
|
+
setSuccess('');
|
|
42
|
+
setResults({});
|
|
43
|
+
const file = (_a = event.target.files) === null || _a === void 0 ? void 0 : _a[0];
|
|
44
|
+
if (!file)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
const text = await file.text();
|
|
48
|
+
const parsed = parseEmailsFromCsv(text);
|
|
49
|
+
if (parsed.length === 0) {
|
|
50
|
+
setError(t('Account.BULK_INVITE_NO_EMAILS', 'No valid email addresses found in CSV.'));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
setEmails(parsed);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
setError(err.message || t('Account.BULK_INVITE_PARSE_FAILED', 'Could not read CSV file.'));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const handleInviteAll = async () => {
|
|
60
|
+
if (emails.length === 0)
|
|
61
|
+
return;
|
|
62
|
+
setBusy(true);
|
|
63
|
+
setError('');
|
|
64
|
+
setSuccess('');
|
|
65
|
+
setResults({});
|
|
66
|
+
const nextResults = {};
|
|
67
|
+
for (const email of emails) {
|
|
68
|
+
try {
|
|
69
|
+
const response = await inviteFn(email);
|
|
70
|
+
nextResults[email] = {
|
|
71
|
+
ok: true,
|
|
72
|
+
message: (response === null || response === void 0 ? void 0 : response.detail) || t('Auth.INVITE_SENT_SUCCESS', 'Invitation sent.'),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
nextResults[email] = {
|
|
77
|
+
ok: false,
|
|
78
|
+
message: t((err === null || err === void 0 ? void 0 : err.code) || 'Auth.INVITE_FAILED'),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
setResults(Object.assign({}, nextResults));
|
|
82
|
+
}
|
|
83
|
+
const okCount = Object.values(nextResults).filter((r) => r.ok).length;
|
|
84
|
+
setSuccess(t('Account.BULK_INVITE_DONE', '{{ok}} / {{total}} invites sent.', {
|
|
85
|
+
ok: okCount,
|
|
86
|
+
total: emails.length,
|
|
87
|
+
}));
|
|
88
|
+
setBusy(false);
|
|
89
|
+
if (onCompleted)
|
|
90
|
+
onCompleted(nextResults);
|
|
91
|
+
};
|
|
92
|
+
return (_jsxs(Box, { sx: { mt: 1 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Account.BULK_INVITE_TITLE', 'Bulk Invite via CSV') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Account.BULK_INVITE_HINT', 'Upload a CSV file containing email addresses. Header "email" is supported.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsxs(Box, { sx: { display: 'flex', gap: 2, alignItems: 'center', mb: 2, flexWrap: 'wrap' }, children: [_jsxs(Button, { variant: "outlined", component: "label", disabled: busy, children: [t('Account.BULK_INVITE_UPLOAD', 'Upload CSV'), _jsx("input", { type: "file", accept: ".csv,text/csv", hidden: true, onChange: handleFile })] }), _jsx(Button, { variant: "contained", onClick: handleInviteAll, disabled: busy || emails.length === 0, children: t('Account.BULK_INVITE_SEND', 'Send Invites') }), _jsx(Typography, { variant: "body2", children: t('Account.BULK_INVITE_COUNT', '{{count}} emails loaded', { count: emails.length }) })] }), busy && (_jsx(Box, { sx: { mb: 2 }, children: _jsx(LinearProgress, { variant: "determinate", value: progress }) })), emails.length > 0 && (_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('Common.STATUS', 'Status') }), _jsx(TableCell, { children: t('Common.DETAILS', 'Details') })] }) }), _jsx(TableBody, { children: emails.map((email) => {
|
|
93
|
+
const row = results[email];
|
|
94
|
+
return (_jsxs(TableRow, { children: [_jsx(TableCell, { children: email }), _jsx(TableCell, { children: row ? (row.ok ? t('Common.SUCCESS', 'Success') : t('Common.ERROR', 'Error')) : '-' }), _jsx(TableCell, { children: (row === null || row === void 0 ? void 0 : row.message) || '-' })] }, email));
|
|
95
|
+
}) })] }) })), done > 0 && (_jsxs(Typography, { variant: "body2", sx: { mt: 2 }, children: [t('Account.BULK_INVITE_PROGRESS', '{{done}} / {{total}} processed', { done, total }), ' - ', t('Account.BULK_INVITE_SUCCESS_COUNT', '{{count}} successful', { count: successCount })] }))] }));
|
|
96
|
+
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import React, { useMemo, useState, useEffect } from 'react';
|
|
3
3
|
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, FormControl, Select, MenuItem, Button, IconButton, Tooltip, CircularProgress, Alert } 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';
|
|
7
7
|
const DEFAULT_ROLES = ['none', 'student', 'teacher', 'admin'];
|
|
8
|
-
export function UserListComponent({ roles = DEFAULT_ROLES, currentUser }) {
|
|
8
|
+
export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraColumns = [], extraRowActions = [], extraContext = null, refreshTrigger = 0, canEditUser, }) {
|
|
9
9
|
const { t } = useTranslation();
|
|
10
10
|
const [users, setUsers] = useState([]);
|
|
11
11
|
const [loading, setLoading] = useState(true);
|
|
12
12
|
const [error, setError] = useState(null);
|
|
13
|
+
const [rowActionLoading, setRowActionLoading] = useState({});
|
|
13
14
|
const loadUsers = async () => {
|
|
14
15
|
setLoading(true);
|
|
15
16
|
setError(null);
|
|
@@ -29,7 +30,7 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser }) {
|
|
|
29
30
|
useEffect(() => {
|
|
30
31
|
loadUsers();
|
|
31
32
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
32
|
-
}, []); // Dependency on apiUrl removed
|
|
33
|
+
}, [refreshTrigger]); // Dependency on apiUrl removed
|
|
33
34
|
const handleDelete = async (userId) => {
|
|
34
35
|
if (!window.confirm(t('UserList.DELETE_CONFIRM', 'Are you sure you want to delete this user?')))
|
|
35
36
|
return;
|
|
@@ -62,7 +63,7 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser }) {
|
|
|
62
63
|
alert(t(err.code || 'Auth.USER_UPDATE_FAILED'));
|
|
63
64
|
}
|
|
64
65
|
};
|
|
65
|
-
const
|
|
66
|
+
const defaultCanEdit = (targetUser) => {
|
|
66
67
|
if (!currentUser)
|
|
67
68
|
return false;
|
|
68
69
|
if (currentUser.is_superuser)
|
|
@@ -80,5 +81,62 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser }) {
|
|
|
80
81
|
}
|
|
81
82
|
return false;
|
|
82
83
|
};
|
|
83
|
-
|
|
84
|
+
const canEdit = (targetUser) => {
|
|
85
|
+
if (typeof canEditUser === 'function') {
|
|
86
|
+
return Boolean(canEditUser({ targetUser, currentUser, extraContext }));
|
|
87
|
+
}
|
|
88
|
+
return defaultCanEdit(targetUser);
|
|
89
|
+
};
|
|
90
|
+
const listContext = useMemo(() => ({
|
|
91
|
+
currentUser,
|
|
92
|
+
extraContext,
|
|
93
|
+
t,
|
|
94
|
+
reloadUsers: loadUsers,
|
|
95
|
+
}), [currentUser, extraContext, t]);
|
|
96
|
+
const visibleExtraColumns = useMemo(() => extraColumns.filter((column) => typeof column.visible === 'function' ? column.visible(listContext) : true), [extraColumns, listContext]);
|
|
97
|
+
const visibleRowActions = useMemo(() => extraRowActions.filter((action) => typeof action.visible === 'function' ? action.visible(listContext) : true), [extraRowActions, listContext]);
|
|
98
|
+
const runRowAction = async (action, user) => {
|
|
99
|
+
const actionId = `${action.key}:${user.id}`;
|
|
100
|
+
setRowActionLoading((prev) => (Object.assign(Object.assign({}, prev), { [actionId]: true })));
|
|
101
|
+
try {
|
|
102
|
+
await action.onClick({
|
|
103
|
+
user,
|
|
104
|
+
canEdit: canEdit(user),
|
|
105
|
+
currentUser,
|
|
106
|
+
extraContext,
|
|
107
|
+
t,
|
|
108
|
+
reloadUsers: loadUsers,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// eslint-disable-next-line no-alert
|
|
113
|
+
alert((err === null || err === void 0 ? void 0 : err.message) || t('Common.OPERATION_FAILED', 'Operation failed.'));
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
setRowActionLoading((prev) => (Object.assign(Object.assign({}, prev), { [actionId]: false })));
|
|
117
|
+
}
|
|
118
|
+
};
|
|
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({
|
|
120
|
+
user: u,
|
|
121
|
+
canEdit: canEdit(u),
|
|
122
|
+
currentUser,
|
|
123
|
+
extraContext,
|
|
124
|
+
t,
|
|
125
|
+
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.') }) }))] })] }) }))] }));
|
|
84
142
|
}
|
|
@@ -1224,4 +1224,58 @@ export const authTranslations = {
|
|
|
1224
1224
|
"en": "Invite",
|
|
1225
1225
|
"sw": "Alika"
|
|
1226
1226
|
},
|
|
1227
|
+
"Common.DELETE": {
|
|
1228
|
+
"de": "Löschen",
|
|
1229
|
+
"fr": "Supprimer",
|
|
1230
|
+
"en": "Delete",
|
|
1231
|
+
"sw": "Futa"
|
|
1232
|
+
},
|
|
1233
|
+
"Common.CANCEL": {
|
|
1234
|
+
"de": "Abbrechen",
|
|
1235
|
+
"fr": "Annuler",
|
|
1236
|
+
"en": "Cancel",
|
|
1237
|
+
"sw": "Ghairi"
|
|
1238
|
+
},
|
|
1239
|
+
"Common.CONFIRM": {
|
|
1240
|
+
"de": "Bestätigen",
|
|
1241
|
+
"fr": "Confirmer",
|
|
1242
|
+
"en": "Confirm",
|
|
1243
|
+
"sw": "Thibitisha"
|
|
1244
|
+
},
|
|
1245
|
+
"Common.SAVE": {
|
|
1246
|
+
"de": "Speichern",
|
|
1247
|
+
"fr": "Enregistrer",
|
|
1248
|
+
"en": "Save",
|
|
1249
|
+
"sw": "Hifadhi"
|
|
1250
|
+
},
|
|
1251
|
+
"Common.BACK": {
|
|
1252
|
+
"de": "Zurück",
|
|
1253
|
+
"fr": "Retour",
|
|
1254
|
+
"en": "Back",
|
|
1255
|
+
"sw": "Rudi"
|
|
1256
|
+
},
|
|
1257
|
+
"Common.OK": {
|
|
1258
|
+
"de": "OK",
|
|
1259
|
+
"fr": "OK",
|
|
1260
|
+
"en": "OK",
|
|
1261
|
+
"sw": "OK"
|
|
1262
|
+
},
|
|
1263
|
+
"Common.ERROR": {
|
|
1264
|
+
"de": "Fehler",
|
|
1265
|
+
"fr": "Erreur",
|
|
1266
|
+
"en": "Error",
|
|
1267
|
+
"sw": "Hata"
|
|
1268
|
+
},
|
|
1269
|
+
"Common.SUCCESS": {
|
|
1270
|
+
"de": "Erfolgreich",
|
|
1271
|
+
"fr": "Succès",
|
|
1272
|
+
"en": "Success",
|
|
1273
|
+
"sw": "Mafanikio"
|
|
1274
|
+
},
|
|
1275
|
+
"Common.LOADING": {
|
|
1276
|
+
"de": "Wird geladen…",
|
|
1277
|
+
"fr": "Chargement…",
|
|
1278
|
+
"en": "Loading…",
|
|
1279
|
+
"sw": "Inapakia..."
|
|
1280
|
+
}
|
|
1227
1281
|
};
|
package/dist/index.js
CHANGED
|
@@ -19,5 +19,8 @@ export { AccountPage } from './pages/AccountPage';
|
|
|
19
19
|
// --- 5. Components (Wiederverwendbare UI-Teile) ---
|
|
20
20
|
export { ProfileComponent } from './components/ProfileComponent';
|
|
21
21
|
export { AccessCodeManager } from './components/AccessCodeManager';
|
|
22
|
+
export { UserListComponent } from './components/UserListComponent';
|
|
23
|
+
export { UserInviteComponent } from './components/UserInviteComponent';
|
|
24
|
+
export { BulkInviteCsvTab } from './components/BulkInviteCsvTab';
|
|
22
25
|
// --- 6. Translations ---
|
|
23
26
|
export { authTranslations } from './i18n/authTranslations';
|
|
@@ -3,6 +3,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { Container, Box, Typography } from '@mui/material';
|
|
5
5
|
// Layout for content pages with larger width (e.g. Profile, Welcome, Input)
|
|
6
|
-
export const WidePage = ({ title, children }) => (_jsxs(Container, { maxWidth:
|
|
6
|
+
export const WidePage = ({ title, children, maxWidth = 'lg' }) => (_jsxs(Container, { maxWidth: maxWidth, sx: { mt: 4 }, children: [title && (_jsx(Typography, { variant: "h4", gutterBottom: true, children: title })), children] }));
|
|
7
7
|
// Layout for forms or narrow pages (e.g. Login, Reset, Invite)
|
|
8
8
|
export const NarrowPage = ({ title, subtitle, children }) => (_jsx(Container, { maxWidth: "md", sx: { mt: 4 }, children: _jsxs(Box, { sx: { maxWidth: 480, mx: 'auto' }, children: [title && (_jsx(Typography, { variant: "h4", gutterBottom: true, children: title })), subtitle && (_jsx(Typography, { paragraph: true, children: subtitle })), children] }) }));
|
|
@@ -19,8 +19,10 @@ import { UserListComponent } from '../components/UserListComponent';
|
|
|
19
19
|
import { UserInviteComponent } from '../components/UserInviteComponent';
|
|
20
20
|
import { AccessCodeManager } from '../components/AccessCodeManager';
|
|
21
21
|
import { SupportRecoveryRequestsTab } from '../components/SupportRecoveryRequestsTab';
|
|
22
|
+
import { BulkInviteCsvTab } from '../components/BulkInviteCsvTab';
|
|
22
23
|
import { updateUserProfile } from '../auth/authApi'; // Ggf. Pfad anpassen
|
|
23
|
-
export function AccountPage() {
|
|
24
|
+
export function AccountPage({ userListExtraColumns = [], userListExtraRowActions = [], userListExtraContext = null, userListRefreshTrigger = 0, userListCanEditUser = null, showBulkInviteCsvTab = false, bulkInviteCsvProps = {}, extraTabs = [], }) {
|
|
25
|
+
var _a;
|
|
24
26
|
const { t } = useTranslation();
|
|
25
27
|
const { user, login, loading } = useContext(AuthContext);
|
|
26
28
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
@@ -44,6 +46,7 @@ export function AccountPage() {
|
|
|
44
46
|
const tabs = useMemo(() => {
|
|
45
47
|
if (!user)
|
|
46
48
|
return [];
|
|
49
|
+
const extensionContext = { user, perms, isSuperUser, t };
|
|
47
50
|
const list = [
|
|
48
51
|
{ value: 'profile', label: t('Account.TAB_PROFILE', 'Profile') },
|
|
49
52
|
{ value: 'security', label: t('Account.TAB_SECURITY', 'Security') },
|
|
@@ -54,6 +57,12 @@ export function AccountPage() {
|
|
|
54
57
|
}
|
|
55
58
|
if (isSuperUser || perms.can_invite) {
|
|
56
59
|
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
|
+
}
|
|
57
66
|
}
|
|
58
67
|
if (isSuperUser || perms.can_manage_access_codes) {
|
|
59
68
|
list.push({ value: 'access', label: t('Account.TAB_ACCESS_CODES', 'Access Codes') });
|
|
@@ -61,8 +70,19 @@ export function AccountPage() {
|
|
|
61
70
|
if (isSuperUser || perms.can_view_support) {
|
|
62
71
|
list.push({ value: 'support', label: t('Account.TAB_SUPPORT', 'Support') });
|
|
63
72
|
}
|
|
73
|
+
extraTabs.forEach((tab) => {
|
|
74
|
+
if (!(tab === null || tab === void 0 ? void 0 : tab.value) || !(tab === null || tab === void 0 ? void 0 : tab.label))
|
|
75
|
+
return;
|
|
76
|
+
const isVisible = typeof tab.visible === 'function' ? tab.visible(extensionContext) : true;
|
|
77
|
+
if (isVisible && !list.some((entry) => entry.value === tab.value)) {
|
|
78
|
+
list.push({
|
|
79
|
+
value: tab.value,
|
|
80
|
+
label: typeof tab.label === 'function' ? tab.label(extensionContext) : tab.label,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
64
84
|
return list;
|
|
65
|
-
}, [user, perms, t, isSuperUser]);
|
|
85
|
+
}, [user, perms, t, isSuperUser, showBulkInviteCsvTab, extraTabs]);
|
|
66
86
|
// 4. Loading & Auth Checks
|
|
67
87
|
if (loading) {
|
|
68
88
|
return (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', mt: 10 }, children: _jsx(CircularProgress, {}) }));
|
|
@@ -74,5 +94,9 @@ export function AccountPage() {
|
|
|
74
94
|
const activeTabExists = tabs.some(t => t.value === currentTab);
|
|
75
95
|
// Falls der Tab nicht erlaubt ist (z.B. manuell in URL eingegeben), Fallback auf 'profile'
|
|
76
96
|
const safeTab = activeTabExists ? currentTab : 'profile';
|
|
77
|
-
|
|
97
|
+
const builtInTabValues = new Set(['profile', 'security', 'users', 'invite', 'bulk-invite-csv', 'access', 'support']);
|
|
98
|
+
const activeExtraTab = builtInTabValues.has(safeTab)
|
|
99
|
+
? null
|
|
100
|
+
: 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 }) }))] }));
|
|
78
102
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Button,
|
|
5
|
+
Typography,
|
|
6
|
+
Alert,
|
|
7
|
+
LinearProgress,
|
|
8
|
+
Table,
|
|
9
|
+
TableBody,
|
|
10
|
+
TableCell,
|
|
11
|
+
TableContainer,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableRow,
|
|
14
|
+
Paper,
|
|
15
|
+
} from '@mui/material';
|
|
16
|
+
import { useTranslation } from 'react-i18next';
|
|
17
|
+
import { requestInviteWithCode } from '../auth/authApi';
|
|
18
|
+
|
|
19
|
+
function parseEmailsFromCsv(text) {
|
|
20
|
+
if (!text) return [];
|
|
21
|
+
|
|
22
|
+
const lines = text
|
|
23
|
+
.split(/\r?\n/)
|
|
24
|
+
.map((line) => line.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
|
|
27
|
+
if (lines.length === 0) return [];
|
|
28
|
+
|
|
29
|
+
const first = lines[0].toLowerCase();
|
|
30
|
+
const hasHeader = first.includes('email');
|
|
31
|
+
const dataLines = hasHeader ? lines.slice(1) : lines;
|
|
32
|
+
const emails = [];
|
|
33
|
+
|
|
34
|
+
dataLines.forEach((line) => {
|
|
35
|
+
const cols = line.split(/[;,|\t]/).map((part) => part.trim());
|
|
36
|
+
const email = cols.find((part) => part.includes('@'));
|
|
37
|
+
if (email) emails.push(email);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return Array.from(new Set(emails.map((e) => e.toLowerCase())));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function BulkInviteCsvTab({
|
|
44
|
+
inviteFn = (email) => requestInviteWithCode(email, null),
|
|
45
|
+
onCompleted,
|
|
46
|
+
}) {
|
|
47
|
+
const { t } = useTranslation();
|
|
48
|
+
const [emails, setEmails] = useState([]);
|
|
49
|
+
const [results, setResults] = useState({});
|
|
50
|
+
const [busy, setBusy] = useState(false);
|
|
51
|
+
const [error, setError] = useState('');
|
|
52
|
+
const [success, setSuccess] = useState('');
|
|
53
|
+
|
|
54
|
+
const total = emails.length;
|
|
55
|
+
const done = Object.keys(results).length;
|
|
56
|
+
const progress = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
57
|
+
|
|
58
|
+
const successCount = useMemo(
|
|
59
|
+
() => Object.values(results).filter((r) => r.ok).length,
|
|
60
|
+
[results],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const handleFile = async (event) => {
|
|
64
|
+
setError('');
|
|
65
|
+
setSuccess('');
|
|
66
|
+
setResults({});
|
|
67
|
+
|
|
68
|
+
const file = event.target.files?.[0];
|
|
69
|
+
if (!file) return;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const text = await file.text();
|
|
73
|
+
const parsed = parseEmailsFromCsv(text);
|
|
74
|
+
if (parsed.length === 0) {
|
|
75
|
+
setError(t('Account.BULK_INVITE_NO_EMAILS', 'No valid email addresses found in CSV.'));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setEmails(parsed);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
setError(err.message || t('Account.BULK_INVITE_PARSE_FAILED', 'Could not read CSV file.'));
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleInviteAll = async () => {
|
|
85
|
+
if (emails.length === 0) return;
|
|
86
|
+
setBusy(true);
|
|
87
|
+
setError('');
|
|
88
|
+
setSuccess('');
|
|
89
|
+
setResults({});
|
|
90
|
+
|
|
91
|
+
const nextResults = {};
|
|
92
|
+
for (const email of emails) {
|
|
93
|
+
try {
|
|
94
|
+
const response = await inviteFn(email);
|
|
95
|
+
nextResults[email] = {
|
|
96
|
+
ok: true,
|
|
97
|
+
message: response?.detail || t('Auth.INVITE_SENT_SUCCESS', 'Invitation sent.'),
|
|
98
|
+
};
|
|
99
|
+
} catch (err) {
|
|
100
|
+
nextResults[email] = {
|
|
101
|
+
ok: false,
|
|
102
|
+
message: t(err?.code || 'Auth.INVITE_FAILED'),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
setResults({ ...nextResults });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const okCount = Object.values(nextResults).filter((r) => r.ok).length;
|
|
109
|
+
setSuccess(
|
|
110
|
+
t('Account.BULK_INVITE_DONE', '{{ok}} / {{total}} invites sent.', {
|
|
111
|
+
ok: okCount,
|
|
112
|
+
total: emails.length,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
setBusy(false);
|
|
116
|
+
if (onCompleted) onCompleted(nextResults);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Box sx={{ mt: 1 }}>
|
|
121
|
+
<Typography variant="h6" gutterBottom>
|
|
122
|
+
{t('Account.BULK_INVITE_TITLE', 'Bulk Invite via CSV')}
|
|
123
|
+
</Typography>
|
|
124
|
+
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
|
125
|
+
{t(
|
|
126
|
+
'Account.BULK_INVITE_HINT',
|
|
127
|
+
'Upload a CSV file containing email addresses. Header "email" is supported.',
|
|
128
|
+
)}
|
|
129
|
+
</Typography>
|
|
130
|
+
|
|
131
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
132
|
+
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
|
|
133
|
+
|
|
134
|
+
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2, flexWrap: 'wrap' }}>
|
|
135
|
+
<Button variant="outlined" component="label" disabled={busy}>
|
|
136
|
+
{t('Account.BULK_INVITE_UPLOAD', 'Upload CSV')}
|
|
137
|
+
<input type="file" accept=".csv,text/csv" hidden onChange={handleFile} />
|
|
138
|
+
</Button>
|
|
139
|
+
<Button variant="contained" onClick={handleInviteAll} disabled={busy || emails.length === 0}>
|
|
140
|
+
{t('Account.BULK_INVITE_SEND', 'Send Invites')}
|
|
141
|
+
</Button>
|
|
142
|
+
<Typography variant="body2">
|
|
143
|
+
{t('Account.BULK_INVITE_COUNT', '{{count}} emails loaded', { count: emails.length })}
|
|
144
|
+
</Typography>
|
|
145
|
+
</Box>
|
|
146
|
+
|
|
147
|
+
{busy && (
|
|
148
|
+
<Box sx={{ mb: 2 }}>
|
|
149
|
+
<LinearProgress variant="determinate" value={progress} />
|
|
150
|
+
</Box>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{emails.length > 0 && (
|
|
154
|
+
<TableContainer component={Paper}>
|
|
155
|
+
<Table size="small">
|
|
156
|
+
<TableHead>
|
|
157
|
+
<TableRow>
|
|
158
|
+
<TableCell>{t('Auth.EMAIL_LABEL', 'Email')}</TableCell>
|
|
159
|
+
<TableCell>{t('Common.STATUS', 'Status')}</TableCell>
|
|
160
|
+
<TableCell>{t('Common.DETAILS', 'Details')}</TableCell>
|
|
161
|
+
</TableRow>
|
|
162
|
+
</TableHead>
|
|
163
|
+
<TableBody>
|
|
164
|
+
{emails.map((email) => {
|
|
165
|
+
const row = results[email];
|
|
166
|
+
return (
|
|
167
|
+
<TableRow key={email}>
|
|
168
|
+
<TableCell>{email}</TableCell>
|
|
169
|
+
<TableCell>
|
|
170
|
+
{row ? (row.ok ? t('Common.SUCCESS', 'Success') : t('Common.ERROR', 'Error')) : '-'}
|
|
171
|
+
</TableCell>
|
|
172
|
+
<TableCell>{row?.message || '-'}</TableCell>
|
|
173
|
+
</TableRow>
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
176
|
+
</TableBody>
|
|
177
|
+
</Table>
|
|
178
|
+
</TableContainer>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{done > 0 && (
|
|
182
|
+
<Typography variant="body2" sx={{ mt: 2 }}>
|
|
183
|
+
{t('Account.BULK_INVITE_PROGRESS', '{{done}} / {{total}} processed', { done, total })}
|
|
184
|
+
{' - '}
|
|
185
|
+
{t('Account.BULK_INVITE_SUCCESS_COUNT', '{{count}} successful', { count: successCount })}
|
|
186
|
+
</Typography>
|
|
187
|
+
)}
|
|
188
|
+
</Box>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react';
|
|
1
|
+
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,
|
|
@@ -11,13 +11,19 @@ import { fetchUsersList, deleteUser, updateUserRole, updateUserSupportStatus } f
|
|
|
11
11
|
const DEFAULT_ROLES = ['none', 'student', 'teacher', 'admin'];
|
|
12
12
|
|
|
13
13
|
export function UserListComponent({
|
|
14
|
-
roles = DEFAULT_ROLES,
|
|
15
|
-
currentUser
|
|
14
|
+
roles = DEFAULT_ROLES,
|
|
15
|
+
currentUser,
|
|
16
|
+
extraColumns = [],
|
|
17
|
+
extraRowActions = [],
|
|
18
|
+
extraContext = null,
|
|
19
|
+
refreshTrigger = 0,
|
|
20
|
+
canEditUser,
|
|
16
21
|
}) {
|
|
17
22
|
const { t } = useTranslation();
|
|
18
23
|
const [users, setUsers] = useState([]);
|
|
19
24
|
const [loading, setLoading] = useState(true);
|
|
20
25
|
const [error, setError] = useState(null);
|
|
26
|
+
const [rowActionLoading, setRowActionLoading] = useState({});
|
|
21
27
|
|
|
22
28
|
const loadUsers = async () => {
|
|
23
29
|
setLoading(true);
|
|
@@ -37,7 +43,7 @@ export function UserListComponent({
|
|
|
37
43
|
useEffect(() => {
|
|
38
44
|
loadUsers();
|
|
39
45
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
-
}, []); // Dependency on apiUrl removed
|
|
46
|
+
}, [refreshTrigger]); // Dependency on apiUrl removed
|
|
41
47
|
|
|
42
48
|
const handleDelete = async (userId) => {
|
|
43
49
|
if (!window.confirm(t('UserList.DELETE_CONFIRM', 'Are you sure you want to delete this user?'))) return;
|
|
@@ -70,7 +76,7 @@ export function UserListComponent({
|
|
|
70
76
|
}
|
|
71
77
|
};
|
|
72
78
|
|
|
73
|
-
const
|
|
79
|
+
const defaultCanEdit = (targetUser) => {
|
|
74
80
|
if (!currentUser) return false;
|
|
75
81
|
if (currentUser.is_superuser) return true;
|
|
76
82
|
|
|
@@ -86,6 +92,55 @@ export function UserListComponent({
|
|
|
86
92
|
}
|
|
87
93
|
return false;
|
|
88
94
|
};
|
|
95
|
+
const canEdit = (targetUser) => {
|
|
96
|
+
if (typeof canEditUser === 'function') {
|
|
97
|
+
return Boolean(canEditUser({ targetUser, currentUser, extraContext }));
|
|
98
|
+
}
|
|
99
|
+
return defaultCanEdit(targetUser);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const listContext = useMemo(() => ({
|
|
103
|
+
currentUser,
|
|
104
|
+
extraContext,
|
|
105
|
+
t,
|
|
106
|
+
reloadUsers: loadUsers,
|
|
107
|
+
}), [currentUser, extraContext, t]);
|
|
108
|
+
|
|
109
|
+
const visibleExtraColumns = useMemo(
|
|
110
|
+
() =>
|
|
111
|
+
extraColumns.filter((column) =>
|
|
112
|
+
typeof column.visible === 'function' ? column.visible(listContext) : true,
|
|
113
|
+
),
|
|
114
|
+
[extraColumns, listContext],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const visibleRowActions = useMemo(
|
|
118
|
+
() =>
|
|
119
|
+
extraRowActions.filter((action) =>
|
|
120
|
+
typeof action.visible === 'function' ? action.visible(listContext) : true,
|
|
121
|
+
),
|
|
122
|
+
[extraRowActions, listContext],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const runRowAction = async (action, user) => {
|
|
126
|
+
const actionId = `${action.key}:${user.id}`;
|
|
127
|
+
setRowActionLoading((prev) => ({ ...prev, [actionId]: true }));
|
|
128
|
+
try {
|
|
129
|
+
await action.onClick({
|
|
130
|
+
user,
|
|
131
|
+
canEdit: canEdit(user),
|
|
132
|
+
currentUser,
|
|
133
|
+
extraContext,
|
|
134
|
+
t,
|
|
135
|
+
reloadUsers: loadUsers,
|
|
136
|
+
});
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// eslint-disable-next-line no-alert
|
|
139
|
+
alert(err?.message || t('Common.OPERATION_FAILED', 'Operation failed.'));
|
|
140
|
+
} finally {
|
|
141
|
+
setRowActionLoading((prev) => ({ ...prev, [actionId]: false }));
|
|
142
|
+
}
|
|
143
|
+
};
|
|
89
144
|
|
|
90
145
|
return (
|
|
91
146
|
<Box>
|
|
@@ -106,6 +161,11 @@ export function UserListComponent({
|
|
|
106
161
|
<TableCell>{t('Profile.NAME_LABEL', 'Name')}</TableCell>
|
|
107
162
|
<TableCell>{t('UserList.ROLE', 'Role')}</TableCell>
|
|
108
163
|
<TableCell>{t('UserList.SUPPORTER', 'Support Agent')}</TableCell>
|
|
164
|
+
{visibleExtraColumns.map((column) => (
|
|
165
|
+
<TableCell key={column.key} align={column.align || 'left'}>
|
|
166
|
+
{typeof column.label === 'function' ? column.label(listContext) : column.label}
|
|
167
|
+
</TableCell>
|
|
168
|
+
))}
|
|
109
169
|
<TableCell>{t('Common.ACTIONS', 'Actions')}</TableCell>
|
|
110
170
|
</TableRow>
|
|
111
171
|
</TableHead>
|
|
@@ -140,7 +200,46 @@ export function UserListComponent({
|
|
|
140
200
|
{u.is_support_agent ? t('Common.YES') : t('Common.NO')}
|
|
141
201
|
</Button>
|
|
142
202
|
</TableCell>
|
|
203
|
+
{visibleExtraColumns.map((column) => (
|
|
204
|
+
<TableCell key={`${column.key}-${u.id}`} align={column.align || 'left'}>
|
|
205
|
+
{column.renderCell({
|
|
206
|
+
user: u,
|
|
207
|
+
canEdit: canEdit(u),
|
|
208
|
+
currentUser,
|
|
209
|
+
extraContext,
|
|
210
|
+
t,
|
|
211
|
+
reloadUsers: loadUsers,
|
|
212
|
+
})}
|
|
213
|
+
</TableCell>
|
|
214
|
+
))}
|
|
143
215
|
<TableCell>
|
|
216
|
+
{visibleRowActions.map((action) => {
|
|
217
|
+
const actionId = `${action.key}:${u.id}`;
|
|
218
|
+
const isBusy = Boolean(rowActionLoading[actionId]);
|
|
219
|
+
const isDisabled = typeof action.disabled === 'function'
|
|
220
|
+
? action.disabled({
|
|
221
|
+
user: u,
|
|
222
|
+
canEdit: canEdit(u),
|
|
223
|
+
currentUser,
|
|
224
|
+
extraContext,
|
|
225
|
+
t,
|
|
226
|
+
})
|
|
227
|
+
: false;
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<Button
|
|
231
|
+
key={`${action.key}-${u.id}`}
|
|
232
|
+
size="small"
|
|
233
|
+
onClick={() => runRowAction(action, u)}
|
|
234
|
+
disabled={isBusy || isDisabled}
|
|
235
|
+
sx={{ mr: 1, mb: 0.5, textTransform: 'none' }}
|
|
236
|
+
>
|
|
237
|
+
{typeof action.label === 'function'
|
|
238
|
+
? action.label({ user: u, t, currentUser, canEdit: canEdit(u) })
|
|
239
|
+
: action.label}
|
|
240
|
+
</Button>
|
|
241
|
+
);
|
|
242
|
+
})}
|
|
144
243
|
<Tooltip title={t('Common.DELETE')}>
|
|
145
244
|
<span>
|
|
146
245
|
<IconButton onClick={() => handleDelete(u.id)} color="error" disabled={!canEdit(u)}>
|
|
@@ -153,7 +252,7 @@ export function UserListComponent({
|
|
|
153
252
|
))}
|
|
154
253
|
{users.length === 0 && (
|
|
155
254
|
<TableRow>
|
|
156
|
-
<TableCell colSpan={5} align="center">
|
|
255
|
+
<TableCell colSpan={5 + visibleExtraColumns.length} align="center">
|
|
157
256
|
{t('UserList.NO_USERS', 'No users found.')}
|
|
158
257
|
</TableCell>
|
|
159
258
|
</TableRow>
|
|
@@ -164,4 +263,4 @@ export function UserListComponent({
|
|
|
164
263
|
)}
|
|
165
264
|
</Box>
|
|
166
265
|
);
|
|
167
|
-
}
|
|
266
|
+
}
|
|
@@ -1272,4 +1272,58 @@ export const authTranslations = {
|
|
|
1272
1272
|
"en": "Invite",
|
|
1273
1273
|
"sw": "Alika"
|
|
1274
1274
|
},
|
|
1275
|
+
"Common.DELETE": {
|
|
1276
|
+
"de": "Löschen",
|
|
1277
|
+
"fr": "Supprimer",
|
|
1278
|
+
"en": "Delete",
|
|
1279
|
+
"sw": "Futa"
|
|
1280
|
+
},
|
|
1281
|
+
"Common.CANCEL": {
|
|
1282
|
+
"de": "Abbrechen",
|
|
1283
|
+
"fr": "Annuler",
|
|
1284
|
+
"en": "Cancel",
|
|
1285
|
+
"sw": "Ghairi"
|
|
1286
|
+
},
|
|
1287
|
+
"Common.CONFIRM": {
|
|
1288
|
+
"de": "Bestätigen",
|
|
1289
|
+
"fr": "Confirmer",
|
|
1290
|
+
"en": "Confirm",
|
|
1291
|
+
"sw": "Thibitisha"
|
|
1292
|
+
},
|
|
1293
|
+
"Common.SAVE": {
|
|
1294
|
+
"de": "Speichern",
|
|
1295
|
+
"fr": "Enregistrer",
|
|
1296
|
+
"en": "Save",
|
|
1297
|
+
"sw": "Hifadhi"
|
|
1298
|
+
},
|
|
1299
|
+
"Common.BACK": {
|
|
1300
|
+
"de": "Zurück",
|
|
1301
|
+
"fr": "Retour",
|
|
1302
|
+
"en": "Back",
|
|
1303
|
+
"sw": "Rudi"
|
|
1304
|
+
},
|
|
1305
|
+
"Common.OK": {
|
|
1306
|
+
"de": "OK",
|
|
1307
|
+
"fr": "OK",
|
|
1308
|
+
"en": "OK",
|
|
1309
|
+
"sw": "OK"
|
|
1310
|
+
},
|
|
1311
|
+
"Common.ERROR": {
|
|
1312
|
+
"de": "Fehler",
|
|
1313
|
+
"fr": "Erreur",
|
|
1314
|
+
"en": "Error",
|
|
1315
|
+
"sw": "Hata"
|
|
1316
|
+
},
|
|
1317
|
+
"Common.SUCCESS": {
|
|
1318
|
+
"de": "Erfolgreich",
|
|
1319
|
+
"fr": "Succès",
|
|
1320
|
+
"en": "Success",
|
|
1321
|
+
"sw": "Mafanikio"
|
|
1322
|
+
},
|
|
1323
|
+
"Common.LOADING": {
|
|
1324
|
+
"de": "Wird geladen…",
|
|
1325
|
+
"fr": "Chargement…",
|
|
1326
|
+
"en": "Loading…",
|
|
1327
|
+
"sw": "Inapakia..."
|
|
1328
|
+
}
|
|
1275
1329
|
};
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,9 @@ export { AccountPage } from './pages/AccountPage';
|
|
|
25
25
|
// --- 5. Components (Wiederverwendbare UI-Teile) ---
|
|
26
26
|
export { ProfileComponent } from './components/ProfileComponent';
|
|
27
27
|
export { AccessCodeManager } from './components/AccessCodeManager';
|
|
28
|
+
export { UserListComponent } from './components/UserListComponent';
|
|
29
|
+
export { UserInviteComponent } from './components/UserInviteComponent';
|
|
30
|
+
export { BulkInviteCsvTab } from './components/BulkInviteCsvTab';
|
|
28
31
|
|
|
29
32
|
// --- 6. Translations ---
|
|
30
|
-
export { authTranslations } from './i18n/authTranslations';
|
|
33
|
+
export { authTranslations } from './i18n/authTranslations';
|
|
@@ -3,8 +3,8 @@ import React from 'react';
|
|
|
3
3
|
import { Container, Box, Typography } from '@mui/material';
|
|
4
4
|
|
|
5
5
|
// Layout for content pages with larger width (e.g. Profile, Welcome, Input)
|
|
6
|
-
export const WidePage = ({ title, children }) => (
|
|
7
|
-
<Container maxWidth=
|
|
6
|
+
export const WidePage = ({ title, children, maxWidth = 'lg' }) => (
|
|
7
|
+
<Container maxWidth={maxWidth} sx={{ mt: 4 }}>
|
|
8
8
|
{title && (
|
|
9
9
|
<Typography variant="h4" gutterBottom>
|
|
10
10
|
{title}
|
|
@@ -28,9 +28,19 @@ import { UserListComponent } from '../components/UserListComponent';
|
|
|
28
28
|
import { UserInviteComponent } from '../components/UserInviteComponent';
|
|
29
29
|
import { AccessCodeManager } from '../components/AccessCodeManager';
|
|
30
30
|
import { SupportRecoveryRequestsTab } from '../components/SupportRecoveryRequestsTab';
|
|
31
|
+
import { BulkInviteCsvTab } from '../components/BulkInviteCsvTab';
|
|
31
32
|
import { updateUserProfile } from '../auth/authApi'; // Ggf. Pfad anpassen
|
|
32
33
|
|
|
33
|
-
export function AccountPage(
|
|
34
|
+
export function AccountPage({
|
|
35
|
+
userListExtraColumns = [],
|
|
36
|
+
userListExtraRowActions = [],
|
|
37
|
+
userListExtraContext = null,
|
|
38
|
+
userListRefreshTrigger = 0,
|
|
39
|
+
userListCanEditUser = null,
|
|
40
|
+
showBulkInviteCsvTab = false,
|
|
41
|
+
bulkInviteCsvProps = {},
|
|
42
|
+
extraTabs = [],
|
|
43
|
+
}) {
|
|
34
44
|
const { t } = useTranslation();
|
|
35
45
|
const { user, login, loading } = useContext(AuthContext);
|
|
36
46
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
@@ -60,6 +70,7 @@ export function AccountPage() {
|
|
|
60
70
|
const tabs = useMemo(() => {
|
|
61
71
|
if (!user) return [];
|
|
62
72
|
|
|
73
|
+
const extensionContext = { user, perms, isSuperUser, t };
|
|
63
74
|
const list = [
|
|
64
75
|
{ value: 'profile', label: t('Account.TAB_PROFILE', 'Profile') },
|
|
65
76
|
{ value: 'security', label: t('Account.TAB_SECURITY', 'Security') },
|
|
@@ -73,6 +84,12 @@ export function AccountPage() {
|
|
|
73
84
|
|
|
74
85
|
if (isSuperUser || perms.can_invite) {
|
|
75
86
|
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
|
+
}
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
if (isSuperUser || perms.can_manage_access_codes) {
|
|
@@ -83,8 +100,19 @@ export function AccountPage() {
|
|
|
83
100
|
list.push({ value: 'support', label: t('Account.TAB_SUPPORT', 'Support') });
|
|
84
101
|
}
|
|
85
102
|
|
|
103
|
+
extraTabs.forEach((tab) => {
|
|
104
|
+
if (!tab?.value || !tab?.label) return;
|
|
105
|
+
const isVisible = typeof tab.visible === 'function' ? tab.visible(extensionContext) : true;
|
|
106
|
+
if (isVisible && !list.some((entry) => entry.value === tab.value)) {
|
|
107
|
+
list.push({
|
|
108
|
+
value: tab.value,
|
|
109
|
+
label: typeof tab.label === 'function' ? tab.label(extensionContext) : tab.label,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
86
114
|
return list;
|
|
87
|
-
}, [user, perms, t, isSuperUser]);
|
|
115
|
+
}, [user, perms, t, isSuperUser, showBulkInviteCsvTab, extraTabs]);
|
|
88
116
|
|
|
89
117
|
// 4. Loading & Auth Checks
|
|
90
118
|
if (loading) {
|
|
@@ -109,6 +137,10 @@ export function AccountPage() {
|
|
|
109
137
|
const activeTabExists = tabs.some(t => t.value === currentTab);
|
|
110
138
|
// Falls der Tab nicht erlaubt ist (z.B. manuell in URL eingegeben), Fallback auf 'profile'
|
|
111
139
|
const safeTab = activeTabExists ? currentTab : 'profile';
|
|
140
|
+
const builtInTabValues = new Set(['profile', 'security', 'users', 'invite', 'bulk-invite-csv', 'access', 'support']);
|
|
141
|
+
const activeExtraTab = builtInTabValues.has(safeTab)
|
|
142
|
+
? null
|
|
143
|
+
: extraTabs.find((tab) => tab.value === safeTab);
|
|
112
144
|
|
|
113
145
|
return (
|
|
114
146
|
<WidePage title={t('Account.TITLE', 'Account & Administration')}>
|
|
@@ -155,6 +187,11 @@ export function AccountPage() {
|
|
|
155
187
|
<UserListComponent
|
|
156
188
|
roles={activeRoles}
|
|
157
189
|
currentUser={user}
|
|
190
|
+
extraColumns={userListExtraColumns}
|
|
191
|
+
extraRowActions={userListExtraRowActions}
|
|
192
|
+
extraContext={userListExtraContext}
|
|
193
|
+
refreshTrigger={userListRefreshTrigger}
|
|
194
|
+
canEditUser={userListCanEditUser}
|
|
158
195
|
/>
|
|
159
196
|
</Box>
|
|
160
197
|
)}
|
|
@@ -165,6 +202,12 @@ export function AccountPage() {
|
|
|
165
202
|
</Box>
|
|
166
203
|
)}
|
|
167
204
|
|
|
205
|
+
{safeTab === 'bulk-invite-csv' && (
|
|
206
|
+
<Box sx={{ mt: 2 }}>
|
|
207
|
+
<BulkInviteCsvTab {...bulkInviteCsvProps} />
|
|
208
|
+
</Box>
|
|
209
|
+
)}
|
|
210
|
+
|
|
168
211
|
{safeTab === 'access' && (
|
|
169
212
|
<Box sx={{ mt: 2 }}>
|
|
170
213
|
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
|
@@ -179,6 +222,12 @@ export function AccountPage() {
|
|
|
179
222
|
<SupportRecoveryRequestsTab />
|
|
180
223
|
</Box>
|
|
181
224
|
)}
|
|
225
|
+
|
|
226
|
+
{activeExtraTab && (
|
|
227
|
+
<Box sx={{ mt: 2 }}>
|
|
228
|
+
{activeExtraTab.render?.({ user, perms, isSuperUser, t })}
|
|
229
|
+
</Box>
|
|
230
|
+
)}
|
|
182
231
|
</WidePage>
|
|
183
232
|
);
|
|
184
|
-
}
|
|
233
|
+
}
|