diff --git a/src/App.jsx b/src/App.jsx
index 3315a71..dc98375 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -57,10 +57,16 @@ class App extends PureComponent {
}
if (status !== 'authed') {
+ if (typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize') {
+ const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
+ window.history.replaceState(null, '', `/login?returnTo=${returnTo}`);
+ }
return ;
}
- if (user?.selectedCompanyId == null) {
+ const isOAuthAuthorize = typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize';
+
+ if (user?.selectedCompanyId == null && !isOAuthAuthorize) {
return ;
}
diff --git a/src/api/index.js b/src/api/index.js
index ae5ffaa..0780025 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -30,4 +30,5 @@ export { shipment } from './shipment.js';
export { shipping } from './shipping.js';
export { payment } from './payment.js';
export { account } from './account.js';
+export { mcp } from './mcp.js';
diff --git a/src/api/mcp.js b/src/api/mcp.js
new file mode 100644
index 0000000..d358eea
--- /dev/null
+++ b/src/api/mcp.js
@@ -0,0 +1,40 @@
+import { http } from './http.js';
+
+export const mcp = {
+ // #region GET /api/mcp/authorize — getAuthorize
+ // MARK: calls GET /api/mcp/authorize
+ async getAuthorize(searchParams) {
+ const query = typeof searchParams === 'string'
+ ? (searchParams.startsWith('?') ? searchParams.slice(1) : searchParams)
+ : new URLSearchParams(searchParams).toString();
+ const url = query ? `/api/mcp/authorize?${query}` : '/api/mcp/authorize';
+ return await http(url);
+ },
+ // #endregion
+
+ // #region POST /api/mcp/authorize — postAuthorize
+ // MARK: calls POST /api/mcp/authorize
+ async postAuthorize(payload) {
+ return await http('/api/mcp/authorize', {
+ method: 'POST',
+ body: payload,
+ });
+ },
+ // #endregion
+
+ // #region GET /api/mcp/authorizations — listAuthorizations
+ // MARK: calls GET /api/mcp/authorizations
+ async listAuthorizations() {
+ return await http('/api/mcp/authorizations');
+ },
+ // #endregion
+
+ // #region DELETE /api/mcp/authorizations/:clientId — revokeAuthorization
+ // MARK: calls DELETE /api/mcp/authorizations/:clientId
+ async revokeAuthorization(clientId) {
+ return await http(`/api/mcp/authorizations/${encodeURIComponent(clientId)}`, {
+ method: 'DELETE',
+ });
+ },
+ // #endregion
+};
diff --git a/src/appRouter.jsx b/src/appRouter.jsx
index 53febdd..cff206d 100644
--- a/src/appRouter.jsx
+++ b/src/appRouter.jsx
@@ -29,11 +29,26 @@ import KundeNeuPage from './routes/kunde/neu';
import KundeGruppenPage from './routes/kunde/gruppen';
import KundeEditPage from './routes/kunde/_entityId';
import StatisticsPage from './routes/infos/StatisticsPage';
+import OAuthAuthorizePage from './routes/oauth/OAuthAuthorizePage';
const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage'));
const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage'));
+function LoginRedirect() {
+ const params = new URLSearchParams(window.location.search);
+ const returnTo = params.get('returnTo');
+ return ;
+}
+
const routes = [
+ {
+ path: '/oauth/authorize',
+ element: ,
+ },
+ {
+ path: '/login',
+ element: ,
+ },
{
path: '/',
element: ,
diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js
index 64f55e9..602a612 100644
--- a/src/automats/orderAutomat.js
+++ b/src/automats/orderAutomat.js
@@ -1,10 +1,10 @@
/**
* @file orderAutomat.js
- * @description Manages cached and paginated company sales orders (`GET /api/order`).
+ * @description Manages cached and chunk-paginated company sales orders (`GET /api/order`).
*
* Upstream Triggers:
* - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes.
- * - UI / Routes: On-demand calls to `ensureLoaded()`, `load()`, or `reload()`.
+ * - UI / Routes: Calls `getItems()`, `requireChunk(chunkIndex)`, `loadChunk(chunkIndex)`, or `reload()`.
*/
import { Automat } from 'react-automat';
@@ -38,47 +38,39 @@ import authAutomat from './authAutomat';
/**
* @typedef {Object} OrderAutomatState
- * @property {Order[]} items Loaded orders for the current page.
+ * @property {Order[]|null} items Pre-filled array of orders/nulls, initially null.
* @property {number} total Total unpaged count for pagination calculation.
- * @property {number} offset Current 0-based offset.
- * @property {number} limit Current page limit.
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
* @property {Error|string|null} error Last load error.
*/
+export const CHUNK_SIZE = 50;
+const PREFETCH_CHUNKS = 2;
+
const initialState = {
- items: [],
- filter: '',
- onlyUnpaid: false,
- onlyUndelivered: false,
- seekMonths: 0,
- seekYears: 0,
- negativeSeekMonths: 0,
+ items: null,
total: 0,
- offset: 0,
- limit: 20,
- hasMore: false,
- loadingMore: false,
status: 'idle',
error: null,
};
let automat;
-let inflightLoad = null;
-let inflightMore = null;
-const FETCH_PAGE_SIZE = 100;
+const loadedChunks = new Set();
+const inflightChunks = new Map();
// MARK: pure order helpers
export function isOrderPaid(order) {
+ if (!order) return false;
return Boolean(order.paid ?? order.payed ?? order.isPaid ?? order.is_paid ?? order.paidAt);
}
export function isOrderDelivered(order) {
+ if (!order) return false;
return Boolean(order.shipped ?? order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt);
}
-
export function getOrderDate(order) {
+ if (!order) return null;
const raw = order.date || order.createdAt;
if (!raw) return null;
const d = new Date(raw);
@@ -87,7 +79,6 @@ export function getOrderDate(order) {
/**
* Calculates cutoff date for negative seek.
- * Negative seek filters orders older than or equal to (today + months).
* @param {number} months Negative integer (e.g. -1 for 1 month ago, -12 for 1 year ago)
* @returns {Date|null}
*/
@@ -101,7 +92,6 @@ export function getNegativeSeekCutoff(months) {
/**
* Formats negative seek duration in human readable German.
- * e.g. -1 -> "-1 Monat", -2 -> "-2 Monate", -12 -> "-1 Jahr", -13 -> "-1 Jahr 1 Monat"
* @param {number} months
* @returns {string}
*/
@@ -120,8 +110,8 @@ export function formatNegativeSeek(months) {
return `-${parts.join(' ')}`;
}
-// MARK: fetches a single page from GET /api/order
-async function fetchOrdersPage(offset = 0, limit = FETCH_PAGE_SIZE) {
+// MARK: fetches a single chunk from GET /api/order
+async function fetchOrdersPage(offset = 0, limit = CHUNK_SIZE) {
const res = await orderApi.list({ offset, limit });
const orders = Array.isArray(res?.orders) ? res.orders : [];
const total = Number(res?.total ?? orders.length);
@@ -129,9 +119,9 @@ async function fetchOrdersPage(offset = 0, limit = FETCH_PAGE_SIZE) {
}
/**
- * Client-side filter over number, external reference, customer name, paid/delivered flags, and negative seek.
+ * Client-side filter helper over loaded orders.
* @param {Order[]} items
- * @param {string|object} filterOrOpts String filter query or options object { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths }
+ * @param {string|object} filterOrOpts String filter query or options object
* @param {(order: Order) => string} [nameOf] Customer name resolver.
*/
export function filterOrders(items, filterOrOpts, nameOf) {
@@ -147,11 +137,11 @@ export function filterOrders(items, filterOrOpts, nameOf) {
const cutoff = getNegativeSeekCutoff(seekMonths);
return items.filter((o) => {
+ if (!o) return false;
if (onlyUnpaid && isOrderPaid(o)) return false;
if (onlyUndelivered && isOrderDelivered(o)) return false;
if (cutoff) {
const orderDate = getOrderDate(o);
- // Older than cutoff point: order date <= cutoff
if (!orderDate || orderDate.getTime() > cutoff.getTime()) {
return false;
}
@@ -166,230 +156,173 @@ export function filterOrders(items, filterOrOpts, nameOf) {
}
const actions = {
- /** Sets the active search filter. */
- filter(query) {
- automat.setState({ filter: String(query ?? '') });
- },
-
- /** Sets whether to filter for only unpaid orders. */
- setOnlyUnpaid(onlyUnpaid) {
- automat.setState({ onlyUnpaid: Boolean(onlyUnpaid) });
- },
-
- /** Sets whether to filter for only undelivered orders. */
- setOnlyUndelivered(onlyUndelivered) {
- automat.setState({ onlyUndelivered: Boolean(onlyUndelivered) });
- },
-
- /** Increments seek by 1 month. */
- addSeekMonth() {
- const seekMonths = (automat.state.seekMonths ?? 0) + 1;
- const seekYears = automat.state.seekYears ?? 0;
- const totalMonths = -(seekMonths + seekYears * 12);
- automat.setState({ seekMonths, negativeSeekMonths: totalMonths });
- },
-
- /** Increments seek by 1 year. */
- addSeekYear() {
- const seekMonths = automat.state.seekMonths ?? 0;
- const seekYears = (automat.state.seekYears ?? 0) + 1;
- const totalMonths = -(seekMonths + seekYears * 12);
- automat.setState({ seekYears, negativeSeekMonths: totalMonths });
- },
-
- /** Cumulative negative seek: adds months (e.g. -1 or -12) to current negativeSeekMonths. */
- addNegativeSeek(months) {
- if (months === -12) {
- actions.addSeekYear();
- } else if (months === -1) {
- actions.addSeekMonth();
- } else {
- const current = Math.min(0, Number(automat.state.negativeSeekMonths ?? 0));
- const next = current + months;
- automat.setState({ negativeSeekMonths: Math.min(0, next) });
+ // MARK: returns current items and triggers chunk 0 if not loaded
+ getItems() {
+ if (automat.state.items === null) {
+ void actions.requireChunk(0);
+ return null;
}
+ return automat.state.items;
},
- /** Resets negative seek back to 0 (all dates). */
- resetNegativeSeek() {
- automat.setState({ seekMonths: 0, seekYears: 0, negativeSeekMonths: 0 });
- },
-
- /** Resets all filters. */
- resetFilters() {
- automat.setState({
- filter: '',
- onlyUnpaid: false,
- onlyUndelivered: false,
- seekMonths: 0,
- seekYears: 0,
- negativeSeekMonths: 0,
- });
- },
-
- /**
- * Synchronously returns current items and triggers loading if idle.
- * On remounts, directly returns cached items from the automat.
- *
- * @param {(order: Order) => string} [nameOf] Optional customer name resolver.
- * @returns {Order[]}
- */
- getOrLoad(nameOf) {
- if (automat.state.status === 'idle') {
- void actions.load().catch(() => {});
+ // MARK: ensures chunkIndex and lookahead chunks are loaded/loading
+ requireChunk(chunkIndex) {
+ const start = Math.max(0, Number(chunkIndex) || 0);
+ const p = actions.loadChunk(start);
+ for (let c = start + 1; c <= start + PREFETCH_CHUNKS; c++) {
+ const total = automat.state.total;
+ if (total > 0 && c * CHUNK_SIZE >= total) {
+ break;
+ }
+ void actions.loadChunk(c);
}
- const { items, filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } = automat.state;
- return filterOrders(items, { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths }, nameOf);
+ return p;
},
- /**
- * Loads the initial 100 orders, deduplicating concurrent callers.
- *
- * @returns {Promise}
- */
- load() {
- if (inflightLoad) return inflightLoad;
- automat.setState({ status: 'loading', error: null });
- inflightLoad = fetchOrdersPage(0, FETCH_PAGE_SIZE)
+ // MARK: fetches a single chunk and populates prefilled array
+ loadChunk(chunkIndex) {
+ const index = Math.max(0, Number(chunkIndex) || 0);
+ if (loadedChunks.has(index)) {
+ return Promise.resolve(automat.state.items);
+ }
+ if (inflightChunks.has(index)) {
+ return inflightChunks.get(index);
+ }
+
+ const offset = index * CHUNK_SIZE;
+ const limit = CHUNK_SIZE;
+ if (automat.state.items === null && automat.state.status === 'idle') {
+ automat.setState({ status: 'loading', error: null });
+ }
+
+ const promise = fetchOrdersPage(offset, limit)
.then(({ orders, total }) => {
+ let items = automat.state.items;
+ const currentTotal = total > 0 ? total : (orders.length + offset);
+
+ if (items === null) {
+ // Pre-fill items array with that many empty (null) items
+ items = new Array(currentTotal).fill(null);
+ } else if (items.length !== currentTotal) {
+ const next = new Array(currentTotal).fill(null);
+ const copyLen = Math.min(items.length, currentTotal);
+ for (let i = 0; i < copyLen; i++) {
+ next[i] = items[i];
+ }
+ items = next;
+ } else {
+ // Shallow copy to notify subscribers and PureComponents
+ items = [...items];
+ }
+
+ // Fill in real received items
+ for (let i = 0; i < orders.length; i++) {
+ const idx = offset + i;
+ if (idx < items.length) {
+ items[idx] = orders[i];
+ }
+ }
+
+ loadedChunks.add(index);
+
automat.setState({
- items: orders,
- total,
- offset: 0,
- limit: FETCH_PAGE_SIZE,
- hasMore: orders.length < total && orders.length >= FETCH_PAGE_SIZE,
- loadingMore: false,
+ items,
+ total: currentTotal,
status: 'ready',
error: null,
});
- return orders;
- })
- .catch((error) => {
- automat.setState({ status: 'failed', error });
- throw error;
- })
- .finally(() => { inflightLoad = null; });
- return inflightLoad;
- },
- /**
- * Loads the next batch of 100 orders when pagination approaches the end.
- *
- * @returns {Promise}
- */
- loadMore(minNeeded) {
- const { items, total, status, loadingMore } = automat.state;
- if (status !== 'ready' || loadingMore) return Promise.resolve(items);
- if (total > 0 && items.length >= total) return Promise.resolve(items);
- if (inflightMore) return inflightMore;
+ // Trigger lookahead prefetching if first chunk just arrived
+ if (index === 0) {
+ for (let c = 1; c <= PREFETCH_CHUNKS; c++) {
+ if (c * CHUNK_SIZE < currentTotal) {
+ void actions.loadChunk(c);
+ }
+ }
+ }
- automat.setState({ loadingMore: true });
- const offset = items.length;
- const fetchLimit =
- minNeeded && minNeeded > offset
- ? Math.max(FETCH_PAGE_SIZE, minNeeded - offset)
- : FETCH_PAGE_SIZE;
- inflightMore = fetchOrdersPage(offset, fetchLimit)
- .then(({ orders: nextBatch, total: newTotal }) => {
- const existingIds = new Set(automat.state.items.map((o) => o.id));
- const uniqueNext = nextBatch.filter((o) => !existingIds.has(o.id));
- const merged = [...automat.state.items, ...uniqueNext];
- const finalTotal = Math.max(newTotal, merged.length);
- automat.setState({
- items: merged,
- total: finalTotal,
- hasMore: nextBatch.length > 0 && merged.length < finalTotal,
- loadingMore: false,
- });
- return merged;
+ return items;
})
.catch((err) => {
- console.error('[orderAutomat] loadMore error:', err);
- automat.setState({ loadingMore: false });
- return automat.state.items;
+ console.error(`[orderAutomat] loadChunk(${index}) error:`, err);
+ if (automat.state.items === null) {
+ automat.setState({ status: 'failed', error: err });
+ }
+ throw err;
})
.finally(() => {
- inflightMore = null;
+ inflightChunks.delete(index);
});
- return inflightMore;
+ inflightChunks.set(index, promise);
+ return promise;
},
- /**
- * Silently re-fetches loaded orders without flashing loading state.
- *
- * @returns {Promise}
- */
- async reload() {
- try {
- const currentCount = Math.max(FETCH_PAGE_SIZE, automat.state.items.length);
- const { orders, total } = await fetchOrdersPage(0, currentCount);
- automat.setState({
- items: orders,
- total,
- hasMore: orders.length < total,
- loadingMore: false,
- status: 'ready',
- error: null,
- });
- return orders;
- } catch (error) {
- automat.setState({ error });
- throw error;
- }
+ // MARK: initial load
+ load() {
+ return actions.loadChunk(0);
},
- /**
- * Ensures orders are loaded once the selected company is known.
- *
- * @returns {Promise}
- */
+ // MARK: ensure loaded
ensureLoaded() {
- if (automat.state.status === 'ready') {
+ if (automat.state.items !== null) {
return Promise.resolve(automat.state.items);
}
- return actions.load();
+ return actions.loadChunk(0);
},
- /**
- * Replaces cached orders without network round-trip.
- *
- * @param {Order[]} items
- * @returns {Order[]}
- */
+ // MARK: reload all previously loaded chunks
+ async reload() {
+ const chunksToReload = Array.from(loadedChunks);
+ loadedChunks.clear();
+ inflightChunks.clear();
+ if (chunksToReload.length === 0) {
+ return actions.loadChunk(0);
+ }
+ await actions.loadChunk(0);
+ for (const c of chunksToReload) {
+ if (c !== 0 && c * CHUNK_SIZE < automat.state.total) {
+ void actions.loadChunk(c);
+ }
+ }
+ return automat.state.items;
+ },
+
+ // MARK: replace items directly
replace(items) {
+ loadedChunks.clear();
+ inflightChunks.clear();
+ const count = Array.isArray(items) ? items.length : 0;
automat.setState({
items,
- total: items.length,
- hasMore: false,
- loadingMore: false,
+ total: count,
status: 'ready',
error: null,
});
return items;
},
- /**
- * Patches an order locally in the automat items cache.
- *
- * @param {number|string} id Order ID.
- * @param {Partial} patch Partial properties to update.
- * @returns {Order[]}
- */
+ // MARK: patches an order locally in the automat items cache
patchOrder(id, patch) {
- const items = automat.state.items.map((o) =>
- o.id === id ? { ...o, ...patch } : o,
- );
- automat.setState({ items });
+ if (!Array.isArray(automat.state.items)) return automat.state.items;
+ let changed = false;
+ const items = automat.state.items.map((o) => {
+ if (o && o.id === id) {
+ changed = true;
+ return { ...o, ...patch };
+ }
+ return o;
+ });
+ if (changed) {
+ automat.setState({ items });
+ }
return items;
},
- /**
- * Resets to idle state (called on logout).
- */
+ // MARK: resets state
reset() {
- inflightLoad = null;
- inflightMore = null;
+ loadedChunks.clear();
+ inflightChunks.clear();
automat.setState(initialState);
},
};
@@ -397,7 +330,20 @@ const actions = {
export const orderAutomat = new Automat(initialState, actions, { name: 'order' });
automat = orderAutomat;
-// Reset on logout or company change (lazy-loaded on demand by views via ensureLoaded).
+// MARK: exported methods for direct invocation
+export function getItems() {
+ return actions.getItems();
+}
+
+export function requireChunk(chunkIndex) {
+ return actions.requireChunk(chunkIndex);
+}
+
+orderAutomat.getItems = getItems;
+orderAutomat.requireChunk = requireChunk;
+orderAutomat.CHUNK_SIZE = CHUNK_SIZE;
+
+// Reset on logout or company change
let lastCompanyId = null;
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId ?? null;
@@ -424,59 +370,32 @@ export function selectOrderTotal(state = automat.state) {
return state.total;
}
-/** Selects whether more orders can be fetched from backend */
-export function selectHasMoreOrders(state = automat.state) {
- return state.hasMore;
-}
-
-/** Selects whether an incremental fetch is currently in flight */
-export function selectIsLoadingMoreOrders(state = automat.state) {
- return state.loadingMore;
-}
-
/** Selects unpaid filter */
export function selectOnlyUnpaid(state = automat.state) {
- return state.onlyUnpaid;
+ return false;
}
/** Selects undelivered filter */
export function selectOnlyUndelivered(state = automat.state) {
- return state.onlyUndelivered;
+ return false;
}
/** Selects negative seek offset in months */
export function selectNegativeSeekMonths(state = automat.state) {
- return state.negativeSeekMonths;
-}
-
-/** Selects seek month count */
-export function selectSeekMonths(state = automat.state) {
- return state.seekMonths ?? 0;
-}
-
-/** Selects seek year count */
-export function selectSeekYears(state = automat.state) {
- return state.seekYears ?? 0;
+ return 0;
}
/**
* Calculates client pagination metadata.
- * Current Page: Math.floor(offset / limit) + 1
- * Total Pages: Math.ceil(total / limit)
- * Next Offset: (page - 1) * limit
*/
export function selectOrderPagination(state = automat.state) {
const total = state.total ?? 0;
- const offset = state.offset ?? 0;
- const limit = state.limit || 20;
- const currentPage = Math.floor(offset / limit) + 1;
- const pageCount = Math.max(1, Math.ceil(total / limit));
return {
total,
- offset,
- limit,
- currentPage,
- pageCount,
+ offset: 0,
+ limit: CHUNK_SIZE,
+ currentPage: 1,
+ pageCount: Math.max(1, Math.ceil(total / CHUNK_SIZE)),
};
}
diff --git a/src/components/LoginPage.jsx b/src/components/LoginPage.jsx
index 6180332..693adac 100644
--- a/src/components/LoginPage.jsx
+++ b/src/components/LoginPage.jsx
@@ -52,6 +52,11 @@ class LoginPage extends PureComponent {
this.setState({ busy: true });
await authAutomat.actions.loginWithGoogle(idToken);
await bootstrapAuthMe();
+ const params = new URLSearchParams(window.location.search);
+ const returnTo = params.get('returnTo');
+ if (returnTo) {
+ window.location.replace(returnTo);
+ }
} catch (err) {
if (err instanceof GoogleSignInCancelledError) return;
logError('[login] Google sign-in failed', {
diff --git a/src/components/manifest/OrderList.jsx b/src/components/manifest/OrderList.jsx
index 20ea720..8bb63a4 100644
--- a/src/components/manifest/OrderList.jsx
+++ b/src/components/manifest/OrderList.jsx
@@ -3,25 +3,20 @@ import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
-import Divider from '@mui/material/Divider';
-import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import ListItemButton from '@mui/material/ListItemButton';
import Typography from '@mui/material/Typography';
import Checkbox from '@mui/material/Checkbox';
-import Switch from '@mui/material/Switch';
+import Skeleton from '@mui/material/Skeleton';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import { enqueueSnackbar } from 'notistack';
-import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError';
import orderAutomat, {
- filterOrders,
+ CHUNK_SIZE,
isOrderPaid,
isOrderDelivered,
- getNegativeSeekCutoff,
- formatNegativeSeek,
} from '../../automats/orderAutomat';
import { order as orderApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError';
@@ -82,13 +77,42 @@ class OrderRow extends PureComponent {
}
render() {
- const { item: order } = this.props;
+ const { item: order, disabled } = this.props;
// Data-free probe (FillSpace measures the row height with `item` undefined).
if (!order) {
+ if (disabled) {
+ return (
+
+
+
+ );
+ }
return (
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
@@ -187,239 +211,13 @@ class OrderRow extends PureComponent {
}
}
-// MARK:
-class OrderSearchField extends PureComponent {
- constructor(props) {
- super(props);
- const s = orderAutomat.state;
- this.state = {
- input: s.filter ?? '',
- onlyUnpaid: s.onlyUnpaid ?? false,
- onlyUndelivered: s.onlyUndelivered ?? false,
- seekMonths: s.seekMonths ?? 0,
- seekYears: s.seekYears ?? 0,
- negativeSeekMonths: s.negativeSeekMonths ?? 0,
- };
- }
-
- componentDidMount() {
- this.unsub = orderAutomat.subscribe(this, (state) => ({
- input: state.filter ?? '',
- onlyUnpaid: state.onlyUnpaid ?? false,
- onlyUndelivered: state.onlyUndelivered ?? false,
- seekMonths: state.seekMonths ?? 0,
- seekYears: state.seekYears ?? 0,
- negativeSeekMonths: state.negativeSeekMonths ?? 0,
- }));
- }
-
- componentWillUnmount() {
- this.unsub?.();
- }
-
- render() {
- const { inputRef, onCreate } = this.props;
- const { input, onlyUnpaid, onlyUndelivered, seekMonths, seekYears } = this.state;
- const monthActive = seekMonths > 0;
- const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
- const yearActive = seekYears > 0;
- const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`;
-
- return (
-
-
- {/* Top row: search field & create button */}
-
- orderAutomat.actions.filter(next)}
- />
-
-
-
- {/* Filter Bar: "Nur ungezahlte", "Nur ungelieferte", Negativeseek (-1 Month, -1 Year, Reset) */}
-
- {/* Left: Unpaid & Undelivered toggles */}
-
- orderAutomat.actions.setOnlyUnpaid(e.target.checked)}
- />
- }
- label={
-
- Nur ungezahlte
-
- }
- sx={{ m: 0 }}
- />
-
-
-
- orderAutomat.actions.setOnlyUndelivered(e.target.checked)}
- />
- }
- label={
-
- Nur ungelieferte
-
- }
- sx={{ m: 0 }}
- />
-
-
- {/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */}
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
// MARK:
export class OrderList extends PureComponent {
constructor(props) {
super(props);
- const s = orderAutomat.state;
+ // getItems() returns current items or null and triggers loading of the first chunk
this.state = {
- status: s.status === 'ready' ? 'ready' : 'loading',
- error: s.error ? backendErrorFrom(s.error) : null,
- filteredItems: orderAutomat.actions.getOrLoad(),
- items: s.items ?? [],
- total: s.total ?? 0,
- filter: s.filter ?? '',
- onlyUnpaid: s.onlyUnpaid ?? false,
- onlyUndelivered: s.onlyUndelivered ?? false,
- negativeSeekMonths: s.negativeSeekMonths ?? 0,
- hasMore: s.hasMore ?? false,
- loadingMore: s.loadingMore ?? false,
+ items: orderAutomat.getItems(),
partiesById: {},
busyId: null,
};
@@ -432,7 +230,74 @@ export class OrderList extends PureComponent {
this.handleTogglePaid = this.handleTogglePaid.bind(this);
this.handleToggleDelivered = this.handleToggleDelivered.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
- this.nameOf = this.nameOf.bind(this);
+ }
+
+ componentDidMount() {
+ this._cancelled = false;
+ // Subscribe to items only
+ this.unsub = orderAutomat.subscribe(this, (state) => ({
+ items: state.items,
+ }));
+ void this.loadParties();
+ }
+
+ componentWillUnmount() {
+ this._cancelled = true;
+ this.unsub?.();
+ }
+
+ async loadParties() {
+ try {
+ const parties = await loadParties('order');
+ if (!this._cancelled) {
+ this.setState({ partiesById: cfg.partiesByIdFromList(parties) });
+ }
+ } catch {
+ // Non-fatal: names fall back to ids.
+ }
+ }
+
+ handlePageChange(page, meta) {
+ const pageSize = meta?.pageSize ?? 10;
+ const startIndex = (page - 1) * pageSize;
+ const items = this.state.items;
+ const maxIndex = items ? items.length - 1 : startIndex;
+ const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
+
+ const startChunk = Math.floor(startIndex / CHUNK_SIZE);
+ const endChunk = Math.floor(endIndex / CHUNK_SIZE);
+
+ orderAutomat.requireChunk(startChunk);
+ if (endChunk !== startChunk) {
+ orderAutomat.requireChunk(endChunk);
+ }
+ }
+
+ handleRetry() {
+ void orderAutomat.actions.requireChunk(0).catch(() => {});
+ }
+
+ handleCreate() {
+ this.props.navigate(cfg.createPath);
+ }
+
+ handleOpen(order) {
+ this.props.navigate(cfg.editPath(order.id));
+ }
+
+ async handleDelete(order) {
+ if (this.state.busyId != null) return;
+ if (!window.confirm(cfg.confirmDelete(order.number))) return;
+ this.setState({ busyId: order.id });
+ try {
+ await orderApi.delete(order.id);
+ await orderAutomat.actions.reload();
+ enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
+ } catch (err) {
+ notifyError(err);
+ } finally {
+ if (!this._cancelled) this.setState({ busyId: null });
+ }
}
async handleTogglePaid(order, checked) {
@@ -463,122 +328,19 @@ export class OrderList extends PureComponent {
}
}
- handlePageChange(page, meta) {
- const pageSize = meta?.pageSize ?? 10;
- const loadedPages = Math.ceil(this.state.items.length / pageSize);
- // Load next batch when the pagination comes close to the end of loaded items (within 2 pages) or beyond.
- if (loadedPages - page <= 2 || page > loadedPages) {
- void orderAutomat.actions.loadMore(page * pageSize);
- }
- }
-
- componentDidMount() {
- this._cancelled = false;
- this.unsub = orderAutomat.subscribe(this, (state) => ({
- items: state.items ?? [],
- total: state.total ?? 0,
- filter: state.filter ?? '',
- onlyUnpaid: state.onlyUnpaid ?? false,
- onlyUndelivered: state.onlyUndelivered ?? false,
- negativeSeekMonths: state.negativeSeekMonths ?? 0,
- hasMore: state.hasMore ?? false,
- loadingMore: state.loadingMore ?? false,
- filteredItems: filterOrders(state.items ?? [], {
- filter: state.filter,
- onlyUnpaid: state.onlyUnpaid,
- onlyUndelivered: state.onlyUndelivered,
- negativeSeekMonths: state.negativeSeekMonths,
- }, this.nameOf),
- status: state.status,
- error: state.error ? backendErrorFrom(state.error) : null,
- }));
- void this.loadAll();
- }
-
- componentWillUnmount() {
- this._cancelled = true;
- this.unsub?.();
- }
-
- async loadAll() {
- try {
- const parties = await loadParties('order');
- if (!this._cancelled) {
- const partiesById = cfg.partiesByIdFromList(parties);
- this.setState((prev) => ({
- partiesById,
- filteredItems: filterOrders(prev.items, {
- filter: prev.filter,
- onlyUnpaid: prev.onlyUnpaid,
- onlyUndelivered: prev.onlyUndelivered,
- negativeSeekMonths: prev.negativeSeekMonths,
- }, (o) => partyDisplayName('order', partiesById[o.entityId])),
- }));
- }
- } catch {
- // Non-fatal: names fall back to ids.
- }
- }
-
- nameOf(order) {
- const party = this.state.partiesById[order.entityId];
- return partyDisplayName('order', party);
- }
-
- handleRetry() {
- void orderAutomat.actions.load().catch(() => { });
- }
-
- handleCreate() {
- this.props.navigate(cfg.createPath);
- }
-
- handleOpen(order) {
- this.props.navigate(cfg.editPath(order.id));
- }
-
- async handleDelete(order) {
- if (this.state.busyId != null) return;
- if (!window.confirm(cfg.confirmDelete(order.number))) return;
- this.setState({ busyId: order.id });
- try {
- await orderApi.delete(order.id);
- await orderAutomat.actions.reload();
- enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
- } catch (err) {
- notifyError(err);
- } finally {
- if (!this._cancelled) this.setState({ busyId: null });
- }
- }
-
render() {
- const {
- status,
- error,
- items,
- total,
- filter,
- onlyUnpaid,
- onlyUndelivered,
- negativeSeekMonths,
- hasMore,
- loadingMore,
- filteredItems,
- partiesById,
- busyId,
- } = this.state;
+ const { items, partiesById, busyId } = this.state;
- if (status === 'loading' || status === 'idle') {
+ if (items === null) {
+ if (orderAutomat.state.status === 'failed') {
+ return ;
+ }
return (
);
}
- if (status === 'failed') {
- return ;
- }
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
@@ -587,58 +349,64 @@ export class OrderList extends PureComponent {
rowContext.onTogglePaid = this.handleTogglePaid;
rowContext.onToggleDelivered = this.handleToggleDelivered;
- const rows = filteredItems ?? [];
- const isFiltered = Boolean(filter || onlyUnpaid || onlyUndelivered || negativeSeekMonths < 0);
- const displayTotal = isFiltered ? rows.length : (total || items.length);
-
return (
- <>
-
-
-
-
- Nr. / Ref.
- Datum
- Kunde
- Bezahlt
- Geliefert
- Pos.
- Gesamt
-
-
- }
- onPageChange={this.handlePageChange}
- />
-
-
- >
+
+
+
+
+
+
+ Nr. / Ref.
+ Datum
+ Kunde
+ Bezahlt
+ Geliefert
+ Pos.
+ Gesamt
+
+
+ }
+ onPageChange={this.handlePageChange}
+ />
+
+
);
}
}
diff --git a/src/components/manifest/OrderPaymentList.jsx b/src/components/manifest/OrderPaymentList.jsx
index 160b976..a267bdb 100644
--- a/src/components/manifest/OrderPaymentList.jsx
+++ b/src/components/manifest/OrderPaymentList.jsx
@@ -398,7 +398,7 @@ export class OrderPaymentList extends PureComponent {
extractAllPayments(orders = this.state.items) {
const list = [];
for (const order of orders) {
- if (!Array.isArray(order.payments)) continue;
+ if (!order || !Array.isArray(order.payments)) continue;
for (const pay of order.payments) {
list.push({
...pay,
diff --git a/src/components/manifest/ShipmentList.jsx b/src/components/manifest/ShipmentList.jsx
index 6e4fab9..8560cc4 100644
--- a/src/components/manifest/ShipmentList.jsx
+++ b/src/components/manifest/ShipmentList.jsx
@@ -486,7 +486,7 @@ export class ShipmentList extends PureComponent {
extractAllShipments(orders = this.state.items) {
const list = [];
for (const order of orders) {
- if (!Array.isArray(order.shipments)) continue;
+ if (!order || !Array.isArray(order.shipments)) continue;
for (const sh of order.shipments) {
list.push({
...sh,
diff --git a/src/components/settings/McpAuthorizationsPanel.jsx b/src/components/settings/McpAuthorizationsPanel.jsx
new file mode 100644
index 0000000..e5d15d1
--- /dev/null
+++ b/src/components/settings/McpAuthorizationsPanel.jsx
@@ -0,0 +1,312 @@
+import { Component } from 'react';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import CircularProgress from '@mui/material/CircularProgress';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import Card from '@mui/material/Card';
+import CardContent from '@mui/material/CardContent';
+import Chip from '@mui/material/Chip';
+import IconButton from '@mui/material/IconButton';
+import Alert from '@mui/material/Alert';
+import Dialog from '@mui/material/Dialog';
+import DialogTitle from '@mui/material/DialogTitle';
+import DialogContent from '@mui/material/DialogContent';
+import DialogContentText from '@mui/material/DialogContentText';
+import DialogActions from '@mui/material/DialogActions';
+import RefreshIcon from '@mui/icons-material/Refresh';
+import SecurityIcon from '@mui/icons-material/Security';
+import ComputerIcon from '@mui/icons-material/Computer';
+import KeyIcon from '@mui/icons-material/Key';
+import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined';
+import { enqueueSnackbar } from 'notistack';
+import { mcp } from '../../api';
+import { notifyError } from '../../utils/notify';
+import { SURFACE_BASE, SURFACE_RAISED, BORDER, DARK_2 } from '../../colors';
+
+export default class McpAuthorizationsPanel extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ status: 'loading',
+ authorizations: [],
+ error: null,
+ confirmTarget: null,
+ revoking: false,
+ };
+
+ this.loadAuthorizations = this.loadAuthorizations.bind(this);
+ this.handleRevokeClick = this.handleRevokeClick.bind(this);
+ this.handleConfirmRevoke = this.handleConfirmRevoke.bind(this);
+ this.handleCloseConfirm = this.handleCloseConfirm.bind(this);
+ }
+
+ componentDidMount() {
+ void this.loadAuthorizations();
+ }
+
+ // #region loadAuthorizations
+ // MARK: calls GET /api/mcp/authorizations from ./api
+ async loadAuthorizations() {
+ this.setState({ status: 'loading', error: null });
+ try {
+ const res = await mcp.listAuthorizations();
+ this.setState({
+ authorizations: res?.authorizations || [],
+ status: 'ready',
+ });
+ } catch (err) {
+ this.setState({
+ status: 'error',
+ error: err?.message || 'Fehler beim Laden der MCP-Autorisierungen',
+ });
+ notifyError(err);
+ }
+ }
+ // #endregion
+
+ handleRevokeClick(client) {
+ this.setState({ confirmTarget: client });
+ }
+
+ handleCloseConfirm() {
+ if (this.state.revoking) return;
+ this.setState({ confirmTarget: null });
+ }
+
+ // #region handleConfirmRevoke
+ // MARK: calls DELETE /api/mcp/authorizations/:clientId from ./api
+ async handleConfirmRevoke() {
+ const { confirmTarget } = this.state;
+ if (!confirmTarget) return;
+
+ this.setState({ revoking: true });
+ try {
+ await mcp.revokeAuthorization(confirmTarget.clientId);
+ enqueueSnackbar(`Autorisierung für "${confirmTarget.clientName}" erfolgreich widerrufen.`, {
+ variant: 'success',
+ });
+ this.setState({ confirmTarget: null, revoking: false });
+ await this.loadAuthorizations();
+ } catch (err) {
+ this.setState({ revoking: false });
+ notifyError(err);
+ }
+ }
+ // #endregion
+
+ render() {
+ const { status, authorizations, error, confirmTarget, revoking } = this.state;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ MCP-Autorisierungen & Sicherheit
+
+
+
+ Verwalten Sie externe MCP-Clients (z. B. Visual Studio Code, Cursor), die Zugriff auf Dashback Tools haben.
+
+
+
+
+
+
+
+ {error && (
+
+ Wiederholen
+
+ }>
+ {error}
+
+ )}
+
+ {status === 'loading' && (
+
+
+
+ )}
+
+ {status === 'ready' && authorizations.length === 0 && (
+
+
+
+ Keine aktiven MCP-Clients
+
+
+ Derzeit sind keine externen Anwendungen autorisiert. Wenn Sie einen MCP-Client wie VS Code mit Dash verbinden, wird er hier aufgeführt.
+
+
+ )}
+
+ {status === 'ready' && authorizations.length > 0 && (
+
+ {authorizations.map((auth) => {
+ const authorizedDate = auth.authorizedAt
+ ? new Date(auth.authorizedAt).toLocaleString('de-DE', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+ : '–';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {auth.clientName || 'Unbenannter Client'}
+
+ {auth.scope && (
+
+ )}
+
+
+ Client ID: {auth.clientId}
+
+
+
+ Autorisiert: {authorizedDate}
+
+ {auth.tokenCount != null && (
+
+
+
+ Aktive Tokens: {auth.tokenCount}
+
+
+ )}
+
+
+
+
+ }
+ onClick={() => this.handleRevokeClick(auth)}
+ sx={{ flexShrink: 0, textTransform: 'none' }}
+ >
+ Widerrufen
+
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* Confirmation Dialog */}
+
+
+ );
+ }
+}
diff --git a/src/components/settings/SettingsDialog.jsx b/src/components/settings/SettingsDialog.jsx
index 43120c7..88539a6 100644
--- a/src/components/settings/SettingsDialog.jsx
+++ b/src/components/settings/SettingsDialog.jsx
@@ -19,6 +19,8 @@ import PrinterSettingsPanel from './PrinterSettingsPanel';
import SettingsKeysPanel from './SettingsKeysPanel';
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
+import McpAuthorizationsPanel from './McpAuthorizationsPanel';
+import SecurityIcon from '@mui/icons-material/Security';
import { SURFACE_BASE } from '../../colors';
const TAB_KEYS = {
@@ -28,6 +30,7 @@ const TAB_KEYS = {
printers: 'printers',
importExport: 'importExport',
posterminal: 'posterminal',
+ security: 'security',
};
const TABS = [
@@ -37,6 +40,7 @@ const TABS = [
{ id: TAB_KEYS.printers, label: 'Drucker', icon: , Panel: PrinterSettingsPanel },
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: , Panel: ImportExportSettingsPanel },
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: , Panel: PosTerminalSettingsPanel },
+ { id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: , Panel: McpAuthorizationsPanel },
];
export default class SettingsDialog extends Component {
diff --git a/src/routes/oauth/OAuthAuthorizePage.jsx b/src/routes/oauth/OAuthAuthorizePage.jsx
new file mode 100644
index 0000000..9619549
--- /dev/null
+++ b/src/routes/oauth/OAuthAuthorizePage.jsx
@@ -0,0 +1,437 @@
+import { Component } from 'react';
+import Box from '@mui/material/Box';
+import Card from '@mui/material/Card';
+import CardContent from '@mui/material/CardContent';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import CircularProgress from '@mui/material/CircularProgress';
+import Stack from '@mui/material/Stack';
+import Chip from '@mui/material/Chip';
+import Alert from '@mui/material/Alert';
+import Divider from '@mui/material/Divider';
+import Avatar from '@mui/material/Avatar';
+import CheckIcon from '@mui/icons-material/Check';
+import CloseIcon from '@mui/icons-material/Close';
+import ComputerIcon from '@mui/icons-material/Computer';
+import SecurityIcon from '@mui/icons-material/Security';
+import SyncAltIcon from '@mui/icons-material/SyncAlt';
+import ConstructionIcon from '@mui/icons-material/Construction';
+import { mcp } from '../../api';
+import authAutomat from '../../automats/authAutomat';
+import { notifyError } from '../../utils/notify';
+import quixposLogo from '../../assets/quixpos-logo.png';
+import {
+ MAIN_BACKGROUND,
+ SURFACE_BASE,
+ SURFACE_RAISED,
+ BORDER,
+ BORDER_STRONG,
+ DARK_2,
+ DARK_4,
+} from '../../colors';
+
+export default class OAuthAuthorizePage extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ authStatus: authAutomat.state.status,
+ loading: true,
+ submitting: null, // 'allow' | 'deny' | null
+ consent: null,
+ error: null,
+ };
+
+ this.fetchConsent = this.fetchConsent.bind(this);
+ this.handleAllow = this.handleAllow.bind(this);
+ this.handleDeny = this.handleDeny.bind(this);
+ this.redirectToLogin = this.redirectToLogin.bind(this);
+ }
+
+ componentDidMount() {
+ document.title = 'Zugriffsanfrage autorisieren * Dash MCP';
+
+ this.unsubAuth = authAutomat.subscribe(this, (state) => {
+ const prevStatus = this.state.authStatus;
+ const nextStatus = state.status;
+ if (prevStatus !== nextStatus) {
+ this.setState({ authStatus: nextStatus });
+ if (nextStatus === 'authed') {
+ void this.fetchConsent();
+ } else if (nextStatus === 'anon') {
+ this.redirectToLogin();
+ }
+ }
+ });
+
+ if (this.state.authStatus === 'authed') {
+ void this.fetchConsent();
+ } else if (this.state.authStatus === 'anon') {
+ this.redirectToLogin();
+ }
+ }
+
+ componentWillUnmount() {
+ this.unsubAuth?.();
+ }
+
+ redirectToLogin() {
+ const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
+ window.location.replace(`/login?returnTo=${returnTo}`);
+ }
+
+ // #region fetchConsent
+ // MARK: calls GET /api/mcp/authorize from ./api
+ async fetchConsent() {
+ this.setState({ loading: true, error: null });
+ try {
+ const search = window.location.search;
+ const data = await mcp.getAuthorize(search);
+ this.setState({ consent: data, loading: false });
+ } catch (err) {
+ this.setState({
+ loading: false,
+ error: err?.message || 'Fehler beim Laden der Autorisierungsdetails',
+ });
+ notifyError(err);
+ }
+ }
+ // #endregion
+
+ // #region handleAllow
+ // MARK: calls POST /api/mcp/authorize (action: allow)
+ async handleAllow() {
+ const { consent, submitting } = this.state;
+ if (!consent || submitting) return;
+
+ const params = consent.params || {};
+ this.setState({ submitting: 'allow', error: null });
+
+ try {
+ const payload = {
+ client_id: params.clientId || consent.client?.clientId,
+ redirect_uri: params.redirectUri,
+ code_challenge: params.codeChallenge,
+ code_challenge_method: params.codeChallengeMethod,
+ state: params.state,
+ scope: params.scope || consent.client?.scope || 'mcp:tools',
+ action: 'allow',
+ };
+
+ const res = await mcp.postAuthorize(payload);
+ const redirectUrl = res?.redirectUrl || res?.data?.redirectUrl;
+ if (redirectUrl) {
+ window.location.href = redirectUrl;
+ } else {
+ throw new Error('Keine Weiterleitungs-URL vom Server erhalten.');
+ }
+ } catch (err) {
+ this.setState({ submitting: null });
+ notifyError(err);
+ }
+ }
+ // #endregion
+
+ // #region handleDeny
+ // MARK: calls POST /api/mcp/authorize (action: deny)
+ async handleDeny() {
+ const { consent, submitting } = this.state;
+ if (!consent || submitting) return;
+
+ const params = consent.params || {};
+ this.setState({ submitting: 'deny', error: null });
+
+ try {
+ const payload = {
+ client_id: params.clientId || consent.client?.clientId,
+ redirect_uri: params.redirectUri,
+ state: params.state,
+ action: 'deny',
+ };
+
+ const res = await mcp.postAuthorize(payload);
+ const redirectUrl = res?.redirectUrl || res?.data?.redirectUrl;
+ if (redirectUrl) {
+ window.location.href = redirectUrl;
+ } else {
+ throw new Error('Keine Weiterleitungs-URL vom Server erhalten.');
+ }
+ } catch (err) {
+ this.setState({ submitting: null });
+ notifyError(err);
+ }
+ }
+ // #endregion
+
+ render() {
+ const { authStatus, loading, submitting, consent, error } = this.state;
+
+ if (authStatus === 'loading' || (authStatus === 'authed' && loading)) {
+ return (
+
+
+
+ Autorisierungsanfrage wird geladen...
+
+
+ );
+ }
+
+ const client = consent?.client || {};
+ const user = consent?.user || authAutomat.state.user || {};
+ const params = consent?.params || {};
+ const clientName = client.clientName || 'MCP-Anwendung';
+ const scope = params.scope || client.scope || 'mcp:tools';
+
+ return (
+
+
+ {/* Top Brand Header */}
+
+
+
+
+ DASH MCP
+
+
+
+ }
+ label="OAuth 2.1"
+ size="small"
+ variant="outlined"
+ sx={{
+ fontSize: '0.75rem',
+ borderColor: BORDER_STRONG,
+ color: 'text.secondary',
+ }}
+ />
+
+
+
+ {error ? (
+
+
+ {error}
+
+
+ Die Autorisierungsanfrage ist ungültig oder abgelaufen. Bitte starten Sie die Anmeldung in Ihrem MCP-Client erneut.
+
+
+
+ ) : (
+
+ {/* Visual handshake illustration */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Main Heading */}
+
+
+ {clientName} autorisieren?
+
+
+ Diese Anwendung möchte Zugriff auf Ihre QuixPOS / Dashback Tools erhalten.
+
+
+
+
+
+ {/* Account row */}
+
+
+
+ {(user.email || 'U')[0].toUpperCase()}
+
+
+
+ Autorisieren als Benutzer
+
+
+ {user.email || 'Unbekanntes Konto'}
+
+
+
+
+
+ {/* Scope & Permissions row */}
+
+
+ Angefordertes Zugriffsrecht
+
+
+
+
+
+ Dashback Tools API
+
+
+
+ Ermöglicht dem MCP-Client das Ausführen von Werkzeugen und Abfragen (Artikel, Lager, Kunden, Aufträge) in Ihrem Namen.
+
+
+
+
+
+ Sie können diese Autorisierung jederzeit in den Einstellungen unter Sicherheit & MCP widerrufen.
+
+
+ {/* Action buttons */}
+
+ }
+ onClick={this.handleDeny}
+ sx={{
+ borderRadius: 2,
+ textTransform: 'none',
+ borderColor: BORDER_STRONG,
+ }}
+ >
+ {submitting === 'deny' ? 'Wird abgelehnt...' : 'Ablehnen'}
+
+
+ : }
+ onClick={this.handleAllow}
+ sx={{
+ borderRadius: 2,
+ textTransform: 'none',
+ fontWeight: 600,
+ }}
+ >
+ {submitting === 'allow' ? 'Autorisiere...' : 'Zulassen'}
+
+
+
+ )}
+
+
+
+ );
+ }
+}