Status: Accepted, stated in the project's written engineering rules. This record covers where Cluster ERP keeps interface state. The arrangement a user owns (home screen, dock, widgets) belongs to the user account on the server; preferences of one device (theme, language, time zone, accessibility, floating button position) stay on that device. The client and the routes are explained on user UI state.
Context¶
The client module states the problem that led to the rule: the home layout once lived only in the browser's local storage, so it "silently differed per browser, per device and even per host name", and a cleared cache looked like a reset (webapp/js/ui-state.js:1-7). The model docstring states the goal: the arrangement follows the person across devices and browsers and survives a restart, a cleared cache, or a different host name (src/cluster_erp/modules/auth/ui_state/models.py:13-21). The project's written engineering rules also require browser tests to use the ui-state guard.
Decision¶
- Per-user layout values live in the table
auth_user_ui_state, one live row per user and key (src/cluster_erp/modules/auth/ui_state/models.py:23-35). - The routes
GET /api/v1/auth/ui-stateandPUT /api/v1/auth/ui-state/{key}read and write the signed-in user's own values. The user id comes from the token, never from the request (src/cluster_erp/modules/auth/ui_state/routes.py:15-39,src/cluster_erp/modules/auth/module.py:58,src/cluster_erp/modules/auth/module.py:68). - Device preferences stay in
localStorage: theme and time zone (webapp/js/prefs.js:5-6), language (webapp/js/i18n.js:9), accessibility (webapp/js/a11y.js:33), and the floating button position (webapp/js/home.js:2965). The desktop client keeps its window and pane layout inQSettings(src/cluster_erp/desktop/core/settings_store.py:10).
The sequence diagram shows a layout change in the web app.
sequenceDiagram participant UI as home screen participant St as ui-state.js participant API as auth ui-state routes participant Svc as UiStateService UI->>St: saveUiState(key, value) St->>St: queue, wait 600 ms St->>API: PUT /auth/ui-state/key with keepalive API->>Svc: put(user.id, key, value) Svc->>Svc: insert or update the live row
In prose: saveUiState updates the local cache, queues the value, and starts a 600 ms timer, so many saves during a drag become one write (webapp/js/ui-state.js:68-73). flush sends one PUT per queued key with keepalive, and the queue is also flushed when the page is hidden or closed (webapp/js/ui-state.js:41-65). The service inserts a row or updates the existing live row of that user and key (src/cluster_erp/modules/auth/ui_state/services.py:30-44).
Consequences¶
- One person can neither read nor overwrite another person's layout; authentication alone guards the routes (
src/cluster_erp/modules/auth/ui_state/routes.py:15-18). - A value is limited to 256 KiB of JSON (
src/cluster_erp/modules/auth/ui_state/schemas.py:8-29), and a key to 60 characters (src/cluster_erp/modules/auth/ui_state/routes.py:32-33). - Each device class keeps its own home arrangement under its own key, for example
springboard_phone(webapp/js/device-class.js:22-26). - When the first read fails, the client keeps its local copy instead of wiping the arrangement (
webapp/js/ui-state.js:29-34). - Browser tests change server-side state of a real account, so they need a guard that restores it.
Where the code enforces it¶
| Mechanism | What it checks | Source |
|---|---|---|
UiStateService |
Every read and write is scoped to one user id | src/cluster_erp/modules/auth/ui_state/services.py:12-44 |
UiStateWrite._bounded |
The stored value is JSON and at most 256 KiB | src/cluster_erp/modules/auth/ui_state/schemas.py:13-29 |
guardUiState |
Browser suites run as the test account, on a known board, and restore the original | web-client/_ui_state_guard.mjs:114-195 |
test_board_touching_suites_use_the_ui_state_guard |
Every suite that touches the layout uses the guard | tests/test_e2e_db_isolation.py:177-200 |
Related pages¶
Verified against commit 92f8051 on 2026-11-13.