diff --git a/.agents/skills/automat/SKILL.md b/.agents/skills/automat/SKILL.md index a5c55ca..ea14f30 100644 --- a/.agents/skills/automat/SKILL.md +++ b/.agents/skills/automat/SKILL.md @@ -90,6 +90,7 @@ export class Automat> { dispose(): void; // Unsubs all, revokes blobs, deletes from window registry static get(name: string): Automat | undefined; + static combine>>(automats: M): Automat, CombinedActions>; } // Fetchers & SSE @@ -168,6 +169,30 @@ const live = new Automat({}, { connectSSE(live, '/api/stream'); ``` +### G. Combine Automats +```js +const app = Automat.combine({ + auth: authAutomat, + company: companyAutomat, +}); + +app.state.auth; // authAutomat.state +app.state.company; // companyAutomat.state +app.actions.auth.login(); // authAutomat.actions.login() +app.actions.company.rename('Acme'); + +// Notified when either child changes; selectors can isolate a branch. +const unsubscribe = app.subscribe(this, (state) => ({ + user: state.auth.user, + companyName: state.company.name, +})); +``` + +The aggregate is derived: child updates automatically update the matching key. Its +`isReady`, `isDirty`, `ready`, `read()`, `setDirty()`, and `reload()` operations cover +all children. Calling `app.dispose()` only removes aggregate subscriptions; it does +not dispose the child automats. + ## 3. Decision Matrix: `setDirty()` vs `reload()` | Method | `isReady` | State during fetch | Suspense | Unmounted | Use Case | diff --git a/src/App.jsx b/src/App.jsx index ab02aef..c73516e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,9 +1,8 @@ -import { Component, Suspense } from 'react'; -import { connect } from 'react-redux'; +import { PureComponent, Suspense } from 'react'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import { RouterProvider } from 'react-router-dom'; -import { selectAuthStatus, selectNeedsCompanySetup } from './store/authSlice'; +import authAutomat from './automats/authAutomat'; import LoginPage from './components/LoginPage'; import CompanySetupDialog from './components/CompanySetupDialog'; import { router } from './appRouter'; @@ -24,9 +23,22 @@ function PageFallback() { ); } -class App extends Component { +class App extends PureComponent { + constructor(props) { + super(props); + this.state = authAutomat.state; + } + + componentDidMount() { + this.unsubscribe = authAutomat.subscribe(this, ['status', 'user']); + } + + componentWillUnmount() { + this.unsubscribe?.(); + } + render() { - const { status, needsCompanySetup } = this.props; + const { status, user } = this.state; if (status === 'loading') { return ( @@ -48,7 +60,7 @@ class App extends Component { return ; } - if (needsCompanySetup) { + if (user?.selectedCompanyId == null) { return ; } @@ -60,9 +72,4 @@ class App extends Component { } } -const mapStateToProps = (state) => ({ - status: selectAuthStatus(state), - needsCompanySetup: selectNeedsCompanySetup(state), -}); - -export default connect(mapStateToProps)(App); +export default App; diff --git a/src/automats/authAutomat.js b/src/automats/authAutomat.js new file mode 100644 index 0000000..5657eda --- /dev/null +++ b/src/automats/authAutomat.js @@ -0,0 +1,180 @@ +import { Automat } from 'react-automat'; +import { auth, company } from '../api'; + +/** + * @typedef {Object} AuthUser + * @property {string|number|null} [selectedCompanyId] Currently selected company. + * @property {*} [key] Additional user properties returned by the API. + */ + +/** + * @typedef {Object} AuthState + * @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions. + * @property {string|null} googleClientId Google Identity Services client ID. + * @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state. + */ + +/** Emitted after an authenticated session has been established. */ +export const AUTH_SESSION_ESTABLISHED = 'auth/sessionEstablished'; +/** Emitted after the active company has changed. */ +export const AUTH_COMPANY_CHANGED = 'auth/companyChanged'; +/** Emitted after the current user has logged out. */ +export const AUTH_LOGGED_OUT = 'auth/loggedOut'; + +/** @type {AuthState} */ +const initialState = { + user: null, + googleClientId: null, + status: 'loading', +}; + +let emit = () => { }; + +/** + * Registers the application-level dispatcher used for authentication events. + * + * @param {(event: {type: string}) => void} dispatch Event dispatcher. + * @returns {void} + */ +export function setAuthEventDispatcher(dispatch) { + emit = dispatch; +} + +/** + * Merges API user data while retaining the current company selection when the + * response does not contain one. + * + * @param {AuthUser|null} current Current user. + * @param {AuthUser|null|undefined} incoming User data returned by the API. + * @returns {AuthUser|null} Merged user. + */ +function mergeUser(current, incoming) { + if (!incoming) return current; + const selectedCompanyId = + incoming.selectedCompanyId ?? current?.selectedCompanyId ?? null; + return { ...current, ...incoming, selectedCompanyId }; +} + +let authAutomat; + +const actions = { + /** + * Restores an existing session or requests it from the authentication API. + * + * @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session] + * Optional preloaded session. + * @returns {Promise} Resolved session response. + */ + async bootstrapSession(session) { + try { + const nextSession = session ?? await auth.me(); + const googleClientId = + nextSession.googleClientId ?? authAutomat.state.googleClientId; + + if (!nextSession.user) { + if (authAutomat.state.status !== 'authed') { + authAutomat.setState({ user: null, googleClientId, status: 'anon' }); + } else { + authAutomat.setState({ googleClientId }); + } + return nextSession; + } + + const user = mergeUser(authAutomat.state.user, nextSession.user); + authAutomat.setState({ user, googleClientId, status: 'authed' }); + emit({ type: AUTH_SESSION_ESTABLISHED }); + return nextSession; + } catch (error) { + if (authAutomat.state.status === 'loading') { + authAutomat.setState({ user: null, status: 'anon' }); + } + throw error; + } + }, + + /** + * Exchanges a Google credential for an authenticated application session. + * + * @param {string} credential Google Identity Services credential. + * @returns {Promise} Authenticated session. + */ + async loginWithGoogle(credential) { + try { + const { session } = await auth.loginWithGoogle(credential); + if (!session?.user) { + throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)'); + } + authAutomat.setState({ + user: session.user, + googleClientId: session.googleClientId ?? authAutomat.state.googleClientId, + status: 'authed', + }); + emit({ type: AUTH_SESSION_ESTABLISHED }); + return session; + } catch (error) { + authAutomat.setState({ user: null, status: 'anon' }); + throw error; + } + }, + + /** + * Selects the company used by the current session. + * + * @param {string|number} companyId Company identifier. + * @returns {Promise} Updated user. + */ + async selectCompany(companyId) { + const data = await company.select(companyId); + const user = mergeUser( + authAutomat.state.user, + data.user ?? { selectedCompanyId: companyId }, + ); + authAutomat.setState({ user, status: 'authed' }); + emit({ type: AUTH_COMPANY_CHANGED }); + return user; + }, + + /** + * Creates a company and selects it for the current user. + * + * @param {Object} payload Company creation payload. + * @returns {Promise} Updated user. + */ + async createCompany(payload) { + const data = await company.create(payload); + let incoming = data.user; + if (!incoming && data.company?.id) { + const selected = await company.select(data.company.id); + incoming = selected.user ?? { selectedCompanyId: data.company.id }; + } + if (!incoming) { + throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.'); + } + + const user = mergeUser(authAutomat.state.user, incoming); + authAutomat.setState({ user, status: 'authed' }); + emit({ type: AUTH_COMPANY_CHANGED }); + return user; + }, + + /** + * Ends the server session, clears the current user, and returns to `/`. + * + * @returns {Promise} + */ + async logout() { + await auth.logout(); + window.history.replaceState(null, '', '/'); + authAutomat.setState({ user: null, status: 'anon' }); + emit({ type: AUTH_LOGGED_OUT }); + }, +}; + +/** + * Global authentication state container. + * + * @type {Automat} + */ +authAutomat = new Automat(initialState, actions, { name: 'auth' }); + +export default authAutomat; diff --git a/src/automats/deliveryEditorAutomat.js b/src/automats/deliveryEditorAutomat.js index 514ccb2..0293f36 100644 --- a/src/automats/deliveryEditorAutomat.js +++ b/src/automats/deliveryEditorAutomat.js @@ -1,6 +1,58 @@ import { Automat } from 'react-automat'; import { supplier as supplierApi } from '../api'; +/** + * @typedef {Object} DeliveryLine + * @property {string|number} key Stable editor key. + * @property {string|null} name Article name. + * @property {string|number|null} sku Supplier or internal article SKU. + * @property {number|null} amount Ordered amount. + * @property {number|null} pu Price unit. + * @property {string|null} unit Unit of measure. + * @property {number|null} netprice Net price. + * @property {number|null} taxrate Tax rate. + */ + +/** + * @typedef {Object} SupplierArticle + * @property {string|number} [id] Supplier-article identifier. + * @property {string|number|null} [sku] Supplier SKU. + * @property {{sku?: string|number|null, eans?: Array, articleean?: Array}} [article] + * Linked internal article. + */ + +/** + * @typedef {Object} DeliveryEditorState + * @property {Array} deliveryEntities Available suppliers or delivery parties. + * @property {string|number|null} id Delivery identifier. + * @property {string|number|null} deliveryId Alias of `id`. + * @property {string|null} date Delivery date. + * @property {string|number|null} deliveryEntity Selected delivery party. + * @property {string|number|null} partyId Alias of `deliveryEntity`. + * @property {string|null} externalNumber External delivery number. + * @property {string|null} external_number API-compatible alias of `externalNumber`. + * @property {string|null} number Internal delivery number. + * @property {string|null} type Delivery type. + * @property {Array} lines Editable lines. + * @property {Array} lineitems Alias of `lines`. + * @property {Array<*>} notes Delivery notes. + * @property {Array} attachments Delivery attachments. + * @property {Array} files Alias of `attachments`. + * @property {Array} supplierArticles Loaded supplier articles. + * @property {Object} supplierArticlesMap Articles indexed by SKU or EAN. + * @property {Object} supplierArticlesByLineKey Matched article per line. + * @property {boolean} supplierArticlesLoading Whether any supplier article request is pending. + * @property {Object} loadingLineKeys Pending lookup state per line. + * @property {string|null} supplierArticlesError Last supplier article error. + * @property {string|null} unmatchedSupplierName Parsed supplier name without a matching entity. + */ + +/** + * Normalizes a partial line into an editor line with a stable key. + * + * @param {Partial} [line] Initial line values. + * @returns {DeliveryLine} Normalized line. + */ export function createEmptyLineitem(line = {}) { return { key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, @@ -14,6 +66,14 @@ export function createEmptyLineitem(line = {}) { }; } +/** + * Indexes supplier articles by supplier SKU, internal SKU, and EAN. Text keys + * are stored both verbatim and lower-cased for case-insensitive lookup. + * + * @param {Array} [articles] Articles to index. + * @param {Object} [existingMap] Existing index to extend. + * @returns {Object} Article lookup map. + */ export function buildSupplierArticlesMap(articles = [], existingMap = {}) { const map = { ...existingMap }; for (const item of articles) { @@ -49,6 +109,7 @@ export function buildSupplierArticlesMap(articles = [], existingMap = {}) { return map; } +/** @type {Omit} */ export const initialDelivery = { id: null, deliveryId: null, @@ -74,11 +135,19 @@ export const initialDelivery = { }; +/** @type {DeliveryEditorState} */ export const initialState = { deliveryEntities: [], ...initialDelivery, }; +/** + * Creates a delivery editor automat or returns the already registered named + * instance. Named instances allow independent state for multiple deliveries. + * + * @param {{name?: string|null}|string} [options] Automat options or name. + * @returns {Automat} Delivery editor automat. + */ export function createDeliveryEditorAutomat(options = {}) { const name = typeof options === 'string' ? options : options?.name ?? null; if (name) { @@ -88,14 +157,21 @@ export function createDeliveryEditorAutomat(options = {}) { let automat; const actions = { + /** @param {string|number|null} id @returns {void} */ setId(id) { automat.setState({ id, deliveryId: id }); }, + /** @param {Array} deliveryEntities @returns {void} */ setDeliveryEntities(deliveryEntities) { automat.setState({ deliveryEntities: deliveryEntities ?? [] }); }, + /** + * Replaces the edited delivery and normalizes API aliases. + * @param {Partial} [delivery] + * @returns {void} + */ setDelivery(delivery = {}) { const rawLines = delivery.lines ?? delivery.lineitems ?? []; const lines = Array.isArray(rawLines) ? rawLines : []; @@ -129,10 +205,16 @@ export function createDeliveryEditorAutomat(options = {}) { } }, + /** @param {string|null} date @returns {void} */ setDate(date) { automat.setState({ date }); }, + /** + * Selects a delivery party and refreshes its supplier articles. + * @param {string|number|null} entityId + * @returns {void} + */ setDeliveryEntity(entityId) { const prevId = automat.state.deliveryEntity ?? automat.state.partyId; const idChanged = prevId !== entityId; @@ -155,15 +237,18 @@ export function createDeliveryEditorAutomat(options = {}) { } }, + /** @param {string|null} name @returns {void} */ setUnmatchedSupplierName(name) { automat.setState({ unmatchedSupplierName: name ?? null }); }, + /** @param {string|number|null} partyId @returns {void} */ setPartyId(partyId) { automat.actions.setDeliveryEntity(partyId); }, + /** @param {string|null} externalNumber @returns {void} */ setExternalNumber(externalNumber) { automat.setState({ externalNumber, @@ -171,24 +256,29 @@ export function createDeliveryEditorAutomat(options = {}) { }); }, + /** @param {string|null} number @returns {void} */ setNumber(number) { automat.setState({ number }); }, + /** @param {string|null} type @returns {void} */ setType(type) { automat.setState({ type }); }, + /** @param {Array} lines @returns {void} */ setLines(lines) { const cleanLines = lines ?? []; automat.setState({ lines: cleanLines, lineitems: cleanLines }); }, + /** @param {Array} lines @returns {void} */ setLineitems(lines) { const cleanLines = lines ?? []; automat.setState({ lines: cleanLines, lineitems: cleanLines }); }, + /** @param {Array<*>|*} notes @returns {void} */ setNotes(notes) { const cleanNotes = Array.isArray(notes) ? notes @@ -196,16 +286,23 @@ export function createDeliveryEditorAutomat(options = {}) { automat.setState({ notes: cleanNotes }); }, + /** @param {Partial} [line] @returns {void} */ addLineitem(line = {}) { const item = createEmptyLineitem(line); const nextLines = [...(automat.state.lines ?? []), item]; automat.setState({ lines: nextLines, lineitems: nextLines }); }, + /** @param {Partial} [line] @returns {void} */ addLine(line = {}) { automat.actions.addLineitem(line); }, + /** + * Updates a line selected by index or key. + * @param {{index?: number, key?: string|number, [key: string]: *}} payload + * @returns {void} + */ updateLineitem(payload) { const { index, key, ...fields } = payload || {}; const currentLines = automat.state.lines ?? []; @@ -223,10 +320,12 @@ export function createDeliveryEditorAutomat(options = {}) { } }, + /** @param {{index?: number, key?: string|number, [key: string]: *}} payload @returns {void} */ updateLine(payload) { automat.actions.updateLineitem(payload); }, + /** @param {number|string|{key: string|number}} payload @returns {void} */ removeLineitem(payload) { const currentLines = automat.state.lines ?? []; let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1; @@ -251,10 +350,17 @@ export function createDeliveryEditorAutomat(options = {}) { } }, + /** @param {number|string|{key: string|number}} payload @returns {void} */ removeLine(payload) { automat.actions.removeLineitem(payload); }, + /** + * Loads supplier articles for all current SKUs and rebuilds line matches. + * @param {string|number|null} [targetEntityId] Supplier to query. + * @param {Array} [targetLines] Lines whose SKUs should be queried. + * @returns {Promise} + */ async fetchSupplierArticles(targetEntityId, targetLines) { const entityId = targetEntityId ?? automat.state.deliveryEntity ?? automat.state.partyId; if (!entityId) { @@ -311,6 +417,12 @@ export function createDeliveryEditorAutomat(options = {}) { } }, + /** + * Loads and matches a supplier article for one line SKU. + * @param {string|number|null} sku SKU to query. + * @param {string|number} [lineKey] Editor line key receiving the match. + * @returns {Promise} + */ async fetchSupplierArticleForSku(sku, lineKey) { const entityId = automat.state.deliveryEntity ?? automat.state.partyId; const trimmed = sku != null ? String(sku).trim() : ''; @@ -370,6 +482,7 @@ export function createDeliveryEditorAutomat(options = {}) { } }, + /** @param {Object} attachment @returns {void} */ addAttachment(attachment) { const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const item = { ...attachment, id }; @@ -377,11 +490,13 @@ export function createDeliveryEditorAutomat(options = {}) { automat.setState({ attachments: next, files: next }); }, + /** @param {Array} attachments @returns {void} */ setAttachments(attachments) { const clean = Array.isArray(attachments) ? attachments : []; automat.setState({ attachments: clean, files: clean }); }, + /** @param {{id: string|number, [key: string]: *}} payload @returns {void} */ updateAttachment(payload) { const { id, ...fields } = payload || {}; const current = automat.state.attachments ?? []; @@ -393,6 +508,7 @@ export function createDeliveryEditorAutomat(options = {}) { } }, + /** @param {string|number|{id?: string|number, fileId?: string|number}} idOrPayload @returns {void} */ removeAttachment(idOrPayload) { const current = automat.state.attachments ?? []; const targetId = @@ -411,12 +527,14 @@ export function createDeliveryEditorAutomat(options = {}) { automat.setState({ attachments: next, files: next }); }, + /** Resets delivery fields while retaining the available entities. @returns {void} */ resetDelivery() { automat.setState({ ...initialDelivery, }); }, + /** Resets the complete editor state. @returns {void} */ resetDeliveryEditor() { automat.setState({ ...initialState, @@ -432,9 +550,17 @@ export function createDeliveryEditorAutomat(options = {}) { return automat; } +/** Shared automat used for new, unsaved deliveries. */ export const deliveryDraftAutomat = createDeliveryEditorAutomat({ name: 'delivery-draft' }); +/** Backward-compatible alias of {@link deliveryDraftAutomat}. */ export const deliveryEditorAutomat = deliveryDraftAutomat; +/** + * Returns the draft automat or the named automat belonging to a saved delivery. + * + * @param {string|number|null} docId Delivery identifier; `null` selects the draft. + * @returns {Automat} Delivery-specific editor automat. + */ export function getDeliveryEditorAutomat(docId) { if (docId == null) { return deliveryDraftAutomat; @@ -478,22 +604,38 @@ export const { } = deliveryEditorAutomat.actions; // Selectors +/** @param {DeliveryEditorState} [state] @returns {string|number|null} */ export const selectDeliveryId = (state = deliveryEditorAutomat.state) => state.deliveryId ?? state.id; +/** @param {DeliveryEditorState} [state] @returns {Array} */ export const selectDeliveryEntities = (state = deliveryEditorAutomat.state) => state.deliveryEntities; +/** @param {DeliveryEditorState} [state] @returns {string|null} */ export const selectDeliveryDate = (state = deliveryEditorAutomat.state) => state.date; +/** @param {DeliveryEditorState} [state] @returns {string|number|null} */ export const selectDeliveryEntity = (state = deliveryEditorAutomat.state) => state.deliveryEntity ?? state.partyId; +/** @param {DeliveryEditorState} [state] @returns {string|null} */ export const selectUnmatchedSupplierName = (state = deliveryEditorAutomat.state) => state.unmatchedSupplierName; +/** @param {DeliveryEditorState} [state] @returns {string|null} */ export const selectDeliveryExternalNumber = (state = deliveryEditorAutomat.state) => state.externalNumber ?? state.external_number; +/** @param {DeliveryEditorState} [state] @returns {string|null} */ export const selectDeliveryNumber = (state = deliveryEditorAutomat.state) => state.number; +/** @param {DeliveryEditorState} [state] @returns {string|null} */ export const selectDeliveryType = (state = deliveryEditorAutomat.state) => state.type; +/** @param {DeliveryEditorState} [state] @returns {Array} */ export const selectDeliveryLineitems = (state = deliveryEditorAutomat.state) => state.lines ?? state.lineitems; +/** @param {DeliveryEditorState} [state] @returns {Array<*>} */ export const selectDeliveryNotes = (state = deliveryEditorAutomat.state) => state.notes; +/** @param {DeliveryEditorState} [state] @returns {Array} */ export const selectDeliveryAttachments = (state = deliveryEditorAutomat.state) => state.attachments; +/** @param {DeliveryEditorState} [state] @returns {Array} */ export const selectSupplierArticles = (state = deliveryEditorAutomat.state) => state.supplierArticles; +/** @param {DeliveryEditorState} [state] @returns {Object} */ export const selectSupplierArticlesMap = (state = deliveryEditorAutomat.state) => state.supplierArticlesMap; +/** @param {DeliveryEditorState} [state] @returns {Object} */ export const selectSupplierArticlesByLineKey = (state = deliveryEditorAutomat.state) => state.supplierArticlesByLineKey; +/** @param {DeliveryEditorState} [state] @returns {boolean} */ export const selectSupplierArticlesLoading = (state = deliveryEditorAutomat.state) => state.supplierArticlesLoading; +/** @param {DeliveryEditorState} [state] @returns {Object} */ export const selectLoadingLineKeys = (state = deliveryEditorAutomat.state) => state.loadingLineKeys; export default deliveryEditorAutomat; diff --git a/src/automats/index.js b/src/automats/index.js index 79a904a..d16b160 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -1,3 +1,5 @@ export * from './deliveryEditorAutomat'; +export { default as authAutomat } from './authAutomat'; +export * from './authAutomat'; export { default as navigationAutomat } from './navigationAutomat'; export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat'; diff --git a/src/automats/navigationAutomat.js b/src/automats/navigationAutomat.js index ea63064..3cd6c9a 100644 --- a/src/automats/navigationAutomat.js +++ b/src/automats/navigationAutomat.js @@ -1,26 +1,31 @@ import { Automat } from 'react-automat'; /** - * Navigation automat to store last visited delivery and article paths. - * Used to return users to their previous context when clicking main menu items. - * - * State shape: - * { - * lastDeliveryPath: string | null, // Last visited delivery path (e.g., '/lieferung/19' or '/lieferung/neu') - * lastArticleSku: string | null, // Last visited article SKU - * } + * @typedef {Object} NavigationState + * @property {string|null} lastDeliveryPath Last delivery path visited by the user. + * @property {string|null} lastArticleSku Last article SKU visited by the user. */ + +/** @type {NavigationState} */ const initialState = { lastDeliveryPath: null, lastArticleSku: null, }; +/** + * Stores the user's most recently visited delivery and article so main-menu + * navigation can restore the previous context. + * + * @type {Automat} + */ const navigationAutomat = new Automat( initialState, { /** * Set the last visited delivery path. - * @param {string|null} path - Full path like '/lieferung/19' or '/lieferung/neu' + * + * @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`. + * @returns {void} */ setLastDeliveryPath(path) { navigationAutomat.setState({ lastDeliveryPath: path }); @@ -28,7 +33,9 @@ const navigationAutomat = new Automat( /** * Set the last visited article SKU. + * * @param {string|null} sku + * @returns {void} */ setLastArticleSku(sku) { navigationAutomat.setState({ lastArticleSku: sku }); @@ -36,6 +43,8 @@ const navigationAutomat = new Automat( /** * Clear all navigation history. + * + * @returns {void} */ clearNavigation() { navigationAutomat.setState({ diff --git a/src/components/AuthBootstrap.jsx b/src/components/AuthBootstrap.jsx index f97de7a..a031266 100644 --- a/src/components/AuthBootstrap.jsx +++ b/src/components/AuthBootstrap.jsx @@ -1,9 +1,8 @@ import { Component } from 'react'; -import { connect } from 'react-redux'; import { enqueueSnackbar } from 'notistack'; import { notifyError } from '../utils/notify'; import { StorageManagerContext } from '../api/storageManagerContext'; -import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice'; +import authAutomat from '../automats/authAutomat'; let sessionBootstrapped = false; @@ -19,7 +18,7 @@ class AuthBootstrap extends Component { if (session.forbidden) { enqueueSnackbar(session.message, { variant: 'warning' }); } - return this.props.bootstrapSession(session).unwrap(); + return authAutomat.actions.bootstrapSession(session); }) .catch((err) => { notifyError(err); @@ -31,8 +30,4 @@ class AuthBootstrap extends Component { } } -const mapDispatchToProps = (dispatch) => ({ - bootstrapSession: (session) => dispatch(bootstrapSessionThunk(session)), -}); - -export default connect(null, mapDispatchToProps)(AuthBootstrap); +export default AuthBootstrap; diff --git a/src/components/CompanySetupDialog.jsx b/src/components/CompanySetupDialog.jsx index 2534a11..57c9e6a 100644 --- a/src/components/CompanySetupDialog.jsx +++ b/src/components/CompanySetupDialog.jsx @@ -1,5 +1,4 @@ import { Component } from 'react'; -import { connect } from 'react-redux'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; @@ -23,11 +22,7 @@ import BusinessIcon from '@mui/icons-material/Business'; import BackendError from './BackendError'; import { company } from '../api'; import { StorageManagerContext } from '../api/storageManagerContext'; -import { - createCompany as createCompanyThunk, - logout as logoutThunk, - selectCompany as selectCompanyThunk, -} from '../store/authSlice'; +import authAutomat from '../automats/authAutomat'; import { defaultIso639, ISO639_OPTIONS, @@ -75,7 +70,7 @@ class CompanySetupDialog extends Component { async handleSelect(companyId) { this.setState({ busy: true, error: null }); try { - await this.props.selectCompany(companyId).unwrap(); + await authAutomat.actions.selectCompany(companyId); await this.context.updateAuthMe(); } catch (err) { this.setState({ error: backendErrorFrom(err) }); @@ -102,14 +97,12 @@ class CompanySetupDialog extends Component { this.setState({ busy: true, error: null }); try { - await this.props - .createCompany({ - name: name.trim(), - iso639, - taxrate: parsedTaxrate, - reducedtaxrate: parsedReducedtaxrate, - }) - .unwrap(); + await authAutomat.actions.createCompany({ + name: name.trim(), + iso639, + taxrate: parsedTaxrate, + reducedtaxrate: parsedReducedtaxrate, + }); await this.context.updateAuthMe(); await this.context.updateCompany(); } catch (err) { @@ -121,7 +114,7 @@ class CompanySetupDialog extends Component { async handleLogout() { try { - await this.props.logout().unwrap(); + await authAutomat.actions.logout(); await this.context.updateAuthMe(); } catch (err) { this.setState({ error: backendErrorFrom(err) }); @@ -293,10 +286,4 @@ class CompanySetupDialog extends Component { } } -const mapDispatchToProps = (dispatch) => ({ - createCompany: (payload) => dispatch(createCompanyThunk(payload)), - selectCompany: (companyId) => dispatch(selectCompanyThunk(companyId)), - logout: () => dispatch(logoutThunk()), -}); - -export default connect(null, mapDispatchToProps)(CompanySetupDialog); +export default CompanySetupDialog; diff --git a/src/components/LoginPage.jsx b/src/components/LoginPage.jsx index 03833a8..3787d36 100644 --- a/src/components/LoginPage.jsx +++ b/src/components/LoginPage.jsx @@ -1,11 +1,10 @@ -import { Component } from 'react'; -import { connect } from 'react-redux'; +import { PureComponent } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import GoogleIcon from '@mui/icons-material/Google'; import { enqueueSnackbar } from 'notistack'; import { notifyErrorMessage } from '../utils/notify'; -import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice'; +import authAutomat from '../automats/authAutomat'; import { StorageManagerContext } from '../api/storageManagerContext'; import { abortGoogleSignIn, @@ -16,17 +15,25 @@ import { requestGoogleIdToken, } from '../auth/googleSignIn'; -class LoginPage extends Component { +class LoginPage extends PureComponent { static contextType = StorageManagerContext; constructor(props) { super(props); - this.state = { busy: false }; + this.state = { ...authAutomat.state, busy: false }; this.handleLogin = this.handleLogin.bind(this); } + componentDidMount() { + this.unsubscribe = authAutomat.subscribe(this, ['status', 'googleClientId']); + } + + componentWillUnmount() { + this.unsubscribe?.(); + } + async handleLogin() { - const { googleClientId } = this.props; + const { googleClientId } = this.state; abortGoogleSignIn(); try { const idToken = await requestGoogleIdToken(googleClientId, { @@ -38,7 +45,7 @@ class LoginPage extends Component { }, }); this.setState({ busy: true }); - await this.props.loginWithGoogle(idToken).unwrap(); + await authAutomat.actions.loginWithGoogle(idToken); await this.context.updateAuthMe(); } catch (err) { if (err instanceof GoogleSignInCancelledError) return; @@ -74,8 +81,7 @@ class LoginPage extends Component { } render() { - const { busy } = this.state; - const { status, googleClientId } = this.props; + const { busy, status, googleClientId } = this.state; const loading = status === 'loading'; const ready = Boolean(googleClientId); @@ -103,13 +109,4 @@ class LoginPage extends Component { } } -const mapStateToProps = (state) => ({ - status: selectAuthStatus(state), - googleClientId: selectGoogleClientId(state), -}); - -const mapDispatchToProps = (dispatch) => ({ - loginWithGoogle: (credential) => dispatch(loginWithGoogle(credential)), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(LoginPage); +export default LoginPage; diff --git a/src/components/UserMenu.jsx b/src/components/UserMenu.jsx index 96d8821..fa15dd3 100644 --- a/src/components/UserMenu.jsx +++ b/src/components/UserMenu.jsx @@ -1,5 +1,4 @@ -import { Component } from 'react'; -import { connect } from 'react-redux'; +import { PureComponent } from 'react'; import Avatar from '@mui/material/Avatar'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; @@ -11,12 +10,12 @@ import LogoutIcon from '@mui/icons-material/Logout'; import BusinessIcon from '@mui/icons-material/Business'; import SettingsIcon from '@mui/icons-material/Settings'; import { StorageManagerContext } from '../api/storageManagerContext'; -import { logout } from '../store/authSlice'; +import authAutomat from '../automats/authAutomat'; import CompanySettingsDialog from './CompanySettingsDialog'; import SettingsDialog from './settings/SettingsDialog'; import { notifyError } from '../utils/notify'; -class UserMenu extends Component { +class UserMenu extends PureComponent { static contextType = StorageManagerContext; constructor(props) { @@ -25,7 +24,7 @@ class UserMenu extends Component { anchorEl: null, settingsOpen: false, numbersOpen: false, - user: null, + user: authAutomat.state.user, }; this.handleOpenMenu = this.handleOpenMenu.bind(this); this.handleCloseMenu = this.handleCloseMenu.bind(this); @@ -37,16 +36,11 @@ class UserMenu extends Component { } componentDidMount() { - void this.loadUser(); + this.unsubscribe = authAutomat.subscribe(this, 'user'); } - async loadUser() { - try { - const session = await this.context.authMePromise; - this.setState({ user: session.user }); - } catch (err) { - notifyError(err); - } + componentWillUnmount() { + this.unsubscribe?.(); } handleOpenMenu(event) { @@ -76,7 +70,7 @@ class UserMenu extends Component { async handleLogout() { this.setState({ anchorEl: null }); try { - await this.props.logout().unwrap(); + await authAutomat.actions.logout(); await this.context.updateAuthMe(); } catch (err) { notifyError(err); @@ -134,8 +128,4 @@ class UserMenu extends Component { } } -const mapDispatchToProps = (dispatch) => ({ - logout: () => dispatch(logout()), -}); - -export default connect(null, mapDispatchToProps)(UserMenu); +export default UserMenu; diff --git a/src/store/authSlice.js b/src/store/authSlice.js deleted file mode 100644 index aa2d1a8..0000000 --- a/src/store/authSlice.js +++ /dev/null @@ -1,109 +0,0 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { auth, company } from '../api'; - -export const bootstrapSession = createAsyncThunk('auth/bootstrap', async (session) => { - if (session) return session; - return auth.me(); -}); - -export const loginWithGoogle = createAsyncThunk('auth/login', async (credential) => { - const { session } = await auth.loginWithGoogle(credential); - if (!session?.user) { - throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)'); - } - return session; -}); - -export const selectCompany = createAsyncThunk('auth/selectCompany', async (companyId) => { - const data = await company.select(companyId); - return data.user ?? { selectedCompanyId: companyId }; -}); - -export const createCompany = createAsyncThunk('auth/createCompany', async (payload) => { - const data = await company.create(payload); - if (data.user) return data.user; - if (data.company?.id) { - const selected = await company.select(data.company.id); - return selected.user ?? { selectedCompanyId: data.company.id }; - } - throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.'); -}); - -export const logout = createAsyncThunk('auth/logout', async () => { - await auth.logout(); - window.history.replaceState(null, '', '/'); -}); - -function mergeUser(state, incoming) { - if (!incoming) return state.user; - const selectedCompanyId = - incoming.selectedCompanyId ?? state.user?.selectedCompanyId ?? null; - return { ...state.user, ...incoming, selectedCompanyId }; -} - -const initialState = { - user: null, - googleClientId: null, - status: 'loading', -}; - -const authSlice = createSlice({ - name: 'auth', - initialState, - reducers: {}, - extraReducers: (builder) => { - builder - .addCase(bootstrapSession.fulfilled, (state, action) => { - const session = action.payload; - state.googleClientId = session.googleClientId ?? state.googleClientId; - if (!session.user) { - if (state.status !== 'authed') { - state.user = null; - state.status = 'anon'; - } - return; - } - state.user = mergeUser(state, session.user); - state.status = 'authed'; - }) - .addCase(bootstrapSession.rejected, (state) => { - if (state.status === 'loading') { - state.user = null; - state.status = 'anon'; - } - }) - .addCase(loginWithGoogle.fulfilled, (state, action) => { - const session = action.payload; - state.user = session.user; - state.googleClientId = session.googleClientId ?? state.googleClientId; - state.status = 'authed'; - }) - .addCase(loginWithGoogle.rejected, (state) => { - state.user = null; - state.status = 'anon'; - }) - .addCase(selectCompany.fulfilled, (state, action) => { - state.user = mergeUser(state, action.payload); - state.status = 'authed'; - }) - .addCase(createCompany.fulfilled, (state, action) => { - state.user = mergeUser(state, action.payload); - state.status = 'authed'; - }) - .addCase(createCompany.rejected, (state, action) => { - console.error('[redux] createCompany.rejected', action.error?.message, action.payload); - }) - .addCase(logout.fulfilled, (state) => { - state.user = null; - state.status = 'anon'; - }); - }, -}); - -export const selectAuthStatus = (state) => state.auth.status; -export const selectAuthUser = (state) => state.auth.user; -export const selectGoogleClientId = (state) => state.auth.googleClientId; -export const selectNeedsCompanySetup = (state) => - state.auth.status === 'authed' && state.auth.user?.selectedCompanyId == null; - -export default authSlice.reducer; diff --git a/src/store/brandSlice.js b/src/store/brandSlice.js index a418224..511cb39 100644 --- a/src/store/brandSlice.js +++ b/src/store/brandSlice.js @@ -1,14 +1,12 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { brand as brandApi } from '../api'; +import authAutomat, { + AUTH_COMPANY_CHANGED, + AUTH_LOGGED_OUT, + AUTH_SESSION_ESTABLISHED, +} from '../automats/authAutomat'; import { requireArray, requireObject } from '../utils/apiShape'; import { catchBackendError } from '../utils/backendError'; -import { - bootstrapSession, - createCompany, - loginWithGoogle, - logout, - selectCompany, -} from './authSlice'; function sortBrands(brands) { return [...brands].sort((a, b) => @@ -71,9 +69,8 @@ const brandSlice = createSlice({ row, ]); }) - .addCase(selectCompany.fulfilled, () => initialState) - .addCase(createCompany.fulfilled, () => initialState) - .addCase(logout.fulfilled, () => initialState); + .addCase(AUTH_COMPANY_CHANGED, () => initialState) + .addCase(AUTH_LOGGED_OUT, () => initialState); }, }); @@ -84,12 +81,9 @@ export const selectBrandsError = (state) => state.brand.error; export function brandLoadListener(listenerMiddleware) { listenerMiddleware.startListening({ matcher: (action) => - bootstrapSession.fulfilled.match(action) || - loginWithGoogle.fulfilled.match(action) || - selectCompany.fulfilled.match(action) || - createCompany.fulfilled.match(action), + action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED, effect: async (_action, api) => { - const companyId = api.getState().auth.user?.selectedCompanyId; + const companyId = authAutomat.state.user?.selectedCompanyId; if (companyId) { api.dispatch(loadBrands()); } diff --git a/src/store/categorySlice.js b/src/store/categorySlice.js index 1a63acc..7dddaac 100644 --- a/src/store/categorySlice.js +++ b/src/store/categorySlice.js @@ -2,7 +2,7 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { category, article } from '../api'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { catchBackendError, resolveBackendError } from '../utils/backendError'; -import { logout } from './authSlice'; +import { AUTH_LOGGED_OUT } from '../automats/authAutomat'; import { loadCurrentCompany } from './companySlice'; export const ROOT_CATEGORY_NAME = 'Home'; @@ -645,7 +645,7 @@ const categorySlice = createSlice({ }; } }) - .addCase(logout.fulfilled, () => initialState); + .addCase(AUTH_LOGGED_OUT, () => initialState); }, }); diff --git a/src/store/companySlice.js b/src/store/companySlice.js index 41893fc..79ae27c 100644 --- a/src/store/companySlice.js +++ b/src/store/companySlice.js @@ -1,18 +1,16 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { company } from '../api'; +import authAutomat, { + AUTH_COMPANY_CHANGED, + AUTH_LOGGED_OUT, + AUTH_SESSION_ESTABLISHED, +} from '../automats/authAutomat'; import { normalizeCompany } from '../utils/companyForm'; -import { - bootstrapSession, - createCompany, - loginWithGoogle, - logout, - selectCompany, -} from './authSlice'; export const loadCurrentCompany = createAsyncThunk( 'company/loadCurrent', - async (_, { getState }) => { - const companyId = getState().auth.user?.selectedCompanyId; + async () => { + const companyId = authAutomat.state.user?.selectedCompanyId; if (!companyId) return null; const data = await company.getById(companyId); return normalizeCompany(data.company); @@ -21,8 +19,8 @@ export const loadCurrentCompany = createAsyncThunk( export const updateCompany = createAsyncThunk( 'company/update', - async (payload, { getState }) => { - const companyId = getState().auth.user?.selectedCompanyId; + async (payload) => { + const companyId = authAutomat.state.user?.selectedCompanyId; if (!companyId) throw new Error('Kein Unternehmen ausgewählt.'); await company.update(companyId, payload); const data = await company.getById(companyId); @@ -66,7 +64,7 @@ const companySlice = createSlice({ state.status = 'failed'; state.error = action.error.message; }) - .addCase(logout.fulfilled, (state) => { + .addCase(AUTH_LOGGED_OUT, (state) => { state.current = null; state.status = 'idle'; state.error = null; @@ -87,12 +85,9 @@ export const selectCompanyRootCategoryId = (state) => state.company.current?.roo export function companyLoadListener(listenerMiddleware) { listenerMiddleware.startListening({ matcher: (action) => - bootstrapSession.fulfilled.match(action) || - loginWithGoogle.fulfilled.match(action) || - selectCompany.fulfilled.match(action) || - createCompany.fulfilled.match(action), + action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED, effect: async (_action, api) => { - const companyId = api.getState().auth.user?.selectedCompanyId; + const companyId = authAutomat.state.user?.selectedCompanyId; if (companyId) { api.dispatch(loadCurrentCompany()); } diff --git a/src/store/index.js b/src/store/index.js index 3b358cf..85a560e 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,5 +1,5 @@ import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; -import authReducer from './authSlice'; +import { setAuthEventDispatcher } from '../automats/authAutomat'; import brandReducer, { brandLoadListener } from './brandSlice'; import categoryReducer from './categorySlice'; import companyReducer, { companyLoadListener } from './companySlice'; @@ -25,16 +25,10 @@ function reduxDevLogger() { ) { return next(action); } - const before = storeApi.getState(); const result = next(action); const after = storeApi.getState(); console.log('[redux]', action.type, { payload: action.payload, - auth: { - selectedCompanyId: after.auth.user?.selectedCompanyId ?? null, - needsCompanySetup: - after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null, - }, company: after.company.current, brands: after.brand.brands.length, posterminals: after.posterminal.terminals.length, @@ -46,7 +40,6 @@ function reduxDevLogger() { export const store = configureStore({ reducer: { - auth: authReducer, company: companyReducer, category: categoryReducer, brand: brandReducer, @@ -58,3 +51,5 @@ export const store = configureStore({ .concat(listenerMiddleware.middleware) .concat(import.meta.env.DEV ? reduxDevLogger() : []), }); + + setAuthEventDispatcher(store.dispatch); diff --git a/src/store/posterminalSlice.js b/src/store/posterminalSlice.js index c6c2d91..ae9c814 100644 --- a/src/store/posterminalSlice.js +++ b/src/store/posterminalSlice.js @@ -1,14 +1,12 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { posterminal } from '../api'; +import authAutomat, { + AUTH_COMPANY_CHANGED, + AUTH_LOGGED_OUT, + AUTH_SESSION_ESTABLISHED, +} from '../automats/authAutomat'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { catchBackendError, resolveBackendError } from '../utils/backendError'; -import { - bootstrapSession, - createCompany, - loginWithGoogle, - logout, - selectCompany, -} from './authSlice'; import { loadCurrentCompany } from './companySlice'; /** @@ -236,7 +234,7 @@ const posterminalSlice = createSlice({ } }) // Reset on logout - .addCase(logout.fulfilled, () => initialState); + .addCase(AUTH_LOGGED_OUT, () => initialState); }, }); @@ -249,12 +247,9 @@ export const { resetPosterminalState } = posterminalSlice.actions; export function posterminalLoadListener(listenerMiddleware) { listenerMiddleware.startListening({ matcher: (action) => - bootstrapSession.fulfilled.match(action) || - loginWithGoogle.fulfilled.match(action) || - selectCompany.fulfilled.match(action) || - createCompany.fulfilled.match(action), + action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED, effect: async (action, api) => { - const companyId = api.getState().auth.user?.selectedCompanyId; + const companyId = authAutomat.state.user?.selectedCompanyId; console.log('[posterminalLoadListener]', action.type, { companyId }); if (companyId) { api.dispatch(loadPosterminals()); diff --git a/src/store/unitSlice.js b/src/store/unitSlice.js index f97f9e8..5d2304b 100644 --- a/src/store/unitSlice.js +++ b/src/store/unitSlice.js @@ -1,13 +1,11 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { unit as unitApi } from '../api'; +import authAutomat, { + AUTH_COMPANY_CHANGED, + AUTH_LOGGED_OUT, + AUTH_SESSION_ESTABLISHED, +} from '../automats/authAutomat'; import { requireArray, requireObject } from '../utils/apiShape'; -import { - bootstrapSession, - createCompany, - loginWithGoogle, - logout, - selectCompany, -} from './authSlice'; function sortUnits(units) { return [...units].sort((a, b) => { @@ -69,9 +67,8 @@ const unitSlice = createSlice({ .addCase(deleteUnit.fulfilled, (state, action) => { state.units = state.units.filter((u) => u.si !== action.payload); }) - .addCase(selectCompany.fulfilled, () => initialState) - .addCase(createCompany.fulfilled, () => initialState) - .addCase(logout.fulfilled, () => initialState); + .addCase(AUTH_COMPANY_CHANGED, () => initialState) + .addCase(AUTH_LOGGED_OUT, () => initialState); }, }); @@ -82,12 +79,9 @@ export const selectUnitsError = (state) => state.unit.error; export function unitLoadListener(listenerMiddleware) { listenerMiddleware.startListening({ matcher: (action) => - bootstrapSession.fulfilled.match(action) || - loginWithGoogle.fulfilled.match(action) || - selectCompany.fulfilled.match(action) || - createCompany.fulfilled.match(action), + action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED, effect: async (_action, api) => { - const companyId = api.getState().auth.user?.selectedCompanyId; + const companyId = authAutomat.state.user?.selectedCompanyId; if (companyId) { api.dispatch(loadUnits()); }