@micha.bigler/ui-core-micha 2.2.5 → 2.2.7

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.
@@ -26,9 +26,9 @@ export const AuthProvider = ({ children }) => {
26
26
  const [authMethods, setAuthMethods] = useState(DEFAULT_AUTH_METHODS);
27
27
  const [loading, setLoading] = useState(true);
28
28
  const mapUserFromApi = (data) => {
29
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
29
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
30
30
  const profile = (data === null || data === void 0 ? void 0 : data.profile) || {};
31
- return Object.assign(Object.assign({}, data), { id: data === null || data === void 0 ? void 0 : data.id, username: data === null || data === void 0 ? void 0 : data.username, email: data === null || data === void 0 ? void 0 : data.email, first_name: data === null || data === void 0 ? void 0 : data.first_name, last_name: data === null || data === void 0 ? void 0 : data.last_name, role: (_b = (_a = data === null || data === void 0 ? void 0 : data.role) !== null && _a !== void 0 ? _a : profile === null || profile === void 0 ? void 0 : profile.role) !== null && _b !== void 0 ? _b : null, language: (_d = (_c = data === null || data === void 0 ? void 0 : data.language) !== null && _c !== void 0 ? _c : profile === null || profile === void 0 ? void 0 : profile.language) !== null && _d !== void 0 ? _d : 'en', is_superuser: Boolean(data === null || data === void 0 ? void 0 : data.is_superuser), is_new: Boolean((_e = data === null || data === void 0 ? void 0 : data.is_new) !== null && _e !== void 0 ? _e : profile === null || profile === void 0 ? void 0 : profile.is_new), is_invited: Boolean((_f = data === null || data === void 0 ? void 0 : data.is_invited) !== null && _f !== void 0 ? _f : profile === null || profile === void 0 ? void 0 : profile.is_invited), accepted_privacy_statement: Boolean((_g = data === null || data === void 0 ? void 0 : data.accepted_privacy_statement) !== null && _g !== void 0 ? _g : profile === null || profile === void 0 ? void 0 : profile.accepted_privacy_statement), accepted_convenience_cookies: Boolean((_h = data === null || data === void 0 ? void 0 : data.accepted_convenience_cookies) !== null && _h !== void 0 ? _h : profile === null || profile === void 0 ? void 0 : profile.accepted_convenience_cookies), is_support_agent: Boolean((_j = data === null || data === void 0 ? void 0 : data.is_support_agent) !== null && _j !== void 0 ? _j : profile === null || profile === void 0 ? void 0 : profile.is_support_agent), support_contact_id: (_l = (_k = data === null || data === void 0 ? void 0 : data.support_contact_id) !== null && _k !== void 0 ? _k : profile === null || profile === void 0 ? void 0 : profile.support_contact_id) !== null && _l !== void 0 ? _l : null, security_state: data === null || data === void 0 ? void 0 : data.security_state, available_roles: (data === null || data === void 0 ? void 0 : data.available_roles) || [], ui_permissions: (data === null || data === void 0 ? void 0 : data.ui_permissions) || {}, can_manage_support_agents: Boolean(data === null || data === void 0 ? void 0 : data.can_manage_support_agents), can_manage: Boolean(data === null || data === void 0 ? void 0 : data.can_manage), is_active: data === null || data === void 0 ? void 0 : data.is_active, last_login: data === null || data === void 0 ? void 0 : data.last_login, date_joined: data === null || data === void 0 ? void 0 : data.date_joined });
31
+ return Object.assign(Object.assign({}, data), { id: data === null || data === void 0 ? void 0 : data.id, username: data === null || data === void 0 ? void 0 : data.username, email: data === null || data === void 0 ? void 0 : data.email, first_name: data === null || data === void 0 ? void 0 : data.first_name, last_name: data === null || data === void 0 ? void 0 : data.last_name, role: (_b = (_a = data === null || data === void 0 ? void 0 : data.role) !== null && _a !== void 0 ? _a : profile === null || profile === void 0 ? void 0 : profile.role) !== null && _b !== void 0 ? _b : null, language: (_d = (_c = data === null || data === void 0 ? void 0 : data.language) !== null && _c !== void 0 ? _c : profile === null || profile === void 0 ? void 0 : profile.language) !== null && _d !== void 0 ? _d : 'en', is_superuser: Boolean(data === null || data === void 0 ? void 0 : data.is_superuser), is_new: Boolean((_e = data === null || data === void 0 ? void 0 : data.is_new) !== null && _e !== void 0 ? _e : profile === null || profile === void 0 ? void 0 : profile.is_new), is_invited: Boolean((_f = data === null || data === void 0 ? void 0 : data.is_invited) !== null && _f !== void 0 ? _f : profile === null || profile === void 0 ? void 0 : profile.is_invited), accepted_privacy_statement: Boolean((_g = data === null || data === void 0 ? void 0 : data.accepted_privacy_statement) !== null && _g !== void 0 ? _g : profile === null || profile === void 0 ? void 0 : profile.accepted_privacy_statement), accepted_convenience_cookies: Boolean((_h = data === null || data === void 0 ? void 0 : data.accepted_convenience_cookies) !== null && _h !== void 0 ? _h : profile === null || profile === void 0 ? void 0 : profile.accepted_convenience_cookies), is_support_agent: Boolean((_j = data === null || data === void 0 ? void 0 : data.is_support_agent) !== null && _j !== void 0 ? _j : profile === null || profile === void 0 ? void 0 : profile.is_support_agent), support_contact_id: (_l = (_k = data === null || data === void 0 ? void 0 : data.support_contact_id) !== null && _k !== void 0 ? _k : profile === null || profile === void 0 ? void 0 : profile.support_contact_id) !== null && _l !== void 0 ? _l : null, security_state: data === null || data === void 0 ? void 0 : data.security_state, available_roles: (data === null || data === void 0 ? void 0 : data.available_roles) || [], ui_permissions: (data === null || data === void 0 ? void 0 : data.ui_permissions) || {}, can_manage_support_agents: Boolean(data === null || data === void 0 ? void 0 : data.can_manage_support_agents), can_manage: Boolean(data === null || data === void 0 ? void 0 : data.can_manage), is_active: data === null || data === void 0 ? void 0 : data.is_active, successful_login: Boolean((_m = data === null || data === void 0 ? void 0 : data.successful_login) !== null && _m !== void 0 ? _m : data === null || data === void 0 ? void 0 : data.last_login), last_login: data === null || data === void 0 ? void 0 : data.last_login, date_joined: data === null || data === void 0 ? void 0 : data.date_joined });
32
32
  };
33
33
  useEffect(() => {
34
34
  let isMounted = true;
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react';
3
3
  import { Alert, Box, Button, TextField, Typography, } from '@mui/material';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { updateAuthPolicy } from '../auth/authApi';
6
- export function AllowedEmailDomainsManager({ domains = [], enabled = false, onPolicyChange, }) {
6
+ export function AllowedEmailDomainsManager({ domains = [], enabled = false, onPolicyChange, canEdit = true, }) {
7
7
  const { t } = useTranslation();
8
8
  const [domainsText, setDomainsText] = useState('');
9
9
  const [busy, setBusy] = useState(false);
@@ -39,5 +39,5 @@ export function AllowedEmailDomainsManager({ domains = [], enabled = false, onPo
39
39
  if (!enabled) {
40
40
  return null;
41
41
  }
42
- return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.ALLOWED_EMAIL_DOMAINS_TITLE', 'Allowed Email Domains') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.ALLOWED_EMAIL_DOMAINS_CARD_HINT', 'Only addresses from these domains can use email-domain sign-up.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsx(TextField, { label: t('Auth.ALLOWED_EMAIL_DOMAINS_LABEL', 'Allowed email domains'), helperText: t('Auth.ALLOWED_EMAIL_DOMAINS_HINT', 'One domain per line, e.g. example.org. You can leave this empty temporarily.'), multiline: true, minRows: 4, fullWidth: true, value: domainsText, onChange: (event) => setDomainsText(event.target.value), disabled: busy }), _jsx(Button, { variant: "contained", sx: { mt: 2 }, onClick: handleSave, disabled: busy, children: t('Common.SAVE', 'Save') })] }));
42
+ return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.ALLOWED_EMAIL_DOMAINS_TITLE', 'Allowed Email Domains') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.ALLOWED_EMAIL_DOMAINS_CARD_HINT', 'Only addresses from these domains can use email-domain sign-up.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsx(TextField, { label: t('Auth.ALLOWED_EMAIL_DOMAINS_LABEL', 'Allowed email domains'), helperText: t('Auth.ALLOWED_EMAIL_DOMAINS_HINT', 'One domain per line, e.g. example.org. You can leave this empty temporarily.'), multiline: true, minRows: 4, fullWidth: true, value: domainsText, onChange: (event) => setDomainsText(event.target.value), disabled: busy || !canEdit }), _jsx(Button, { variant: "contained", sx: { mt: 2 }, onClick: handleSave, disabled: busy || !canEdit, children: t('Common.SAVE', 'Save') })] }));
43
43
  }
@@ -3,13 +3,17 @@ import React, { useEffect, useState } from 'react';
3
3
  import { Alert, Box, FormControl, FormControlLabel, Radio, RadioGroup, Typography, } from '@mui/material';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { fetchAuthPolicy, updateAuthPolicy } from '../auth/authApi';
6
- export function AuthFactorRequirementCard() {
6
+ export function AuthFactorRequirementCard({ canEdit = true, policy = null }) {
7
7
  const { t } = useTranslation();
8
8
  const [value, setValue] = useState('1');
9
9
  const [busy, setBusy] = useState(false);
10
10
  const [error, setError] = useState('');
11
11
  const [success, setSuccess] = useState('');
12
12
  useEffect(() => {
13
+ if (policy) {
14
+ setValue(String((policy === null || policy === void 0 ? void 0 : policy.required_auth_factor_count) || 1));
15
+ return undefined;
16
+ }
13
17
  let active = true;
14
18
  (async () => {
15
19
  try {
@@ -25,8 +29,11 @@ export function AuthFactorRequirementCard() {
25
29
  return () => {
26
30
  active = false;
27
31
  };
28
- }, []);
32
+ }, [policy]);
29
33
  const handleChange = async (event) => {
34
+ if (!canEdit) {
35
+ return;
36
+ }
30
37
  const nextValue = event.target.value;
31
38
  const previous = value;
32
39
  setValue(nextValue);
@@ -45,5 +52,5 @@ export function AuthFactorRequirementCard() {
45
52
  setBusy(false);
46
53
  }
47
54
  };
48
- return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.AUTH_FACTOR_TITLE', 'Authentication Factors') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.AUTH_FACTOR_HINT', 'Choose whether one factor is enough or two factors are required.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsx(FormControl, { children: _jsxs(RadioGroup, { value: value, onChange: handleChange, children: [_jsx(FormControlLabel, { value: "1", control: _jsx(Radio, { disabled: busy }), label: t('Auth.ONE_FACTOR_LABEL', 'One factor is enough') }), _jsx(FormControlLabel, { value: "2", control: _jsx(Radio, { disabled: busy }), label: t('Auth.TWO_FACTOR_LABEL', 'Two factors are required') })] }) })] }));
55
+ return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.AUTH_FACTOR_TITLE', 'Authentication Requirements') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.AUTH_FACTOR_HINT', 'Define the minimum number of authentication factors required for sign-in.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsx(FormControl, { children: _jsxs(RadioGroup, { value: value, onChange: handleChange, children: [_jsx(FormControlLabel, { value: "1", control: _jsx(Radio, { disabled: busy || !canEdit }), label: t('Auth.ONE_FACTOR_LABEL', 'Allow single-factor authentication') }), _jsx(FormControlLabel, { value: "2", control: _jsx(Radio, { disabled: busy || !canEdit }), label: t('Auth.TWO_FACTOR_LABEL', 'Require two-factor authentication') })] }) })] }));
49
56
  }
@@ -11,7 +11,7 @@ function clampExpiryDays(value) {
11
11
  }
12
12
  return parsed;
13
13
  }
14
- export function QrSignupValidityManager({ enabled = false, expiryDays = DEFAULT_EXPIRY_DAYS, onPolicyChange, }) {
14
+ export function QrSignupValidityManager({ enabled = false, expiryDays = DEFAULT_EXPIRY_DAYS, onPolicyChange, canEdit = true, }) {
15
15
  const { t } = useTranslation();
16
16
  const [currentExpiryDays, setCurrentExpiryDays] = useState(String(expiryDays || DEFAULT_EXPIRY_DAYS));
17
17
  const [busy, setBusy] = useState(false);
@@ -44,5 +44,5 @@ export function QrSignupValidityManager({ enabled = false, expiryDays = DEFAULT_
44
44
  if (!enabled) {
45
45
  return null;
46
46
  }
47
- return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.SIGNUP_QR_VALIDITY_TITLE', 'QR Signup Validity') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.SIGNUP_QR_VALIDITY_HINT', 'Set the default validity for newly generated QR signup links.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsxs(Stack, { direction: { xs: 'column', sm: 'row' }, spacing: 1.5, alignItems: { sm: 'flex-start' }, children: [_jsx(TextField, { label: t('Auth.SIGNUP_QR_EXPIRY_DAYS_LABEL', 'QR signup validity (days)'), helperText: t('Auth.SIGNUP_QR_EXPIRY_DAYS_HINT', 'Default validity for newly generated QR signup links.'), type: "number", value: currentExpiryDays, onChange: (event) => setCurrentExpiryDays(event.target.value), disabled: busy, sx: { flex: 1 } }), _jsx(Button, { variant: "contained", onClick: handleSave, disabled: busy, sx: { minWidth: 120, mt: { sm: '8px' } }, children: t('Common.SAVE', 'Save') })] })] }));
47
+ return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.SIGNUP_QR_VALIDITY_TITLE', 'QR Signup Validity') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.SIGNUP_QR_VALIDITY_HINT', 'Set the default validity for newly generated QR signup links.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsxs(Stack, { direction: { xs: 'column', sm: 'row' }, spacing: 1.5, alignItems: { sm: 'flex-start' }, children: [_jsx(TextField, { label: t('Auth.SIGNUP_QR_EXPIRY_DAYS_LABEL', 'QR signup validity (days)'), helperText: t('Auth.SIGNUP_QR_EXPIRY_DAYS_HINT', 'Default validity for newly generated QR signup links.'), type: "number", value: currentExpiryDays, onChange: (event) => setCurrentExpiryDays(event.target.value), disabled: busy || !canEdit, sx: { flex: 1 } }), _jsx(Button, { variant: "contained", onClick: handleSave, disabled: busy || !canEdit, sx: { minWidth: 120, mt: { sm: '8px' } }, children: t('Common.SAVE', 'Save') })] })] }));
48
48
  }
@@ -13,7 +13,7 @@ const EMPTY_POLICY = {
13
13
  signup_qr_expiry_days: 90,
14
14
  required_auth_factor_count: 1,
15
15
  };
16
- export function RegistrationMethodsManager({ policy: authPolicy, error = '', onPolicyChange, }) {
16
+ export function RegistrationMethodsManager({ policy: authPolicy, error = '', onPolicyChange, canEdit = true, }) {
17
17
  const { t } = useTranslation();
18
18
  const [policyState, setPolicyState] = useState(EMPTY_POLICY);
19
19
  const [busyField, setBusyField] = useState('');
@@ -49,5 +49,5 @@ export function RegistrationMethodsManager({ policy: authPolicy, error = '', onP
49
49
  if (!authPolicy && !error) {
50
50
  return (_jsx(Box, { sx: { py: 3, display: 'flex', justifyContent: 'center' }, children: _jsx(CircularProgress, { size: 28 }) }));
51
51
  }
52
- return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.REGISTRATION_METHODS_TITLE', 'Registration Methods') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.REGISTRATION_METHODS_HINT', 'Choose which signup and invite flows are active for this app.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), saveError && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: saveError }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsxs(Stack, { spacing: 1, children: [_jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policyState.allow_admin_invite), onChange: toggle('allow_admin_invite'), disabled: Boolean(busyField) })), label: t('Auth.ADMIN_INVITE_LABEL', 'Admin invite') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policyState.allow_self_signup_access_code), onChange: toggle('allow_self_signup_access_code'), disabled: Boolean(busyField) })), label: t('Auth.SIGNUP_ACCESS_CODE_LABEL', 'Self-signup with access code') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policyState.allow_self_signup_open), onChange: toggle('allow_self_signup_open'), disabled: Boolean(busyField) })), label: t('Auth.SIGNUP_OPEN_LABEL', 'Open self-signup') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policyState.allow_self_signup_email_domain), onChange: toggle('allow_self_signup_email_domain'), disabled: Boolean(busyField) })), label: t('Auth.SIGNUP_EMAIL_DOMAIN_LABEL', 'Self-signup by email domain') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policyState.allow_self_signup_qr), onChange: toggle('allow_self_signup_qr'), disabled: Boolean(busyField) })), label: t('Auth.SIGNUP_QR_LABEL', 'Self-signup by QR') })] }), policyState.allow_self_signup_email_domain && !(policyState.allowed_email_domains || []).length && (_jsx(Alert, { severity: "info", sx: { mt: 2 }, children: t('Auth.EMAIL_DOMAIN_CURRENTLY_BLOCKED_HINT', 'Email-domain signup is enabled, but it stays blocked until at least one allowed domain is saved.') }))] }));
52
+ return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.REGISTRATION_METHODS_TITLE', 'Registration Methods') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Auth.REGISTRATION_METHODS_HINT', 'Choose which signup and invite flows are active for this app.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), saveError && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: saveError }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsxs(Stack, { spacing: 1, children: [_jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policyState.allow_admin_invite), onChange: toggle('allow_admin_invite'), disabled: Boolean(busyField) || !canEdit })), label: t('Auth.ADMIN_INVITE_LABEL', 'Admin invite') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policyState.allow_self_signup_access_code), onChange: toggle('allow_self_signup_access_code'), disabled: Boolean(busyField) || !canEdit })), label: t('Auth.SIGNUP_ACCESS_CODE_LABEL', 'Self-signup with access code') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policyState.allow_self_signup_open), onChange: toggle('allow_self_signup_open'), disabled: Boolean(busyField) || !canEdit })), label: t('Auth.SIGNUP_OPEN_LABEL', 'Open self-signup') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policyState.allow_self_signup_email_domain), onChange: toggle('allow_self_signup_email_domain'), disabled: Boolean(busyField) || !canEdit })), label: t('Auth.SIGNUP_EMAIL_DOMAIN_LABEL', 'Self-signup by email domain') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(policyState.allow_self_signup_qr), onChange: toggle('allow_self_signup_qr'), disabled: Boolean(busyField) || !canEdit })), label: t('Auth.SIGNUP_QR_LABEL', 'Self-signup by QR') })] }), policyState.allow_self_signup_email_domain && !(policyState.allowed_email_domains || []).length && (_jsx(Alert, { severity: "info", sx: { mt: 2 }, children: t('Auth.EMAIL_DOMAIN_CURRENTLY_BLOCKED_HINT', 'Email-domain signup is enabled, but it stays blocked until at least one allowed domain is saved.') }))] }));
53
53
  }
@@ -1,11 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // src/components/SupportRecoveryRequestsTab.jsx
3
- import React, { useEffect, useState } from 'react';
4
- import { Box, Typography, Table, TableHead, TableBody, TableRow, TableCell, TableContainer, Paper, Button, CircularProgress, Alert, Stack, Dialog, DialogTitle, DialogContent, DialogActions, TextField, } from '@mui/material';
3
+ import React, { useContext, useEffect, useState } from 'react';
4
+ import { Alert, Autocomplete, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Paper, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, } from '@mui/material';
5
+ import CloseIcon from '@mui/icons-material/Close';
5
6
  import { useTranslation } from 'react-i18next';
6
- import { fetchRecoveryRequests, approveRecoveryRequest, rejectRecoveryRequest } from '../auth/authApi';
7
+ import { AuthContext } from '../auth/AuthContext';
8
+ import { approveRecoveryRequest, fetchRecoveryRequests, fetchUsersList, rejectRecoveryRequest, updateUserSupportStatus, } from '../auth/authApi';
7
9
  export function SupportRecoveryRequestsTab() {
8
10
  const { t } = useTranslation();
11
+ const { user } = useContext(AuthContext);
12
+ const canManageAgents = Boolean((user === null || user === void 0 ? void 0 : user.is_superuser) || (user === null || user === void 0 ? void 0 : user.can_manage_support_agents));
9
13
  const [requests, setRequests] = useState([]);
10
14
  const [loading, setLoading] = useState(true);
11
15
  const [errorKey, setErrorKey] = useState(null);
@@ -13,6 +17,11 @@ export function SupportRecoveryRequestsTab() {
13
17
  const [dialogOpen, setDialogOpen] = useState(false);
14
18
  const [dialogNote, setDialogNote] = useState('');
15
19
  const [selectedRequest, setSelectedRequest] = useState(null);
20
+ const [allUsers, setAllUsers] = useState([]);
21
+ const [agentLoading, setAgentLoading] = useState(false);
22
+ const [agentErrorKey, setAgentErrorKey] = useState(null);
23
+ const [selectedAgentCandidate, setSelectedAgentCandidate] = useState(null);
24
+ const [agentActionUserId, setAgentActionUserId] = useState(null);
16
25
  const loadRequests = async () => {
17
26
  setLoading(true);
18
27
  setErrorKey(null);
@@ -27,10 +36,39 @@ export function SupportRecoveryRequestsTab() {
27
36
  setLoading(false);
28
37
  }
29
38
  };
39
+ const loadUsers = async () => {
40
+ if (!canManageAgents) {
41
+ setAllUsers([]);
42
+ setAgentErrorKey(null);
43
+ return;
44
+ }
45
+ setAgentLoading(true);
46
+ setAgentErrorKey(null);
47
+ try {
48
+ const data = await fetchUsersList();
49
+ const list = Array.isArray(data) ? data : (Array.isArray(data === null || data === void 0 ? void 0 : data.results) ? data.results : []);
50
+ list.sort((a, b) => {
51
+ const emailA = String((a === null || a === void 0 ? void 0 : a.email) || '').toLocaleLowerCase();
52
+ const emailB = String((b === null || b === void 0 ? void 0 : b.email) || '').toLocaleLowerCase();
53
+ return emailA.localeCompare(emailB);
54
+ });
55
+ setAllUsers(list);
56
+ }
57
+ catch (err) {
58
+ setAgentErrorKey(err.code || 'Auth.USER_LIST_FAILED');
59
+ }
60
+ finally {
61
+ setAgentLoading(false);
62
+ }
63
+ };
30
64
  useEffect(() => {
31
65
  loadRequests();
32
66
  // eslint-disable-next-line react-hooks/exhaustive-deps
33
67
  }, [statusFilter]);
68
+ useEffect(() => {
69
+ loadUsers();
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, [canManageAgents]);
34
72
  const openDialog = (req) => {
35
73
  setSelectedRequest(req);
36
74
  setDialogNote('');
@@ -67,10 +105,62 @@ export function SupportRecoveryRequestsTab() {
67
105
  setErrorKey(err.code || 'Support.RECOVERY_REQUEST_REJECT_FAILED');
68
106
  }
69
107
  };
108
+ const getUserDisplayName = (entry) => {
109
+ if ((entry === null || entry === void 0 ? void 0 : entry.first_name) || (entry === null || entry === void 0 ? void 0 : entry.last_name)) {
110
+ return `${entry.first_name || ''} ${entry.last_name || ''}`.trim();
111
+ }
112
+ return (entry === null || entry === void 0 ? void 0 : entry.username) || '';
113
+ };
114
+ const getUserOptionLabel = (entry) => {
115
+ if (!entry)
116
+ return '';
117
+ const name = getUserDisplayName(entry);
118
+ if (name && (entry === null || entry === void 0 ? void 0 : entry.email))
119
+ return `${name} (${entry.email})`;
120
+ return (entry === null || entry === void 0 ? void 0 : entry.email) || name || `#${(entry === null || entry === void 0 ? void 0 : entry.id) || ''}`;
121
+ };
122
+ const supportAgents = allUsers.filter((entry) => Boolean(entry === null || entry === void 0 ? void 0 : entry.is_support_agent));
123
+ const availableAgentCandidates = allUsers.filter((entry) => !(entry === null || entry === void 0 ? void 0 : entry.is_support_agent));
124
+ const handleAddAgent = async () => {
125
+ if (!selectedAgentCandidate)
126
+ return;
127
+ setAgentActionUserId(selectedAgentCandidate.id);
128
+ setAgentErrorKey(null);
129
+ try {
130
+ await updateUserSupportStatus(selectedAgentCandidate.id, true);
131
+ setSelectedAgentCandidate(null);
132
+ await loadUsers();
133
+ }
134
+ catch (err) {
135
+ setAgentErrorKey(err.code || 'Auth.USER_SUPPORT_UPDATE_FAILED');
136
+ }
137
+ finally {
138
+ setAgentActionUserId(null);
139
+ }
140
+ };
141
+ const handleRemoveAgent = async (agentUser) => {
142
+ if (!(agentUser === null || agentUser === void 0 ? void 0 : agentUser.id))
143
+ return;
144
+ setAgentActionUserId(agentUser.id);
145
+ setAgentErrorKey(null);
146
+ try {
147
+ await updateUserSupportStatus(agentUser.id, false);
148
+ if ((selectedAgentCandidate === null || selectedAgentCandidate === void 0 ? void 0 : selectedAgentCandidate.id) === agentUser.id) {
149
+ setSelectedAgentCandidate(null);
150
+ }
151
+ await loadUsers();
152
+ }
153
+ catch (err) {
154
+ setAgentErrorKey(err.code || 'Auth.USER_SUPPORT_UPDATE_FAILED');
155
+ }
156
+ finally {
157
+ setAgentActionUserId(null);
158
+ }
159
+ };
70
160
  if (loading) {
71
161
  return (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', mt: 4 }, children: _jsx(CircularProgress, {}) }));
72
162
  }
73
- return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Support.RECOVERY_REQUESTS_TITLE', 'Account recovery requests') }), _jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: t('Support.RECOVERY_REQUESTS_DESCRIPTION', 'Users who cannot complete MFA can request support. You can review their request and approve or reject it.') }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), _jsxs(Stack, { direction: "row", spacing: 2, sx: { mb: 2 }, children: [_jsx(Button, { variant: statusFilter === 'pending' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('pending'), children: t('Support.RECOVERY_FILTER_PENDING', 'Open') }), _jsx(Button, { variant: statusFilter === 'approved' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('approved'), children: t('Support.RECOVERY_FILTER_APPROVED', 'Approved') }), _jsx(Button, { variant: statusFilter === 'rejected' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('rejected'), children: t('Support.RECOVERY_FILTER_REJECTED', 'Rejected') })] }), requests.length === 0 ? (_jsx(Typography, { variant: "body2", children: t('Support.RECOVERY_REQUESTS_EMPTY', 'No recovery requests for this filter.') })) : (_jsx(TableContainer, { component: Paper, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('Support.RECOVERY_COL_CREATED', 'Created') }), _jsx(TableCell, { children: t('Support.RECOVERY_USER', 'User') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_STATUS', 'Status') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_ACTIONS', 'Actions') })] }) }), _jsx(TableBody, { children: requests.map((req) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: req.created_at
163
+ return (_jsxs(Box, { children: [canManageAgents && (_jsxs(Paper, { variant: "outlined", sx: { p: 2, mb: 3, borderRadius: 2 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Support.AGENTS_TITLE', 'Support agents') }), _jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: t('Support.AGENTS_DESCRIPTION', 'Assign which users are allowed to handle support requests.') }), agentErrorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(agentErrorKey) })), _jsxs(Stack, { direction: { xs: 'column', md: 'row' }, spacing: 2, sx: { mb: 2, alignItems: { md: 'flex-start' } }, children: [_jsx(Autocomplete, { fullWidth: true, options: availableAgentCandidates, value: selectedAgentCandidate, loading: agentLoading, onChange: (_event, value) => setSelectedAgentCandidate(value), isOptionEqualToValue: (option, value) => option.id === value.id, getOptionLabel: getUserOptionLabel, renderInput: (params) => (_jsx(TextField, Object.assign({}, params, { label: t('Support.AGENTS_SELECT_LABEL', 'Select user'), placeholder: t('Support.AGENTS_SELECT_PLACEHOLDER', 'Choose a user') }))) }), _jsx(Button, { variant: "contained", onClick: handleAddAgent, disabled: !selectedAgentCandidate || agentLoading || Boolean(agentActionUserId), sx: { minWidth: 160 }, children: t('Support.AGENTS_ADD', 'Add as agent') })] }), agentLoading ? (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', py: 3 }, children: _jsx(CircularProgress, { size: 24 }) })) : supportAgents.length === 0 ? (_jsx(Typography, { variant: "body2", children: t('Support.AGENTS_EMPTY', 'No support agents assigned yet.') })) : (_jsx(TableContainer, { component: Paper, variant: "outlined", children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('Profile.NAME_LABEL', 'Name') }), _jsx(TableCell, { children: t('Auth.EMAIL_LABEL', 'Email') }), _jsx(TableCell, { align: "right", children: t('Common.ACTIONS', 'Actions') })] }) }), _jsx(TableBody, { children: supportAgents.map((agentUser) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: getUserDisplayName(agentUser) || '-' }), _jsx(TableCell, { children: agentUser.email || '-' }), _jsx(TableCell, { align: "right", children: _jsx(IconButton, { size: "small", color: "error", onClick: () => handleRemoveAgent(agentUser), disabled: agentActionUserId === agentUser.id, "aria-label": t('Support.AGENTS_REMOVE', 'Remove support agent'), children: _jsx(CloseIcon, { fontSize: "small" }) }) })] }, agentUser.id))) })] }) }))] })), _jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Support.RECOVERY_REQUESTS_TITLE', 'Account recovery requests') }), _jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: t('Support.RECOVERY_REQUESTS_DESCRIPTION', 'Users who cannot complete MFA can request support. You can review their request and approve or reject it.') }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), _jsxs(Stack, { direction: "row", spacing: 2, sx: { mb: 2 }, children: [_jsx(Button, { variant: statusFilter === 'pending' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('pending'), children: t('Support.RECOVERY_FILTER_PENDING', 'Open') }), _jsx(Button, { variant: statusFilter === 'approved' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('approved'), children: t('Support.RECOVERY_FILTER_APPROVED', 'Approved') }), _jsx(Button, { variant: statusFilter === 'rejected' ? 'contained' : 'outlined', size: "small", onClick: () => setStatusFilter('rejected'), children: t('Support.RECOVERY_FILTER_REJECTED', 'Rejected') })] }), requests.length === 0 ? (_jsx(Typography, { variant: "body2", children: t('Support.RECOVERY_REQUESTS_EMPTY', 'No recovery requests for this filter.') })) : (_jsx(TableContainer, { component: Paper, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('Support.RECOVERY_COL_CREATED', 'Created') }), _jsx(TableCell, { children: t('Support.RECOVERY_USER', 'User') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_STATUS', 'Status') }), _jsx(TableCell, { children: t('Support.RECOVERY_COL_ACTIONS', 'Actions') })] }) }), _jsx(TableBody, { children: requests.map((req) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: req.created_at
74
164
  ? new Date(req.created_at).toLocaleString()
75
165
  : '-' }), _jsx(TableCell, { children: req.user_email || req.user }), _jsx(TableCell, { children: t(`Support.RECOVERY_STATUS_${req.status}`, req.status) }), _jsx(TableCell, { children: _jsx(Button, { variant: "contained", size: "small", onClick: () => openDialog(req), disabled: req.status !== 'pending', children: t('Support.RECOVERY_ACTION_REVIEW', 'Review') }) })] }, req.id))) })] }) })), _jsxs(Dialog, { open: dialogOpen, onClose: closeDialog, fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('Support.RECOVERY_REVIEW_DIALOG_TITLE', 'Review recovery request') }), _jsxs(DialogContent, { dividers: true, children: [selectedRequest && (_jsxs(Box, { sx: { mb: 2 }, children: [_jsxs(Typography, { variant: "body2", sx: { mb: 1 }, children: [_jsxs("strong", { children: [t('Support.RECOVERY_USER', 'User'), ":"] }), ' ', selectedRequest.user_email || selectedRequest.user] }), _jsxs(Typography, { variant: "body2", sx: { mb: 1 }, children: [_jsxs("strong", { children: [t('Support.RECOVERY_REVIEW_CREATED', 'Requested at'), ":"] }), ' ', selectedRequest.created_at
76
166
  ? new Date(selectedRequest.created_at).toLocaleString()
@@ -4,7 +4,7 @@ import { Box, Typography, FormControl, Select, MenuItem, Button, Tooltip, Circul
4
4
  import { DataGrid } from '@mui/x-data-grid';
5
5
  import DeleteIcon from '@mui/icons-material/Delete';
6
6
  import { useTranslation } from 'react-i18next';
7
- import { fetchUsersList, deleteUser, updateUserRole, updateUserSupportStatus } from '../auth/authApi';
7
+ import { fetchUsersList, deleteUser, updateUserRole } from '../auth/authApi';
8
8
  const DEFAULT_ROLES = ['none', 'student', 'teacher', 'admin'];
9
9
  export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraColumns = [], extraRowActions = [], extraContext = null, refreshTrigger = 0, canEditUser, }) {
10
10
  const { t } = useTranslation();
@@ -60,15 +60,6 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
60
60
  alert(t(err.code || 'Auth.USER_ROLE_UPDATE_FAILED'));
61
61
  }
62
62
  };
63
- const handleToggleSupporter = async (userId, newValue) => {
64
- try {
65
- await updateUserSupportStatus(userId, newValue);
66
- await loadUsers();
67
- }
68
- catch (err) {
69
- alert(t(err.code || 'Auth.USER_UPDATE_FAILED'));
70
- }
71
- };
72
63
  const defaultCanEdit = (targetUser) => {
73
64
  if (!currentUser)
74
65
  return false;
@@ -142,6 +133,8 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
142
133
  return String(value).toLocaleLowerCase();
143
134
  };
144
135
  const getSearchBucketForUser = (user) => {
136
+ var _a;
137
+ const hasSuccessfulLogin = Boolean((_a = user === null || user === void 0 ? void 0 : user.successful_login) !== null && _a !== void 0 ? _a : user === null || user === void 0 ? void 0 : user.last_login);
145
138
  const baseValues = [
146
139
  user === null || user === void 0 ? void 0 : user.id,
147
140
  user === null || user === void 0 ? void 0 : user.email,
@@ -151,7 +144,7 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
151
144
  getUserDisplayName(user),
152
145
  user === null || user === void 0 ? void 0 : user.role,
153
146
  user === null || user === void 0 ? void 0 : user.language,
154
- (user === null || user === void 0 ? void 0 : user.is_support_agent) ? t('Common.YES', 'Yes') : t('Common.NO', 'No'),
147
+ hasSuccessfulLogin ? t('Common.YES', 'Yes') : t('Common.NO', 'No'),
155
148
  ];
156
149
  const extraValues = visibleExtraColumns.map((column) => getExtraColumnSearchValue(column, user));
157
150
  return [...baseValues, ...extraValues].map((value) => toSearchText(value)).filter(Boolean);
@@ -202,25 +195,27 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
202
195
  valueGetter: (_value, row) => getUserDisplayName(row),
203
196
  },
204
197
  {
205
- field: 'role',
206
- headerName: t('UserList.ROLE', 'Role'),
198
+ field: 'successful_login',
199
+ headerName: t('UserList.SUCCESSFUL_LOGIN', 'Successful Login'),
207
200
  minWidth: 180,
208
201
  flex: 0.8,
209
- valueGetter: (_value, row) => (row === null || row === void 0 ? void 0 : row.role) || 'none',
202
+ valueGetter: (_value, row) => { var _a; return Boolean((_a = row === null || row === void 0 ? void 0 : row.successful_login) !== null && _a !== void 0 ? _a : row === null || row === void 0 ? void 0 : row.last_login); },
210
203
  renderCell: (params) => {
211
- const user = params.row;
212
- return (_jsx(FormControl, { size: "small", fullWidth: true, sx: controlSx, disabled: !canEdit(user), children: _jsx(Select, { value: user.role || 'none', onChange: (event) => handleChangeRole(user.id, event.target.value), variant: "outlined", children: roles.map((role) => _jsx(MenuItem, { value: role, children: role }, role)) }) }));
204
+ var _a, _b, _c;
205
+ return (_jsx(Typography, { variant: "body2", children: Boolean((_b = (_a = params.row) === null || _a === void 0 ? void 0 : _a.successful_login) !== null && _b !== void 0 ? _b : (_c = params.row) === null || _c === void 0 ? void 0 : _c.last_login)
206
+ ? t('Common.YES', 'Yes')
207
+ : t('Common.NO', 'No') }));
213
208
  },
214
209
  },
215
210
  {
216
- field: 'is_support_agent',
217
- headerName: t('UserList.SUPPORTER', 'Support Agent'),
218
- minWidth: 190,
211
+ field: 'role',
212
+ headerName: t('UserList.ROLE', 'Role'),
213
+ minWidth: 180,
219
214
  flex: 0.8,
220
- valueGetter: (_value, row) => Boolean(row === null || row === void 0 ? void 0 : row.is_support_agent),
215
+ valueGetter: (_value, row) => (row === null || row === void 0 ? void 0 : row.role) || 'none',
221
216
  renderCell: (params) => {
222
217
  const user = params.row;
223
- return (_jsx(FormControl, { size: "small", fullWidth: true, sx: controlSx, disabled: !canEdit(user), children: _jsxs(Select, { value: user.is_support_agent ? 'yes' : 'no', onChange: (event) => handleToggleSupporter(user.id, event.target.value === 'yes'), variant: "outlined", children: [_jsx(MenuItem, { value: "yes", children: t('Common.YES', 'Yes') }), _jsx(MenuItem, { value: "no", children: t('Common.NO', 'No') })] }) }));
218
+ return (_jsx(FormControl, { size: "small", fullWidth: true, sx: controlSx, disabled: !canEdit(user), children: _jsx(Select, { value: user.role || 'none', onChange: (event) => handleChangeRole(user.id, event.target.value), variant: "outlined", children: roles.map((role) => _jsx(MenuItem, { value: role, children: role }, role)) }) }));
224
219
  },
225
220
  },
226
221
  ];
@@ -1290,6 +1290,36 @@ export const authTranslations = {
1290
1290
  "en": "Authentication settings saved.",
1291
1291
  "sw": "Mipangilio ya uthibitishaji imehifadhiwa."
1292
1292
  },
1293
+ "Auth.AUTH_FACTOR_TITLE": {
1294
+ "de": "Authentifizierungsanforderungen",
1295
+ "fr": "Exigences d'authentification",
1296
+ "en": "Authentication Requirements",
1297
+ "sw": "Mahitaji ya uthibitishaji"
1298
+ },
1299
+ "Auth.AUTH_FACTOR_HINT": {
1300
+ "de": "Legen Sie fest, wie viele Authentifizierungsfaktoren mindestens für die Anmeldung erforderlich sind.",
1301
+ "fr": "Définissez le nombre minimal de facteurs d'authentification requis pour la connexion.",
1302
+ "en": "Define the minimum number of authentication factors required for sign-in.",
1303
+ "sw": "Weka idadi ya chini ya vipengele vya uthibitishaji vinavyohitajika ili kuingia."
1304
+ },
1305
+ "Auth.ONE_FACTOR_LABEL": {
1306
+ "de": "Single-Factor-Authentifizierung erlauben",
1307
+ "fr": "Autoriser l'authentification à un facteur",
1308
+ "en": "Allow single-factor authentication",
1309
+ "sw": "Ruhusu uthibitishaji wa kipengele kimoja"
1310
+ },
1311
+ "Auth.TWO_FACTOR_LABEL": {
1312
+ "de": "Zwei-Faktor-Authentifizierung erzwingen",
1313
+ "fr": "Exiger l'authentification à deux facteurs",
1314
+ "en": "Require two-factor authentication",
1315
+ "sw": "Hitaji uthibitishaji wa vipengele viwili"
1316
+ },
1317
+ "Auth.AUTH_FACTOR_SAVE_SUCCESS": {
1318
+ "de": "Die Anforderung für Authentifizierungsfaktoren wurde gespeichert.",
1319
+ "fr": "L'exigence relative aux facteurs d'authentification a été enregistrée.",
1320
+ "en": "Factor requirement saved.",
1321
+ "sw": "Hitaji la vipengele vya uthibitishaji limehifadhiwa."
1322
+ },
1293
1323
  "Auth.REGISTRATION_METHODS_TITLE": {
1294
1324
  "de": "Registrierungsmethoden",
1295
1325
  "fr": "Méthodes d'inscription",
@@ -40,6 +40,13 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
40
40
  const perms = (user === null || user === void 0 ? void 0 : user.ui_permissions) || {};
41
41
  // NEU: Superuser-Flag prüfen
42
42
  const isSuperUser = (user === null || user === void 0 ? void 0 : user.is_superuser) || false;
43
+ const canViewUsers = Boolean(isSuperUser || perms.can_view_users);
44
+ const canViewInvite = Boolean(isSuperUser || perms.can_view_invite || perms.can_invite);
45
+ const canViewAuthPolicy = Boolean(isSuperUser || perms.can_view_auth_policy);
46
+ const canWriteAuthPolicy = Boolean(isSuperUser || perms.can_write_auth_policy);
47
+ const canSendInvites = Boolean(isSuperUser || perms.can_send_invites);
48
+ const canManageAccessCodes = Boolean(isSuperUser || perms.can_manage_access_codes);
49
+ const canManageSignupQr = Boolean(isSuperUser || perms.can_manage_signup_qr);
43
50
  const handleTabChange = (_event, newValue) => {
44
51
  setSearchParams({ tab: newValue });
45
52
  };
@@ -49,7 +56,7 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
49
56
  };
50
57
  useEffect(() => {
51
58
  let active = true;
52
- const canLoadPolicy = Boolean(user) && (isSuperUser || perms.can_invite || perms.can_manage_access_codes);
59
+ const canLoadPolicy = Boolean(user) && (canViewAuthPolicy || canManageSignupQr || canManageAccessCodes);
53
60
  if (!canLoadPolicy) {
54
61
  setAuthPolicy(null);
55
62
  setAuthPolicyError('');
@@ -74,7 +81,7 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
74
81
  return () => {
75
82
  active = false;
76
83
  };
77
- }, [user, isSuperUser, perms.can_invite, perms.can_manage_access_codes, t]);
84
+ }, [user, canViewAuthPolicy, canManageSignupQr, canManageAccessCodes, t]);
78
85
  // 3. Dynamic Tabs (angepasst für Superuser)
79
86
  const tabs = useMemo(() => {
80
87
  if (!user)
@@ -85,10 +92,10 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
85
92
  { value: 'security', label: t('Account.TAB_SECURITY', 'Security') },
86
93
  ];
87
94
  // Logik: Superuser darf ALLES, sonst gelten die spezifischen Permissions
88
- if (isSuperUser || perms.can_view_users) {
95
+ if (canViewUsers) {
89
96
  list.push({ value: 'users', label: t('Account.TAB_USERS', 'Users') });
90
97
  }
91
- if (isSuperUser || perms.can_invite || perms.can_manage_access_codes) {
98
+ if (canViewInvite) {
92
99
  list.push({ value: 'invite', label: t('Account.TAB_INVITE', 'Invite') });
93
100
  }
94
101
  if (isSuperUser || perms.can_view_support) {
@@ -106,7 +113,7 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
106
113
  }
107
114
  });
108
115
  return list;
109
- }, [user, perms, t, isSuperUser, extraTabs]);
116
+ }, [user, perms, t, isSuperUser, extraTabs, canViewUsers, canViewInvite]);
110
117
  // 4. Loading & Auth Checks
111
118
  if (loading) {
112
119
  return (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', mt: 10 }, children: _jsx(CircularProgress, {}) }));
@@ -122,5 +129,5 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
122
129
  const activeExtraTab = builtInTabValues.has(safeTab)
123
130
  ? null
124
131
  : extraTabs.find((tab) => tab.value === safeTab);
125
- 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(RegistrationMethodsManager, { policy: authPolicy, error: authPolicyError, onPolicyChange: setAuthPolicy }) })), (isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(AuthFactorRequirementCard, {}) })), (isSuperUser || perms.can_invite) && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_admin_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(UserInviteComponent, {}) })), (isSuperUser || perms.can_manage_access_codes) && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_access_code) && (_jsxs(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.ACCESS_CODE_MANAGER_TITLE', 'Access Codes') }), _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 && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_admin_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(BulkInviteCsvTab, Object.assign({}, bulkInviteCsvProps)) })), (isSuperUser || perms.can_invite) && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_email_domain) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(AllowedEmailDomainsManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_email_domain), domains: (authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allowed_email_domains) || [], onPolicyChange: setAuthPolicy }) })), (isSuperUser || perms.can_invite) && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(QrSignupValidityManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr), expiryDays: authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.signup_qr_expiry_days, onPolicyChange: setAuthPolicy }) })), (isSuperUser || perms.can_invite) && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(QrSignupManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr), expiryDays: authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.signup_qr_expiry_days }) }))] }) })), 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 }) }))] }));
132
+ 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: [canViewAuthPolicy && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(AuthFactorRequirementCard, { canEdit: canWriteAuthPolicy, policy: authPolicy }) })), canViewAuthPolicy && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(RegistrationMethodsManager, { policy: authPolicy, error: authPolicyError, onPolicyChange: setAuthPolicy, canEdit: canWriteAuthPolicy }) })), canSendInvites && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_admin_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(UserInviteComponent, {}) })), canManageAccessCodes && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_access_code) && (_jsxs(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.ACCESS_CODE_MANAGER_TITLE', 'Access Codes') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.') }), _jsx(AccessCodeManager, {})] })), canSendInvites && showBulkInviteCsvTab && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_admin_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(BulkInviteCsvTab, Object.assign({}, bulkInviteCsvProps)) })), canViewAuthPolicy && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_email_domain) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(AllowedEmailDomainsManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_email_domain), domains: (authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allowed_email_domains) || [], onPolicyChange: setAuthPolicy, canEdit: canWriteAuthPolicy }) })), canViewAuthPolicy && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(QrSignupValidityManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr), expiryDays: authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.signup_qr_expiry_days, onPolicyChange: setAuthPolicy, canEdit: canWriteAuthPolicy }) })), canManageSignupQr && Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(QrSignupManager, { enabled: Boolean(authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.allow_self_signup_qr), expiryDays: authPolicy === null || authPolicy === void 0 ? void 0 : authPolicy.signup_qr_expiry_days }) }))] }) })), 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 }) }))] }));
126
133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.2.5",
3
+ "version": "2.2.7",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "private": false,
@@ -20,6 +20,7 @@
20
20
  "build": "tsc -p tsconfig.build.json"
21
21
  },
22
22
  "devDependencies": {
23
+ "typescript": "^5.9.3"
23
24
  },
24
25
  "dependencies": {
25
26
  "react-i18next": "^16.3.5"
@@ -64,6 +64,7 @@ export const AuthProvider = ({ children }) => {
64
64
  can_manage_support_agents: Boolean(data?.can_manage_support_agents),
65
65
  can_manage: Boolean(data?.can_manage),
66
66
  is_active: data?.is_active,
67
+ successful_login: Boolean(data?.successful_login ?? data?.last_login),
67
68
  last_login: data?.last_login,
68
69
  date_joined: data?.date_joined,
69
70
  };
@@ -13,6 +13,7 @@ export function AllowedEmailDomainsManager({
13
13
  domains = [],
14
14
  enabled = false,
15
15
  onPolicyChange,
16
+ canEdit = true,
16
17
  }) {
17
18
  const { t } = useTranslation();
18
19
  const [domainsText, setDomainsText] = useState('');
@@ -76,10 +77,10 @@ export function AllowedEmailDomainsManager({
76
77
  fullWidth
77
78
  value={domainsText}
78
79
  onChange={(event) => setDomainsText(event.target.value)}
79
- disabled={busy}
80
+ disabled={busy || !canEdit}
80
81
  />
81
82
 
82
- <Button variant="contained" sx={{ mt: 2 }} onClick={handleSave} disabled={busy}>
83
+ <Button variant="contained" sx={{ mt: 2 }} onClick={handleSave} disabled={busy || !canEdit}>
83
84
  {t('Common.SAVE', 'Save')}
84
85
  </Button>
85
86
  </Box>
@@ -11,7 +11,7 @@ import {
11
11
  import { useTranslation } from 'react-i18next';
12
12
  import { fetchAuthPolicy, updateAuthPolicy } from '../auth/authApi';
13
13
 
14
- export function AuthFactorRequirementCard() {
14
+ export function AuthFactorRequirementCard({ canEdit = true, policy = null }) {
15
15
  const { t } = useTranslation();
16
16
  const [value, setValue] = useState('1');
17
17
  const [busy, setBusy] = useState(false);
@@ -19,6 +19,10 @@ export function AuthFactorRequirementCard() {
19
19
  const [success, setSuccess] = useState('');
20
20
 
21
21
  useEffect(() => {
22
+ if (policy) {
23
+ setValue(String(policy?.required_auth_factor_count || 1));
24
+ return undefined;
25
+ }
22
26
  let active = true;
23
27
  (async () => {
24
28
  try {
@@ -33,9 +37,12 @@ export function AuthFactorRequirementCard() {
33
37
  return () => {
34
38
  active = false;
35
39
  };
36
- }, []);
40
+ }, [policy]);
37
41
 
38
42
  const handleChange = async (event) => {
43
+ if (!canEdit) {
44
+ return;
45
+ }
39
46
  const nextValue = event.target.value;
40
47
  const previous = value;
41
48
  setValue(nextValue);
@@ -56,17 +63,28 @@ export function AuthFactorRequirementCard() {
56
63
  return (
57
64
  <Box>
58
65
  <Typography variant="h6" gutterBottom>
59
- {t('Auth.AUTH_FACTOR_TITLE', 'Authentication Factors')}
66
+ {t('Auth.AUTH_FACTOR_TITLE', 'Authentication Requirements')}
60
67
  </Typography>
61
68
  <Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
62
- {t('Auth.AUTH_FACTOR_HINT', 'Choose whether one factor is enough or two factors are required.')}
69
+ {t(
70
+ 'Auth.AUTH_FACTOR_HINT',
71
+ 'Define the minimum number of authentication factors required for sign-in.',
72
+ )}
63
73
  </Typography>
64
74
  {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
65
75
  {success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
66
76
  <FormControl>
67
77
  <RadioGroup value={value} onChange={handleChange}>
68
- <FormControlLabel value="1" control={<Radio disabled={busy} />} label={t('Auth.ONE_FACTOR_LABEL', 'One factor is enough')} />
69
- <FormControlLabel value="2" control={<Radio disabled={busy} />} label={t('Auth.TWO_FACTOR_LABEL', 'Two factors are required')} />
78
+ <FormControlLabel
79
+ value="1"
80
+ control={<Radio disabled={busy || !canEdit} />}
81
+ label={t('Auth.ONE_FACTOR_LABEL', 'Allow single-factor authentication')}
82
+ />
83
+ <FormControlLabel
84
+ value="2"
85
+ control={<Radio disabled={busy || !canEdit} />}
86
+ label={t('Auth.TWO_FACTOR_LABEL', 'Require two-factor authentication')}
87
+ />
70
88
  </RadioGroup>
71
89
  </FormControl>
72
90
  </Box>
@@ -24,6 +24,7 @@ export function QrSignupValidityManager({
24
24
  enabled = false,
25
25
  expiryDays = DEFAULT_EXPIRY_DAYS,
26
26
  onPolicyChange,
27
+ canEdit = true,
27
28
  }) {
28
29
  const { t } = useTranslation();
29
30
  const [currentExpiryDays, setCurrentExpiryDays] = useState(String(expiryDays || DEFAULT_EXPIRY_DAYS));
@@ -83,13 +84,13 @@ export function QrSignupValidityManager({
83
84
  type="number"
84
85
  value={currentExpiryDays}
85
86
  onChange={(event) => setCurrentExpiryDays(event.target.value)}
86
- disabled={busy}
87
+ disabled={busy || !canEdit}
87
88
  sx={{ flex: 1 }}
88
89
  />
89
90
  <Button
90
91
  variant="contained"
91
92
  onClick={handleSave}
92
- disabled={busy}
93
+ disabled={busy || !canEdit}
93
94
  sx={{ minWidth: 120, mt: { sm: '8px' } }}
94
95
  >
95
96
  {t('Common.SAVE', 'Save')}
@@ -26,6 +26,7 @@ export function RegistrationMethodsManager({
26
26
  policy: authPolicy,
27
27
  error = '',
28
28
  onPolicyChange,
29
+ canEdit = true,
29
30
  }) {
30
31
  const { t } = useTranslation();
31
32
  const [policyState, setPolicyState] = useState(EMPTY_POLICY);
@@ -85,7 +86,7 @@ export function RegistrationMethodsManager({
85
86
  <Switch
86
87
  checked={Boolean(policyState.allow_admin_invite)}
87
88
  onChange={toggle('allow_admin_invite')}
88
- disabled={Boolean(busyField)}
89
+ disabled={Boolean(busyField) || !canEdit}
89
90
  />
90
91
  )}
91
92
  label={t('Auth.ADMIN_INVITE_LABEL', 'Admin invite')}
@@ -95,7 +96,7 @@ export function RegistrationMethodsManager({
95
96
  <Switch
96
97
  checked={Boolean(policyState.allow_self_signup_access_code)}
97
98
  onChange={toggle('allow_self_signup_access_code')}
98
- disabled={Boolean(busyField)}
99
+ disabled={Boolean(busyField) || !canEdit}
99
100
  />
100
101
  )}
101
102
  label={t('Auth.SIGNUP_ACCESS_CODE_LABEL', 'Self-signup with access code')}
@@ -105,7 +106,7 @@ export function RegistrationMethodsManager({
105
106
  <Switch
106
107
  checked={Boolean(policyState.allow_self_signup_open)}
107
108
  onChange={toggle('allow_self_signup_open')}
108
- disabled={Boolean(busyField)}
109
+ disabled={Boolean(busyField) || !canEdit}
109
110
  />
110
111
  )}
111
112
  label={t('Auth.SIGNUP_OPEN_LABEL', 'Open self-signup')}
@@ -115,7 +116,7 @@ export function RegistrationMethodsManager({
115
116
  <Switch
116
117
  checked={Boolean(policyState.allow_self_signup_email_domain)}
117
118
  onChange={toggle('allow_self_signup_email_domain')}
118
- disabled={Boolean(busyField)}
119
+ disabled={Boolean(busyField) || !canEdit}
119
120
  />
120
121
  )}
121
122
  label={t('Auth.SIGNUP_EMAIL_DOMAIN_LABEL', 'Self-signup by email domain')}
@@ -125,7 +126,7 @@ export function RegistrationMethodsManager({
125
126
  <Switch
126
127
  checked={Boolean(policyState.allow_self_signup_qr)}
127
128
  onChange={toggle('allow_self_signup_qr')}
128
- disabled={Boolean(busyField)}
129
+ disabled={Boolean(busyField) || !canEdit}
129
130
  />
130
131
  )}
131
132
  label={t('Auth.SIGNUP_QR_LABEL', 'Self-signup by QR')}
@@ -1,30 +1,42 @@
1
1
  // src/components/SupportRecoveryRequestsTab.jsx
2
- import React, { useEffect, useState } from 'react';
2
+ import React, { useContext, useEffect, useState } from 'react';
3
3
  import {
4
+ Alert,
5
+ Autocomplete,
4
6
  Box,
5
- Typography,
6
- Table,
7
- TableHead,
8
- TableBody,
9
- TableRow,
10
- TableCell,
11
- TableContainer,
12
- Paper,
13
7
  Button,
14
8
  CircularProgress,
15
- Alert,
16
- Stack,
17
9
  Dialog,
18
- DialogTitle,
19
- DialogContent,
20
10
  DialogActions,
11
+ DialogContent,
12
+ DialogTitle,
13
+ IconButton,
14
+ Paper,
15
+ Stack,
16
+ Table,
17
+ TableBody,
18
+ TableCell,
19
+ TableContainer,
20
+ TableHead,
21
+ TableRow,
21
22
  TextField,
23
+ Typography,
22
24
  } from '@mui/material';
25
+ import CloseIcon from '@mui/icons-material/Close';
23
26
  import { useTranslation } from 'react-i18next';
24
- import { fetchRecoveryRequests, approveRecoveryRequest, rejectRecoveryRequest } from '../auth/authApi';
27
+ import { AuthContext } from '../auth/AuthContext';
28
+ import {
29
+ approveRecoveryRequest,
30
+ fetchRecoveryRequests,
31
+ fetchUsersList,
32
+ rejectRecoveryRequest,
33
+ updateUserSupportStatus,
34
+ } from '../auth/authApi';
25
35
 
26
36
  export function SupportRecoveryRequestsTab() {
27
37
  const { t } = useTranslation();
38
+ const { user } = useContext(AuthContext);
39
+ const canManageAgents = Boolean(user?.is_superuser || user?.can_manage_support_agents);
28
40
 
29
41
  const [requests, setRequests] = useState([]);
30
42
  const [loading, setLoading] = useState(true);
@@ -34,6 +46,11 @@ export function SupportRecoveryRequestsTab() {
34
46
  const [dialogOpen, setDialogOpen] = useState(false);
35
47
  const [dialogNote, setDialogNote] = useState('');
36
48
  const [selectedRequest, setSelectedRequest] = useState(null);
49
+ const [allUsers, setAllUsers] = useState([]);
50
+ const [agentLoading, setAgentLoading] = useState(false);
51
+ const [agentErrorKey, setAgentErrorKey] = useState(null);
52
+ const [selectedAgentCandidate, setSelectedAgentCandidate] = useState(null);
53
+ const [agentActionUserId, setAgentActionUserId] = useState(null);
37
54
 
38
55
  const loadRequests = async () => {
39
56
  setLoading(true);
@@ -48,11 +65,41 @@ export function SupportRecoveryRequestsTab() {
48
65
  }
49
66
  };
50
67
 
68
+ const loadUsers = async () => {
69
+ if (!canManageAgents) {
70
+ setAllUsers([]);
71
+ setAgentErrorKey(null);
72
+ return;
73
+ }
74
+
75
+ setAgentLoading(true);
76
+ setAgentErrorKey(null);
77
+ try {
78
+ const data = await fetchUsersList();
79
+ const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []);
80
+ list.sort((a, b) => {
81
+ const emailA = String(a?.email || '').toLocaleLowerCase();
82
+ const emailB = String(b?.email || '').toLocaleLowerCase();
83
+ return emailA.localeCompare(emailB);
84
+ });
85
+ setAllUsers(list);
86
+ } catch (err) {
87
+ setAgentErrorKey(err.code || 'Auth.USER_LIST_FAILED');
88
+ } finally {
89
+ setAgentLoading(false);
90
+ }
91
+ };
92
+
51
93
  useEffect(() => {
52
94
  loadRequests();
53
95
  // eslint-disable-next-line react-hooks/exhaustive-deps
54
96
  }, [statusFilter]);
55
97
 
98
+ useEffect(() => {
99
+ loadUsers();
100
+ // eslint-disable-next-line react-hooks/exhaustive-deps
101
+ }, [canManageAgents]);
102
+
56
103
  const openDialog = (req) => {
57
104
  setSelectedRequest(req);
58
105
  setDialogNote('');
@@ -89,6 +136,57 @@ export function SupportRecoveryRequestsTab() {
89
136
  }
90
137
  };
91
138
 
139
+ const getUserDisplayName = (entry) => {
140
+ if (entry?.first_name || entry?.last_name) {
141
+ return `${entry.first_name || ''} ${entry.last_name || ''}`.trim();
142
+ }
143
+ return entry?.username || '';
144
+ };
145
+
146
+ const getUserOptionLabel = (entry) => {
147
+ if (!entry) return '';
148
+ const name = getUserDisplayName(entry);
149
+ if (name && entry?.email) return `${name} (${entry.email})`;
150
+ return entry?.email || name || `#${entry?.id || ''}`;
151
+ };
152
+
153
+ const supportAgents = allUsers.filter((entry) => Boolean(entry?.is_support_agent));
154
+ const availableAgentCandidates = allUsers.filter((entry) => !entry?.is_support_agent);
155
+
156
+ const handleAddAgent = async () => {
157
+ if (!selectedAgentCandidate) return;
158
+
159
+ setAgentActionUserId(selectedAgentCandidate.id);
160
+ setAgentErrorKey(null);
161
+ try {
162
+ await updateUserSupportStatus(selectedAgentCandidate.id, true);
163
+ setSelectedAgentCandidate(null);
164
+ await loadUsers();
165
+ } catch (err) {
166
+ setAgentErrorKey(err.code || 'Auth.USER_SUPPORT_UPDATE_FAILED');
167
+ } finally {
168
+ setAgentActionUserId(null);
169
+ }
170
+ };
171
+
172
+ const handleRemoveAgent = async (agentUser) => {
173
+ if (!agentUser?.id) return;
174
+
175
+ setAgentActionUserId(agentUser.id);
176
+ setAgentErrorKey(null);
177
+ try {
178
+ await updateUserSupportStatus(agentUser.id, false);
179
+ if (selectedAgentCandidate?.id === agentUser.id) {
180
+ setSelectedAgentCandidate(null);
181
+ }
182
+ await loadUsers();
183
+ } catch (err) {
184
+ setAgentErrorKey(err.code || 'Auth.USER_SUPPORT_UPDATE_FAILED');
185
+ } finally {
186
+ setAgentActionUserId(null);
187
+ }
188
+ };
189
+
92
190
  if (loading) {
93
191
  return (
94
192
  <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
@@ -99,6 +197,102 @@ export function SupportRecoveryRequestsTab() {
99
197
 
100
198
  return (
101
199
  <Box>
200
+ {canManageAgents && (
201
+ <Paper variant="outlined" sx={{ p: 2, mb: 3, borderRadius: 2 }}>
202
+ <Typography variant="h6" gutterBottom>
203
+ {t('Support.AGENTS_TITLE', 'Support agents')}
204
+ </Typography>
205
+
206
+ <Typography variant="body2" sx={{ mb: 2 }}>
207
+ {t(
208
+ 'Support.AGENTS_DESCRIPTION',
209
+ 'Assign which users are allowed to handle support requests.',
210
+ )}
211
+ </Typography>
212
+
213
+ {agentErrorKey && (
214
+ <Alert severity="error" sx={{ mb: 2 }}>
215
+ {t(agentErrorKey)}
216
+ </Alert>
217
+ )}
218
+
219
+ <Stack
220
+ direction={{ xs: 'column', md: 'row' }}
221
+ spacing={2}
222
+ sx={{ mb: 2, alignItems: { md: 'flex-start' } }}
223
+ >
224
+ <Autocomplete
225
+ fullWidth
226
+ options={availableAgentCandidates}
227
+ value={selectedAgentCandidate}
228
+ loading={agentLoading}
229
+ onChange={(_event, value) => setSelectedAgentCandidate(value)}
230
+ isOptionEqualToValue={(option, value) => option.id === value.id}
231
+ getOptionLabel={getUserOptionLabel}
232
+ renderInput={(params) => (
233
+ <TextField
234
+ {...params}
235
+ label={t('Support.AGENTS_SELECT_LABEL', 'Select user')}
236
+ placeholder={t('Support.AGENTS_SELECT_PLACEHOLDER', 'Choose a user')}
237
+ />
238
+ )}
239
+ />
240
+
241
+ <Button
242
+ variant="contained"
243
+ onClick={handleAddAgent}
244
+ disabled={!selectedAgentCandidate || agentLoading || Boolean(agentActionUserId)}
245
+ sx={{ minWidth: 160 }}
246
+ >
247
+ {t('Support.AGENTS_ADD', 'Add as agent')}
248
+ </Button>
249
+ </Stack>
250
+
251
+ {agentLoading ? (
252
+ <Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
253
+ <CircularProgress size={24} />
254
+ </Box>
255
+ ) : supportAgents.length === 0 ? (
256
+ <Typography variant="body2">
257
+ {t('Support.AGENTS_EMPTY', 'No support agents assigned yet.')}
258
+ </Typography>
259
+ ) : (
260
+ <TableContainer component={Paper} variant="outlined">
261
+ <Table size="small">
262
+ <TableHead>
263
+ <TableRow>
264
+ <TableCell>{t('Profile.NAME_LABEL', 'Name')}</TableCell>
265
+ <TableCell>{t('Auth.EMAIL_LABEL', 'Email')}</TableCell>
266
+ <TableCell align="right">
267
+ {t('Common.ACTIONS', 'Actions')}
268
+ </TableCell>
269
+ </TableRow>
270
+ </TableHead>
271
+ <TableBody>
272
+ {supportAgents.map((agentUser) => (
273
+ <TableRow key={agentUser.id}>
274
+ <TableCell>{getUserDisplayName(agentUser) || '-'}</TableCell>
275
+ <TableCell>{agentUser.email || '-'}</TableCell>
276
+ <TableCell align="right">
277
+ <IconButton
278
+ size="small"
279
+ color="error"
280
+ onClick={() => handleRemoveAgent(agentUser)}
281
+ disabled={agentActionUserId === agentUser.id}
282
+ aria-label={t('Support.AGENTS_REMOVE', 'Remove support agent')}
283
+ >
284
+ <CloseIcon fontSize="small" />
285
+ </IconButton>
286
+ </TableCell>
287
+ </TableRow>
288
+ ))}
289
+ </TableBody>
290
+ </Table>
291
+ </TableContainer>
292
+ )}
293
+ </Paper>
294
+ )}
295
+
102
296
  <Typography variant="h6" gutterBottom>
103
297
  {t('Support.RECOVERY_REQUESTS_TITLE', 'Account recovery requests')}
104
298
  </Typography>
@@ -14,7 +14,7 @@ import {
14
14
  import { DataGrid } from '@mui/x-data-grid';
15
15
  import DeleteIcon from '@mui/icons-material/Delete';
16
16
  import { useTranslation } from 'react-i18next';
17
- import { fetchUsersList, deleteUser, updateUserRole, updateUserSupportStatus } from '../auth/authApi';
17
+ import { fetchUsersList, deleteUser, updateUserRole } from '../auth/authApi';
18
18
 
19
19
  const DEFAULT_ROLES = ['none', 'student', 'teacher', 'admin'];
20
20
 
@@ -79,15 +79,6 @@ export function UserListComponent({
79
79
  }
80
80
  };
81
81
 
82
- const handleToggleSupporter = async (userId, newValue) => {
83
- try {
84
- await updateUserSupportStatus(userId, newValue);
85
- await loadUsers();
86
- } catch (err) {
87
- alert(t(err.code || 'Auth.USER_UPDATE_FAILED'));
88
- }
89
- };
90
-
91
82
  const defaultCanEdit = (targetUser) => {
92
83
  if (!currentUser) return false;
93
84
  if (currentUser.is_superuser) return true;
@@ -179,6 +170,7 @@ export function UserListComponent({
179
170
  };
180
171
 
181
172
  const getSearchBucketForUser = (user) => {
173
+ const hasSuccessfulLogin = Boolean(user?.successful_login ?? user?.last_login);
182
174
  const baseValues = [
183
175
  user?.id,
184
176
  user?.email,
@@ -188,7 +180,7 @@ export function UserListComponent({
188
180
  getUserDisplayName(user),
189
181
  user?.role,
190
182
  user?.language,
191
- user?.is_support_agent ? t('Common.YES', 'Yes') : t('Common.NO', 'No'),
183
+ hasSuccessfulLogin ? t('Common.YES', 'Yes') : t('Common.NO', 'No'),
192
184
  ];
193
185
 
194
186
  const extraValues = visibleExtraColumns.map((column) => getExtraColumnSearchValue(column, user));
@@ -241,6 +233,20 @@ export function UserListComponent({
241
233
  flex: 1,
242
234
  valueGetter: (_value, row) => getUserDisplayName(row),
243
235
  },
236
+ {
237
+ field: 'successful_login',
238
+ headerName: t('UserList.SUCCESSFUL_LOGIN', 'Successful Login'),
239
+ minWidth: 180,
240
+ flex: 0.8,
241
+ valueGetter: (_value, row) => Boolean(row?.successful_login ?? row?.last_login),
242
+ renderCell: (params) => (
243
+ <Typography variant="body2">
244
+ {Boolean(params.row?.successful_login ?? params.row?.last_login)
245
+ ? t('Common.YES', 'Yes')
246
+ : t('Common.NO', 'No')}
247
+ </Typography>
248
+ ),
249
+ },
244
250
  {
245
251
  field: 'role',
246
252
  headerName: t('UserList.ROLE', 'Role'),
@@ -262,28 +268,6 @@ export function UserListComponent({
262
268
  );
263
269
  },
264
270
  },
265
- {
266
- field: 'is_support_agent',
267
- headerName: t('UserList.SUPPORTER', 'Support Agent'),
268
- minWidth: 190,
269
- flex: 0.8,
270
- valueGetter: (_value, row) => Boolean(row?.is_support_agent),
271
- renderCell: (params) => {
272
- const user = params.row;
273
- return (
274
- <FormControl size="small" fullWidth sx={controlSx} disabled={!canEdit(user)}>
275
- <Select
276
- value={user.is_support_agent ? 'yes' : 'no'}
277
- onChange={(event) => handleToggleSupporter(user.id, event.target.value === 'yes')}
278
- variant="outlined"
279
- >
280
- <MenuItem value="yes">{t('Common.YES', 'Yes')}</MenuItem>
281
- <MenuItem value="no">{t('Common.NO', 'No')}</MenuItem>
282
- </Select>
283
- </FormControl>
284
- );
285
- },
286
- },
287
271
  ];
288
272
 
289
273
  const mappedExtraColumns = visibleExtraColumns.map((column) => ({
@@ -1337,6 +1337,36 @@ export const authTranslations = {
1337
1337
  "en": "Authentication settings saved.",
1338
1338
  "sw": "Mipangilio ya uthibitishaji imehifadhiwa."
1339
1339
  },
1340
+ "Auth.AUTH_FACTOR_TITLE": {
1341
+ "de": "Authentifizierungsanforderungen",
1342
+ "fr": "Exigences d'authentification",
1343
+ "en": "Authentication Requirements",
1344
+ "sw": "Mahitaji ya uthibitishaji"
1345
+ },
1346
+ "Auth.AUTH_FACTOR_HINT": {
1347
+ "de": "Legen Sie fest, wie viele Authentifizierungsfaktoren mindestens für die Anmeldung erforderlich sind.",
1348
+ "fr": "Définissez le nombre minimal de facteurs d'authentification requis pour la connexion.",
1349
+ "en": "Define the minimum number of authentication factors required for sign-in.",
1350
+ "sw": "Weka idadi ya chini ya vipengele vya uthibitishaji vinavyohitajika ili kuingia."
1351
+ },
1352
+ "Auth.ONE_FACTOR_LABEL": {
1353
+ "de": "Single-Factor-Authentifizierung erlauben",
1354
+ "fr": "Autoriser l'authentification à un facteur",
1355
+ "en": "Allow single-factor authentication",
1356
+ "sw": "Ruhusu uthibitishaji wa kipengele kimoja"
1357
+ },
1358
+ "Auth.TWO_FACTOR_LABEL": {
1359
+ "de": "Zwei-Faktor-Authentifizierung erzwingen",
1360
+ "fr": "Exiger l'authentification à deux facteurs",
1361
+ "en": "Require two-factor authentication",
1362
+ "sw": "Hitaji uthibitishaji wa vipengele viwili"
1363
+ },
1364
+ "Auth.AUTH_FACTOR_SAVE_SUCCESS": {
1365
+ "de": "Die Anforderung für Authentifizierungsfaktoren wurde gespeichert.",
1366
+ "fr": "L'exigence relative aux facteurs d'authentification a été enregistrée.",
1367
+ "en": "Factor requirement saved.",
1368
+ "sw": "Hitaji la vipengele vya uthibitishaji limehifadhiwa."
1369
+ },
1340
1370
  "Auth.REGISTRATION_METHODS_TITLE": {
1341
1371
  "de": "Registrierungsmethoden",
1342
1372
  "fr": "Méthodes d'inscription",
@@ -62,6 +62,13 @@ export function AccountPage({
62
62
 
63
63
  // NEU: Superuser-Flag prüfen
64
64
  const isSuperUser = user?.is_superuser || false;
65
+ const canViewUsers = Boolean(isSuperUser || perms.can_view_users);
66
+ const canViewInvite = Boolean(isSuperUser || perms.can_view_invite || perms.can_invite);
67
+ const canViewAuthPolicy = Boolean(isSuperUser || perms.can_view_auth_policy);
68
+ const canWriteAuthPolicy = Boolean(isSuperUser || perms.can_write_auth_policy);
69
+ const canSendInvites = Boolean(isSuperUser || perms.can_send_invites);
70
+ const canManageAccessCodes = Boolean(isSuperUser || perms.can_manage_access_codes);
71
+ const canManageSignupQr = Boolean(isSuperUser || perms.can_manage_signup_qr);
65
72
 
66
73
  const handleTabChange = (_event, newValue) => {
67
74
  setSearchParams({ tab: newValue });
@@ -74,7 +81,7 @@ export function AccountPage({
74
81
 
75
82
  useEffect(() => {
76
83
  let active = true;
77
- const canLoadPolicy = Boolean(user) && (isSuperUser || perms.can_invite || perms.can_manage_access_codes);
84
+ const canLoadPolicy = Boolean(user) && (canViewAuthPolicy || canManageSignupQr || canManageAccessCodes);
78
85
 
79
86
  if (!canLoadPolicy) {
80
87
  setAuthPolicy(null);
@@ -102,7 +109,7 @@ export function AccountPage({
102
109
  return () => {
103
110
  active = false;
104
111
  };
105
- }, [user, isSuperUser, perms.can_invite, perms.can_manage_access_codes, t]);
112
+ }, [user, canViewAuthPolicy, canManageSignupQr, canManageAccessCodes, t]);
106
113
 
107
114
  // 3. Dynamic Tabs (angepasst für Superuser)
108
115
  const tabs = useMemo(() => {
@@ -116,11 +123,11 @@ export function AccountPage({
116
123
 
117
124
  // Logik: Superuser darf ALLES, sonst gelten die spezifischen Permissions
118
125
 
119
- if (isSuperUser || perms.can_view_users) {
126
+ if (canViewUsers) {
120
127
  list.push({ value: 'users', label: t('Account.TAB_USERS', 'Users') });
121
128
  }
122
129
 
123
- if (isSuperUser || perms.can_invite || perms.can_manage_access_codes) {
130
+ if (canViewInvite) {
124
131
  list.push({ value: 'invite', label: t('Account.TAB_INVITE', 'Invite') });
125
132
  }
126
133
 
@@ -140,7 +147,7 @@ export function AccountPage({
140
147
  });
141
148
 
142
149
  return list;
143
- }, [user, perms, t, isSuperUser, extraTabs]);
150
+ }, [user, perms, t, isSuperUser, extraTabs, canViewUsers, canViewInvite]);
144
151
 
145
152
  // 4. Loading & Auth Checks
146
153
  if (loading) {
@@ -227,29 +234,30 @@ export function AccountPage({
227
234
  {safeTab === 'invite' && (
228
235
  <Box sx={{ mt: 2 }}>
229
236
  <Stack spacing={2.5}>
230
- {(isSuperUser || perms.can_invite) && (
237
+ {canViewAuthPolicy && (
238
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
239
+ <AuthFactorRequirementCard canEdit={canWriteAuthPolicy} policy={authPolicy} />
240
+ </Paper>
241
+ )}
242
+
243
+ {canViewAuthPolicy && (
231
244
  <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
232
245
  <RegistrationMethodsManager
233
246
  policy={authPolicy}
234
247
  error={authPolicyError}
235
248
  onPolicyChange={setAuthPolicy}
249
+ canEdit={canWriteAuthPolicy}
236
250
  />
237
251
  </Paper>
238
252
  )}
239
253
 
240
- {(isSuperUser || perms.can_invite) && (
241
- <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
242
- <AuthFactorRequirementCard />
243
- </Paper>
244
- )}
245
-
246
- {(isSuperUser || perms.can_invite) && Boolean(authPolicy?.allow_admin_invite) && (
254
+ {canSendInvites && Boolean(authPolicy?.allow_admin_invite) && (
247
255
  <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
248
256
  <UserInviteComponent />
249
257
  </Paper>
250
258
  )}
251
259
 
252
- {(isSuperUser || perms.can_manage_access_codes) && Boolean(authPolicy?.allow_self_signup_access_code) && (
260
+ {canManageAccessCodes && Boolean(authPolicy?.allow_self_signup_access_code) && (
253
261
  <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
254
262
  <Typography variant="h6" gutterBottom>
255
263
  {t('Auth.ACCESS_CODE_MANAGER_TITLE', 'Access Codes')}
@@ -261,33 +269,35 @@ export function AccountPage({
261
269
  </Paper>
262
270
  )}
263
271
 
264
- {(isSuperUser || perms.can_invite) && showBulkInviteCsvTab && Boolean(authPolicy?.allow_admin_invite) && (
272
+ {canSendInvites && showBulkInviteCsvTab && Boolean(authPolicy?.allow_admin_invite) && (
265
273
  <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
266
274
  <BulkInviteCsvTab {...bulkInviteCsvProps} />
267
275
  </Paper>
268
276
  )}
269
277
 
270
- {(isSuperUser || perms.can_invite) && Boolean(authPolicy?.allow_self_signup_email_domain) && (
278
+ {canViewAuthPolicy && Boolean(authPolicy?.allow_self_signup_email_domain) && (
271
279
  <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
272
280
  <AllowedEmailDomainsManager
273
281
  enabled={Boolean(authPolicy?.allow_self_signup_email_domain)}
274
282
  domains={authPolicy?.allowed_email_domains || []}
275
283
  onPolicyChange={setAuthPolicy}
284
+ canEdit={canWriteAuthPolicy}
276
285
  />
277
286
  </Paper>
278
287
  )}
279
288
 
280
- {(isSuperUser || perms.can_invite) && Boolean(authPolicy?.allow_self_signup_qr) && (
289
+ {canViewAuthPolicy && Boolean(authPolicy?.allow_self_signup_qr) && (
281
290
  <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
282
291
  <QrSignupValidityManager
283
292
  enabled={Boolean(authPolicy?.allow_self_signup_qr)}
284
293
  expiryDays={authPolicy?.signup_qr_expiry_days}
285
294
  onPolicyChange={setAuthPolicy}
295
+ canEdit={canWriteAuthPolicy}
286
296
  />
287
297
  </Paper>
288
298
  )}
289
299
 
290
- {(isSuperUser || perms.can_invite) && Boolean(authPolicy?.allow_self_signup_qr) && (
300
+ {canManageSignupQr && Boolean(authPolicy?.allow_self_signup_qr) && (
291
301
  <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
292
302
  <QrSignupManager
293
303
  enabled={Boolean(authPolicy?.allow_self_signup_qr)}