@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.
@@ -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 canEdit = (targetUser) => {
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
- 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') }), _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') }) }), _jsx(TableCell, { children: _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, align: "center", children: t('UserList.NO_USERS', 'No users found.') }) }))] })] }) }))] }));
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: "md", sx: { mt: 4 }, children: [title && (_jsx(Typography, { variant: "h4", gutterBottom: true, children: title })), children] }));
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
- 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 }) })), safeTab === 'invite' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(UserInviteComponent, {}) })), 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, {}) }))] }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.1.13",
3
+ "version": "2.1.15",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "private": false,
@@ -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 canEdit = (targetUser) => {
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="md" sx={{ mt: 4 }}>
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
+ }