From a9e942df7a9a040d7ef8d07494017bc6cf9ca562 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 5 Aug 2026 08:19:13 +0200 Subject: [PATCH] u --- src/api/storageManager.jsx | 414 ++++++++++++++++++----- src/components/AuthBootstrap.jsx | 10 +- src/components/CompanySettingsDialog.jsx | 78 ++--- src/components/CompanySetupDialog.jsx | 15 +- src/components/LoginPage.jsx | 4 + src/components/UserMenu.jsx | 38 ++- src/main.jsx | 8 +- src/store/authSlice.js | 5 +- 8 files changed, 417 insertions(+), 155 deletions(-) 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);