diff --git a/src/api/storageManager.jsx b/src/api/storageManager.jsx
index b4a2559..0c9968f 100644
--- a/src/api/storageManager.jsx
+++ b/src/api/storageManager.jsx
@@ -1,9 +1,27 @@
import { createContext, useContext, useRef } from 'react';
-import { articleIndex, brand, category, company, posterminal, unit } from './index';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
export const StorageManagerContext = createContext(null);
+function enc(value) {
+ return encodeURIComponent(String(value));
+}
+
+function buildUrl(baseUrl, path) {
+ return new URL(path, baseUrl || window.location.origin).href;
+}
+
+function buildPath(path, query) {
+ if (!query) return path;
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(query)) {
+ if (value == null) continue;
+ params.set(key, String(value));
+ }
+ const qs = params.toString();
+ return qs ? `${path}?${qs}` : path;
+}
+
/**
* Stale-while-revalidate cache: readers keep the current promise; update()
* swaps only after a successful reload. Failed updates leave the cache intact.
@@ -33,64 +51,258 @@ function createCache(label, load) {
};
}
-async function loadArticleIndex() {
- const data = await articleIndex.list();
- requireObject(data, 'GET /api/articleIndex');
- const articles = requireArray(data.articles, 'articleIndex.articles');
- // Map backend rows to the recordset shape used by the result list.
- // `categories` is a comma-joined string (formatCategoryList splits on ',').
- return articles.map((a) => ({
- id: a.id,
- sku: a.sku,
- name: a.name,
- eans: a.eans ?? [],
- categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null,
- }));
-}
+class StorageManager {
+ #baseUrl;
-async function loadBrands() {
- const data = await brand.list();
- requireObject(data, 'GET /api/brand');
- return requireArray(data.brands, 'brands');
-}
+ constructor(baseUrl = window.location.origin) {
+ this.#baseUrl = baseUrl;
-async function loadUnits() {
- const data = await unit.list();
- requireObject(data, 'GET /api/unit');
- return requireArray(data.units, 'units');
-}
+ this.auth = {
+ /** POST /api/auth/login */
+ login: (body) => this.#json('POST', '/api/auth/login', { body }),
+ /** POST /api/auth/logout */
+ logout: () => this.#json('POST', '/api/auth/logout'),
+ /** GET /api/auth/me */
+ me: () => this.#json('GET', '/api/auth/me'),
+ /** PUT /api/auth/selected-company */
+ selectedCompany: (body) => this.#json('PUT', '/api/auth/selected-company', { body }),
+ };
-async function loadCompanies() {
- const data = await company.list();
- requireObject(data, 'GET /api/company');
- return requireArray(data.companies, 'companies');
-}
+ this.health = {
+ /** GET /api/health */
+ get: () => this.#json('GET', '/api/health'),
+ };
-async function loadCategoryTree() {
- const data = await category.getTree();
- requireObject(data, 'GET /api/category/tree');
- requireArray(data.tree, 'category/tree.tree');
- requireArray(data.articles, 'category/tree.articles');
- requireDefined(data.languageId, 'category/tree.languageId');
- return data;
-}
+ this.company = {
+ /** GET /api/company */
+ list: () => this.#json('GET', '/api/company'),
+ /** POST /api/company */
+ create: (body) => this.#json('POST', '/api/company', { body }),
+ /** PUT /api/company/{id} */
+ update: (id, body) => this.#json('PUT', `/api/company/${enc(id)}`, { body }),
+ /** DELETE /api/company/{id} */
+ delete: (id) => this.#empty('DELETE', `/api/company/${enc(id)}`),
+ };
-async function loadPosterminals() {
- const data = await posterminal.list();
- requireObject(data, 'GET /api/posterminal');
- return requireArray(data.posterminals, 'posterminals');
-}
+ this.unit = {
+ /** GET /api/unit */
+ list: () => this.#json('GET', '/api/unit'),
+ /** PUT /api/unit */
+ upsert: (body) => this.#json('PUT', '/api/unit', { body }),
+ /** DELETE /api/unit?si=… */
+ delete: (si) => this.#empty('DELETE', '/api/unit', { query: { si } }),
+ };
-function createStorageManager() {
- const articleIndexCache = createCache('GET /articleIndex', loadArticleIndex);
- const brandCache = createCache('GET /brand', loadBrands);
- const unitCache = createCache('GET /unit', loadUnits);
- const companyCache = createCache('GET /company', loadCompanies);
- const categoryTreeCache = createCache('GET /category/tree', loadCategoryTree);
- const posterminalCache = createCache('GET /posterminal', loadPosterminals);
+ this.brand = {
+ /** GET /api/brand */
+ list: () => this.#json('GET', '/api/brand'),
+ /** PUT /api/brand */
+ upsert: (body) => this.#json('PUT', '/api/brand', { body }),
+ /** DELETE /api/brand/{id} */
+ delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`),
+ };
- async function doSearch({ target, filter, offset, maxCount, onUpdate }) {
- const articles = await articleIndexCache.promise;
+ this.category = {
+ /** GET /api/category/tree */
+ tree: () => this.#json('GET', '/api/category/tree'),
+ /** GET /api/category/export.zip */
+ exportZip: () => this.#blob('GET', '/api/category/export.zip'),
+ /** POST /api/category */
+ create: (body) => this.#json('POST', '/api/category', { body }),
+ /** GET /api/category/{id}/articles */
+ articles: (id) => this.#json('GET', `/api/category/${enc(id)}/articles`),
+ /** PATCH /api/category/{id} */
+ update: (id, body) => this.#json('PATCH', `/api/category/${enc(id)}`, { body }),
+ /** DELETE /api/category/{id} */
+ delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`),
+ /** PUT /api/category/order */
+ order: (body) => this.#json('PUT', '/api/category/order', { body }),
+ /** POST /api/category/{id}/image — raw AVIF body */
+ addImage: (id, avifBytes) =>
+ this.#json('POST', `/api/category/${enc(id)}/image`, {
+ body: avifBytes,
+ contentType: 'image/avif',
+ raw: true,
+ }),
+ /** DELETE /api/category/{id}/image */
+ deleteImage: (id) => this.#json('DELETE', `/api/category/${enc(id)}/image`),
+ };
+
+ this.article = {
+ /** GET /api/articleIndex */
+ index: () => this.#json('GET', '/api/articleIndex'),
+ /** POST /api/article */
+ create: (body) => this.#json('POST', '/api/article', { body }),
+ /** GET /api/article/export.zip */
+ exportZip: (query = {}) => this.#blob('GET', '/api/article/export.zip', { query }),
+ /** GET /api/article/{sku} */
+ get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`),
+ /** PATCH /api/article/{sku} */
+ update: (sku, body) => this.#json('PATCH', `/api/article/${enc(sku)}`, { body }),
+ /** DELETE /api/article/{sku} */
+ delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`),
+ /** PUT /api/article/{sku}/prices */
+ setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }),
+ /** POST /api/article/{sku}/image — raw AVIF body */
+ addImage: (sku, avifBytes) =>
+ this.#json('POST', `/api/article/${enc(sku)}/image`, {
+ body: avifBytes,
+ contentType: 'image/avif',
+ raw: true,
+ }),
+ /** DELETE /api/article/{sku}/image/{fullHash} */
+ deleteImage: (sku, fullHash) =>
+ this.#empty('DELETE', `/api/article/${enc(sku)}/image/${enc(fullHash)}`),
+ /** PUT /api/article/{sku}/images/order */
+ orderImages: (sku, body) =>
+ this.#json('PUT', `/api/article/${enc(sku)}/images/order`, { body }),
+ /** PUT /api/article/{sku}/eans */
+ setEans: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/eans`, { body }),
+ /** PUT /api/article/{sku}/brand */
+ setBrand: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/brand`, { body }),
+ /** PUT /api/article/{sku}/weight */
+ setWeight: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/weight`, { body }),
+ /** PUT /api/article/{sku}/categories */
+ setCategories: (sku, body) =>
+ this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }),
+ };
+
+ this.posterminal = {
+ /** GET /api/posterminal */
+ list: () => this.#json('GET', '/api/posterminal'),
+ /** PUT /api/posterminal */
+ upsert: (body) => this.#json('PUT', '/api/posterminal', { body }),
+ /** DELETE /api/posterminal/{id} */
+ delete: (id) => this.#empty('DELETE', `/api/posterminal/${enc(id)}`),
+ /** PUT /api/posterminal/{id}/category/{categoryId} */
+ setOn: (id, categoryId) =>
+ this.#json('PUT', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
+ /** DELETE /api/posterminal/{id}/category/{categoryId} */
+ setOff: (id, categoryId) =>
+ this.#json('DELETE', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
+ /** PUT /api/posterminal/{id}/all */
+ setOnAll: (id) => this.#json('PUT', `/api/posterminal/${enc(id)}/all`),
+ /** DELETE /api/posterminal/{id}/all */
+ setOffAll: (id) => this.#json('DELETE', `/api/posterminal/${enc(id)}/all`),
+ };
+
+ this.image = {
+ /** GET /api/image/{fullHash} → AVIF blob */
+ get: (fullHash) => this.#blob('GET', `/api/image/${enc(fullHash)}`),
+ /** Absolute URL for
(same-origin / cookie auth). */
+ url: (fullHash) => buildUrl(this.#baseUrl, `/api/image/${enc(fullHash)}`),
+ };
+
+ this.thumb = {
+ /** GET /api/thumb/{fullHash} → AVIF blob */
+ get: (fullHash) => this.#blob('GET', `/api/thumb/${enc(fullHash)}`),
+ /** Absolute URL for
(same-origin / cookie auth). */
+ url: (fullHash) => buildUrl(this.#baseUrl, `/api/thumb/${enc(fullHash)}`),
+ };
+
+ this.#initCaches();
+ }
+
+ #initCaches() {
+ const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
+ const articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex());
+ const brandCache = createCache('GET /brand', () => this.#loadBrands());
+ const unitCache = createCache('GET /unit', () => this.#loadUnits());
+ const companyCache = createCache('GET /company', () => this.#loadCompanies());
+ const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
+ const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
+
+ Object.defineProperties(this, {
+ authMePromise: { get: () => authMeCache.promise },
+ updateAuthMe: { value: authMeCache.update },
+ articleIndexPromise: { get: () => articleIndexCache.promise },
+ updateArticleIndex: { value: articleIndexCache.update },
+ /** @deprecated use updateArticleIndex */
+ update: { value: articleIndexCache.update },
+ brandPromise: { get: () => brandCache.promise },
+ updateBrand: { value: brandCache.update },
+ unitPromise: { get: () => unitCache.promise },
+ updateUnit: { value: unitCache.update },
+ companyPromise: { get: () => companyCache.promise },
+ updateCompany: { value: companyCache.update },
+ categoryTreePromise: { get: () => categoryTreeCache.promise },
+ updateCategoryTree: { value: categoryTreeCache.update },
+ posterminalPromise: { get: () => posterminalCache.promise },
+ updatePosterminal: { value: posterminalCache.update },
+ });
+ }
+
+ async #loadAuthMe() {
+ try {
+ const data = await this.auth.me();
+ requireObject(data, 'GET /api/auth/me');
+ return {
+ user: data.user,
+ googleClientId: data.googleClientId,
+ };
+ } catch (err) {
+ if (err.status === 401 || err.status === 403) {
+ return {
+ user: null,
+ googleClientId: err.data?.googleClientId,
+ forbidden: err.status === 403,
+ message: err.message,
+ };
+ }
+ throw err;
+ }
+ }
+
+ async #loadArticleIndex() {
+ const data = await this.article.index();
+ requireObject(data, 'GET /api/articleIndex');
+ const articles = requireArray(data.articles, 'articleIndex.articles');
+ // Map backend rows to the recordset shape used by the result list.
+ // `categories` is a comma-joined string (formatCategoryList splits on ',').
+ return articles.map((a) => ({
+ id: a.id,
+ sku: a.sku,
+ name: a.name,
+ eans: a.eans ?? [],
+ categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null,
+ }));
+ }
+
+ async #loadBrands() {
+ const data = await this.brand.list();
+ requireObject(data, 'GET /api/brand');
+ return requireArray(data.brands, 'brands');
+ }
+
+ async #loadUnits() {
+ const data = await this.unit.list();
+ requireObject(data, 'GET /api/unit');
+ return requireArray(data.units, 'units');
+ }
+
+ async #loadCompanies() {
+ const data = await this.company.list();
+ requireObject(data, 'GET /api/company');
+ return requireArray(data.companies, 'companies');
+ }
+
+ async #loadCategoryTree() {
+ const data = await this.category.tree();
+ requireObject(data, 'GET /api/category/tree');
+ requireArray(data.tree, 'category/tree.tree');
+ requireArray(data.articles, 'category/tree.articles');
+ requireDefined(data.languageId, 'category/tree.languageId');
+ return data;
+ }
+
+ async #loadPosterminals() {
+ const data = await this.posterminal.list();
+ requireObject(data, 'GET /api/posterminal');
+ return requireArray(data.posterminals, 'posterminals');
+ }
+
+ async doSearch({ target, filter, offset, maxCount, onUpdate }) {
+ const articles = await this.articleIndexPromise;
const query = target.trim().toLowerCase();
const matches = articles.filter(
(p) =>
@@ -106,41 +318,77 @@ function createStorageManager() {
});
}
- return {
- doSearch,
+ async #json(method, path, { body, query, contentType, raw } = {}) {
+ const url = buildPath(path, query);
+ const headers = {};
+ let payload;
+ if (body !== undefined) {
+ if (raw) {
+ headers['Content-Type'] = contentType;
+ payload = body;
+ } else {
+ headers['Content-Type'] = contentType || 'application/json';
+ payload = JSON.stringify(body);
+ }
+ }
- get articleIndexPromise() {
- return articleIndexCache.promise;
- },
- updateArticleIndex: articleIndexCache.update,
- /** @deprecated use updateArticleIndex */
- update: articleIndexCache.update,
+ const res = await fetch(url, {
+ method,
+ headers,
+ credentials: 'include',
+ body: payload,
+ });
- get brandPromise() {
- return brandCache.promise;
- },
- updateBrand: brandCache.update,
+ if (!res.ok) {
+ let message = res.statusText;
+ let data;
+ try {
+ data = await res.json();
+ message = data.error || data.message || message;
+ } catch {
+ message = (await res.text().catch(() => message)) || message;
+ }
+ const err = new Error(message || `Request failed (${res.status})`);
+ err.status = res.status;
+ err.path = url;
+ err.method = method;
+ err.data = data;
+ throw err;
+ }
+ if (res.status === 204) return null;
+ return res.json();
+ }
- get unitPromise() {
- return unitCache.promise;
- },
- updateUnit: unitCache.update,
+ async #empty(method, path, { query } = {}) {
+ await this.#json(method, path, { query });
+ return null;
+ }
- get companyPromise() {
- return companyCache.promise;
- },
- updateCompany: companyCache.update,
+ async #blob(method, path, { query } = {}) {
+ const url = buildPath(path, query);
+ const res = await fetch(url, {
+ method,
+ credentials: 'include',
+ });
- get categoryTreePromise() {
- return categoryTreeCache.promise;
- },
- updateCategoryTree: categoryTreeCache.update,
-
- get posterminalPromise() {
- return posterminalCache.promise;
- },
- updatePosterminal: posterminalCache.update,
- };
+ if (!res.ok) {
+ let message = res.statusText;
+ let data;
+ try {
+ data = await res.json();
+ message = data.error || data.message || message;
+ } catch {
+ message = (await res.text().catch(() => message)) || message;
+ }
+ const err = new Error(message || `Request failed (${res.status})`);
+ err.status = res.status;
+ err.path = url;
+ err.method = method;
+ err.data = data;
+ throw err;
+ }
+ return res.blob();
+ }
}
/**
@@ -149,7 +397,7 @@ function createStorageManager() {
export function StorageManagerProvider({ children }) {
const mgrRef = useRef(null);
if (!mgrRef.current) {
- mgrRef.current = createStorageManager();
+ mgrRef.current = new StorageManager();
}
return (
diff --git a/src/components/AuthBootstrap.jsx b/src/components/AuthBootstrap.jsx
index 44bb017..0e8cefa 100644
--- a/src/components/AuthBootstrap.jsx
+++ b/src/components/AuthBootstrap.jsx
@@ -2,22 +2,24 @@ import { Component } from 'react';
import { connect } from 'react-redux';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../utils/notify';
+import { StorageManagerContext } from '../api/storageManager';
import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice';
let sessionBootstrapped = false;
class AuthBootstrap extends Component {
+ static contextType = StorageManagerContext;
+
componentDidMount() {
if (sessionBootstrapped) return;
sessionBootstrapped = true;
- this.props
- .bootstrapSession()
- .unwrap()
+ this.context.authMePromise
.then((session) => {
if (session.forbidden) {
enqueueSnackbar(session.message, { variant: 'warning' });
}
+ return this.props.bootstrapSession(session).unwrap();
})
.catch((err) => {
notifyError(err);
@@ -30,7 +32,7 @@ class AuthBootstrap extends Component {
}
const mapDispatchToProps = (dispatch) => ({
- bootstrapSession: () => dispatch(bootstrapSessionThunk()),
+ bootstrapSession: (session) => dispatch(bootstrapSessionThunk(session)),
});
export default connect(null, mapDispatchToProps)(AuthBootstrap);
diff --git a/src/components/CompanySettingsDialog.jsx b/src/components/CompanySettingsDialog.jsx
index 5a443ee..f8944f0 100644
--- a/src/components/CompanySettingsDialog.jsx
+++ b/src/components/CompanySettingsDialog.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';
@@ -27,20 +26,12 @@ import { enqueueSnackbar } from 'notistack';
import BackendError from './BackendError';
import SingleLineTextEdit from './SingleLineTextEdit';
import { StorageManagerContext } from '../api/storageManager';
-import { selectAuthUser } from '../store/authSlice';
-import { updateCompany as updateCompanyThunk } from '../store/companySlice';
-import {
- createPosterminal,
- deletePosterminal,
- enableAllTerminalCategories,
-} from '../store/posterminalSlice';
-import { loadCategoryTree } from '../store/categorySlice';
-import { requireDefined } from '../utils/apiShape';
+import { requireDefined, requireObject } from '../utils/apiShape';
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
import { notifyError } from '../utils/notify';
-class CompanySettingsDialog extends Component {
+export default class CompanySettingsDialog extends Component {
static contextType = StorageManagerContext;
constructor(props) {
@@ -74,27 +65,25 @@ class CompanySettingsDialog extends Component {
}
}
- syncFormFromCompany(company) {
+ syncFormFromCompany(companyRow) {
this.setState({
- name: company.name,
- iso639: company.iso639,
- taxrate: company.taxrate != null ? String(company.taxrate) : '',
- reducedtaxrate: company.reducedtaxrate != null ? String(company.reducedtaxrate) : '',
+ name: companyRow.name,
+ iso639: companyRow.iso639,
+ taxrate: companyRow.taxrate != null ? String(companyRow.taxrate) : '',
+ reducedtaxrate: companyRow.reducedtaxrate != null ? String(companyRow.reducedtaxrate) : '',
});
}
async loadCompany() {
try {
- const companyId = requireDefined(
- this.props.selectedCompanyId,
- 'selectedCompanyId',
- );
+ const session = await this.context.authMePromise;
+ const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
const companies = await this.context.companyPromise;
- const company = requireDefined(
+ const companyRow = requireDefined(
companies.find((c) => c.id === companyId),
'company',
);
- this.syncFormFromCompany(company);
+ this.syncFormFromCompany(companyRow);
} catch (err) {
notifyError(err);
}
@@ -115,13 +104,14 @@ class CompanySettingsDialog extends Component {
this.setState({ creatingTerminal: true });
try {
- const terminal = await this.props.createPosterminal({ name: trimmed }).unwrap();
+ const data = await this.context.posterminal.upsert({ name: trimmed });
+ requireObject(data, 'PUT /api/posterminal');
+ requireObject(data.posterminal, 'posterminal');
+ requireDefined(data.posterminal.id, 'posterminal.id');
- // If "enable all" toggle is on, enable all categories for this terminal
- if (enableAllForNew && terminal.id) {
- await this.props.enableAllTerminalCategories({ terminalId: terminal.id }).unwrap();
- // Reload category tree so tiles reflect the updated posterminalIds
- this.props.loadCategoryTree();
+ if (enableAllForNew) {
+ await this.context.posterminal.setOnAll(data.posterminal.id);
+ await this.context.updateCategoryTree();
}
const terminals = await this.context.updatePosterminal();
@@ -135,7 +125,7 @@ class CompanySettingsDialog extends Component {
async handleDeleteTerminal(terminal) {
try {
- await this.props.deletePosterminal({ terminalId: terminal.id }).unwrap();
+ await this.context.posterminal.delete(terminal.id);
const terminals = await this.context.updatePosterminal();
this.setState({ terminals });
} catch (err) {
@@ -162,14 +152,14 @@ class CompanySettingsDialog extends Component {
this.setState({ busy: true, error: null });
try {
- await this.props
- .saveCompany({
- name: name.trim(),
- iso639,
- taxrate: parsedTaxrate,
- reducedtaxrate: parsedReducedtaxrate,
- })
- .unwrap();
+ const session = await this.context.authMePromise;
+ const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
+ await this.context.company.update(companyId, {
+ name: name.trim(),
+ iso639,
+ taxrate: parsedTaxrate,
+ reducedtaxrate: parsedReducedtaxrate,
+ });
await this.context.updateCompany();
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
onClose();
@@ -350,17 +340,3 @@ class CompanySettingsDialog extends Component {
);
}
}
-
-const mapStateToProps = (state) => ({
- selectedCompanyId: selectAuthUser(state)?.selectedCompanyId,
-});
-
-const mapDispatchToProps = (dispatch) => ({
- saveCompany: (payload) => dispatch(updateCompanyThunk(payload)),
- createPosterminal: (payload) => dispatch(createPosterminal(payload)),
- deletePosterminal: (payload) => dispatch(deletePosterminal(payload)),
- enableAllTerminalCategories: (payload) => dispatch(enableAllTerminalCategories(payload)),
- loadCategoryTree: () => dispatch(loadCategoryTree()),
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(CompanySettingsDialog);
diff --git a/src/components/CompanySetupDialog.jsx b/src/components/CompanySetupDialog.jsx
index 78e890a..b7afad7 100644
--- a/src/components/CompanySetupDialog.jsx
+++ b/src/components/CompanySetupDialog.jsx
@@ -22,6 +22,7 @@ import Typography from '@mui/material/Typography';
import BusinessIcon from '@mui/icons-material/Business';
import BackendError from './BackendError';
import { company } from '../api';
+import { StorageManagerContext } from '../api/storageManager';
import {
createCompany as createCompanyThunk,
logout as logoutThunk,
@@ -35,6 +36,8 @@ import {
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
class CompanySetupDialog extends Component {
+ static contextType = StorageManagerContext;
+
constructor(props) {
super(props);
this.state = {
@@ -73,6 +76,7 @@ class CompanySetupDialog extends Component {
this.setState({ busy: true, error: null });
try {
await this.props.selectCompany(companyId).unwrap();
+ await this.context.updateAuthMe();
} catch (err) {
this.setState({ error: backendErrorFrom(err) });
} finally {
@@ -106,6 +110,8 @@ class CompanySetupDialog extends Component {
reducedtaxrate: parsedReducedtaxrate,
})
.unwrap();
+ await this.context.updateAuthMe();
+ await this.context.updateCompany();
} catch (err) {
this.setState({ error: backendErrorFrom(err) });
} finally {
@@ -113,8 +119,13 @@ class CompanySetupDialog extends Component {
}
}
- handleLogout() {
- void this.props.logout();
+ async handleLogout() {
+ try {
+ await this.props.logout().unwrap();
+ await this.context.updateAuthMe();
+ } catch (err) {
+ this.setState({ error: backendErrorFrom(err) });
+ }
}
handleClose(_event, reason) {
diff --git a/src/components/LoginPage.jsx b/src/components/LoginPage.jsx
index 260d705..0af8552 100644
--- a/src/components/LoginPage.jsx
+++ b/src/components/LoginPage.jsx
@@ -6,6 +6,7 @@ import GoogleIcon from '@mui/icons-material/Google';
import { enqueueSnackbar } from 'notistack';
import { notifyErrorMessage } from '../utils/notify';
import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice';
+import { StorageManagerContext } from '../api/storageManager';
import {
abortGoogleSignIn,
FEDCM_BLOCKED_MESSAGE,
@@ -16,6 +17,8 @@ import {
} from '../auth/googleSignIn';
class LoginPage extends Component {
+ static contextType = StorageManagerContext;
+
constructor(props) {
super(props);
this.state = { busy: false };
@@ -36,6 +39,7 @@ class LoginPage extends Component {
});
this.setState({ busy: true });
await this.props.loginWithGoogle(idToken).unwrap();
+ await this.context.updateAuthMe();
} catch (err) {
if (err instanceof GoogleSignInCancelledError) return;
logError('[login] Google sign-in failed', {
diff --git a/src/components/UserMenu.jsx b/src/components/UserMenu.jsx
index 1c731ee..5119465 100644
--- a/src/components/UserMenu.jsx
+++ b/src/components/UserMenu.jsx
@@ -9,15 +9,20 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import LogoutIcon from '@mui/icons-material/Logout';
import BusinessIcon from '@mui/icons-material/Business';
-import { logout, selectAuthUser } from '../store/authSlice';
+import { StorageManagerContext } from '../api/storageManager';
+import { logout } from '../store/authSlice';
import CompanySettingsDialog from './CompanySettingsDialog';
+import { notifyError } from '../utils/notify';
class UserMenu extends Component {
+ static contextType = StorageManagerContext;
+
constructor(props) {
super(props);
this.state = {
anchorEl: null,
settingsOpen: false,
+ user: null,
};
this.handleOpenMenu = this.handleOpenMenu.bind(this);
this.handleCloseMenu = this.handleCloseMenu.bind(this);
@@ -26,6 +31,19 @@ class UserMenu extends Component {
this.handleLogout = this.handleLogout.bind(this);
}
+ componentDidMount() {
+ void this.loadUser();
+ }
+
+ async loadUser() {
+ try {
+ const session = await this.context.authMePromise;
+ this.setState({ user: session.user });
+ } catch (err) {
+ notifyError(err);
+ }
+ }
+
handleOpenMenu(event) {
this.setState({ anchorEl: event.currentTarget });
}
@@ -42,14 +60,18 @@ class UserMenu extends Component {
this.setState({ settingsOpen: false });
}
- handleLogout() {
+ async handleLogout() {
this.setState({ anchorEl: null });
- void this.props.logout();
+ try {
+ await this.props.logout().unwrap();
+ await this.context.updateAuthMe();
+ } catch (err) {
+ notifyError(err);
+ }
}
render() {
- const { user } = this.props;
- const { anchorEl, settingsOpen } = this.state;
+ const { user, anchorEl, settingsOpen } = this.state;
const open = Boolean(anchorEl);
return (
@@ -92,12 +114,8 @@ class UserMenu extends Component {
}
}
-const mapStateToProps = (state) => ({
- user: selectAuthUser(state),
-});
-
const mapDispatchToProps = (dispatch) => ({
logout: () => dispatch(logout()),
});
-export default connect(mapStateToProps, mapDispatchToProps)(UserMenu);
+export default connect(null, mapDispatchToProps)(UserMenu);
diff --git a/src/main.jsx b/src/main.jsx
index 98a3bc7..e80a548 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -33,11 +33,11 @@ createRoot(document.getElementById('root')).render(
autoHideDuration={2000}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
-
-
+
+
-
-
+
+
,
diff --git a/src/store/authSlice.js b/src/store/authSlice.js
index 1b2d668..aa2d1a8 100644
--- a/src/store/authSlice.js
+++ b/src/store/authSlice.js
@@ -1,7 +1,10 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { auth, company } from '../api';
-export const bootstrapSession = createAsyncThunk('auth/bootstrap', async () => auth.me());
+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);