u
This commit is contained in:
207
src/components/settings/MembersSettingsPanel.jsx
Normal file
207
src/components/settings/MembersSettingsPanel.jsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import companyAutomat, { selectIsCompanyAdmin } from '../../automats/companyAutomat';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'user', label: 'Benutzer' },
|
||||
{ value: 'admin', label: 'Administrator' },
|
||||
];
|
||||
|
||||
const selectState = (state) => ({
|
||||
isAdmin: state.current?.role === 'admin',
|
||||
members: state.members,
|
||||
membersStatus: state.membersStatus,
|
||||
invites: state.invites,
|
||||
invitesStatus: state.invitesStatus,
|
||||
});
|
||||
|
||||
/**
|
||||
* Company members and pending auto-invites. Visible and usable by admins only.
|
||||
*/
|
||||
export default class MembersSettingsPanel extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...selectState(companyAutomat.state),
|
||||
memberEmail: '',
|
||||
inviteEmail: '',
|
||||
inviteRole: 'user',
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = companyAutomat.subscribe(this, selectState);
|
||||
this.loadIfAdmin();
|
||||
}
|
||||
|
||||
componentDidUpdate(_prevProps, prevState) {
|
||||
if (!prevState.isAdmin && this.state.isAdmin) this.loadIfAdmin();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
loadIfAdmin() {
|
||||
if (!selectIsCompanyAdmin()) return;
|
||||
companyAutomat.actions.ensureMembersLoaded().catch(notifyError);
|
||||
companyAutomat.actions.ensureInvitesLoaded().catch(notifyError);
|
||||
}
|
||||
|
||||
// Runs a mutation, mapping the backend's 404/409 to friendly messages.
|
||||
run = async (fn, messages = {}) => {
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
await fn();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const normalized = backendErrorFrom(err);
|
||||
const text = messages[normalized.status];
|
||||
if (text) enqueueSnackbar(text, { variant: 'warning' });
|
||||
else notifyError(err);
|
||||
return false;
|
||||
} finally {
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
};
|
||||
|
||||
handleAddMember = async (event) => {
|
||||
event.preventDefault();
|
||||
const email = this.state.memberEmail.trim();
|
||||
if (!email) return;
|
||||
const ok = await this.run(() => companyAutomat.actions.addMember(email, 'user'), {
|
||||
404: 'Kein registrierter Benutzer mit dieser E-Mail. Bitte stattdessen einladen.',
|
||||
409: 'Benutzer gehört bereits zum Unternehmen.',
|
||||
});
|
||||
if (ok) this.setState({ memberEmail: '' });
|
||||
};
|
||||
|
||||
handleSetRole = (member, role) =>
|
||||
this.run(() => companyAutomat.actions.setMemberRole(member.id, role), {
|
||||
409: 'Der letzte Administrator kann nicht herabgestuft werden.',
|
||||
});
|
||||
|
||||
handleRemoveMember = (member) =>
|
||||
this.run(() => companyAutomat.actions.removeMember(member.id), {
|
||||
409: 'Der letzte Administrator kann nicht entfernt werden.',
|
||||
});
|
||||
|
||||
handleCreateInvite = async (event) => {
|
||||
event.preventDefault();
|
||||
const email = this.state.inviteEmail.trim();
|
||||
if (!email) return;
|
||||
const ok = await this.run(() => companyAutomat.actions.createInvite(email, this.state.inviteRole), {
|
||||
409: 'Diese E-Mail ist bereits eingeladen oder registriert (dann direkt als Mitglied hinzufügen).',
|
||||
});
|
||||
if (ok) this.setState({ inviteEmail: '' });
|
||||
};
|
||||
|
||||
handleDeleteInvite = (invite) => this.run(() => companyAutomat.actions.deleteInvite(invite.id));
|
||||
|
||||
renderRoleSelect(value, onChange, disabled) {
|
||||
return (
|
||||
<Select size="small" value={value} disabled={disabled} onChange={(e) => onChange(e.target.value)}>
|
||||
{ROLES.map((r) => (
|
||||
<MenuItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isAdmin, members, membersStatus, invites, invitesStatus, memberEmail, inviteEmail, inviteRole, busy } =
|
||||
this.state;
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Typography color="text.secondary">Nur Administratoren können Mitglieder verwalten.</Typography>;
|
||||
}
|
||||
|
||||
const loading = membersStatus === 'loading' || invitesStatus === 'loading';
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Mitglieder
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Registrierte Benutzer per E-Mail hinzufügen. Sie können dann zu diesem Unternehmen wechseln.
|
||||
</Typography>
|
||||
<Stack component="form" direction="row" spacing={1} onSubmit={this.handleAddMember} sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="email"
|
||||
label="E-Mail"
|
||||
value={memberEmail}
|
||||
onChange={(e) => this.setState({ memberEmail: e.target.value })}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Button type="submit" variant="contained" disabled={busy || !memberEmail.trim()}>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</Stack>
|
||||
{loading && members.length === 0 && <CircularProgress size={20} />}
|
||||
{members.map((m) => (
|
||||
<Stack key={m.id} direction="row" spacing={1} alignItems="center" sx={{ py: 0.5 }}>
|
||||
<Typography sx={{ flex: 1 }}>{m.email}</Typography>
|
||||
{this.renderRoleSelect(m.role, (role) => this.handleSetRole(m, role), busy)}
|
||||
<IconButton aria-label="Entfernen" disabled={busy} onClick={() => this.handleRemoveMember(m)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Einladungen
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
E-Mails, die bei der Registrierung automatisch zum Unternehmen hinzugefügt werden.
|
||||
</Typography>
|
||||
<Stack component="form" direction="row" spacing={1} onSubmit={this.handleCreateInvite} sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="email"
|
||||
label="E-Mail"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => this.setState({ inviteEmail: e.target.value })}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
{this.renderRoleSelect(inviteRole, (role) => this.setState({ inviteRole: role }), busy)}
|
||||
<Button type="submit" variant="contained" disabled={busy || !inviteEmail.trim()}>
|
||||
Einladen
|
||||
</Button>
|
||||
</Stack>
|
||||
{invites.length === 0 && invitesStatus === 'ready' && (
|
||||
<Typography color="text.secondary">Keine offenen Einladungen.</Typography>
|
||||
)}
|
||||
{invites.map((i) => (
|
||||
<Stack key={i.id} direction="row" spacing={1} alignItems="center" sx={{ py: 0.5 }}>
|
||||
<Typography sx={{ flex: 1 }}>{i.email}</Typography>
|
||||
<Typography color="text.secondary">{ROLES.find((r) => r.value === i.role)?.label ?? i.role}</Typography>
|
||||
<IconButton aria-label="Einladung löschen" disabled={busy} onClick={() => this.handleDeleteInvite(i)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ import SettingsKeysPanel from './SettingsKeysPanel';
|
||||
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
|
||||
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
||||
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
|
||||
import MembersSettingsPanel from './MembersSettingsPanel';
|
||||
import GroupOutlinedIcon from '@mui/icons-material/GroupOutlined';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import BackupSettingsPanel from './BackupSettingsPanel';
|
||||
import HealthSettingsPanel from './HealthSettingsPanel';
|
||||
import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined';
|
||||
@@ -42,6 +45,7 @@ const TAB_KEYS = {
|
||||
backup: 'backup',
|
||||
posterminal: 'posterminal',
|
||||
security: 'security',
|
||||
members: 'members',
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
@@ -54,6 +58,7 @@ const TABS = [
|
||||
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
|
||||
{ id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel },
|
||||
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
|
||||
{ id: TAB_KEYS.members, label: 'Mitglieder', icon: <GroupOutlinedIcon />, Panel: MembersSettingsPanel },
|
||||
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
|
||||
];
|
||||
|
||||
@@ -61,9 +66,10 @@ function getDefaultTab(vhost) {
|
||||
return vhost ? TAB_KEYS.company : TAB_KEYS.health;
|
||||
}
|
||||
|
||||
function getVisibleTabs(vhost) {
|
||||
if (!vhost) return TABS;
|
||||
return TABS.filter(
|
||||
function getVisibleTabs(vhost, isAdmin) {
|
||||
const tabs = isAdmin ? TABS : TABS.filter((t) => t.id !== TAB_KEYS.members);
|
||||
if (!vhost) return tabs;
|
||||
return tabs.filter(
|
||||
(t) => t.id !== TAB_KEYS.health && t.id !== TAB_KEYS.backup && t.id !== TAB_KEYS.printers
|
||||
);
|
||||
}
|
||||
@@ -76,6 +82,7 @@ export default class SettingsDialog extends Component {
|
||||
tab: getDefaultTab(vhost),
|
||||
schemaVersion: authAutomat.state.schemaVersion,
|
||||
vhost,
|
||||
isAdmin: companyAutomat.state.current?.role === 'admin',
|
||||
};
|
||||
this.handleTabChange = this.handleTabChange.bind(this);
|
||||
}
|
||||
@@ -85,10 +92,14 @@ export default class SettingsDialog extends Component {
|
||||
schemaVersion: state.schemaVersion,
|
||||
vhost: Boolean(state.vhost),
|
||||
}));
|
||||
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
|
||||
isAdmin: state.current?.role === 'admin',
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubAuth?.();
|
||||
this.unsubCompany?.();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -129,8 +140,8 @@ export default class SettingsDialog extends Component {
|
||||
|
||||
render() {
|
||||
const { open, onClose } = this.props;
|
||||
const { tab, schemaVersion, vhost } = this.state;
|
||||
const visibleTabs = getVisibleTabs(vhost);
|
||||
const { tab, schemaVersion, vhost, isAdmin } = this.state;
|
||||
const visibleTabs = getVisibleTabs(vhost, isAdmin);
|
||||
const activeTab = visibleTabs.some((t) => t.id === tab) ? tab : visibleTabs[0]?.id;
|
||||
|
||||
const paperSx = {
|
||||
|
||||
@@ -8,3 +8,4 @@ export { default as ImportExportSettingsPanel } from './ImportExportSettingsPane
|
||||
export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel';
|
||||
export { default as BackupSettingsPanel } from './BackupSettingsPanel';
|
||||
export { default as HealthSettingsPanel } from './HealthSettingsPanel';
|
||||
export { default as MembersSettingsPanel } from './MembersSettingsPanel';
|
||||
|
||||
Reference in New Issue
Block a user