Skip to content

[US-17.1 / OBT-254] fix(auth): commit session state atomically to stop Access Denied flash - #38

Open
levigtri wants to merge 2 commits into
mainfrom
levigft/obt-254-us-171-nao-ver-um-flash-de-access-denied-ao-entrar-em-outra
Open

[US-17.1 / OBT-254] fix(auth): commit session state atomically to stop Access Denied flash#38
levigtri wants to merge 2 commits into
mainfrom
levigft/obt-254-us-171-nao-ver-um-flash-de-access-denied-ao-entrar-em-outra

Conversation

@levigtri

@levigtri levigtri commented Jul 13, 2026

Copy link
Copy Markdown
Member

Summary

Signing out of one account and into another briefly rendered the Access Denied screen for 1-2 seconds before the app settled into its normal state. The tokens and the refresh interceptor were never at fault — the session state was.

AuthContext.login() published the user into React state and only then fetched the user's permissions (myRoles, myManagedOrgs). Between those two points the app sat in a half-built session: user was set, isLoading was already false (it is switched off at boot when there is no token), but the permission arrays were still empty. LoginPage redirects the moment user exists, so AppShell mounted and evaluated its guards against empty permissions and rendered AccessDeniedPage. When the requests landed, the arrays filled and the screen silently corrected itself.

The root cause is representational: an empty permission array is indistinguishable from "not loaded yet", and that is precisely what the guards read. This PR makes the session atomic — user and permissions now live in one Session object that is committed in a single state update — so the half-built session that produced the flash can no longer be represented at all.

A platform admin never saw the bug (is_platform_admin arrives inside /auth/me), and a page refresh never triggered it (restoreSession() loads everything under isLoading), which is why it only surfaced when switching into a manager account.

Changes

  1. Atomic session statesrc/contexts/AuthContext.tsxuser, appRoles and managedOrgIds are consolidated into a single Session object behind one useState. A shared loadSession() helper fetches the user and both permission endpoints in parallel and returns the fully-formed session, so login() and restoreSession() commit through the same path and neither can publish a partial one.

  2. Login no longer leaves orphan tokens on failuresrc/contexts/AuthContext.tsx — if any permission call rejects, login() clears both tokens from localStorage and rethrows instead of leaving the user authenticated-but-permissionless, stranded on a denied screen with only a "Sign out" button.

  3. refreshUser() reloads permissions toosrc/contexts/AuthContext.tsx — it previously refreshed only the user object, leaving roles and managed orgs stale after a role change.

  4. AdminRoute waits for the sessionsrc/App.tsx — it denied access without checking isLoading. It now renders the spinner while the session loads. It was protected transitively by AppShell today; this closes the gap directly.

  5. Caches cleared on logoutsrc/stores/languagesStore.ts, src/stores/phasesStore.ts, src/contexts/AuthContext.tsx — both Zustand caches (5min and 2min TTL) survived a logout, so a second account saw the first account's cached languages and phases until the TTL expired. logout() now resets both.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

Testing

  1. Sign in as a manager (non-platform-admin) account, then sign out and sign in as another manager account. The app goes straight from the login form to the dashboard — no "Access Denied" frame, no matter how slow the permission requests are.
  2. Repeat with a platform admin account, and with a manager → admin switch, to confirm no regression on either path.
  3. Refresh the page (F5) while signed in — the session restores as before, showing the spinner and never a denied screen.
  4. Throttle the network (DevTools → Slow 3G) and sign in. The login button stays in its "Signing in…" state for the whole load; the app never navigates into a permissionless shell.
  5. With the network throttled, sign in and force a permission endpoint to fail (e.g. block /api/auth/my-roles). The user stays on the login page with an error toast, and tc_access_token / tc_refresh_token are absent from localStorage — previously the tokens persisted and the user was stuck on Access Denied.
  6. Sign in as account A, visit Languages and Phases, sign out, then sign in as account B and visit both pages. B fetches its own data instead of briefly showing A's cached rows.
  7. npm run typecheck, npm run lint, npm run build.

Summary by CodeRabbit

  • Bug Fixes
    • Improved authentication loading behavior to prevent premature access checks during session restoration.
    • Centralized session handling for login, logout, and session recovery.
    • Ensured authentication failures clear stored credentials safely.
    • Reset cached languages and phases when signing out.
  • User Experience
    • Added a loading indicator while authentication status is being determined.
  • Data Management
    • Added reset functionality for cached languages and phases.

login() published the user into state before fetching myRoles and
myManagedOrgs, leaving a 1-2s window where user was set, isLoading was
already false, and the permission arrays were still empty. LoginPage
redirects as soon as user exists, so the shell mounted and evaluated its
guards against empty permissions, rendering AccessDeniedPage until the
requests landed. Empty permission arrays are indistinguishable from "not
loaded yet", which is exactly what the guards read.

User and permissions now live in a single Session object, so a half-built
session cannot be represented: login only commits once every permission
call has resolved, and clears the tokens if any of them fails instead of
leaving orphan tokens in localStorage with the user stuck on a denied
screen. refreshUser reloads permissions alongside the user, which it
previously left stale.

AdminRoute now renders the spinner while the session loads rather than
denying access, and logout resets the languages and phases caches so a
second account no longer sees the first account's cached data.
@linear-code

linear-code Bot commented Jul 13, 2026

Copy link
Copy Markdown

OBT-254

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Authentication state is consolidated into a session object, with centralized loading and token handling. Logout now clears cached language and phase data. Admin routes show a spinner while authentication restores, and both stores expose reset methods.

Changes

Auth session lifecycle

Layer / File(s) Summary
Session-owned store reset contracts
src/stores/languagesStore.ts, src/stores/phasesStore.ts
Language and phase stores add reset methods that clear cached data, loading state, and fetch timestamps.
Consolidated session loading
src/contexts/AuthContext.tsx
AuthProvider groups user, roles, and managed organization IDs into a session, loads them together, and centralizes token clearing during login and restoration.
Auth loading and logout integration
src/App.tsx, src/contexts/AuthContext.tsx
AdminRoute renders LoadingSpinner while authentication loads; logout clears tokens, session state, and both stores before navigating to /login.

Sequence Diagram(s)

sequenceDiagram
  participant AdminRoute
  participant AuthProvider
  participant AuthAPI
  participant LanguagesStore
  participant PhasesStore
  AdminRoute->>AuthProvider: read isLoading
  AuthProvider->>AuthAPI: load /me, roles, and managed organizations
  AuthAPI-->>AuthProvider: session data
  AuthProvider-->>AdminRoute: render spinner or authorization result
  AuthProvider->>LanguagesStore: reset on logout
  AuthProvider->>PhasesStore: reset on logout
  AuthProvider-->>AdminRoute: navigate to /login
Loading

Suggested reviewers: joaocarvoli

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: atomic auth session updates to prevent the Access Denied flash.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch levigft/obt-254-us-171-nao-ver-um-flash-de-access-denied-ao-entrar-em-outra

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/stores/languagesStore.ts (1)

11-57: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Store reset() doesn't invalidate in-flight fetch() calls in either store.

Both languagesStore.ts and phasesStore.ts add a reset() (called from AuthContext.tsx's logout()) that synchronously clears state, but their fetch() implementations unconditionally commit fetched data on resolution regardless of whether a reset() happened in between. A fetch started before logout that resolves after logout/reset silently repopulates the store with data fetched under the previous session, and — because lastFetched is set fresh — that stale data is then served as "current" for up to CACHE_TTL. This reintroduces exactly the class of stale-state-during-account-switch bug this PR is otherwise fixing.

  • src/stores/languagesStore.ts#L11-L57: add a generation/version counter incremented in reset(), and have fetch() skip committing (set(...)) if the counter changed since the call started; also guard the "duplicate fetch" subscriber resolution against a reset happening mid-wait.
  • src/stores/phasesStore.ts#L12-L53: apply the same generation-counter guard around the set({ phases, dependencies, ... }) commit in fetch().
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/languagesStore.ts` around lines 11 - 57, Prevent pre-reset fetches
from repopulating either store by adding a generation counter to
src/stores/languagesStore.ts#L11-L57 and src/stores/phasesStore.ts#L12-L53:
capture the generation at fetch start, increment it in reset(), and skip the
fetch result commit when the generation changes. In languagesStore.ts, also make
the duplicate-fetch subscriber stop waiting and resolve with the reset state
when reset occurs during the wait.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/stores/languagesStore.ts`:
- Line 11: Protect the languages store from stale in-flight requests by adding a
generation counter shared by reset() and fetch(). Increment the generation in
reset(), and have each fetch capture its generation before awaiting; only the
matching request may commit state or resolve duplicate-fetch subscribers.
Invalidate or retain those subscribers until a current-generation fetch
completes so they do not receive reset’s empty languages result.

In `@src/stores/phasesStore.ts`:
- Line 12: Update the phasesStore fetch/reset flow so an in-flight fetch cannot
commit results after reset() invalidates the current session state. Add the same
request-generation or cancellation guard used by languagesStore.ts, and have the
fetch completion update phases, dependencies, lastFetched, and loading only when
it still belongs to the active generation; preserve reset()’s cleared state
otherwise.

---

Outside diff comments:
In `@src/stores/languagesStore.ts`:
- Around line 11-57: Prevent pre-reset fetches from repopulating either store by
adding a generation counter to src/stores/languagesStore.ts#L11-L57 and
src/stores/phasesStore.ts#L12-L53: capture the generation at fetch start,
increment it in reset(), and skip the fetch result commit when the generation
changes. In languagesStore.ts, also make the duplicate-fetch subscriber stop
waiting and resolve with the reset state when reset occurs during the wait.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Pro Plus

Run ID: 85b78624-5b61-46ff-9f78-db51b5131dd8

📥 Commits

Reviewing files that changed from the base of the PR and between 130fc4a and d379f98.

📒 Files selected for processing (4)
  • src/App.tsx
  • src/contexts/AuthContext.tsx
  • src/stores/languagesStore.ts
  • src/stores/phasesStore.ts

lastFetched: number | null
fetch: () => Promise<LanguageResponse[]>
invalidate: () => void
reset: () => void

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

reset() doesn't guard against an in-flight fetch() clobbering the reset state.

If reset() (called from logout()) runs while a fetch() is mid-flight, the pending call's .then still unconditionally commits { languages: data, lastFetched: Date.now(), loading: false } once it resolves — overwriting the just-reset store with data fetched under the previous session/account. Any caller queued on the "duplicate fetch" subscriber (Lines 30-37) also resolves early with the emptied languages array instead of the real result.

♻️ Proposed fix using a generation counter
+let generation = 0
+
 export const useLanguagesStore = create<LanguagesStore>((set, get) => ({
   languages: [],
   loading: false,
   lastFetched: null,

   fetch: async () => {
     const state = get()
     if (state.lastFetched && Date.now() - state.lastFetched < CACHE_TTL && state.languages.length > 0) {
       return state.languages
     }
     if (state.loading) {
       return new Promise<LanguageResponse[]>((resolve) => {
         const unsub = useLanguagesStore.subscribe((s) => {
           if (!s.loading) {
             unsub()
             resolve(s.languages)
           }
         })
       })
     }
+    const myGeneration = generation
     set({ loading: true })
     try {
       const { data } = await languagesAPI.list()
-      set({ languages: data, lastFetched: Date.now(), loading: false })
+      if (myGeneration !== generation) return get().languages
+      set({ languages: data, lastFetched: Date.now(), loading: false })
       return data
     } catch {
-      set({ loading: false })
+      if (myGeneration === generation) set({ loading: false })
       return state.languages
     }
   },

   invalidate: () => {
     set({ lastFetched: null })
   },

   reset: () => {
+    generation++
     set({ languages: [], loading: false, lastFetched: null })
   },

Also applies to: 54-57

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/languagesStore.ts` at line 11, Protect the languages store from
stale in-flight requests by adding a generation counter shared by reset() and
fetch(). Increment the generation in reset(), and have each fetch capture its
generation before awaiting; only the matching request may commit state or
resolve duplicate-fetch subscribers. Invalidate or retain those subscribers
until a current-generation fetch completes so they do not receive reset’s empty
languages result.

Comment thread src/stores/phasesStore.ts
lastFetched: number | null
fetch: () => Promise<void>
invalidate: () => void
reset: () => void

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

reset() doesn't guard against an in-flight fetch() clobbering the reset state.

Same issue as languagesStore.ts: if a fetch() is in flight when reset() (called from logout()) runs, the pending call still unconditionally commits { phases: data, dependencies: depsMap, lastFetched: Date.now(), loading: false } once it resolves, silently undoing the reset with data fetched under the previous session.

Also applies to: 50-53

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/phasesStore.ts` at line 12, Update the phasesStore fetch/reset
flow so an in-flight fetch cannot commit results after reset() invalidates the
current session state. Add the same request-generation or cancellation guard
used by languagesStore.ts, and have the fetch completion update phases,
dependencies, lastFetched, and loading only when it still belongs to the active
generation; preserve reset()’s cleared state otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant