Skip to content

fix(ux/session): anchor sidebar nav + share session across tabs - #487

Merged
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/issue-462-cross-tab-session
May 19, 2026
Merged

fix(ux/session): anchor sidebar nav + share session across tabs#487
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/issue-462-cross-tab-session

Conversation

@cristim

@cristim cristim commented May 19, 2026

Copy link
Copy Markdown
Member

Summary

Closes #462.

Two QA findings around tab/session behaviour, fixed together because both
touch the sidebar nav surface and the auth bootstrap path.

(a) "Open Link in New Tab" on sidebar items. The left-side nav was
six <button class="tab-btn"> elements so right-click did not offer the
browser's "Open Link in New Tab" affordance. Converted each to
<a class="tab-btn" href="https://github.com/{tab}"> while preserving role="tab",
data-tab, aria-*, and the SVG icon + label. The SPA click handler in
app.ts now preventDefault()s only for un-modified left clicks;
middle-clicks (button=1), Ctrl/Cmd/Shift-clicks fall through so the
browser opens a new tab/window natively. Admin links to
/admin/general (its default sub-tab) so a cold-open in a fresh tab
lands on a real route.

(b) Cross-tab session. Tokens lived in sessionStorage, which is
tab-scoped, so a second tab on the same origin forced re-login. Moved
authToken, apiKey, csrfToken to localStorage and registered a
storage event listener that signs the other tabs out when one tab
clears the keys (i.e. logout propagates across tabs within ~1s).
Reverse migration copies any leftover sessionStorage values into
localStorage on first initAuth() after upgrade so users stay signed
in across the release.

Security note

  • localStorage widens the XSS exposure window from "tab close" to
    "explicit logout" relative to the previous sessionStorage setup.
    The tradeoff is documented in a SECURITY: comment block at the top
    of frontend/src/api/client.ts. The issue body called out the same
    tradeoff and explicitly authorized it (admins compare Settings vs.
    Opportunities side by side, so multi-tab needs to work).
  • Mitigations:
    1. Cross-tab logout via the storage event so a sign-out anywhere
      clears the others.
    2. Same-origin only; no third-party iframes.
    3. HttpOnly session cookies remain the cleaner long-term option (per
      the issue discussion); that is a larger refactor (Authorization
      header to credentials-mode cookies) tracked separately.
  • No new CSRF surface: CSRF token moves alongside the auth token (same
    storage tier, no cookie change, no SameSite change).
  • No new injection surface: anchor href values are hard-coded HTML,
    not interpolated from user input.

Tests

  • New frontend/src/__tests__/sidebar-anchors.test.ts:
    • Asserts each of the six sidebar items renders as <a href="https://github.com/{tab}">
      so middle/right-click works (the issue's headline ask).
    • Asserts the click handler preventDefaults for un-modified left
      clicks, but lets Ctrl/Cmd-click, Shift-click, and middle-click fall
      through to the browser.
  • frontend/src/__tests__/api.test.ts:
    • New test: fresh tab inherits an existing valid session
      (localStorage already has a token => isAuthenticated() returns
      true without a re-login).
    • New test: sessionStorage to localStorage reverse migration on
      upgrade.
    • Existing storage assertions moved from sessionStorageMock to
      localStorageMock.

Full suite: 1754 tests pass; tsc --noEmit clean.

Test plan

  • CI: jest + tsc green.
  • In a browser: log in, open a second tab on the dashboard URL,
    confirm no login prompt.
  • Right-click any sidebar nav item: confirm "Open Link in New Tab"
    appears.
  • Ctrl/Cmd-click a sidebar item: opens in a new tab routed to the
    right section.
  • In one tab, click Sign Out: confirm the other tab reloads back to
    the login modal within ~1s.

Summary by CodeRabbit

  • New Features

    • Authentication credentials now persist across browser tabs on the same origin.
  • Bug Fixes

    • Sidebar navigation now properly supports opening links in new tabs and windows with modifier keys while maintaining single-page app behavior for standard clicks.
  • Tests

    • Added comprehensive test coverage for sidebar navigation behavior and authentication persistence.

Review Change Stack

Closes #462. Two related QA findings, fixed together because both touch
the same nav surface and the same auth bootstrap path:

(a) Sidebar nav items were `<button>` so right-click "Open Link in New
Tab" was missing. Converted each to `<a href="https://github.com/{tab}">` with the existing
role=tab + data-tab attributes preserved. The SPA click handler now
preventDefaults only for un-modified left clicks; middle-clicks and
Ctrl/Cmd/Shift-clicks fall through so the browser handles open-in-new-tab
natively. CSS gets text-decoration:none + :visited override so the
rendered look matches the previous button rows.

(b) Auth tokens lived in sessionStorage (tab-scoped), so opening a second
tab on the same origin forced a re-login. Moved authToken/apiKey/csrfToken
to localStorage and added a `storage` event listener that propagates
logout across tabs (clearing the keys in one tab reloads the others into
the login modal). Reverse migration copies any leftover sessionStorage
values into localStorage on first init after upgrade so users stay signed
in. The XSS-exposure-window widening is documented in a SECURITY block at
the top of client.ts with the rationale and mitigations.

Tests: new __tests__/sidebar-anchors.test.ts asserts each of the six
sidebar items renders as `<a href="https://github.com/{tab}">` (so middle/right-click
works), and that the click handler preventDefaults only for un-modified
left clicks (lets the browser handle Ctrl/Cmd/Shift/middle clicks).
api.test.ts gains a fresh-tab bootstrap assertion (existing localStorage
token => isAuthenticated() === true) and the sessionStorage → localStorage
reverse-migration assertion. Existing api.test.ts storage assertions
moved from sessionStorageMock to localStorageMock.
@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/m Days type/bug Defect labels May 19, 2026
@cristim

cristim commented May 19, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@cristim has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 30 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec88fea0-0920-42e9-8ceb-909c06d5d56b

📥 Commits

Reviewing files that changed from the base of the PR and between c47db4b and 0962bc4.

📒 Files selected for processing (2)
  • frontend/src/__tests__/api.test.ts
  • frontend/src/__tests__/sidebar-anchors.test.ts
📝 Walkthrough

Walkthrough

This PR addresses issue #462 by migrating authentication tokens to localStorage for cross-tab session support and converting sidebar navigation from buttons to semantic anchor elements with SPA-safe click handling. The changes enable new tabs on the same origin to reuse existing sessions and allow standard browser affordances like "open in new tab" for sidebar navigation.

Changes

Session Management and Navigation Link Improvements

Layer / File(s) Summary
Auth Storage Migration to localStorage
frontend/src/api/client.ts
Token storage moved from sessionStorage to localStorage with STORAGE_KEYS constant; initAuth() loads from localStorage, migrates legacy sessionStorage entries, and installs a cross-tab storage event listener for sign-out propagation; setAuthToken, setCsrfToken, setApiKey, and clearAuth updated to persist/remove tokens in localStorage while cleaning up legacy entries.
Auth Storage Test Coverage
frontend/src/__tests__/api.test.ts
Tests updated to verify localStorage initialization, legacy migration from sessionStorage, token persistence/removal, and that login/setupAdmin flows write tokens to localStorage; clearAuth test confirms removal of all tokens from localStorage and cleanup of sessionStorage entries.
Sidebar Markup and Styling Conversion
frontend/src/index.html, frontend/src/styles/layout.css
Sidebar navigation converted from non-navigating <button> to semantic <a> elements with proper href targets; CSS updated to suppress default anchor underline and ensure visited-state color matches non-visited state via --cudly-text-muted.
Click Handler and SPA Routing Integration
frontend/src/app.ts
setupEventListeners updated to inspect MouseEvent for modifiers and button type; unmodified left-clicks prevent default and route via switchTab(tab), while Ctrl/Cmd, Shift, and middle-click interactions fall through to browser defaults (open in new tab, new window, etc.).
Sidebar Navigation Test Suite
frontend/src/__tests__/sidebar-anchors.test.ts
New comprehensive test file verifies sidebar anchors render as <a> elements with correct href and role="tab" attributes; click handling tests confirm SPA interception for standard clicks while allowing modifier-key and middle-click to bypass SPA routing.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

type/chore

Poem

🐰 Tokens now persist across tabs so fine,
Anchors with href make navs align,
SPA clicks stay caught, but Ctrl+click flies,
New sessions born—no login surprise!
Cross-tab dreams hop true, at last! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly reflects the two main changes: converting sidebar nav to anchors and migrating session storage to localStorage for cross-tab sharing.
Linked Issues check ✅ Passed The pull request comprehensively addresses both #462 requirements: sidebar navigation items converted to anchors with proper href attributes, and session tokens migrated to localStorage with cross-tab sync via storage events.
Out of Scope Changes check ✅ Passed All changes remain tightly scoped to the two core objectives: sidebar navigation structure/styling, event handling for anchor clicks, and auth state storage migration.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-462-cross-tab-session

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

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
frontend/src/__tests__/sidebar-anchors.test.ts (1)

135-146: ⚡ Quick win

Add an explicit metaKey (Cmd-click) test case.

The current case validates ctrlKey only, so a regression in the metaKey branch could slip through on macOS.

Proposed test addition
   test('Ctrl/Cmd-click falls through to the browser (no preventDefault, no switchTab)', () => {
     const link = document.querySelector('a.tab-btn') as HTMLAnchorElement;
     const evt = new MouseEvent('click', {
       bubbles: true,
       cancelable: true,
       button: 0,
       ctrlKey: true,
     });
     link.dispatchEvent(evt);
     expect(evt.defaultPrevented).toBe(false);
     expect(switchTab).not.toHaveBeenCalled();
   });
+
+  test('Cmd-click (metaKey) falls through to the browser', () => {
+    const link = document.querySelector('a.tab-btn') as HTMLAnchorElement;
+    const evt = new MouseEvent('click', {
+      bubbles: true,
+      cancelable: true,
+      button: 0,
+      metaKey: true,
+    });
+    link.dispatchEvent(evt);
+    expect(evt.defaultPrevented).toBe(false);
+    expect(switchTab).not.toHaveBeenCalled();
+  });
🤖 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 `@frontend/src/__tests__/sidebar-anchors.test.ts` around lines 135 - 146, Add a
parallel test for Cmd-click by creating a MouseEvent with metaKey: true (similar
to the existing Ctrl-click test) in
frontend/src/__tests__/sidebar-anchors.test.ts: reuse the same query for the
anchor (link), dispatch the event (evt with bubbles, cancelable, button: 0,
metaKey: true), and assert evt.defaultPrevented is false and switchTab has not
been called; you can copy the existing "Ctrl/Cmd-click falls through..." test or
add a separate test case to ensure the metaKey branch is covered.
frontend/src/__tests__/api.test.ts (1)

137-150: 💤 Low value

Optional: Add missing assertion for csrfToken removal from sessionStorage.

The implementation iterates over all STORAGE_KEYS and removes each from both localStorage and sessionStorage. The test verifies sessionStorageMock.removeItem for authToken and apiKey but omits csrfToken.

Proposed fix
       expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('authToken');
       expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('apiKey');
+      expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('csrfToken');
       expect(isAuthenticated()).toBe(false);
🤖 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 `@frontend/src/__tests__/api.test.ts` around lines 137 - 150, The test for
clearAuth is missing an assertion that sessionStorage.removeItem is called for
'csrfToken'; update the 'clearAuth' describe block test to also
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('csrfToken') so the
test verifies removal of all keys in STORAGE_KEYS from both storages (functions
referenced: clearAuth, setAuthToken, setApiKey, isAuthenticated; mocks:
sessionStorageMock/removeItem, localStorageMock/removeItem).
🤖 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 `@frontend/src/__tests__/sidebar-anchors.test.ts`:
- Around line 111-121: The beforeEach currently appends an anchor and calls
setupEventListeners without clearing prior DOM or listeners; update the test
setup to reset the DOM and avoid listener accumulation by clearing document.body
(e.g., document.body.innerHTML = '') or removing existing .tab-btn anchors
before creating the new <a>, and ensure any global listeners bound by
setupEventListeners are not duplicated (remove/rebind or call a teardown like
removeEventListeners in afterEach). Specifically modify the beforeEach (and/or
add afterEach) surrounding the creation of the anchor element and the call to
setupEventListeners so jest.clearAllMocks remains but the DOM and event
listeners are reset between tests.

---

Nitpick comments:
In `@frontend/src/__tests__/api.test.ts`:
- Around line 137-150: The test for clearAuth is missing an assertion that
sessionStorage.removeItem is called for 'csrfToken'; update the 'clearAuth'
describe block test to also
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('csrfToken') so the
test verifies removal of all keys in STORAGE_KEYS from both storages (functions
referenced: clearAuth, setAuthToken, setApiKey, isAuthenticated; mocks:
sessionStorageMock/removeItem, localStorageMock/removeItem).

In `@frontend/src/__tests__/sidebar-anchors.test.ts`:
- Around line 135-146: Add a parallel test for Cmd-click by creating a
MouseEvent with metaKey: true (similar to the existing Ctrl-click test) in
frontend/src/__tests__/sidebar-anchors.test.ts: reuse the same query for the
anchor (link), dispatch the event (evt with bubbles, cancelable, button: 0,
metaKey: true), and assert evt.defaultPrevented is false and switchTab has not
been called; you can copy the existing "Ctrl/Cmd-click falls through..." test or
add a separate test case to ensure the metaKey branch is covered.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: cbe79c01-b993-46e2-8443-da4e0899cdf7

📥 Commits

Reviewing files that changed from the base of the PR and between 90f37e4 and c47db4b.

📒 Files selected for processing (6)
  • frontend/src/__tests__/api.test.ts
  • frontend/src/__tests__/sidebar-anchors.test.ts
  • frontend/src/api/client.ts
  • frontend/src/app.ts
  • frontend/src/index.html
  • frontend/src/styles/layout.css

Comment thread frontend/src/__tests__/sidebar-anchors.test.ts
- sidebar-anchors.test.ts: explicitly clear document.body in beforeEach
  of the click-handling describe so prior anchors do not accumulate
  click listeners across runs (CR actionable).
- sidebar-anchors.test.ts: add a separate metaKey (Cmd-click on macOS)
  fall-through test so a regression on either of ctrlKey or metaKey
  cannot slip through (CR nit).
- api.test.ts: assert clearAuth() removes csrfToken from sessionStorage
  alongside authToken and apiKey to cover the full STORAGE_KEYS sweep
  (CR nit).
@cristim

cristim commented May 19, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit 43678d4 into feat/multicloud-web-frontend May 19, 2026
5 checks passed
cristim added a commit that referenced this pull request May 19, 2026
* test(frontend/auth): cover cross-tab logout sync (closes #493)

PR #487 added installStorageListener() in api/client.ts as the
compensating control for moving tokens off sessionStorage. The
behaviour is implemented but had no automated regression test; if the
listener's key filter, newValue check, or idempotency guard ever
regresses, the SECURITY claim in client.ts becomes silently false.

Add crosstab-session.test.ts covering:
- cleared auth keys (authToken, apiKey, csrfToken) zero in-memory state
  and call window.location.reload
- non-auth keys are ignored
- partial updates (newValue !== null, e.g. token refresh) are ignored
- initAuth() is idempotent: storage listener installs exactly once

Each test loads api/client in jest.isolateModules() so the
storageListenerInstalled guard is fresh per test.

* test(frontend/auth): patch only window.location.reload (CR nit)

Capture the original window.location reference before tests and restore
it in an afterEach, so each test starts from a clean Location object.
This implements CR's teardown recommendation while keeping the
Object.defineProperty(window, 'location', ...) approach required by jsdom
(window.location.reload is non-configurable and cannot be patched directly).
cristim added a commit that referenced this pull request Jun 9, 2026
* test(frontend/auth): cover cross-tab logout sync (closes #493)

PR #487 added installStorageListener() in api/client.ts as the
compensating control for moving tokens off sessionStorage. The
behaviour is implemented but had no automated regression test; if the
listener's key filter, newValue check, or idempotency guard ever
regresses, the SECURITY claim in client.ts becomes silently false.

Add crosstab-session.test.ts covering:
- cleared auth keys (authToken, apiKey, csrfToken) zero in-memory state
  and call window.location.reload
- non-auth keys are ignored
- partial updates (newValue !== null, e.g. token refresh) are ignored
- initAuth() is idempotent: storage listener installs exactly once

Each test loads api/client in jest.isolateModules() so the
storageListenerInstalled guard is fresh per test.

* test(frontend/auth): patch only window.location.reload (CR nit)

Capture the original window.location reference before tests and restore
it in an afterEach, so each test starts from a clean Location object.
This implements CR's teardown recommendation while keeping the
Object.defineProperty(window, 'location', ...) approach required by jsdom
(window.location.reload is non-configurable and cannot be patched directly).

* test(frontend/auth): clear localStorage between cross-tab logout tests (CR nit)

initAuth() seeds in-memory auth from localStorage, so without clearing it
between tests a stale token from one case could leak into the next. Extend
the existing afterEach to call localStorage.clear() for proper isolation.

* test(frontend/auth): address CR nits on cross-tab logout test

- replace the out! non-null assertion in loadAuth() with an explicit
  guard that throws a clear error if isolateModules never ran
- add a regression case for the !e.key guard in installStorageListener:
  a null-key storage event (localStorage.clear()) must not clear
  in-memory auth or reload
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant