Skip to content

fix(global-store): load site/user reactively from auth state#36190

Merged
oidacra merged 5 commits into
mainfrom
fix/global-store-auth-reactive-state
Jun 16, 2026
Merged

fix(global-store): load site/user reactively from auth state#36190
oidacra merged 5 commits into
mainfrom
fix/global-store-auth-reactive-state

Conversation

@oidacra

@oidacra oidacra commented Jun 16, 2026

Copy link
Copy Markdown
Member

What

Make GlobalStore load the current site and user reactively from the authentication state instead of a one-shot load at app bootstrap.

Fixes #36189

Why

GlobalStore (providedIn: 'root') is instantiated at app bootstrap via provideAppInitializer (DotAppLifecycleEffect injects it) — before the session cookie is valid (e.g. while on the login page). Its feature onInit hooks ran a one-shot load (withSite.loadCurrentSite(), withUser.loadLoggedUser()) that fired pre-auth, got a 401, logged a console.warn, and never retried.

Because login navigates through the SPA router (router.navigate(['/']), no full page reload), nothing re-triggered the loads. Result: after an initial login the header showed "Select a site" and Pages crashed with Cannot read properties of null (reading 'userId'). A manual refresh recreated the store with a valid session, masking the bug.

How

  • withSite / withUser: load reactively via loginService.watchUser(() => store.loadX()). watchUser() fires immediately when a session already exists (refresh, once AuthGuard resolves auth via loadAuth()) and again on every login — covering initial login, refresh and re-login uniformly. This mirrors the reactive pattern the legacy SiteService already used (loginService.watchUser(() => this.loadCurrentSite())), which the modern store had dropped.
  • DotPageActionsService: resolve userId via getCurrentUser() instead of reading the GlobalStore.loggedUser() signal synchronously at construction time (defensive — reading a store signal synchronously in a constructor is inherently racy).
  • store.spec: mock LoginService and add coverage for the auth-reactive load behavior (no load before auth; load on auth notification; reload on re-login).

Testing

  • nx test global-store — 177 passing (incl. new auth-reactive cases)
  • nx test dotcms-ui — passing (incl. dot-page-actions, main-legacy)
  • nx lint global-store dotcms-ui — passing (Nx module boundaries OK: global-store → @dotcms/dotcms-js already used by withWebSocket)

Manual

  1. Log in to the backend, land on /starter, do not refresh.
  2. Header site selector shows the current site (not "Select a site").
  3. Open Pages — no userId null error; menus build with correct permissions.

GlobalStore is instantiated at app bootstrap (provideAppInitializer), before
the session cookie is valid (e.g. on the login page). Its one-shot onInit loads
(withSite.loadCurrentSite / withUser.loadLoggedUser) therefore fired pre-auth,
got a 401, and never retried. Because login navigates via the SPA router with no
full page reload, nothing re-triggered them, leaving siteDetails/loggedUser null
until a manual refresh (the header showed 'Select a site' and Pages crashed with
'Cannot read properties of null (reading userId)').

- withSite/withUser: load reactively via LoginService.watchUser(), which fires
  immediately when a session already exists (refresh, once AuthGuard resolves
  auth via loadAuth()) and on every login. Mirrors the legacy SiteService
  pattern and covers initial login, refresh and re-login uniformly.
- dot-page-actions: resolve userId via getCurrentUser() instead of reading the
  GlobalStore loggedUser signal synchronously at construction time.
- store.spec: mock LoginService and cover the auth-reactive load behavior.
@claude

claude Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @nicobytes's task in 1m 4s —— View job


Rollback Safety Analysis

  • Gather context from PR data
  • Read rollback-unsafe categories reference
  • Get full PR diff
  • Analyze diff against unsafe categories
  • Apply appropriate label

Result: ✅ Safe To Rollback

All 5 changed files are pure frontend Angular/TypeScript code — no backend, no database, no Elasticsearch, no REST API contract changes:

File Change Category check
with-site.feature.ts Replaced one-shot onInit load with reactive LoginService.auth$ subscription No DB/ES/API impact
with-user.feature.ts Same reactive pattern for user load No DB/ES/API impact
dot-page-actions.service.ts Replaced GlobalStore.loggedUser() signal read with getCurrentUser() async call No DB/ES/API impact
dot-page-actions.service.spec.ts Test cleanup matching the above Tests only
store.spec.ts New auth-reactive test coverage Tests only

Against every category in the reference document:

  • C-1 through C-4 (structural DB / ES / content model changes): No database migrations, no runonce tasks, no contentlet_as_json touches. ✅
  • H-1 through H-8 (data migrations, renames, field types, VTL viewtools): No data transformations, no schema changes, no VTL viewtool interface changes. ✅
  • M-1 through M-4 (column type, push publishing, REST/GraphQL API, OSGi): No API contract changes — DotCurrentUserService.getCurrentUser() is an existing internal method called differently in the same frontend service, not an exposed REST endpoint. ✅

The label AI: Safe To Rollback has been applied.

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

🤖 Bedrock Review — deepseek.v3.2

[🟠 High] core-web/apps/dotcms-ui/src/app/portlets/dot-pages/services/dot-page-actions.service.ts:394 — The switchMap operator is used without handling potential errors from getCurrentUser(). If this call fails (e.g., network error, 401), the entire permission initialization will silently stop, leaving #contentletsPermissions and #htmlPagesPermissions signals in their default state (likely null). This could cause the page actions menu to incorrectly show/hide options based on incomplete permissions.

[🟡 Medium] core-web/libs/global-store/src/lib/features/with-site/with-site.feature.ts:159 — The reactive subscription in onInit uses takeUntilDestroyed(destroyRef), but the destroyRef is injected inside the hook closure. If the store is a singleton (as it is), its DestroyRef corresponds to the application root, which never destroys. This means the subscription will live for the entire app lifetime, which is likely intended but could lead to memory leaks if the store feature is ever used in a short-lived, scoped context. Consider documenting this lifetime assumption.

[🟡 Medium] core-web/libs/global-store/src/lib/features/with-user/with-user.feature.ts:100 — Same DestroyRef lifetime consideration as in with-site.feature.ts. The subscription is tied to the store's lifecycle, but for the root singleton, this is effectively the app lifecycle.

[🟡 Medium] core-web/libs/global-store/src/lib/store.spec.ts:65 — The test uses jest.clearAllMocks() in beforeEach, which will reset mock implementations, including the mockReturnValue defined in createServiceFactory. This could lead to flaky tests if the mock setup relies on those implementations persisting across tests. It's safer to reset only the call counts using jest.clearAllMocks() or to re-setup the mocks explicitly in each test.


Run: #27636850985 · tokens: in: 5065 · out: 431 · total: 5496

@semgrep-code-dotcms-test

Copy link
Copy Markdown
Contributor

Legal Risk

The following dependencies were released under a license that
has been flagged by your organization for consideration.

Recommendation

While merging is not directly blocked, it's best to pause and consider what it means to use this license before continuing. If you are unsure, reach out to your security team or Semgrep admin to address this issue.

GPL-2.0

MPL-2.0

- store.spec: reset shared mock call counts in beforeEach (clearAllMocks)
  instead of an in-test mockClear, removing the test-order dependency.
- store.spec: add error-path coverage — getCurrentUser/getCurrentSite failing
  after auth fires must leave loggedUser/siteDetails null without crashing.
- withSite/withUser: clarify the watchUser() comment to distinguish the
  synchronous branch (auth already set) from the async auth$ branch (refresh
  via AuthGuard loadAuth(), and every login via setAuth()).
Address AI-review feedback: watchUser fired loadCurrentSite/loadLoggedUser on
every auth emission (login, login-as, logoutAs, loadAuth), causing redundant
reloads even when the user did not change. Subscribe to loginService.auth$
(seeded with the current auth via startWith), map to user.userId and
distinctUntilChanged, so the reactive load only runs when the authenticated
user actually changes — login-as (which only sets loginAsUser) no longer
triggers a redundant site/user reload. Behavior on login/refresh/re-login is
unchanged.

Tests updated to drive auth$ and cover dedupe (same user) vs reload
(different user).
Add takeUntilDestroyed(destroyRef) to the auth-reactive subscriptions in
withSite/withUser so they are torn down with the store. No leak today (root
singleton + root LoginService share the app lifetime), but this makes the
teardown explicit and safe if the feature is ever composed into a scoped store.
@oidacra

oidacra commented Jun 16, 2026

Copy link
Copy Markdown
Member Author

Re: Bedrock AI review — addressing the three findings:

dot-page-actions.service.ts:394 (switchMap without error handling): this isn't accurate. .subscribe({ error }) receives errors from the entire pipeline — RxJS propagates an error from the inner switchMap observable to the outer stream. If either getCurrentUser() or getUserPermissions() fails, the error handler runs httpErrorManagerService.handle(error, true), which surfaces it to the user. There is a single handler covering the whole pipe, so the failure is not silent — no change needed here.

with-site.feature.ts / with-user.feature.ts (reload on every auth emission): addressed in 13b3d42. The features now subscribe to loginService.auth$, map to user.userId and apply distinctUntilChanged, so the reactive load only runs when the authenticated user actually changes; re-emissions that don't change the user (e.g. login-as, which only sets loginAsUser) no longer trigger a redundant site/user reload. The subscription is tied to the store lifecycle via takeUntilDestroyed (b23447b). Login / refresh / re-login behavior is unchanged.

@oidacra oidacra enabled auto-merge June 16, 2026 17:43
@oidacra oidacra added this pull request to the merge queue Jun 16, 2026
Merged via the queue into main with commit c9283c5 Jun 16, 2026
39 of 40 checks passed
@oidacra oidacra deleted the fix/global-store-auth-reactive-state branch June 16, 2026 19:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Global site/user state empty after SPA login until refresh (GlobalStore loads pre-auth)

3 participants