Skip to content

Commit 994df66

Browse files
authored
Merge pull request #2565 from exceptionless/issue/resource-organization-navigation
Fix organization context when following resource links
2 parents e5de122 + 294d40f commit 994df66

11 files changed

Lines changed: 251 additions & 65 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import type { Page } from '@playwright/test';
2+
3+
import { expect, test } from '../fixtures/e2e-test';
4+
import { seedRepresentativeEvent } from '../support/event-data';
5+
6+
test('organization deep links select the destination organization', async ({ e2eScenario, e2eSecondaryOrganization, page }) => {
7+
await page.goto(`/next/organization/${e2eSecondaryOrganization.organizationId}/manage?from=link#settings`);
8+
9+
await expect(page).toHaveURL(new RegExp(`/organization/${e2eSecondaryOrganization.organizationId}/manage\\?from=link#settings$`));
10+
await expect(page.getByRole('heading', { exact: true, name: `${e2eSecondaryOrganization.organizationName} Settings` })).toBeVisible();
11+
await expectOrganization(page, e2eSecondaryOrganization.organizationId);
12+
expect(e2eScenario.organizationId).not.toBe(e2eSecondaryOrganization.organizationId);
13+
});
14+
15+
for (const resource of ['project', 'stack', 'event', 'stack event', 'project stack', 'mismatched project stack'] as const) {
16+
test(`${resource} deep links select the destination organization`, async ({ e2eApi, e2eScenario, e2eSecondaryOrganization, page }) => {
17+
const event = resource === 'project' ? undefined : await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, e2eSecondaryOrganization);
18+
let href: string;
19+
switch (resource) {
20+
case 'event':
21+
href = `/next/event/${event!.id}`;
22+
break;
23+
case 'mismatched project stack':
24+
href = `/next/project/${e2eScenario.projectId}/stacks/${event!.stack_id}`;
25+
break;
26+
case 'project':
27+
href = `/next/project/${e2eSecondaryOrganization.projectId}/settings`;
28+
break;
29+
case 'project stack':
30+
href = `/next/project/${e2eSecondaryOrganization.projectId}/stacks/${event!.stack_id}`;
31+
break;
32+
case 'stack':
33+
href = `/next/stack/${event!.stack_id}`;
34+
break;
35+
case 'stack event':
36+
href = `/next/stack/${event!.stack_id}/event/${event!.id}`;
37+
break;
38+
}
39+
40+
await page.goto(href);
41+
await expectOrganization(page, e2eSecondaryOrganization.organizationId);
42+
if (event) {
43+
await expect(page.getByText(e2eSecondaryOrganization.message, { exact: true }).filter({ visible: true }).first()).toBeVisible();
44+
await expect(page).toHaveURL(resource === 'project stack' ? href : new RegExp(`/stack/${event.stack_id}/event/${event.id}$`));
45+
} else {
46+
await expect(page.getByRole('heading', { exact: true, name: `${e2eSecondaryOrganization.projectName} Settings` })).toBeVisible();
47+
}
48+
49+
await page.reload();
50+
await expectOrganization(page, e2eSecondaryOrganization.organizationId);
51+
await page.getByRole('button').filter({ hasText: e2eSecondaryOrganization.organizationName }).filter({ visible: true }).first().click();
52+
await page.getByRole('menuitem').filter({ hasText: e2eScenario.organizationName }).click();
53+
await expectOrganization(page, e2eScenario.organizationId);
54+
await expect(page).toHaveURL(/\/next\/stack\/all$/);
55+
});
56+
}
57+
58+
async function expectOrganization(page: Page, organizationId: string): Promise<void> {
59+
await expect(async () => {
60+
const activeOrganizationId = await page.evaluate(() => JSON.parse(localStorage.getItem('organization') ?? 'null'));
61+
expect(activeOrganizationId).toBe(organizationId);
62+
}).toPass({ timeout: 10_000 });
63+
}
64+
65+
test('Exie usage organization links switch context and support browser history', async ({ e2eScenario, e2eSecondaryOrganization, page }) => {
66+
const organizations = [e2eScenario, e2eSecondaryOrganization];
67+
await page.route('**/api/v2/admin/assistant-usage?*', async (route) => {
68+
await route.fulfill({
69+
json: {
70+
active_organizations: 2,
71+
completion_tokens: 0,
72+
cost_usd: 0,
73+
month: '2026-09-01',
74+
organizations: organizations.map((item) => ({
75+
blocked_by_concurrency: 0,
76+
blocked_by_cost_limit: 0,
77+
blocked_by_rate_limit: 0,
78+
blocked_by_token_limit: 0,
79+
cancelled: 0,
80+
completed: 0,
81+
completion_tokens: 0,
82+
cost_usd: 0,
83+
failed: 0,
84+
last_used_utc: new Date().toISOString(),
85+
organization_id: item.organizationId,
86+
organization_name: item.organizationName,
87+
plan_id: 'FREE',
88+
prompt_tokens: 0,
89+
provider_requests: 0,
90+
tool_calls: 0,
91+
turns: 0
92+
})),
93+
prompt_tokens: 0,
94+
turns: 0
95+
}
96+
});
97+
});
98+
99+
await page.goto('/next/system/exie');
100+
await page.getByRole('link', { exact: true, name: e2eSecondaryOrganization.organizationName }).click();
101+
await expect(page.getByRole('heading', { exact: true, name: `${e2eSecondaryOrganization.organizationName} Settings` })).toBeVisible();
102+
await expectOrganization(page, e2eSecondaryOrganization.organizationId);
103+
await expect(page).toHaveURL(new RegExp(`/organization/${e2eSecondaryOrganization.organizationId}/manage$`));
104+
105+
await test.info().attach('organization-link-destination', { body: await page.screenshot(), contentType: 'image/png' });
106+
await page.goto(`/next/organization/${e2eScenario.organizationId}/manage`);
107+
await expectOrganization(page, e2eScenario.organizationId);
108+
await expect(page.getByRole('heading', { exact: true, name: `${e2eScenario.organizationName} Settings` })).toBeVisible();
109+
await page.goBack();
110+
await expect(page).toHaveURL(new RegExp(`/organization/${e2eSecondaryOrganization.organizationId}/manage$`));
111+
await expectOrganization(page, e2eSecondaryOrganization.organizationId);
112+
await page.goForward();
113+
await expect(page).toHaveURL(new RegExp(`/organization/${e2eScenario.organizationId}/manage$`));
114+
await expectOrganization(page, e2eScenario.organizationId);
115+
});
116+
117+
test('organization users and billing links request the destination organization', async ({ e2eScenario, e2eSecondaryOrganization, page }) => {
118+
const usersResponse = page.waitForResponse((response) => response.url().includes(`/organizations/${e2eSecondaryOrganization.organizationId}/users`));
119+
await page.goto(`/next/organization/${e2eSecondaryOrganization.organizationId}/users`);
120+
expect((await usersResponse).ok()).toBe(true);
121+
await expectOrganization(page, e2eSecondaryOrganization.organizationId);
122+
await expect(page.getByRole('button', { name: 'Invite User' })).toBeVisible();
123+
124+
const billingResponse = page.waitForResponse((response) => response.url().includes(`/organizations/${e2eScenario.organizationId}/invoices`));
125+
await page.goto(`/next/organization/${e2eScenario.organizationId}/billing`);
126+
expect([200, 404]).toContain((await billingResponse).status());
127+
await expectOrganization(page, e2eScenario.organizationId);
128+
await expect(page).toHaveURL(new RegExp(`/organization/${e2eScenario.organizationId}/billing$`));
129+
});
130+
131+
test('organization project links switch before redirecting to the project list', async ({ e2eSecondaryOrganization, page }) => {
132+
await page.goto(`/next/organization/${e2eSecondaryOrganization.organizationId}/projects`);
133+
await expectOrganization(page, e2eSecondaryOrganization.organizationId);
134+
await expect(page).toHaveURL(/\/next\/project\/list/);
135+
await expect(page.getByText(e2eSecondaryOrganization.projectName, { exact: true }).filter({ visible: true }).first()).toBeVisible();
136+
});
137+
138+
test('stack links select the owner when no events remain', async ({ e2eApi, e2eScenario, e2eSecondaryOrganization, page }) => {
139+
const event = await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, e2eSecondaryOrganization);
140+
await page.route(`**/api/v2/stacks/${event.stack_id}/events?*`, async (route) => {
141+
await route.fulfill({ json: [] });
142+
});
143+
await page.goto(`/next/stack/${event.stack_id}`);
144+
await expect(page.getByText('No events available for this stack.')).toBeVisible();
145+
await expectOrganization(page, e2eSecondaryOrganization.organizationId);
146+
await expect(page).toHaveURL(new RegExp(`/stack/${event.stack_id}$`));
147+
});
148+
149+
test('authorized invoice links select their organization', async ({ e2eSecondaryOrganization, page }) => {
150+
const invoiceId = 'navigation-invoice';
151+
await page.route(`**/api/v2/organizations/invoice/${invoiceId}`, async (route) => {
152+
await route.fulfill({
153+
json: {
154+
date: new Date().toISOString(),
155+
id: invoiceId,
156+
items: [],
157+
organization_id: e2eSecondaryOrganization.organizationId,
158+
organization_name: e2eSecondaryOrganization.organizationName,
159+
paid: true,
160+
status: 'paid',
161+
total: 0
162+
}
163+
});
164+
});
165+
await page.goto(`/next/payment/${invoiceId}`);
166+
await expect(page.getByRole('cell', { exact: true, name: e2eSecondaryOrganization.organizationName })).toBeVisible();
167+
await expectOrganization(page, e2eSecondaryOrganization.organizationId);
168+
await expect(page).toHaveURL(new RegExp(`/payment/${invoiceId}$`));
169+
});
170+
171+
test.describe('member access', () => {
172+
test.use({ e2eUseGeneratedUser: true });
173+
174+
test('members can follow links to another organization they belong to', async ({ e2eScenario, e2eSecondaryOrganization, page }) => {
175+
await page.goto(`/next/organization/${e2eSecondaryOrganization.organizationId}/manage`);
176+
await expect(page.getByRole('heading', { exact: true, name: `${e2eSecondaryOrganization.organizationName} Settings` })).toBeVisible();
177+
await expectOrganization(page, e2eSecondaryOrganization.organizationId);
178+
await page.goto(`/next/project/${e2eScenario.projectId}/settings`);
179+
await expect(page.getByRole('heading', { exact: true, name: `${e2eScenario.projectName} Settings` })).toBeVisible();
180+
await expectOrganization(page, e2eScenario.organizationId);
181+
});
182+
183+
for (const status of [403, 404]) {
184+
test(`denied resource lookups (${status}) preserve the current organization`, async ({ e2eScenario, page }) => {
185+
const resourceId = '000000000000000000000001';
186+
await page.route(new RegExp(`/api/v2/(organizations|projects|stacks|events)/${resourceId}(?:[/?]|$)`), async (route) => {
187+
await route.fulfill({ body: JSON.stringify({ status, title: 'Resource unavailable' }), contentType: 'application/problem+json', status });
188+
});
189+
for (const resource of ['organization', 'project', 'stack', 'event']) {
190+
const response = page.waitForResponse((item) => item.url().includes(`/api/v2/${resource}s/${resourceId}`));
191+
const suffix = resource === 'organization' ? '/manage' : resource === 'project' ? '/settings' : '';
192+
await page.goto(`/next/${resource}/${resourceId}${suffix}`);
193+
expect((await response).status()).toBe(status);
194+
await expect(
195+
page
196+
.getByText(/Resource unavailable|could not be found|Unable to load stack event details/)
197+
.filter({ visible: true })
198+
.first()
199+
).toBeVisible();
200+
await expectOrganization(page, e2eScenario.organizationId);
201+
}
202+
});
203+
}
204+
});

src/Exceptionless.Web/ClientApp/src/lib/features/users/components/table/options.svelte.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,12 @@ export function getColumns<TUser extends ViewUser>(organizationId: string): Colu
6767
export function getTableOptions<TUser extends ViewUser>(
6868
queryParameters: GetOrganizationUsersParams,
6969
queryResponse: CreateQueryResult<FetchClientResponse<TUser[]>, ProblemDetails>,
70-
organizationId: string
70+
organizationId: (() => string) | string
7171
) {
7272
return getSharedTableOptions<TUser>({
7373
columnPersistenceKey: 'users-column-visibility',
7474
get columns() {
75-
return getColumns<TUser>(organizationId);
75+
return getColumns<TUser>(typeof organizationId === 'function' ? organizationId() : organizationId);
7676
},
7777
paginationStrategy: 'offset',
7878
get queryData() {

src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId=objectid]/+page.svelte

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,10 @@
1111
import EventsOverview from '$features/events/components/events-overview.svelte';
1212
import { buildEventDetailsHref } from '$features/events/components/summary';
1313
import { organization } from '$features/organizations/context.svelte';
14-
import { watch } from 'runed';
1514
import { toast } from 'svelte-sonner';
1615
1716
import { getEventsNavigationOptionsForFilter, redirectToEventsWithFilter } from '../../redirect-to-events.svelte.js';
1817
19-
// TODO: Have this happen automatically when the organization changes.
20-
watch(
21-
() => organization.current,
22-
() => {
23-
goto(resolve('/(app)/event'));
24-
},
25-
{
26-
lazy: true
27-
}
28-
);
29-
3018
async function filterChanged(addedOrUpdated: FacetedFilter.IFilter) {
3119
await redirectToEventsWithFilter(organization.current, addedOrUpdated, getEventsNavigationOptionsForFilter(addedOrUpdated));
3220
}
@@ -41,6 +29,7 @@
4129
}
4230
4331
async function handleEventLoaded(event: PersistentEvent) {
32+
organization.current = event.organization_id;
4433
assistantPageContext.setPageEvent(event);
4534
await goto(buildEventDetailsHref(event.id, event.stack_id), {
4635
replaceState: true

src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/+layout.svelte

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,8 @@
4444
return;
4545
}
4646
47-
if (organizationQuery.isSuccess && organization.current && organizationId !== organization.current) {
48-
goto(page.url.pathname.replace(`/organization/${organizationId}`, `/organization/${organization.current}`));
49-
return;
47+
if (organizationQuery.isSuccess) {
48+
organization.current = organizationQuery.data.id;
5049
}
5150
});
5251
</script>
@@ -83,6 +82,8 @@
8382
</A>
8483
{/each}
8584
</nav>
86-
{@render children()}
85+
{#if organizationQuery.isSuccess}
86+
{@render children()}
87+
{/if}
8788
</div>
8889
</div>

src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/billing/+page.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { resolve } from '$app/paths';
3+
import { page } from '$app/state';
34
import ErrorMessage from '$comp/error-message.svelte';
45
import Currency from '$comp/formatters/currency.svelte';
56
import DateTime from '$comp/formatters/date-time.svelte';
@@ -12,7 +13,6 @@
1213
import { ChangePlanDialog } from '$features/billing';
1314
import { getInvoiceStatusLabel } from '$features/billing/invoice';
1415
import { getInvoicesQuery, getOrganizationQuery } from '$features/organizations/api.svelte';
15-
import { organization } from '$features/organizations/context.svelte';
1616
import GlobalUser from '$features/users/components/global-user.svelte';
1717
import { createQueryParameters } from '$shared/query-params';
1818
import CreditCard from '@lucide/svelte/icons/credit-card';
@@ -22,15 +22,15 @@
2222
const organizationQuery = getOrganizationQuery({
2323
route: {
2424
get id() {
25-
return organization.current;
25+
return page.params.organizationId;
2626
}
2727
}
2828
});
2929
3030
const invoicesQuery = getInvoicesQuery({
3131
route: {
3232
get organizationId() {
33-
return organization.current!;
33+
return page.params.organizationId!;
3434
}
3535
}
3636
});

src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/users/+page.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
<script lang="ts">
22
import type { ViewUser } from '$features/users/models';
33
4+
import { page } from '$app/state';
45
import DataTableViewOptions from '$comp/data-table/data-table-view-options.svelte';
56
import { Muted } from '$comp/typography';
67
import { Button } from '$comp/ui/button';
78
import { showBillingDialogOnUpgradeProblem } from '$features/billing';
89
import { addOrganizationUser } from '$features/organizations/api.svelte';
9-
import { organization } from '$features/organizations/context.svelte';
1010
import { DEFAULT_LIMIT } from '$features/shared/api/api.svelte';
1111
import { type GetOrganizationUsersParams, getOrganizationUsersQuery } from '$features/users/api.svelte';
1212
import InviteUserDialog from '$features/users/components/invite-user-dialog.svelte';
@@ -18,7 +18,7 @@
1818
import { createTable } from '@tanstack/svelte-table';
1919
import { toast } from 'svelte-sonner';
2020
21-
const organizationId = organization.current!;
21+
const organizationId = $derived(page.params.organizationId || '');
2222
2323
const DEFAULT_PARAMS = {
2424
limit: DEFAULT_LIMIT
@@ -52,7 +52,7 @@
5252
}
5353
});
5454
55-
const table = createTable(getTableOptions<ViewUser>(usersQueryParameters, usersQuery, organizationId));
55+
const table = createTable(getTableOptions<ViewUser>(usersQueryParameters, usersQuery, () => organizationId));
5656
5757
const addUserMutation = addOrganizationUser({
5858
route: {

src/Exceptionless.Web/ClientApp/src/routes/(app)/payment/[id]/+page@.svelte

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@
2626
});
2727
2828
$effect(() => {
29-
if (!accessToken.current || !organization.current || invoiceQuery.isError) {
29+
if (!accessToken.current || invoiceQuery.isError) {
3030
void goto(resolve('/'));
31+
return;
3132
}
3233
33-
if (invoiceQuery.isSuccess && invoiceQuery.data?.organization_id !== organization.current) {
34-
void goto(resolve('/'));
34+
if (invoiceQuery.isSuccess && invoiceQuery.data?.organization_id) {
35+
organization.current = invoiceQuery.data.organization_id;
3536
}
3637
});
3738
</script>

src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
goto(resolve('/(app)/project/list'));
6464
}
6565
66-
if (projectQuery.isSuccess && projectQuery.data.organization_id !== organization.current) {
66+
if (projectQuery.isSuccess) {
6767
organization.current = projectQuery.data.organization_id;
6868
}
6969
});

0 commit comments

Comments
 (0)