Navigation: Inject orgId into all navigations (#120978)

* Frontend: inject orgId into all SPA navigations via LocationService

Append ?orgId=<N> to every SPA navigation at the history layer in
LocationService (push/replace), gated on multi-org, so URLs stay
shareable across orgs. Wires the orgId getter at startup in app.ts and
covers the behaviour in LocationService tests.

Fixes #105040

Signed-off-by: QuentinBisson <quentin@giantswarm.io>

* refactor: fix test

Signed-off-by: QuentinBisson <quentin@giantswarm.io>

* prune suppressions

Signed-off-by: QuentinBisson <quentin@giantswarm.io>

* Frontend: keep current path for query/hash-only orgId navigations

appendOrgId resolved query-only ('?tab=x') and hash-only ('#h') strings
against a dummy base and always returned url.pathname, forcing relative
navigations to '/'. Omit pathname for those so the router keeps the
current path, and cover both cases in tests.

Signed-off-by: QuentinBisson <quentin@giantswarm.io>

* Frontend: register orgId getter before post-login redirect

---------

Signed-off-by: QuentinBisson <quentin@giantswarm.io>
Co-authored-by: Laura Benz <laura.benz@grafana.com>
Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
Co-authored-by: joshhunt <josh.hunt@grafana.com>
This commit is contained in:
Quentin Bisson
2026-08-02 12:09:44 +01:00
committed by GitHub
co-authored by Laura Benz Ashley Harrison joshhunt
parent 9859f65ae0
commit 5417094cb0
14 changed files with 279 additions and 67 deletions
@@ -163,8 +163,8 @@ test.describe(
await page.getByTestId(selectors.pages.ConfirmModal.input).fill('Delete');
await page.getByTestId(selectors.pages.ConfirmModal.delete).click();
// Wait for redirect to home after deletion
await page.waitForURL('**/');
// Wait for redirect to home after deletion (?orgId=N may be appended by locationService)
await page.waitForURL((url) => url.pathname === '/');
// Navigate to recently deleted
await page.goto('/dashboard/recently-deleted');
@@ -49,7 +49,7 @@ test.describe(
// wait for the page to reload before trying to navigate, otherwise this can cause flakes
// see e.g. https://github.com/microsoft/playwright/issues/21451#issuecomment-1502251404
await expect(page.getByTestId(selectors.components.UserProfile.preferencesSaveButton)).not.toBeDisabled();
await page.waitForURL('/profile');
await page.waitForURL((url) => new URL(url).pathname === '/profile');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(selectors.components.TimeZonePicker.containerV2)).toContainText('Tokyo');
+1 -4
View File
@@ -338,11 +338,8 @@
}
},
"packages/grafana-runtime/src/services/LocationService.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
},
"@typescript-eslint/no-explicit-any": {
"count": 5
"count": 4
}
},
"packages/grafana-runtime/src/services/backendSrv.ts": {
@@ -61,6 +61,154 @@ describe('LocationService', () => {
});
});
describe('appendOrgId', () => {
const wrapper = locationService as HistoryWrapper;
beforeEach(() => {
wrapper.setOrgIdGetter(() => 7);
});
afterAll(() => {
wrapper.setOrgIdGetter(() => 0);
});
it('returns input unchanged when getter returns 0 / negative / NaN', () => {
wrapper.setOrgIdGetter(() => 0);
expect(wrapper.appendOrgId('/p')).toBe('/p');
wrapper.setOrgIdGetter(() => -1);
expect(wrapper.appendOrgId('/p')).toBe('/p');
wrapper.setOrgIdGetter(() => NaN);
expect(wrapper.appendOrgId('/p')).toBe('/p');
});
it('appends orgId to a bare string path', () => {
expect(wrapper.appendOrgId('/p')).toEqual({ pathname: '/p', search: '?orgId=7', hash: '' });
});
it('appends orgId with & when the string path already has a query', () => {
expect(wrapper.appendOrgId('/p?a=1')).toEqual({ pathname: '/p', search: '?a=1&orgId=7', hash: '' });
});
it('preserves the fragment on a bare string path', () => {
expect(wrapper.appendOrgId('/p#h')).toEqual({ pathname: '/p', search: '?orgId=7', hash: '#h' });
});
it('preserves the fragment when the string path has a query', () => {
expect(wrapper.appendOrgId('/p?a=1#h')).toEqual({ pathname: '/p', search: '?a=1&orgId=7', hash: '#h' });
});
it('leaves a string path unchanged when orgId is already present', () => {
expect(wrapper.appendOrgId('/p?orgId=3')).toBe('/p?orgId=3');
});
it('does not false-match notOrgId=', () => {
expect(wrapper.appendOrgId('/p?notOrgId=5')).toEqual({
pathname: '/p',
search: '?notOrgId=5&orgId=7',
hash: '',
});
});
it('keeps the current path for a query-only string (no pathname)', () => {
expect(wrapper.appendOrgId('?inspectTab=help')).toEqual({ search: '?inspectTab=help&orgId=7', hash: '' });
});
it('keeps the current path for a hash-only string (no pathname)', () => {
expect(wrapper.appendOrgId('#h')).toEqual({ search: '?orgId=7', hash: '#h' });
});
it('leaves a query-only string unchanged when orgId is already present', () => {
expect(wrapper.appendOrgId('?orgId=3')).toBe('?orgId=3');
});
it('floors fractional orgId', () => {
wrapper.setOrgIdGetter(() => 7.9);
expect(wrapper.appendOrgId('/p')).toEqual({ pathname: '/p', search: '?orgId=7', hash: '' });
});
it('appends orgId to a LocationDescriptor without a search property', () => {
expect(wrapper.appendOrgId({ pathname: '/p' })).toEqual({
pathname: '/p',
search: '?orgId=7',
});
});
it('appends orgId to a LocationDescriptor with only a hash', () => {
expect(wrapper.appendOrgId({ pathname: '/p', hash: '#h' })).toEqual({
pathname: '/p',
search: '?orgId=7',
hash: '#h',
});
});
it('appends orgId to a LocationDescriptor with a search', () => {
expect(wrapper.appendOrgId({ pathname: '/p', search: '?a=1' })).toEqual({
pathname: '/p',
search: '?a=1&orgId=7',
});
});
it('leaves a LocationDescriptor unchanged when orgId is already present', () => {
expect(wrapper.appendOrgId({ pathname: '/p', search: '?orgId=3' })).toEqual({
pathname: '/p',
search: '?orgId=3',
});
});
});
describe('orgId injection on push/replace', () => {
const wrapper = locationService as HistoryWrapper;
beforeEach(() => {
wrapper.setOrgIdGetter(() => 7);
});
afterAll(() => {
wrapper.setOrgIdGetter(() => 0);
});
it('push() injects orgId into a string path with fragment', () => {
locationService.push('/test?foo=1#section');
expect(locationService.getLocation().search).toBe('?foo=1&orgId=7');
expect(locationService.getLocation().hash).toBe('#section');
});
it('push() injects orgId into a LocationDescriptor', () => {
locationService.push({ pathname: '/test', search: '?foo=1', hash: '#section' });
expect(locationService.getLocation().search).toBe('?foo=1&orgId=7');
expect(locationService.getLocation().hash).toBe('#section');
});
it('replace() injects orgId the same way', () => {
locationService.replace('/test?foo=1#section');
expect(locationService.getLocation().search).toBe('?foo=1&orgId=7');
expect(locationService.getLocation().hash).toBe('#section');
});
it('detached history.push call (as react-router <Link> does) still injects orgId', () => {
const history = wrapper.getHistory();
// react-router does: const method = history.push; method(loc)
const detachedPush = history.push;
detachedPush('/test?foo=1');
expect(locationService.getLocation().search).toBe('?foo=1&orgId=7');
});
it('partial() injects orgId when the current url lacks it', () => {
// Reset to a path without orgId
wrapper.setOrgIdGetter(() => 0);
locationService.push('/test?foo=1');
wrapper.setOrgIdGetter(() => 7);
locationService.partial({ bar: 2 });
expect(locationService.getLocation().search).toBe('?foo=1&bar=2&orgId=7');
});
it('createHref injects orgId so rendered <a href> values are shareable', () => {
const href = wrapper.createHref({ pathname: '/test', search: '?foo=1' });
expect(href).toContain('orgId=7');
expect(href).toContain('foo=1');
});
});
describe('hook access', () => {
it('can set and access service from a context', () => {
const locationServiceLocal = new HistoryWrapper();
@@ -30,47 +30,112 @@ export interface LocationService {
update: (update: LocationUpdate) => void;
}
/** @internal */
export class HistoryWrapper implements LocationService {
private readonly history: H.History;
/**
* Wraps `H.History` so every navigation — programmatic push/replace,
* `<Link>` clicks, and `<a href>` rendering via createHref — flows through
* `appendOrgId` at one chokepoint. `getHistory()` returns `this`, so
* react-router uses the wrapper too.
* @internal
*/
export class HistoryWrapper implements LocationService, H.History {
private readonly base: H.History;
private locationObservable: BehaviorSubject<H.Location>;
private orgIdGetter?: () => number;
constructor(history?: H.History) {
// If no history passed create an in memory one if being called from test
this.history =
this.base =
history ||
(process.env.NODE_ENV === 'test'
? H.createMemoryHistory({ initialEntries: ['/'] })
: H.createBrowserHistory({ basename: config.appSubUrl ?? '/' }));
this.locationObservable = new BehaviorSubject(this.history.location);
this.history.listen((location) => {
this.locationObservable.next(location);
});
this.partial = this.partial.bind(this);
this.push = this.push.bind(this);
this.replace = this.replace.bind(this);
this.getSearch = this.getSearch.bind(this);
this.getHistory = this.getHistory.bind(this);
this.getLocation = this.getLocation.bind(this);
this.locationObservable = new BehaviorSubject(this.base.location);
this.base.listen((location) => this.locationObservable.next(location));
}
// The history library mutates these on the base instance after each
// navigation, so getters keep readers in sync.
get length() {
return this.base.length;
}
get action() {
return this.base.action;
}
get location() {
return this.base.location;
}
// Arrow class fields auto-bind, so detached calls (`const m = history.push; m(loc)`)
// keep `this` and the orgId injection still fires.
push: H.History['push'] = (location, state) => this.base.push(this.appendOrgId(location), state);
replace: H.History['replace'] = (location, state) => this.base.replace(this.appendOrgId(location), state);
createHref: H.History['createHref'] = (location) => this.base.createHref(this.appendOrgId(location));
go: H.History['go'] = (n) => this.base.go(n);
goBack: H.History['goBack'] = () => this.base.goBack();
goForward: H.History['goForward'] = () => this.base.goForward();
block: H.History['block'] = (prompt) => this.base.block(prompt);
listen: H.History['listen'] = (listener) => this.base.listen(listener);
setOrgIdGetter(fn: () => number) {
this.orgIdGetter = fn;
}
appendOrgId(location: H.LocationDescriptorObject): H.LocationDescriptorObject;
appendOrgId(location: H.Path | H.LocationDescriptor): H.Path | H.LocationDescriptor;
appendOrgId(location: H.Path | H.LocationDescriptor): H.Path | H.LocationDescriptor {
const orgId = this.orgIdGetter?.() ?? 0;
if (!Number.isFinite(orgId) || orgId <= 0) {
return location;
}
const orgIdStr = String(Math.floor(orgId));
if (typeof location === 'string') {
const url = new URL(location, 'http://_');
if (url.searchParams.has('orgId')) {
return location;
}
url.searchParams.set('orgId', orgIdStr);
// A query- or hash-only string is a relative navigation that keeps the
// current path; omitting pathname lets the router preserve it instead of
// resolving to '/'.
const relative = location.startsWith('?') || location.startsWith('#');
return relative
? { search: url.search, hash: url.hash }
: { pathname: url.pathname, search: url.search, hash: url.hash };
}
const params = new URLSearchParams(location.search ?? '');
if (params.has('orgId')) {
return location;
}
params.set('orgId', orgIdStr);
return { ...location, search: `?${params.toString()}` };
}
// LocationService
getLocationObservable() {
return this.locationObservable.asObservable();
}
getHistory() {
return this.history;
return this;
}
getSearch() {
return new URLSearchParams(this.history.location.search);
return new URLSearchParams(this.base.location.search);
}
getLocation() {
return this.base.location;
}
getSearchObject() {
return locationSearchToObject(this.base.location.search);
}
partial(query: Record<string, any>, replace?: boolean) {
const currentLocation = this.history.location;
const currentLocation = this.base.location;
const newQuery = this.getSearchObject();
for (const key in query) {
@@ -85,36 +150,27 @@ export class HistoryWrapper implements LocationService {
const updatedUrl = urlUtil.renderUrl(currentLocation.pathname, newQuery);
if (replace) {
this.history.replace(updatedUrl, this.history.location.state);
this.replace(updatedUrl, currentLocation.state);
} else {
this.history.push(updatedUrl, this.history.location.state);
this.push(updatedUrl, currentLocation.state);
}
}
push(location: H.Path | H.LocationDescriptor) {
this.history.push(location);
}
replace(location: H.Path | H.LocationDescriptor) {
this.history.replace(location);
}
reload() {
const prevState = (this.history.location.state as any)?.routeReloadCounter;
this.history.replace({
...this.history.location,
state: { routeReloadCounter: prevState ? prevState + 1 : 1 },
const state = this.base.location.state;
let prevCounter: number | undefined;
if (state !== null && typeof state === 'object' && 'routeReloadCounter' in state) {
const counter = state.routeReloadCounter;
if (typeof counter === 'number') {
prevCounter = counter;
}
}
this.base.replace({
...this.base.location,
state: { routeReloadCounter: prevCounter !== undefined ? prevCounter + 1 : 1 },
});
}
getLocation() {
return this.history.location;
}
getSearchObject() {
return locationSearchToObject(this.history.location.search);
}
/** @deprecated use partial, push or replace instead */
update(options: LocationUpdate) {
deprecationWarning('LocationSrv', 'update', 'partial, push or replace');
+9
View File
@@ -22,6 +22,7 @@ import {
import { DEFAULT_LANGUAGE } from '@grafana/i18n';
import { initializeI18n, loadNamespacedResources } from '@grafana/i18n/internal';
import {
HistoryWrapper,
locationService,
setBackendSrv,
setDataSourceSrv,
@@ -280,6 +281,14 @@ export class GrafanaApp {
getVariablesUrlParams: getVariablesUrlParams,
});
// For multi-org users, ensure every SPA navigation carries ?orgId so
// dashboard / alert URLs are shareable across orgs. Single-org users
// (the OSS / Cloud majority) skip this, no URL pollution. Registered
// before handleRedirectTo() so the post-login redirect gets orgId too.
if (locationService instanceof HistoryWrapper && contextSrv.user.orgCount > 1) {
locationService.setOrgIdGetter(() => contextSrv.user.orgId);
}
if (config.featureToggles.useSessionStorageForRedirection) {
handleRedirectTo();
}
@@ -1,5 +1,4 @@
import { type SceneObjectUrlSyncHandler, type SceneObjectUrlValues, type VizPanel } from '@grafana/scenes';
import { contextSrv } from 'app/core/services/context_srv';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { createDashboardEditViewFor } from '../settings/createDashboardEditViewFor';
@@ -28,7 +27,6 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
editview: state.editview?.getUrlKey(),
editPanel: state.editPanel?.getUrlKey() || undefined,
shareView: state.shareView,
orgId: contextSrv.user.orgId.toString(),
};
}
@@ -56,6 +56,7 @@ jest.mock('@grafana/runtime', () => {
locationService: {
...actual.locationService,
push: jest.fn(),
getHistory: () => actual.locationService,
},
};
});
@@ -112,10 +112,10 @@ export function initializeFromURL(
const oldQuery = location.getSearchObject();
// we create the default query params from the current URL, omitting all the properties we know should be in the final url.
// This includes params from previous schema versions and 'schemaVersion', 'panes', 'orgId' as we want to replace those.
// This includes params from previous schema versions and 'schemaVersion', 'panes' as we want to replace those.
let defaults: Record<string, unknown> = {};
for (const [key, value] of Object.entries(oldQuery).filter(
([key]) => !['schemaVersion', 'panes', 'orgId', 'left', 'right'].includes(key)
([key]) => !['schemaVersion', 'panes', 'left', 'right'].includes(key)
)) {
defaults[key] = value;
}
@@ -124,7 +124,6 @@ export function initializeFromURL(
// we set the schemaVersion as the first parameter so that when URLs are truncated the schemaVersion is more likely to be present.
schemaVersion: `${urlState.schemaVersion}`,
panes: JSON.stringify(panesObj),
orgId: `${orgId}`,
...defaults,
});
@@ -202,7 +202,7 @@ export function setupExplore(options?: SetupOptions): {
<OpenFeatureProvider client={getTestFeatureFlagClient()}>
<Provider store={storeState}>
<GrafanaContext.Provider value={contextMock}>
<Router history={history}>
<Router history={location.getHistory()}>
<QueriesDrawerContextProvider>
<FinalProvider>
{options?.withAppChrome ? (
@@ -157,9 +157,9 @@ describe('PlaylistSrv', () => {
// Start the playlist
await srv.start(mockPlaylist);
// Get history entries
// Get history entries via the underlying MemoryHistory (test-env only)
const history = locationService.getHistory();
const entries = (history as unknown as { entries: Location[] }).entries;
const entries = (history as unknown as { base: { entries: Location[] } }).base.entries;
// The current entry should be the first dashboard
expect(entries[entries.length - 1].pathname).toBe('/url/to/aaa');
@@ -6,14 +6,18 @@ import { type Playlist } from '../../api/clients/playlist/v1';
import { StartModal } from './StartModal';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
locationService: {
...jest.requireActual('@grafana/runtime').locationService,
push: jest.fn(),
},
reportInteraction: jest.fn(),
}));
jest.mock('@grafana/runtime', () => {
const actual = jest.requireActual('@grafana/runtime');
return {
...actual,
locationService: {
...actual.locationService,
push: jest.fn(),
getHistory: () => actual.locationService,
},
reportInteraction: jest.fn(),
};
});
const mockPlaylist: Playlist = {
apiVersion: 'playlist.grafana.app/v1',
@@ -82,7 +82,7 @@ export const ConfigEditor = (props: Props) => {
</InlineField>
{options.jsonData.handleGrafanaManagedAlerts && (
<Text variant="bodySmall" color="secondary">
Make sure to enable the alert forwarding on the <Link to="/alerting/admin">settings page</Link>.
Make sure to enable the alert forwarding on the <Link to={'/alerting/admin'}>settings page</Link>.
</Text>
)}
</Box>
+1 -1
View File
@@ -71,7 +71,7 @@ const getWrapper = ({
* Conditional router - either a MemoryRouter or just a Fragment
*/
const PotentialRouter = renderWithRouter
? ({ children }: PropsWithChildren) => <Router history={history}>{children}</Router>
? ({ children }: PropsWithChildren) => <Router history={locationService.getHistory()}>{children}</Router>
: ({ children }: PropsWithChildren) => <Fragment>{children}</Fragment>;
const PotentialCompatRouter = renderWithRouter ? CompatRouter : Fragment;