From bc284c6d42c6f59341602f829b08afbede257b87 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 2 Nov 2026 00:19:03 +0100 Subject: [PATCH] u --- .agents/skills/backend/SKILL.md | 33 ++- src/api/autoinvite.js | 24 ++ src/api/companyuser.js | 30 +++ src/api/index.js | 2 + src/automats/companyAutomat.js | 150 +++++++++++++ src/automats/index.js | 6 + .../settings/MembersSettingsPanel.jsx | 207 ++++++++++++++++++ src/components/settings/SettingsDialog.jsx | 21 +- src/components/settings/index.js | 1 + src/utils/companyForm.js | 1 + 10 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 src/api/autoinvite.js create mode 100644 src/api/companyuser.js create mode 100644 src/components/settings/MembersSettingsPanel.jsx diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index 45afd76..a88a551 100644 --- a/.agents/skills/backend/SKILL.md +++ b/.agents/skills/backend/SKILL.md @@ -131,7 +131,7 @@ Strip supplierName?: str from T11 (resolve via entityId in cached supplier list) # Dash API Routes Overview (All) -Overview of all 307 API endpoints across all modules. +Overview of all 314 API endpoints across all modules. ### 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 }` - `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 }` -- `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 }` - `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 }` @@ -407,6 +407,18 @@ _none_ - Params: `body: { companyId: int }` - 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 #### List backups and service status — `GET /backup` @@ -548,6 +560,21 @@ _none_ - Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - 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 #### List customer groups — `GET /customergroup` @@ -654,6 +681,8 @@ _none_ - `ledger`: `{}` - `attributename`: `{}` - `propertyname`: `{}` + - `companyuser`: `{}` + - `autoinvite`: `{}` - `labeltemplate`: `{}` - `printer`: `{}` - `printeragent`: `{}` diff --git a/src/api/autoinvite.js b/src/api/autoinvite.js new file mode 100644 index 0000000..4415d91 --- /dev/null +++ b/src/api/autoinvite.js @@ -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 +}; diff --git a/src/api/companyuser.js b/src/api/companyuser.js new file mode 100644 index 0000000..ddcbf55 --- /dev/null +++ b/src/api/companyuser.js @@ -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 +}; diff --git a/src/api/index.js b/src/api/index.js index 2a39d9c..faf2ba1 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -34,6 +34,8 @@ export { shipment } from './shipment.js'; export { shipping } from './shipping.js'; export { payment } from './payment.js'; export { account } from './account.js'; +export { companyuser } from './companyuser.js'; +export { autoinvite } from './autoinvite.js'; export { mcp } from './mcp.js'; export { backup } from './backup.js'; export { carrier } from './carrier.js'; diff --git a/src/automats/companyAutomat.js b/src/automats/companyAutomat.js index 181dc85..e0a1c68 100644 --- a/src/automats/companyAutomat.js +++ b/src/automats/companyAutomat.js @@ -15,6 +15,7 @@ * - Brands (`brands`, `filteredBrands`, `brandFilter`) * - Product attributes & values (`attributes`) * - Payment accounts (`accounts`) + * - Members & pending invites (`members`, `invites`; admin only, loaded on demand) * * Lives with the 1st-level navigation shell (`AppShell`) and triggers when * `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`). @@ -39,6 +40,8 @@ import { attributename as attributenameApi, attributevalue as attributevalueApi, account as accountApi, + companyuser as companyuserApi, + autoinvite as autoinviteApi, } from '../api'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { normalizeCompany } from '../utils/companyForm'; @@ -330,6 +333,14 @@ const initialState = { accounts: [], accountsStatus: 'idle', accountsError: null, + + // 14. Members & invites (admin only; never part of loadAll) + members: [], + membersStatus: 'idle', + membersError: null, + invites: [], + invitesStatus: 'idle', + invitesError: null, }; let companyAutomat; @@ -350,6 +361,8 @@ let inflightCarriers = null; let inflightBrands = null; let inflightAttributes = null; let inflightAccounts = null; +let inflightMembers = null; +let inflightInvites = null; // #endregion // #region internal state updaters & fetchers @@ -565,6 +578,8 @@ const actions = { inflightBrands = null; inflightAttributes = null; inflightAccounts = null; + inflightMembers = null; + inflightInvites = null; companyAutomat.setState({ ...initialState, viewerPosition: companyAutomat.state.viewerPosition, @@ -1709,6 +1724,122 @@ const actions = { return accounts; }, // #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', () => { 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', () => { void companyAutomat.actions.reload().catch(() => {}); }); @@ -1885,6 +2025,16 @@ export const selectAttributeStatus = () => companyAutomat.state.attributesStatus // Accounts selectors export const selectAccounts = () => companyAutomat.state.accounts; 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 export default companyAutomat; diff --git a/src/automats/index.js b/src/automats/index.js index b45ec03..76f7c63 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -178,6 +178,12 @@ export { selectAttributeStatus, selectAccounts, selectAccountsStatus, + selectCompanyRole, + selectIsCompanyAdmin, + selectMembers, + selectMembersStatus, + selectInvites, + selectInvitesStatus, } from './companyAutomat'; export { default as categoryAutomat } from './categoryAutomat'; diff --git a/src/components/settings/MembersSettingsPanel.jsx b/src/components/settings/MembersSettingsPanel.jsx new file mode 100644 index 0000000..81ff75b --- /dev/null +++ b/src/components/settings/MembersSettingsPanel.jsx @@ -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 ( + + ); + } + + render() { + const { isAdmin, members, membersStatus, invites, invitesStatus, memberEmail, inviteEmail, inviteRole, busy } = + this.state; + + if (!isAdmin) { + return Nur Administratoren können Mitglieder verwalten.; + } + + const loading = membersStatus === 'loading' || invitesStatus === 'loading'; + + return ( + + + + Mitglieder + + + Registrierte Benutzer per E-Mail hinzufügen. Sie können dann zu diesem Unternehmen wechseln. + + + this.setState({ memberEmail: e.target.value })} + sx={{ flex: 1 }} + /> + + + {loading && members.length === 0 && } + {members.map((m) => ( + + {m.email} + {this.renderRoleSelect(m.role, (role) => this.handleSetRole(m, role), busy)} + this.handleRemoveMember(m)}> + + + + ))} + + + + + Einladungen + + + E-Mails, die bei der Registrierung automatisch zum Unternehmen hinzugefügt werden. + + + this.setState({ inviteEmail: e.target.value })} + sx={{ flex: 1 }} + /> + {this.renderRoleSelect(inviteRole, (role) => this.setState({ inviteRole: role }), busy)} + + + {invites.length === 0 && invitesStatus === 'ready' && ( + Keine offenen Einladungen. + )} + {invites.map((i) => ( + + {i.email} + {ROLES.find((r) => r.value === i.role)?.label ?? i.role} + this.handleDeleteInvite(i)}> + + + + ))} + + + ); + } +} diff --git a/src/components/settings/SettingsDialog.jsx b/src/components/settings/SettingsDialog.jsx index 1342673..15dba6f 100644 --- a/src/components/settings/SettingsDialog.jsx +++ b/src/components/settings/SettingsDialog.jsx @@ -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: , Panel: ImportExportSettingsPanel }, { id: TAB_KEYS.backup, label: 'Backups', icon: , Panel: BackupSettingsPanel }, { id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: , Panel: PosTerminalSettingsPanel }, + { id: TAB_KEYS.members, label: 'Mitglieder', icon: , Panel: MembersSettingsPanel }, { id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: , 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 = { diff --git a/src/components/settings/index.js b/src/components/settings/index.js index 94cfe1f..7df130c 100644 --- a/src/components/settings/index.js +++ b/src/components/settings/index.js @@ -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'; diff --git a/src/utils/companyForm.js b/src/utils/companyForm.js index 23b1696..cf6ebc9 100644 --- a/src/utils/companyForm.js +++ b/src/utils/companyForm.js @@ -22,5 +22,6 @@ export function normalizeCompany(raw, overrides = {}) { reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate, taxfreerate: overrides.taxfreerate !== undefined ? overrides.taxfreerate : raw.taxfreerate, rootCategoryId: overrides.rootCategoryId !== undefined ? overrides.rootCategoryId : raw.rootCategoryId, + role: overrides.role !== undefined ? overrides.role : raw.role, }; }