This commit is contained in:
seb
2026-08-05 08:19:13 +02:00
parent 3000d7d45c
commit a9e942df7a
8 changed files with 417 additions and 155 deletions

View File

@@ -1,9 +1,27 @@
import { createContext, useContext, useRef } from 'react'; import { createContext, useContext, useRef } from 'react';
import { articleIndex, brand, category, company, posterminal, unit } from './index';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
export const StorageManagerContext = createContext(null); 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() * Stale-while-revalidate cache: readers keep the current promise; update()
* swaps only after a successful reload. Failed updates leave the cache intact. * swaps only after a successful reload. Failed updates leave the cache intact.
@@ -33,64 +51,258 @@ function createCache(label, load) {
}; };
} }
async function loadArticleIndex() { class StorageManager {
const data = await articleIndex.list(); #baseUrl;
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 function loadBrands() { constructor(baseUrl = window.location.origin) {
const data = await brand.list(); this.#baseUrl = baseUrl;
requireObject(data, 'GET /api/brand');
return requireArray(data.brands, 'brands');
}
async function loadUnits() { this.auth = {
const data = await unit.list(); /** POST /api/auth/login */
requireObject(data, 'GET /api/unit'); login: (body) => this.#json('POST', '/api/auth/login', { body }),
return requireArray(data.units, 'units'); /** 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() { this.health = {
const data = await company.list(); /** GET /api/health */
requireObject(data, 'GET /api/company'); get: () => this.#json('GET', '/api/health'),
return requireArray(data.companies, 'companies'); };
}
async function loadCategoryTree() { this.company = {
const data = await category.getTree(); /** GET /api/company */
requireObject(data, 'GET /api/category/tree'); list: () => this.#json('GET', '/api/company'),
requireArray(data.tree, 'category/tree.tree'); /** POST /api/company */
requireArray(data.articles, 'category/tree.articles'); create: (body) => this.#json('POST', '/api/company', { body }),
requireDefined(data.languageId, 'category/tree.languageId'); /** PUT /api/company/{id} */
return data; 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() { this.unit = {
const data = await posterminal.list(); /** GET /api/unit */
requireObject(data, 'GET /api/posterminal'); list: () => this.#json('GET', '/api/unit'),
return requireArray(data.posterminals, 'posterminals'); /** 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() { this.brand = {
const articleIndexCache = createCache('GET /articleIndex', loadArticleIndex); /** GET /api/brand */
const brandCache = createCache('GET /brand', loadBrands); list: () => this.#json('GET', '/api/brand'),
const unitCache = createCache('GET /unit', loadUnits); /** PUT /api/brand */
const companyCache = createCache('GET /company', loadCompanies); upsert: (body) => this.#json('PUT', '/api/brand', { body }),
const categoryTreeCache = createCache('GET /category/tree', loadCategoryTree); /** DELETE /api/brand/{id} */
const posterminalCache = createCache('GET /posterminal', loadPosterminals); delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`),
};
async function doSearch({ target, filter, offset, maxCount, onUpdate }) { this.category = {
const articles = await articleIndexCache.promise; /** 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 <img src> (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 <img src> (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 query = target.trim().toLowerCase();
const matches = articles.filter( const matches = articles.filter(
(p) => (p) =>
@@ -106,41 +318,77 @@ function createStorageManager() {
}); });
} }
return { async #json(method, path, { body, query, contentType, raw } = {}) {
doSearch, 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() { const res = await fetch(url, {
return articleIndexCache.promise; method,
}, headers,
updateArticleIndex: articleIndexCache.update, credentials: 'include',
/** @deprecated use updateArticleIndex */ body: payload,
update: articleIndexCache.update, });
get brandPromise() { if (!res.ok) {
return brandCache.promise; let message = res.statusText;
}, let data;
updateBrand: brandCache.update, 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() { async #empty(method, path, { query } = {}) {
return unitCache.promise; await this.#json(method, path, { query });
}, return null;
updateUnit: unitCache.update, }
get companyPromise() { async #blob(method, path, { query } = {}) {
return companyCache.promise; const url = buildPath(path, query);
}, const res = await fetch(url, {
updateCompany: companyCache.update, method,
credentials: 'include',
});
get categoryTreePromise() { if (!res.ok) {
return categoryTreeCache.promise; let message = res.statusText;
}, let data;
updateCategoryTree: categoryTreeCache.update, try {
data = await res.json();
get posterminalPromise() { message = data.error || data.message || message;
return posterminalCache.promise; } catch {
}, message = (await res.text().catch(() => message)) || message;
updatePosterminal: posterminalCache.update, }
}; 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 }) { export function StorageManagerProvider({ children }) {
const mgrRef = useRef(null); const mgrRef = useRef(null);
if (!mgrRef.current) { if (!mgrRef.current) {
mgrRef.current = createStorageManager(); mgrRef.current = new StorageManager();
} }
return ( return (

View File

@@ -2,22 +2,24 @@ import { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../utils/notify'; import { notifyError } from '../utils/notify';
import { StorageManagerContext } from '../api/storageManager';
import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice'; import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice';
let sessionBootstrapped = false; let sessionBootstrapped = false;
class AuthBootstrap extends Component { class AuthBootstrap extends Component {
static contextType = StorageManagerContext;
componentDidMount() { componentDidMount() {
if (sessionBootstrapped) return; if (sessionBootstrapped) return;
sessionBootstrapped = true; sessionBootstrapped = true;
this.props this.context.authMePromise
.bootstrapSession()
.unwrap()
.then((session) => { .then((session) => {
if (session.forbidden) { if (session.forbidden) {
enqueueSnackbar(session.message, { variant: 'warning' }); enqueueSnackbar(session.message, { variant: 'warning' });
} }
return this.props.bootstrapSession(session).unwrap();
}) })
.catch((err) => { .catch((err) => {
notifyError(err); notifyError(err);
@@ -30,7 +32,7 @@ class AuthBootstrap extends Component {
} }
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
bootstrapSession: () => dispatch(bootstrapSessionThunk()), bootstrapSession: (session) => dispatch(bootstrapSessionThunk(session)),
}); });
export default connect(null, mapDispatchToProps)(AuthBootstrap); export default connect(null, mapDispatchToProps)(AuthBootstrap);

View File

@@ -1,5 +1,4 @@
import { Component } from 'react'; import { Component } from 'react';
import { connect } from 'react-redux';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
@@ -27,20 +26,12 @@ import { enqueueSnackbar } from 'notistack';
import BackendError from './BackendError'; import BackendError from './BackendError';
import SingleLineTextEdit from './SingleLineTextEdit'; import SingleLineTextEdit from './SingleLineTextEdit';
import { StorageManagerContext } from '../api/storageManager'; import { StorageManagerContext } from '../api/storageManager';
import { selectAuthUser } from '../store/authSlice'; import { requireDefined, requireObject } from '../utils/apiShape';
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 { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm'; import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
import { backendErrorFrom, isBackendFailure } from '../utils/backendError'; import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
import { notifyError } from '../utils/notify'; import { notifyError } from '../utils/notify';
class CompanySettingsDialog extends Component { export default class CompanySettingsDialog extends Component {
static contextType = StorageManagerContext; static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
@@ -74,27 +65,25 @@ class CompanySettingsDialog extends Component {
} }
} }
syncFormFromCompany(company) { syncFormFromCompany(companyRow) {
this.setState({ this.setState({
name: company.name, name: companyRow.name,
iso639: company.iso639, iso639: companyRow.iso639,
taxrate: company.taxrate != null ? String(company.taxrate) : '', taxrate: companyRow.taxrate != null ? String(companyRow.taxrate) : '',
reducedtaxrate: company.reducedtaxrate != null ? String(company.reducedtaxrate) : '', reducedtaxrate: companyRow.reducedtaxrate != null ? String(companyRow.reducedtaxrate) : '',
}); });
} }
async loadCompany() { async loadCompany() {
try { try {
const companyId = requireDefined( const session = await this.context.authMePromise;
this.props.selectedCompanyId, const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
'selectedCompanyId',
);
const companies = await this.context.companyPromise; const companies = await this.context.companyPromise;
const company = requireDefined( const companyRow = requireDefined(
companies.find((c) => c.id === companyId), companies.find((c) => c.id === companyId),
'company', 'company',
); );
this.syncFormFromCompany(company); this.syncFormFromCompany(companyRow);
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);
} }
@@ -115,13 +104,14 @@ class CompanySettingsDialog extends Component {
this.setState({ creatingTerminal: true }); this.setState({ creatingTerminal: true });
try { 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) {
if (enableAllForNew && terminal.id) { await this.context.posterminal.setOnAll(data.posterminal.id);
await this.props.enableAllTerminalCategories({ terminalId: terminal.id }).unwrap(); await this.context.updateCategoryTree();
// Reload category tree so tiles reflect the updated posterminalIds
this.props.loadCategoryTree();
} }
const terminals = await this.context.updatePosterminal(); const terminals = await this.context.updatePosterminal();
@@ -135,7 +125,7 @@ class CompanySettingsDialog extends Component {
async handleDeleteTerminal(terminal) { async handleDeleteTerminal(terminal) {
try { try {
await this.props.deletePosterminal({ terminalId: terminal.id }).unwrap(); await this.context.posterminal.delete(terminal.id);
const terminals = await this.context.updatePosterminal(); const terminals = await this.context.updatePosterminal();
this.setState({ terminals }); this.setState({ terminals });
} catch (err) { } catch (err) {
@@ -162,14 +152,14 @@ class CompanySettingsDialog extends Component {
this.setState({ busy: true, error: null }); this.setState({ busy: true, error: null });
try { try {
await this.props const session = await this.context.authMePromise;
.saveCompany({ const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
name: name.trim(), await this.context.company.update(companyId, {
iso639, name: name.trim(),
taxrate: parsedTaxrate, iso639,
reducedtaxrate: parsedReducedtaxrate, taxrate: parsedTaxrate,
}) reducedtaxrate: parsedReducedtaxrate,
.unwrap(); });
await this.context.updateCompany(); await this.context.updateCompany();
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' }); enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
onClose(); 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);

View File

@@ -22,6 +22,7 @@ import Typography from '@mui/material/Typography';
import BusinessIcon from '@mui/icons-material/Business'; import BusinessIcon from '@mui/icons-material/Business';
import BackendError from './BackendError'; import BackendError from './BackendError';
import { company } from '../api'; import { company } from '../api';
import { StorageManagerContext } from '../api/storageManager';
import { import {
createCompany as createCompanyThunk, createCompany as createCompanyThunk,
logout as logoutThunk, logout as logoutThunk,
@@ -35,6 +36,8 @@ import {
import { backendErrorFrom, isBackendFailure } from '../utils/backendError'; import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
class CompanySetupDialog extends Component { class CompanySetupDialog extends Component {
static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
@@ -73,6 +76,7 @@ class CompanySetupDialog extends Component {
this.setState({ busy: true, error: null }); this.setState({ busy: true, error: null });
try { try {
await this.props.selectCompany(companyId).unwrap(); await this.props.selectCompany(companyId).unwrap();
await this.context.updateAuthMe();
} catch (err) { } catch (err) {
this.setState({ error: backendErrorFrom(err) }); this.setState({ error: backendErrorFrom(err) });
} finally { } finally {
@@ -106,6 +110,8 @@ class CompanySetupDialog extends Component {
reducedtaxrate: parsedReducedtaxrate, reducedtaxrate: parsedReducedtaxrate,
}) })
.unwrap(); .unwrap();
await this.context.updateAuthMe();
await this.context.updateCompany();
} catch (err) { } catch (err) {
this.setState({ error: backendErrorFrom(err) }); this.setState({ error: backendErrorFrom(err) });
} finally { } finally {
@@ -113,8 +119,13 @@ class CompanySetupDialog extends Component {
} }
} }
handleLogout() { async handleLogout() {
void this.props.logout(); try {
await this.props.logout().unwrap();
await this.context.updateAuthMe();
} catch (err) {
this.setState({ error: backendErrorFrom(err) });
}
} }
handleClose(_event, reason) { handleClose(_event, reason) {

View File

@@ -6,6 +6,7 @@ import GoogleIcon from '@mui/icons-material/Google';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { notifyErrorMessage } from '../utils/notify'; import { notifyErrorMessage } from '../utils/notify';
import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice'; import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice';
import { StorageManagerContext } from '../api/storageManager';
import { import {
abortGoogleSignIn, abortGoogleSignIn,
FEDCM_BLOCKED_MESSAGE, FEDCM_BLOCKED_MESSAGE,
@@ -16,6 +17,8 @@ import {
} from '../auth/googleSignIn'; } from '../auth/googleSignIn';
class LoginPage extends Component { class LoginPage extends Component {
static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { busy: false }; this.state = { busy: false };
@@ -36,6 +39,7 @@ class LoginPage extends Component {
}); });
this.setState({ busy: true }); this.setState({ busy: true });
await this.props.loginWithGoogle(idToken).unwrap(); await this.props.loginWithGoogle(idToken).unwrap();
await this.context.updateAuthMe();
} catch (err) { } catch (err) {
if (err instanceof GoogleSignInCancelledError) return; if (err instanceof GoogleSignInCancelledError) return;
logError('[login] Google sign-in failed', { logError('[login] Google sign-in failed', {

View File

@@ -9,15 +9,20 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import LogoutIcon from '@mui/icons-material/Logout'; import LogoutIcon from '@mui/icons-material/Logout';
import BusinessIcon from '@mui/icons-material/Business'; 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 CompanySettingsDialog from './CompanySettingsDialog';
import { notifyError } from '../utils/notify';
class UserMenu extends Component { class UserMenu extends Component {
static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
anchorEl: null, anchorEl: null,
settingsOpen: false, settingsOpen: false,
user: null,
}; };
this.handleOpenMenu = this.handleOpenMenu.bind(this); this.handleOpenMenu = this.handleOpenMenu.bind(this);
this.handleCloseMenu = this.handleCloseMenu.bind(this); this.handleCloseMenu = this.handleCloseMenu.bind(this);
@@ -26,6 +31,19 @@ class UserMenu extends Component {
this.handleLogout = this.handleLogout.bind(this); 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) { handleOpenMenu(event) {
this.setState({ anchorEl: event.currentTarget }); this.setState({ anchorEl: event.currentTarget });
} }
@@ -42,14 +60,18 @@ class UserMenu extends Component {
this.setState({ settingsOpen: false }); this.setState({ settingsOpen: false });
} }
handleLogout() { async handleLogout() {
this.setState({ anchorEl: null }); this.setState({ anchorEl: null });
void this.props.logout(); try {
await this.props.logout().unwrap();
await this.context.updateAuthMe();
} catch (err) {
notifyError(err);
}
} }
render() { render() {
const { user } = this.props; const { user, anchorEl, settingsOpen } = this.state;
const { anchorEl, settingsOpen } = this.state;
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
return ( return (
@@ -92,12 +114,8 @@ class UserMenu extends Component {
} }
} }
const mapStateToProps = (state) => ({
user: selectAuthUser(state),
});
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
logout: () => dispatch(logout()), logout: () => dispatch(logout()),
}); });
export default connect(mapStateToProps, mapDispatchToProps)(UserMenu); export default connect(null, mapDispatchToProps)(UserMenu);

View File

@@ -33,11 +33,11 @@ createRoot(document.getElementById('root')).render(
autoHideDuration={2000} autoHideDuration={2000}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
> >
<AuthBootstrap> <StorageManagerProvider>
<StorageManagerProvider> <AuthBootstrap>
<App /> <App />
</StorageManagerProvider> </AuthBootstrap>
</AuthBootstrap> </StorageManagerProvider>
</SnackbarProvider> </SnackbarProvider>
</ThemeProvider> </ThemeProvider>
</Provider>, </Provider>,

View File

@@ -1,7 +1,10 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { auth, company } from '../api'; 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) => { export const loginWithGoogle = createAsyncThunk('auth/login', async (credential) => {
const { session } = await auth.loginWithGoogle(credential); const { session } = await auth.loginWithGoogle(credential);