From 4c5ccf7f64f72896428fc905b7cea4c850669e8f Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 13 Oct 2026 00:33:38 +0200 Subject: [PATCH] u --- src/App.jsx | 8 +- src/api/index.js | 1 + src/api/mcp.js | 40 ++ src/appRouter.jsx | 15 + src/automats/orderAutomat.js | 407 +++++-------- src/components/LoginPage.jsx | 5 + src/components/manifest/OrderList.jsx | 564 ++++++------------ src/components/manifest/OrderPaymentList.jsx | 2 +- src/components/manifest/ShipmentList.jsx | 2 +- .../settings/McpAuthorizationsPanel.jsx | 312 ++++++++++ src/components/settings/SettingsDialog.jsx | 4 + src/routes/oauth/OAuthAuthorizePage.jsx | 437 ++++++++++++++ 12 files changed, 1152 insertions(+), 645 deletions(-) create mode 100644 src/api/mcp.js create mode 100644 src/components/settings/McpAuthorizationsPanel.jsx create mode 100644 src/routes/oauth/OAuthAuthorizePage.jsx 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} + + + )} + + + + + + + + + ); + })} + + )} + + {/* Confirmation Dialog */} + + + Autorisierung widerrufen? + + + + Möchten Sie den Zugriff für {confirmTarget?.clientName} wirklich widerrufen? + Alle bestehenden Sitzungen und Tokens für diesen MCP-Client werden sofort ungültig. + + + + + + + + + ); + } +} 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 */} + + + + + + + )} + + + + ); + } +}