This commit is contained in:
seb
2026-11-02 00:19:03 +01:00
parent 0181e08657
commit bc284c6d42
10 changed files with 468 additions and 7 deletions

View File

@@ -131,7 +131,7 @@ Strip supplierName?: str from T11 (resolve via entityId in cached supplier list)
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 307 API endpoints across all modules. Overview of all 314 API endpoints across all modules.
### Notation ### Notation
@@ -158,7 +158,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }` - `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T30[], articleCount?: int }` - `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T30[], articleCount?: int }`
- `T18` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }` - `T18` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }`
- `T19` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }` - `T19` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str }`
- `T20` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }` - `T20` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
- `T21` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }` - `T21` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
- `T22` = `{ id: int, agentprinterId: int, type: str, state: str, data: str, createdAt: str, printeragentId?: int|null, completedAt?: str|null }` - `T22` = `{ id: int, agentprinterId: int, type: str, state: str, data: str, createdAt: str, printeragentId?: int|null, completedAt?: str|null }`
@@ -407,6 +407,18 @@ _none_
- Params: `body: { companyId: int }` - Params: `body: { companyId: int }`
- Returns: `200 { user: T35 }` - Returns: `200 { user: T35 }`
### Section: /autoinvite
#### List auto-invitations — `GET /autoinvite`
_none_
- Returns: `200 { autoinvites: { id: int, email: str, role: str, createdAt: str }[] }`
#### Create auto-invitation — `POST /autoinvite`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { autoinvite: { id: int, email: str, role: str } }`
#### Delete auto-invitation — `DELETE /autoinvite/{id}`
- Params: `id: int`
- Returns: `204 void`
### Section: /backup ### Section: /backup
#### List backups and service status — `GET /backup` #### List backups and service status — `GET /backup`
@@ -548,6 +560,21 @@ _none_
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T19 }` - Returns: `200 { company: T19 }`
### Section: /companyuser
#### List company users — `GET /companyuser`
_none_
- Returns: `200 { companyusers: { id: int, googleAuthId: int, email: str, role: str }[] }`
#### Add an existing user to the company — `POST /companyuser`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { companyuser: { id: int, googleAuthId: int, email: str, role: str } }`
#### Remove a user from the company — `DELETE /companyuser/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Change a company user's role — `PATCH /companyuser/{id}`
- Params: `id: int, body: { role: str }`
- Returns: `200 { companyuser: { id: int, googleAuthId: int, role: str } }`
### Section: /customergroup ### Section: /customergroup
#### List customer groups — `GET /customergroup` #### List customer groups — `GET /customergroup`
@@ -654,6 +681,8 @@ _none_
- `ledger`: `{}` - `ledger`: `{}`
- `attributename`: `{}` - `attributename`: `{}`
- `propertyname`: `{}` - `propertyname`: `{}`
- `companyuser`: `{}`
- `autoinvite`: `{}`
- `labeltemplate`: `{}` - `labeltemplate`: `{}`
- `printer`: `{}` - `printer`: `{}`
- `printeragent`: `{}` - `printeragent`: `{}`

24
src/api/autoinvite.js Normal file
View File

@@ -0,0 +1,24 @@
import { http } from './http.js';
/**
* Pending invites for emails that are not registered yet (admin only).
*/
export const autoinvite = {
// #region GET /api/autoinvite — list pending invites
list() {
return http('/api/autoinvite');
},
// #endregion
// #region POST /api/autoinvite — create an invite
create(body) {
return http('/api/autoinvite', { method: 'POST', body });
},
// #endregion
// #region DELETE /api/autoinvite/:id — delete an invite
delete(id) {
return http(`/api/autoinvite/${id}`, { method: 'DELETE' });
},
// #endregion
};

30
src/api/companyuser.js Normal file
View File

@@ -0,0 +1,30 @@
import { http } from './http.js';
/**
* Company members (admin only).
*/
export const companyuser = {
// #region GET /api/companyuser — list members
list() {
return http('/api/companyuser');
},
// #endregion
// #region POST /api/companyuser — add an existing user by email
create(body) {
return http('/api/companyuser', { method: 'POST', body });
},
// #endregion
// #region PATCH /api/companyuser/:id — change role
setRole(id, role) {
return http(`/api/companyuser/${id}`, { method: 'PATCH', body: { role } });
},
// #endregion
// #region DELETE /api/companyuser/:id — remove a member
delete(id) {
return http(`/api/companyuser/${id}`, { method: 'DELETE' });
},
// #endregion
};

View File

@@ -34,6 +34,8 @@ export { shipment } from './shipment.js';
export { shipping } from './shipping.js'; export { shipping } from './shipping.js';
export { payment } from './payment.js'; export { payment } from './payment.js';
export { account } from './account.js'; export { account } from './account.js';
export { companyuser } from './companyuser.js';
export { autoinvite } from './autoinvite.js';
export { mcp } from './mcp.js'; export { mcp } from './mcp.js';
export { backup } from './backup.js'; export { backup } from './backup.js';
export { carrier } from './carrier.js'; export { carrier } from './carrier.js';

View File

@@ -15,6 +15,7 @@
* - Brands (`brands`, `filteredBrands`, `brandFilter`) * - Brands (`brands`, `filteredBrands`, `brandFilter`)
* - Product attributes & values (`attributes`) * - Product attributes & values (`attributes`)
* - Payment accounts (`accounts`) * - Payment accounts (`accounts`)
* - Members & pending invites (`members`, `invites`; admin only, loaded on demand)
* *
* Lives with the 1st-level navigation shell (`AppShell`) and triggers when * Lives with the 1st-level navigation shell (`AppShell`) and triggers when
* `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`). * `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`).
@@ -39,6 +40,8 @@ import {
attributename as attributenameApi, attributename as attributenameApi,
attributevalue as attributevalueApi, attributevalue as attributevalueApi,
account as accountApi, account as accountApi,
companyuser as companyuserApi,
autoinvite as autoinviteApi,
} from '../api'; } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { normalizeCompany } from '../utils/companyForm'; import { normalizeCompany } from '../utils/companyForm';
@@ -330,6 +333,14 @@ const initialState = {
accounts: [], accounts: [],
accountsStatus: 'idle', accountsStatus: 'idle',
accountsError: null, accountsError: null,
// 14. Members & invites (admin only; never part of loadAll)
members: [],
membersStatus: 'idle',
membersError: null,
invites: [],
invitesStatus: 'idle',
invitesError: null,
}; };
let companyAutomat; let companyAutomat;
@@ -350,6 +361,8 @@ let inflightCarriers = null;
let inflightBrands = null; let inflightBrands = null;
let inflightAttributes = null; let inflightAttributes = null;
let inflightAccounts = null; let inflightAccounts = null;
let inflightMembers = null;
let inflightInvites = null;
// #endregion // #endregion
// #region internal state updaters & fetchers // #region internal state updaters & fetchers
@@ -565,6 +578,8 @@ const actions = {
inflightBrands = null; inflightBrands = null;
inflightAttributes = null; inflightAttributes = null;
inflightAccounts = null; inflightAccounts = null;
inflightMembers = null;
inflightInvites = null;
companyAutomat.setState({ companyAutomat.setState({
...initialState, ...initialState,
viewerPosition: companyAutomat.state.viewerPosition, viewerPosition: companyAutomat.state.viewerPosition,
@@ -1709,6 +1724,122 @@ const actions = {
return accounts; return accounts;
}, },
// #endregion // #endregion
// #region 14. Members (companyuser) & invites (autoinvite) — admin only
// MARK: calls companyuser.* / autoinvite.* from ../api/index.js
loadMembers() {
if (inflightMembers) return inflightMembers;
inflightMembers = (async () => {
companyAutomat.setState({ membersStatus: 'loading', membersError: null });
try {
const res = await companyuserApi.list();
const members = requireArray(res?.companyusers, 'companyusers');
companyAutomat.setState({ members, membersStatus: 'ready', membersError: null });
return members;
} catch (error) {
companyAutomat.setState({ membersStatus: 'failed', membersError: error });
throw error;
} finally {
inflightMembers = null;
}
})();
return inflightMembers;
},
async reloadMembers() {
try {
const res = await companyuserApi.list();
const members = requireArray(res?.companyusers, 'companyusers');
companyAutomat.setState({ members, membersStatus: 'ready', membersError: null });
return members;
} catch (error) {
companyAutomat.setState({ membersError: error });
throw error;
}
},
ensureMembersLoaded() {
if (companyAutomat.state.membersStatus === 'ready') {
return Promise.resolve(companyAutomat.state.members);
}
return actions.loadMembers();
},
/** Adds an already registered user by email. Rejects with 404/409 from the backend. */
async addMember(email, role = 'user') {
const res = await companyuserApi.create({ email, role });
const member = requireObject(res?.companyuser, 'companyuser');
companyAutomat.setState({ members: [...companyAutomat.state.members, member] });
return member;
},
/** Rejects with 409 when demoting the last admin. */
async setMemberRole(id, role) {
const res = await companyuserApi.setRole(id, role);
const member = requireObject(res?.companyuser, 'companyuser');
companyAutomat.setState({
members: companyAutomat.state.members.map((m) => (m.id === member.id ? member : m)),
});
return member;
},
/** Rejects with 409 when removing the last admin. */
async removeMember(id) {
await companyuserApi.delete(id);
companyAutomat.setState({ members: companyAutomat.state.members.filter((m) => m.id !== id) });
},
loadInvites() {
if (inflightInvites) return inflightInvites;
inflightInvites = (async () => {
companyAutomat.setState({ invitesStatus: 'loading', invitesError: null });
try {
const res = await autoinviteApi.list();
const invites = requireArray(res?.autoinvites, 'autoinvites');
companyAutomat.setState({ invites, invitesStatus: 'ready', invitesError: null });
return invites;
} catch (error) {
companyAutomat.setState({ invitesStatus: 'failed', invitesError: error });
throw error;
} finally {
inflightInvites = null;
}
})();
return inflightInvites;
},
async reloadInvites() {
try {
const res = await autoinviteApi.list();
const invites = requireArray(res?.autoinvites, 'autoinvites');
companyAutomat.setState({ invites, invitesStatus: 'ready', invitesError: null });
return invites;
} catch (error) {
companyAutomat.setState({ invitesError: error });
throw error;
}
},
ensureInvitesLoaded() {
if (companyAutomat.state.invitesStatus === 'ready') {
return Promise.resolve(companyAutomat.state.invites);
}
return actions.loadInvites();
},
/** Rejects with 409 when the email is already registered (use addMember then). */
async createInvite(email, role = 'user') {
const res = await autoinviteApi.create({ email, role });
const invite = requireObject(res?.autoinvite, 'autoinvite');
companyAutomat.setState({ invites: [...companyAutomat.state.invites, invite] });
return invite;
},
async deleteInvite(id) {
await autoinviteApi.delete(id);
companyAutomat.setState({ invites: companyAutomat.state.invites.filter((i) => i.id !== id) });
},
// #endregion
}; };
/** /**
@@ -1807,6 +1938,15 @@ sseAutomat.actions.on('attributename', () => {
sseAutomat.actions.on('account', () => { sseAutomat.actions.on('account', () => {
void companyAutomat.actions.reloadAccounts().catch(() => {}); void companyAutomat.actions.reloadAccounts().catch(() => {});
}); });
// Admin-only lists: only refresh once an admin view has loaded them.
sseAutomat.actions.on('companyuser', () => {
if (companyAutomat.state.membersStatus !== 'ready') return;
void companyAutomat.actions.reloadMembers().catch(() => {});
});
sseAutomat.actions.on('autoinvite', () => {
if (companyAutomat.state.invitesStatus !== 'ready') return;
void companyAutomat.actions.reloadInvites().catch(() => {});
});
sseAutomat.actions.on('company', () => { sseAutomat.actions.on('company', () => {
void companyAutomat.actions.reload().catch(() => {}); void companyAutomat.actions.reload().catch(() => {});
}); });
@@ -1885,6 +2025,16 @@ export const selectAttributeStatus = () => companyAutomat.state.attributesStatus
// Accounts selectors // Accounts selectors
export const selectAccounts = () => companyAutomat.state.accounts; export const selectAccounts = () => companyAutomat.state.accounts;
export const selectAccountsStatus = () => companyAutomat.state.accountsStatus; export const selectAccountsStatus = () => companyAutomat.state.accountsStatus;
// Own role in the selected company ('admin' | 'user' | undefined)
export const selectCompanyRole = () => companyAutomat.state.current?.role;
export const selectIsCompanyAdmin = () => companyAutomat.state.current?.role === 'admin';
// Members & invites selectors (admin only)
export const selectMembers = () => companyAutomat.state.members;
export const selectMembersStatus = () => companyAutomat.state.membersStatus;
export const selectInvites = () => companyAutomat.state.invites;
export const selectInvitesStatus = () => companyAutomat.state.invitesStatus;
// #endregion // #endregion
export default companyAutomat; export default companyAutomat;

View File

@@ -178,6 +178,12 @@ export {
selectAttributeStatus, selectAttributeStatus,
selectAccounts, selectAccounts,
selectAccountsStatus, selectAccountsStatus,
selectCompanyRole,
selectIsCompanyAdmin,
selectMembers,
selectMembersStatus,
selectInvites,
selectInvitesStatus,
} from './companyAutomat'; } from './companyAutomat';
export { default as categoryAutomat } from './categoryAutomat'; export { default as categoryAutomat } from './categoryAutomat';

View 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>
);
}
}

View File

@@ -23,6 +23,9 @@ import SettingsKeysPanel from './SettingsKeysPanel';
import ImportExportSettingsPanel from './ImportExportSettingsPanel'; import ImportExportSettingsPanel from './ImportExportSettingsPanel';
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel'; import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
import McpAuthorizationsPanel from './McpAuthorizationsPanel'; 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 BackupSettingsPanel from './BackupSettingsPanel';
import HealthSettingsPanel from './HealthSettingsPanel'; import HealthSettingsPanel from './HealthSettingsPanel';
import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined'; import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined';
@@ -42,6 +45,7 @@ const TAB_KEYS = {
backup: 'backup', backup: 'backup',
posterminal: 'posterminal', posterminal: 'posterminal',
security: 'security', security: 'security',
members: 'members',
}; };
const TABS = [ const TABS = [
@@ -54,6 +58,7 @@ const TABS = [
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel }, { id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
{ id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel }, { id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel },
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel }, { 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 }, { 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; return vhost ? TAB_KEYS.company : TAB_KEYS.health;
} }
function getVisibleTabs(vhost) { function getVisibleTabs(vhost, isAdmin) {
if (!vhost) return TABS; const tabs = isAdmin ? TABS : TABS.filter((t) => t.id !== TAB_KEYS.members);
return TABS.filter( if (!vhost) return tabs;
return tabs.filter(
(t) => t.id !== TAB_KEYS.health && t.id !== TAB_KEYS.backup && t.id !== TAB_KEYS.printers (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), tab: getDefaultTab(vhost),
schemaVersion: authAutomat.state.schemaVersion, schemaVersion: authAutomat.state.schemaVersion,
vhost, vhost,
isAdmin: companyAutomat.state.current?.role === 'admin',
}; };
this.handleTabChange = this.handleTabChange.bind(this); this.handleTabChange = this.handleTabChange.bind(this);
} }
@@ -85,10 +92,14 @@ export default class SettingsDialog extends Component {
schemaVersion: state.schemaVersion, schemaVersion: state.schemaVersion,
vhost: Boolean(state.vhost), vhost: Boolean(state.vhost),
})); }));
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
} }
componentWillUnmount() { componentWillUnmount() {
this.unsubAuth?.(); this.unsubAuth?.();
this.unsubCompany?.();
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@@ -129,8 +140,8 @@ export default class SettingsDialog extends Component {
render() { render() {
const { open, onClose } = this.props; const { open, onClose } = this.props;
const { tab, schemaVersion, vhost } = this.state; const { tab, schemaVersion, vhost, isAdmin } = this.state;
const visibleTabs = getVisibleTabs(vhost); const visibleTabs = getVisibleTabs(vhost, isAdmin);
const activeTab = visibleTabs.some((t) => t.id === tab) ? tab : visibleTabs[0]?.id; const activeTab = visibleTabs.some((t) => t.id === tab) ? tab : visibleTabs[0]?.id;
const paperSx = { const paperSx = {

View File

@@ -8,3 +8,4 @@ export { default as ImportExportSettingsPanel } from './ImportExportSettingsPane
export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel'; export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel';
export { default as BackupSettingsPanel } from './BackupSettingsPanel'; export { default as BackupSettingsPanel } from './BackupSettingsPanel';
export { default as HealthSettingsPanel } from './HealthSettingsPanel'; export { default as HealthSettingsPanel } from './HealthSettingsPanel';
export { default as MembersSettingsPanel } from './MembersSettingsPanel';

View File

@@ -22,5 +22,6 @@ export function normalizeCompany(raw, overrides = {}) {
reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate, reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate,
taxfreerate: overrides.taxfreerate !== undefined ? overrides.taxfreerate : raw.taxfreerate, taxfreerate: overrides.taxfreerate !== undefined ? overrides.taxfreerate : raw.taxfreerate,
rootCategoryId: overrides.rootCategoryId !== undefined ? overrides.rootCategoryId : raw.rootCategoryId, rootCategoryId: overrides.rootCategoryId !== undefined ? overrides.rootCategoryId : raw.rootCategoryId,
role: overrides.role !== undefined ? overrides.role : raw.role,
}; };
} }