From f29b497793136d9c6256a5ebc848d8324c898ba4 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Tue, 8 Sep 2026 07:19:07 +0200 Subject: [PATCH 01/10] chore: start implementing redirect to an accessible org Co-Authored-By: Claude Opus 5 From cda01e9edfea2726809182f03005ba5501a77b9e Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Tue, 8 Sep 2026 07:20:01 +0200 Subject: [PATCH 02/10] docs: add implementation plan for redirect to an accessible org Co-Authored-By: Claude Opus 5 --- ...-09-08-01-redirect-to-an-accessible-org.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 specs/todos/2026-09-08-01-redirect-to-an-accessible-org.md diff --git a/specs/todos/2026-09-08-01-redirect-to-an-accessible-org.md b/specs/todos/2026-09-08-01-redirect-to-an-accessible-org.md new file mode 100644 index 000000000..245d20d60 --- /dev/null +++ b/specs/todos/2026-09-08-01-redirect-to-an-accessible-org.md @@ -0,0 +1,220 @@ +--- +model: opus +effort: high +--- + +# Entering the live demo from another org's login page strands the visitor in that org, and any session that lands on an org it cannot use gets a dead-end 403 instead of its own org + +## Problem + +### 1. The observed bug + +On production (v0.26.0, which already carries #344 / spec +[2026-09-07-02](../done/2026/09/2026-09-07-02-demo-deep-link-and-own-check-edit.md)), +entering the live demo from `https://solidping.io/dash0/orgs/default/login` lands the +visitor in `/orgs/default`, not in the demo org. The demo user is not a member of +`default`, so every org-scoped request answers 403, the layout renders "Permission +Denied", and that card's only button links back to the very same org +([error-views.tsx:33](../../web/dash0/src/components/shared/error-views.tsx:33)) — a +dead end with no way into the demo. + +Two paths produce it. One is a race, the other is deterministic: + +**(a) The "Try the live demo" button.** `enterDemo` +([login.tsx:452](../../web/dash0/src/routes/orgs/$org/login.tsx:452)) calls +`login("demo", …)` and then `routeResult(result, "demo")` +([login.tsx:396](../../web/dash0/src/routes/orgs/$org/login.tsx:396)), which navigates to +`/orgs/demo`. But `login()` also flips `isAuthenticated`, and the "already authenticated" +effect at [login.tsx:382-387](../../web/dash0/src/routes/orgs/$org/login.tsx:382) then +fires `goToDestination(resolveDestination(org, …), replace = true)` with `org` being the +**URL's** org — `default`. `demoAutoLoginOwnsRedirect` +([lib/demo.ts:175](../../web/dash0/src/lib/demo.ts:175)) silences that effect only when +the `?demo` flag is present: spec 2026-09-07-02 §2 fixed exactly this race for the deep +link and left the button path with it. Whichever navigation commits last wins, and a +`replace` to `/orgs/default` overrides the in-flight `/orgs/demo`. + +**(b) A returning visitor who still holds a demo session** (the demo session refreshes +like any other) opening `/orgs/default/login` *without* the flag — the marketing site's +plain login link, a bookmark, the browser's URL completion. No login happens at all; the +same effect sends them straight to `/orgs/default`. This one is not timing-dependent. + +The existing E2E test catches neither. "the login page offers a one-click entry into the +demo" ([demo-account.spec.ts:41-53](../../web/dash0/e2e/demo-account.spec.ts:41)) waits +for `/\/orgs\/[^/]+/` — a pattern the login page's own URL already matches — and then +asserts the demo banner, which `DemoBanner` renders off `user.isDemo` on every org page +([demo-banner.tsx:34](../../web/dash0/src/components/shared/demo-banner.tsx:34)), +including the wrong one. A visitor stranded on `/orgs/test` with a demo session passes +that test. + +### 2. The general problem the demo merely exposes + +Any session can land on an org URL it cannot use: a bookmark to an org you were removed +from, a link a colleague pasted from *their* org, a slug from an old email. The org layout +already handles the *member-of-another-org* case — `needsOrgSwitch` +([$org.tsx:1017-1024](../../web/dash0/src/routes/orgs/$org.tsx:1017)) re-mints the token +via `switchOrg` when the URL's org is in `auth.organizations`. When it is **not**, the +comment says "non-members fall through to the normal 403 handling" +([$org.tsx:1010-1011](../../web/dash0/src/routes/orgs/$org.tsx:1010)): every child query +403s, `QueryErrorView` shows `PermissionDenied` +([error-views.tsx:143](../../web/dash0/src/components/shared/error-views.tsx:143)), and +its "Return to dashboard" loops on the same org. Nothing ever sends the user to an org +they *can* use — even though the client already holds the full list: `/auth/me` returns +`organizations` ([service.go:418](../../server/internal/handlers/auth/service.go:418)) and +`AuthContext` exposes it as `auth.organizations` +([AuthContext.tsx:95](../../web/dash0/src/contexts/AuthContext.tsx:95)) next to the +session's own org `auth.org` +([AuthContext.tsx:94](../../web/dash0/src/contexts/AuthContext.tsx:94)). + +### 3. Which org to fall back to — decided + +The ask left the choice open (last accessed / most checks / first created). Two facts +settle it cheaply: + +- **The session's org *is* "last accessed".** Every login and every `switchOrg` re-mints + the token for one org, so `auth.org` is by construction the org this browser used most + recently. The backend already uses the same signal for the same purpose at login time: + `resolveDefaultOrg` + ([service.go:1051-1067](../../server/internal/handlers/auth/service.go:1051)) picks the + org of the user's **most recent refresh token**, and only then falls back to the first + membership ([service.go:1069-1077](../../server/internal/handlers/auth/service.go:1069)). +- **"First membership" already has one meaning; keep it.** `ListMembersByUser` orders + `created_at DESC` ([sqlite.go:916](../../server/internal/db/sqlite/sqlite.go:916), + [postgres.go:990](../../server/internal/db/postgres/postgres.go:990)), so `[0]` is the + most recently *joined* org. That same `[0]` is what the login fallback returns + ([service.go:957](../../server/internal/handlers/auth/service.go:957)), and the order + is what the org switcher (`CommandMenu`) and the login `orgChoice` picker display. + Re-ordering it for this feature would reshuffle three UIs for no user-visible gain. + +So the rule is **session org, then `organizations[0]`** — the client-side mirror of what +the backend does on login. No new column, no checks count on `OrganizationSummary`, no +new endpoint. + +## Proposal + +### A. One pure helper: the org this session can use + +Add `web/dash0/src/lib/accessible-org.ts` with a unit-tested pure function: + +```ts +export function pickAccessibleOrg( + urlOrg: string, + session: { org: string | null; organizations: OrganizationSummary[]; isSuperAdmin: boolean }, +): string | null +``` + +1. `isSuperAdmin` → `urlOrg` (super admins cross orgs on their claims alone, + [$org.tsx:1010](../../web/dash0/src/routes/orgs/$org.tsx:1010)). +2. `urlOrg` is in `organizations` → `urlOrg`. +3. `session.org` is in `organizations` → `session.org` (last accessed, see §3). +4. `organizations[0]` if any. +5. otherwise `null` → the caller sends the user to `/no-org` + ([routes/no-org.tsx](../../web/dash0/src/routes/no-org.tsx)). + +Both call sites below go through it so the two redirects can never disagree. + +### B. Login page: redirect an authenticated visitor to an org they can use + +- The already-authenticated effect + ([login.tsx:382-387](../../web/dash0/src/routes/orgs/$org/login.tsx:382)) resolves its + destination with `pickAccessibleOrg(org, auth)` instead of the raw URL `org`, and lists + `auth.org` / `auth.organizations` / `auth.user?.isSuperAdmin` in its dependencies so it + evaluates against the *fresh* session after `applyLoginResponse`. With that, both + branches of race (a) land in `demo`, and case (b) is fixed outright — a demo session on + `/orgs/default/login` goes to `/orgs/demo`. +- Verify `applyLoginResponse` stores `organizations` from the **login** response (the + `orgChoice` branch already reads `result.organizations`, so the payload carries them) + and not only from a later `/auth/me`; otherwise step 3 of the helper sees an empty list + right after login and the effect could still pick the URL org. +- `resolveDestination` ([login-destination.ts:43](../../web/dash0/src/lib/login-destination.ts:43)) + needs no change: it already refuses a `returnTo` whose org differs from the resolved + org, so a stale `returnTo=/dash0/orgs/default/…` cannot drag the visitor back. +- The `?demo` flag path (`demoOwnsRedirect`) and the org picker (`showOrgPicker`) keep + their current behaviour. + +### C. Org layout: a non-member is sent to their own org, not to a 403 + +Next to `needsOrgSwitch` in [$org.tsx](../../web/dash0/src/routes/orgs/$org.tsx:1017), +add the complementary case: when `auth.isAuthenticated && !auth.isLoading && !isLoginPage`, +no OAuth token is in the URL (that flow does its own hard redirect, +[$org.tsx:1049-1071](../../web/dash0/src/routes/orgs/$org.tsx:1049)), and +`pickAccessibleOrg(org, auth) !== org`: + +- navigate to `/orgs/$org` of the picked org with `replace: true` — the **org root**, not + the current sub-path: check/status-page/incident uids do not carry across orgs, so a + preserved sub-path would just 404 one hop later; `null` → `/no-org`. +- Guard against loops exactly like the switch effect does: at most one redirect per `org` + (a ref like `switchingForOrgRef`), and never while `isSwitchOrgInFlight()` + ([AuthContext.tsx:154](../../web/dash0/src/contexts/AuthContext.tsx:154)) — a switcher + UI mid-`switchOrg()` transiently makes `auth.org` disagree with the URL. +- Super admins are untouched (helper step 1). A member who lacks a *role* for one page + still gets `PermissionDenied` from `QueryErrorView` — that component stays as is; this + spec removes only the non-member dead end. +- Show a short toast on this redirect ("You don't have access to ``, showing + `` instead") using the existing toast primitive from the design reference. Add + the key to **every** locale file — `bun run test:unit` is what catches a missing one. + The demo cases in §B never reach this branch, so a first-time demo visitor never sees + the toast. + +Explicitly out of scope: org slug aliases after a rename (dash0 has no alias handling +today — a stale slug already 403s, and will now land you in your own org, which is +strictly better), and 404 handling for a super admin visiting an org that does not +exist. + +### D. Tests + +- **Unit** — `lib/accessible-org.test.ts`: table over super admin, URL org in list, + session org preferred over `[0]`, `[0]` when the session org is gone, empty list → null. +- **E2E, demo** ([demo-account.spec.ts](../../web/dash0/e2e/demo-account.spec.ts)): + - tighten the button test so it asserts the landing org is the demo org **and** + `expect(page.url()).not.toContain("/orgs/test")`, the pattern the flag tests already + use ([:108](../../web/dash0/e2e/demo-account.spec.ts:108), + [:136](../../web/dash0/e2e/demo-account.spec.ts:136)). Run it several times before + the fix to confirm it reproduces race (a) rather than passing by timing. + - new: "a returning demo session opening /orgs/test/login (no flag) lands in the demo, + not on Permission Denied" — case (b), deterministic. +- **E2E, general** (new file or `create-org.spec.ts`): sign in as the test user, + `page.goto("orgs/not-my-org")` → URL ends on `/orgs/test`, the toast is visible, and + no request answered 403 — copy the "no 403s" harness from + [create-org.spec.ts:25](../../web/dash0/e2e/create-org.spec.ts:25). Add the super-admin + variant if a fixture exists (URL org kept, no redirect). +- No backend change is expected. If the implementer does touch the membership ordering + after all, the `resolveFromMemberships` tests and the org switcher order must move + together — one ordering for all three consumers listed in §3. + +## Implementation Plan + +1. **§A — `web/dash0/src/lib/accessible-org.ts`.** Pure `pickAccessibleOrg(urlOrg, session)` + implementing the five ordered rules (super admin → urlOrg; urlOrg in list; session org in + list; `organizations[0]`; else `null`). Typed against `OrganizationSummary` from + `AuthContext`. Table-driven vitest in `accessible-org.test.ts` covering every rule plus the + "session org no longer a member" and "empty list" edges. + +2. **§B — `web/dash0/src/routes/orgs/$org/login.tsx`.** The already-authenticated effect + resolves through `pickAccessibleOrg(org, auth)`; `null` → `navigate({ to: "/no-org" })`. + The effect's dependency array gains `auth.org`, `auth.organizations`, + `auth.user?.isSuperAdmin` so it re-evaluates against the session `applyLoginResponse` just + stored. `demoOwnsRedirect` and `showOrgPicker` branches are untouched. + `applyLoginResponse` already stores `organizations` from the login response (falling back to + `/auth/me` when the payload carries none) — verified, no change needed. `resolveDestination` + unchanged: its org-match rule drops a stale cross-org `returnTo` on its own. + +3. **§C — `web/dash0/src/routes/orgs/$org.tsx`.** Alongside `needsOrgSwitch`, compute + `accessibleOrg = pickAccessibleOrg(org, auth)` and a `needsOrgRedirect` predicate + (authenticated, not loading, not the login page, no OAuth token in the URL, + `accessibleOrg !== org`). Its effect dedupes with a `redirectingForOrgRef` (at most one + redirect per URL org), skips while `isSwitchOrgInFlight()`, navigates + `replace: true` to `/orgs/$org` of the picked org (org root, never the sub-path) or to + `/no-org`, and fires a `toast.warning` naming both slugs. + +4. **Locales.** New key `org.accessRedirect.toast` (interpolating `{{requested}}` / + `{{shown}}`) written into `en`, `fr`, `de`, `es` via a Python `OrderedDict` round-trip. + +5. **§D — tests.** Unit file above; `e2e/demo-account.spec.ts` button test tightened to assert + the demo org and `not.toContain("/orgs/test")` (run several times pre-fix to confirm it + reproduces race (a)); new deterministic returning-demo-session test (no flag); new + `e2e/accessible-org-redirect.spec.ts` covering the general non-member case with the + create-org "no 403s" harness. + +6. **QA.** `npx tsc -b`, scoped eslint (no NEW errors vs `HEAD`), `bun run test:unit`, + `make build-dash0`, `bun run lint`, and the affected Playwright files against a side-car + `SP_RUNMODE=test` server on port 4010. From ac168408d9e142397644e82e15f2a33f2857eb3e Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Tue, 8 Sep 2026 07:22:33 +0200 Subject: [PATCH 03/10] feat(dash0): pick the org a session can actually use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pickAccessibleOrg mirrors the backend's login-time resolution — super admin keeps the URL's org, then the URL's org when it is a membership, then the session's own org (last accessed), then organizations[0], then null for a user with no organization at all. Pure and unit-tested, so the two redirect call sites that follow can never disagree and bounce a user between them. Co-Authored-By: Claude Opus 5 --- web/dash0/src/lib/accessible-org.test.ts | 102 +++++++++++++++++++++++ web/dash0/src/lib/accessible-org.ts | 63 ++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 web/dash0/src/lib/accessible-org.test.ts create mode 100644 web/dash0/src/lib/accessible-org.ts diff --git a/web/dash0/src/lib/accessible-org.test.ts b/web/dash0/src/lib/accessible-org.test.ts new file mode 100644 index 000000000..0fcc283a3 --- /dev/null +++ b/web/dash0/src/lib/accessible-org.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { pickAccessibleOrg, type AccessibleOrgSession } from "./accessible-org"; +import type { OrganizationSummary } from "@/contexts/AuthContext"; + +const orgs = (...slugs: string[]): OrganizationSummary[] => + slugs.map((slug) => ({ slug, role: "member" })); + +const session = ( + over: Partial = {}, +): AccessibleOrgSession => ({ + org: null, + organizations: [], + isSuperAdmin: false, + ...over, +}); + +describe("pickAccessibleOrg", () => { + const cases: { + name: string; + urlOrg: string; + session: AccessibleOrgSession; + want: string | null; + }[] = [ + { + name: "a super admin keeps the URL's org even with no membership at all", + urlOrg: "someone-else", + session: session({ org: "hq", organizations: [], isSuperAdmin: true }), + want: "someone-else", + }, + { + name: "a super admin keeps the URL's org over their own session org", + urlOrg: "someone-else", + session: session({ + org: "hq", + organizations: orgs("hq"), + isSuperAdmin: true, + }), + want: "someone-else", + }, + { + name: "the URL's org is kept when it is a membership", + urlOrg: "acmetech", + session: session({ org: "acmetech", organizations: orgs("acmetech") }), + want: "acmetech", + }, + { + name: "the URL's org is kept even when the token names another org (OrgLayout switches instead)", + urlOrg: "acmetech", + session: session({ org: "other", organizations: orgs("other", "acmetech") }), + want: "acmetech", + }, + { + name: "a non-member URL falls back to the session org, not to organizations[0]", + urlOrg: "not-my-org", + session: session({ org: "acmetech", organizations: orgs("newest", "acmetech") }), + want: "acmetech", + }, + { + name: "the demo case: a demo session on another org's login page goes to the demo org", + urlOrg: "default", + session: session({ org: "demo", organizations: orgs("demo") }), + want: "demo", + }, + { + name: "organizations[0] when the session org is no longer a membership", + urlOrg: "not-my-org", + session: session({ org: "removed-from", organizations: orgs("newest", "older") }), + want: "newest", + }, + { + name: "organizations[0] when the session carries no org at all", + urlOrg: "not-my-org", + session: session({ org: null, organizations: orgs("newest", "older") }), + want: "newest", + }, + { + name: "null when the user belongs to no organization (the caller sends them to /no-org)", + urlOrg: "not-my-org", + session: session({ org: null, organizations: [] }), + want: null, + }, + { + name: "null even when the token still names an org the user no longer belongs to", + urlOrg: "not-my-org", + session: session({ org: "removed-from", organizations: [] }), + want: null, + }, + ]; + + for (const c of cases) { + it(c.name, () => { + expect(pickAccessibleOrg(c.urlOrg, c.session)).toBe(c.want); + }); + } + + it("is idempotent: re-running on its own answer is a no-op (no redirect loop)", () => { + const s = session({ org: "acmetech", organizations: orgs("newest", "acmetech") }); + const first = pickAccessibleOrg("not-my-org", s); + expect(first).toBe("acmetech"); + expect(pickAccessibleOrg(first as string, s)).toBe(first); + }); +}); diff --git a/web/dash0/src/lib/accessible-org.ts b/web/dash0/src/lib/accessible-org.ts new file mode 100644 index 000000000..3bb1a7554 --- /dev/null +++ b/web/dash0/src/lib/accessible-org.ts @@ -0,0 +1,63 @@ +import type { OrganizationSummary } from "@/contexts/AuthContext"; + +/** + * The session half of what {@link pickAccessibleOrg} needs: the org the current + * token is scoped to, every org this user is a member of, and whether the user + * is a super admin. Structural on purpose — `useAuth()`'s context value + * satisfies it directly at both call sites, and a unit test can pass a literal. + */ +export interface AccessibleOrgSession { + /** The org the current access token is scoped to (`auth.org`). */ + org: string | null; + /** Every org this user belongs to (`auth.organizations`, from /auth/me). */ + organizations: OrganizationSummary[]; + /** `auth.user?.isSuperAdmin === true`. */ + isSuperAdmin: boolean; +} + +/** + * Picks the org this session can actually use when the URL names `urlOrg`. + * + * Any session can land on an org URL it cannot use: a bookmark to an org you + * were removed from, a link a colleague pasted from *their* org, the live demo + * entered from another org's login page. Before spec 2026-09-08-01 that dead- + * ended — every org-scoped request 403'd and `PermissionDenied`'s only button + * linked back to the very same org. Nothing sent the user to an org they *can* + * use, even though the client already holds the whole list. + * + * The order below mirrors what the backend does at login time + * (`resolveDefaultOrg` in server/internal/handlers/auth/service.go): prefer the + * org this browser used most recently, then the first membership. + * + * 1. **Super admin → `urlOrg`.** Super admins cross orgs on their claims alone + * and are never switched or redirected (see OrgLayout's `needsOrgSwitch`). + * 2. **`urlOrg` is a membership → `urlOrg`.** The URL is fine as it stands; + * a token minted for another org is OrgLayout's switch case, not this one. + * 3. **The session's org → `session.org`.** `auth.org` IS "last accessed": every + * login and every `switchOrg` re-mints the token for exactly one org. Only + * honored while it is still a membership — a user removed from the org their + * token names must not be sent there. + * 4. **`organizations[0]`.** The same fallback the login path uses; the list is + * ordered `created_at DESC` (most recently joined first) and that order is + * what the org switcher and the login org picker already display. + * 5. **`null`** — this session belongs to no organization at all. The caller + * sends it to `/no-org`. + * + * Pure: no React, no network, no storage. Both redirect call sites (the login + * page's already-authenticated effect and OrgLayout's non-member guard) route + * through it so the two can never disagree and bounce a user between them. + */ +export function pickAccessibleOrg( + urlOrg: string, + session: AccessibleOrgSession, +): string | null { + if (session.isSuperAdmin) return urlOrg; + + const isMember = (slug: string | null): slug is string => + slug !== null && session.organizations.some((o) => o.slug === slug); + + if (isMember(urlOrg)) return urlOrg; + if (isMember(session.org)) return session.org; + + return session.organizations[0]?.slug ?? null; +} From 626f19107bbf254be4777f8195af36b7113d0f69 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Tue, 8 Sep 2026 07:24:06 +0200 Subject: [PATCH 04/10] fix(dash0): send an authenticated visitor to an org they can use, not to the URL's The login page's already-authenticated effect resolved its destination from the URL's org. That org is only a hint: /orgs//login is reachable from a marketing link, a bookmark, or the "Try the live demo" button offered on every org's login page. A visitor who is not a member of that org landed on "Permission Denied", whose only button links back to the same org. It now resolves through pickAccessibleOrg, with the session fields it reads in the dependency list so it evaluates against the session applyLoginResponse just stored. A session with no organization at all goes to /no-org. This also closes the second half of the demo race spec 2026-09-07-02 left open: the button path. The tightened E2E test failed 5 runs out of 5 before the fix (the visitor reached /orgs/demo, then a replace() to /orgs/test overrode it) and passes after it. Co-Authored-By: Claude Opus 5 --- web/dash0/e2e/demo-account.spec.ts | 19 +++++++-- web/dash0/src/routes/orgs/$org/login.tsx | 51 +++++++++++++++++++++++- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/web/dash0/e2e/demo-account.spec.ts b/web/dash0/e2e/demo-account.spec.ts index 52d32bc69..bdc690b30 100644 --- a/web/dash0/e2e/demo-account.spec.ts +++ b/web/dash0/e2e/demo-account.spec.ts @@ -38,7 +38,19 @@ test.describe("Public live demo", () => { expect(demo?.password).toBeTruthy(); }); - test("the login page offers a one-click entry into the demo", async ({ page }) => { + test("the login page offers a one-click entry into the demo", async ({ + page, + request, + }) => { + // Spec 2026-09-08-01 §D. This assertion used to be + // `waitForURL(/\/orgs\/[^/]+/)` + the demo banner — a pattern the LOGIN + // page's own URL already matches, and a banner `DemoBanner` renders off + // `user.isDemo` on every org page including the wrong one. A visitor + // stranded on /orgs/test with a demo session passed it. Name the demo org + // and refuse the URL's org instead, exactly like the ?demo flag tests do. + const demo = await demoConfig(request); + const org = demo?.orgSlug as string; + await page.goto("orgs/test/login"); await page.waitForLoadState("networkidle"); @@ -47,9 +59,10 @@ test.describe("Public live demo", () => { await demoButton.click(); - // One click must land inside the demo org, banner and all. - await page.waitForURL(/\/orgs\/[^/]+/, { timeout: 20000 }); + // One click must land inside the DEMO org, banner and all. + await page.waitForURL(new RegExp(`/orgs/${org}(/|$)`), { timeout: 20000 }); await expect(page.getByTestId("demo-banner")).toBeVisible({ timeout: 20000 }); + expect(page.url()).not.toContain("/orgs/test"); }); test("?demo=1 signs the visitor in on load", async ({ page }) => { diff --git a/web/dash0/src/routes/orgs/$org/login.tsx b/web/dash0/src/routes/orgs/$org/login.tsx index 01a209fd7..71f213563 100644 --- a/web/dash0/src/routes/orgs/$org/login.tsx +++ b/web/dash0/src/routes/orgs/$org/login.tsx @@ -27,6 +27,7 @@ import { ApiError } from "@/api/client"; import { useVersion, useProviders } from "@/api/hooks"; import { useDemoConfig, usePublicConfigLoading } from "@/api/public-config"; import { demoAutoLoginOwnsRedirect, parseDemoFlag } from "@/lib/demo"; +import { pickAccessibleOrg } from "@/lib/accessible-org"; import { getLastAuthMethod, setLastAuthMethod, @@ -379,12 +380,58 @@ function LoginPage() { demoConfigLoading, ); + // The destination is the org this session can actually USE, not the org the + // URL happens to name (spec 2026-09-08-01 §B). The URL's org is only ever a + // hint here: /orgs//login is reachable from a marketing link, a + // bookmark, an old email, or the "Try the live demo" button offered on EVERY + // org's login page. Sending an authenticated visitor to an org they are not + // a member of dead-ends them on "Permission Denied", whose only button links + // back to that same org. + // + // This is also the second half of the demo race (spec 2026-09-07-02 fixed + // only the ?demo-flag branch): the button's own `routeResult` navigates to + // /orgs/demo while login() flips isAuthenticated, and whichever navigation + // commits last wins. Now both branches resolve to the same org, so the race + // has no wrong outcome left to reach. + // + // The dependency list carries the session fields the pick reads + // (auth.org / auth.organizations / isSuperAdmin) so the effect re-evaluates + // against the session applyLoginResponse just stored rather than a stale one. + const sessionOrg = auth.org; + const sessionOrganizations = auth.organizations; + const isSuperAdmin = user?.isSuperAdmin === true; + useEffect(() => { if (demoOwnsRedirect) return; if (isAuthenticated && !showOrgPicker) { - goToDestination(resolveDestination(org, returnTo, BASE_PATH), true); + const accessibleOrg = pickAccessibleOrg(org, { + org: sessionOrg, + organizations: sessionOrganizations, + isSuperAdmin, + }); + // No organization at all — /no-org is the screen that offers creating or + // joining one, and is where every other org-less path already lands. + if (accessibleOrg === null) { + navigate({ to: "/no-org", replace: true }); + return; + } + // resolveDestination needs no change: it already drops a `returnTo` + // whose org segment differs from the resolved org, so a stale + // returnTo=/dash0/orgs//… cannot drag the visitor back. + goToDestination(resolveDestination(accessibleOrg, returnTo, BASE_PATH), true); } - }, [demoOwnsRedirect, isAuthenticated, showOrgPicker, org, returnTo, goToDestination]); + }, [ + demoOwnsRedirect, + isAuthenticated, + showOrgPicker, + org, + returnTo, + goToDestination, + navigate, + sessionOrg, + sessionOrganizations, + isSuperAdmin, + ]); // `loginOrg` is the org the credentials were actually for. It defaults to the // org whose login page we are on, which is right for every ordinary sign-in — From 4313b9ffb848fae74690a7dc7137f709a1d5aad4 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Tue, 8 Sep 2026 07:33:32 +0200 Subject: [PATCH 05/10] fix(dash0): send a non-member to an org they can use instead of a dead-end 403 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org layout switched the session's token when the URL named another org the user belongs to, and let everything else "fall through to the normal 403 handling": every child query answered 403, QueryErrorView rendered "Permission Denied", and that card's only button linked back to the very same org. A bookmark to an org you were removed from, or a link pasted from a colleague's org, had no way out but editing the URL by hand. It now redirects to the org pickAccessibleOrg names (or /no-org when there is none), with a toast saying which org was refused and which is being shown. The destination is the org ROOT, never the current sub-path: check, status page and incident uids do not carry across organizations. Children are gated behind the redirect the same way the switch case gates them, so no org-scoped request is fired — and refused — on the way out. Super admins are untouched, and a member merely lacking a role for one page still gets Permission Denied. Co-Authored-By: Claude Opus 5 --- web/dash0/e2e/accessible-org-redirect.spec.ts | 187 ++++++++++++++++++ web/dash0/e2e/demo-account.spec.ts | 36 ++++ web/dash0/src/locales/de/org.json | 3 + web/dash0/src/locales/en/org.json | 3 + web/dash0/src/locales/es/org.json | 3 + web/dash0/src/locales/fr/org.json | 3 + web/dash0/src/routes/orgs/$org.tsx | 78 ++++++++ 7 files changed, 313 insertions(+) create mode 100644 web/dash0/e2e/accessible-org-redirect.spec.ts diff --git a/web/dash0/e2e/accessible-org-redirect.spec.ts b/web/dash0/e2e/accessible-org-redirect.spec.ts new file mode 100644 index 000000000..76de81680 --- /dev/null +++ b/web/dash0/e2e/accessible-org-redirect.spec.ts @@ -0,0 +1,187 @@ +import { test as base, expect, type Page } from "@playwright/test"; +import { test, API_BASE } from "./fixtures"; + +/** + * Spec 2026-09-08-01 §C — landing on an org you cannot use is no longer a dead + * end. + * + * Any session can end up on a foreign org URL: a bookmark to an org you were + * removed from, a link a colleague pasted from THEIR org, a slug from an old + * email, the live demo entered from another org's login page. Before this + * spec, the org layout let non-members "fall through to the normal 403 + * handling": every child query answered 403, `QueryErrorView` rendered + * "Permission Denied", and that card's only button linked back to the very + * same org. There was no way out but editing the URL by hand. + * + * The client already holds the answer — `/auth/me` returns every org the user + * belongs to — so the layout now redirects to one they can use, and says so. + */ + +/** An org slug nobody is a member of. */ +const FOREIGN_ORG = "not-my-org"; + +/** + * Provisions a REAL ordinary (non-super-admin) member and seeds the browser + * with its session. + * + * The suite's shared `test@test.com` fixture cannot carry these tests: it is a + * **superadmin** (server/test/testdata/testdata.go), and a superadmin crosses + * orgs on its claims alone — `pickAccessibleOrg` deliberately hands it back the + * URL's org untouched. Running the non-member case as that user would assert + * nothing at all. So: seed a zero-org user through the test-only + * `POST /api/v1/test/users` endpoint (the same one create-org.spec.ts uses, and + * for the same reason — `POST /auth/register` is not usable in this + * environment), log in for real, and create one organization through + * `POST /api/v1/orgs`, whose response carries the fresh org-scoped session the + * browser then adopts. + * + * @returns the slug of the single org this session belongs to. + */ +async function seedOrdinaryMember(page: Page): Promise { + const stamp = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + const email = `accessible-org-${stamp}@unknown.example`; + const password = "Strong-Pass-123!"; + + const created = await page.request.post(`${API_BASE}/api/v1/test/users`, { + data: { email, password, name: "Accessible Org User" }, + }); + if (created.status() !== 201) { + test.skip( + true, + `test user-seed endpoint unavailable (server not in SP_RUNMODE=test?): ${created.status()}`, + ); + } + + const loginResp = await page.request.post(`${API_BASE}/api/v1/auth/login`, { + data: { email, password }, + }); + expect(loginResp.status()).toBe(200); + const noOrgSession = (await loginResp.json()) as { accessToken: string }; + + const slug = `acc-${stamp}`; + const orgResp = await page.request.post(`${API_BASE}/api/v1/orgs`, { + headers: { Authorization: `Bearer ${noOrgSession.accessToken}` }, + data: { name: `Accessible Org ${stamp}`, slug }, + }); + expect(orgResp.status()).toBe(201); + const session = (await orgResp.json()) as { + slug: string; + accessToken: string; + refreshToken?: string; + expiresIn?: number; + }; + expect(session.slug).toBe(slug); + + await page.addInitScript( + ({ accessToken, refreshToken, expiresIn }) => { + localStorage.setItem("solidping_session_token", accessToken as string); + if (refreshToken) { + localStorage.setItem("solidping_refresh_token", refreshToken as string); + } + if (expiresIn) { + localStorage.setItem( + "solidping_expires_at", + String(Date.now() + Number(expiresIn) * 1000), + ); + localStorage.setItem("solidping_expires_in", String(expiresIn)); + } + }, + { + accessToken: session.accessToken, + refreshToken: session.refreshToken ?? "", + expiresIn: session.expiresIn ?? 0, + }, + ); + + return slug; +} + +// Uses the plain (unauthenticated) `page` fixture: every test here brings its +// own session, either a freshly provisioned ordinary member or — for the +// super-admin control — the shared fixture. +base.describe("Redirect to an accessible org", () => { + base("a member landing on an org they do not belong to is redirected to their own, with no 403", async ({ + page, + }) => { + const myOrg = await seedOrdinaryMember(page); + + // The "no 403s" harness from create-org.spec.ts. The whole point of the + // fix is that the children never mount against the foreign org, so not one + // org-scoped request may be refused on the way out. + const forbiddenUrls: string[] = []; + page.on("response", (response) => { + if (response.status() === 403) forbiddenUrls.push(response.url()); + }); + + await page.goto(`orgs/${FOREIGN_ORG}`); + + await page.waitForURL(new RegExp(`/orgs/${myOrg}(/|$)`), { timeout: 20000 }); + expect(page.url()).not.toContain(FOREIGN_ORG); + + // The redirect is explained rather than silent — otherwise the URL just + // changes under the visitor with no hint that their link was wrong. + await expect(page.getByText(new RegExp(FOREIGN_ORG))).toBeVisible({ + timeout: 10000, + }); + + // And the destination is a WORKING dashboard, not another error card. + await expect(page.getByTestId("sidebar-trigger")).toBeVisible({ + timeout: 20000, + }); + await page.waitForLoadState("networkidle"); + expect( + forbiddenUrls, + "no request may be answered 403 on the way out of a foreign org", + ).toEqual([]); + }); + + base("a sub-path of a foreign org lands on the org root, not a 404 one hop later", async ({ + page, + }) => { + // Check / status-page / incident uids do not carry across organizations, + // so the sub-path is deliberately dropped: preserving it would only 404 + // against the org the visitor was sent to. + const myOrg = await seedOrdinaryMember(page); + + await page.goto( + `orgs/${FOREIGN_ORG}/checks/00000000-0000-0000-0000-000000000000`, + ); + + await page.waitForURL(new RegExp(`/orgs/${myOrg}/?$`), { timeout: 20000 }); + await expect(page.getByTestId("sidebar-trigger")).toBeVisible({ + timeout: 20000, + }); + }); + + base("an org the user IS a member of is never redirected away", async ({ + page, + }) => { + // The positive control: without it, a guard that always redirected would + // pass both tests above. + const myOrg = await seedOrdinaryMember(page); + + await page.goto(`orgs/${myOrg}`); + await page.waitForLoadState("networkidle"); + + expect(page.url()).toContain(`/orgs/${myOrg}`); + await expect(page.getByTestId("sidebar-trigger")).toBeVisible({ + timeout: 20000, + }); + }); +}); + +test.describe("Redirect to an accessible org (super admin)", () => { + test("a super admin keeps the org the URL names", async ({ + authenticatedPage: page, + }) => { + // `test@test.com` is a superadmin (testdata.go). Super admins cross orgs on + // their claims alone, so the guard must leave them exactly where they + // asked to be — including on an org they hold no membership in. This is + // the second half of the positive control: it proves the redirect keys on + // membership rather than firing for every unfamiliar slug. + await page.goto(`orgs/${FOREIGN_ORG}`); + await page.waitForLoadState("networkidle"); + + expect(page.url()).toContain(FOREIGN_ORG); + }); +}); diff --git a/web/dash0/e2e/demo-account.spec.ts b/web/dash0/e2e/demo-account.spec.ts index bdc690b30..71f895992 100644 --- a/web/dash0/e2e/demo-account.spec.ts +++ b/web/dash0/e2e/demo-account.spec.ts @@ -65,6 +65,42 @@ test.describe("Public live demo", () => { expect(page.url()).not.toContain("/orgs/test"); }); + test("a returning demo session opening another org's login page lands in the demo", async ({ + page, + request, + }) => { + // Spec 2026-09-08-01, case (b) — deterministic, no race involved. The demo + // session refreshes like any other, so a returning visitor following the + // marketing site's plain login link (or a bookmark, or URL completion) + // arrives on /orgs/test/login with NO ?demo flag. No login happens at all; + // the already-authenticated effect used to send them straight to + // /orgs/test, an org the demo account is not a member of, where every + // request 403s and "Permission Denied" links back to that same org. + const demo = await demoConfig(request); + const org = demo?.orgSlug as string; + + await page.goto("orgs/test/login?demo=1"); + await page.waitForURL(new RegExp(`/orgs/${org}(/|$)`), { timeout: 20000 }); + await expect(page.getByTestId("demo-banner")).toBeVisible({ timeout: 20000 }); + + // Now the returning visit: the plain login URL of a DIFFERENT org. + const forbiddenUrls: string[] = []; + page.on("response", (response) => { + if (response.status() === 403) forbiddenUrls.push(response.url()); + }); + + await page.goto("orgs/test/login"); + + await page.waitForURL(new RegExp(`/orgs/${org}(/|$)`), { timeout: 20000 }); + await expect(page.getByTestId("demo-banner")).toBeVisible({ timeout: 20000 }); + expect(page.url()).not.toContain("/orgs/test"); + await page.waitForLoadState("networkidle"); + expect( + forbiddenUrls, + "the visitor must never reach the dead-end 403 in an org they cannot use", + ).toEqual([]); + }); + test("?demo=1 signs the visitor in on load", async ({ page }) => { // The marketing site's deep link: land in a working dashboard, not a form. await page.goto("orgs/test/login?demo=1"); diff --git a/web/dash0/src/locales/de/org.json b/web/dash0/src/locales/de/org.json index d9b8d4063..0032747ee 100644 --- a/web/dash0/src/locales/de/org.json +++ b/web/dash0/src/locales/de/org.json @@ -372,5 +372,8 @@ "actions": "Aktionen", "cancel": "Token abbrechen" } + }, + "accessRedirect": { + "toast": "Sie haben keinen Zugriff auf {{requested}} — stattdessen wird {{shown}} angezeigt." } } diff --git a/web/dash0/src/locales/en/org.json b/web/dash0/src/locales/en/org.json index 203effb74..3a1e9a5ac 100644 --- a/web/dash0/src/locales/en/org.json +++ b/web/dash0/src/locales/en/org.json @@ -372,5 +372,8 @@ "actions": "Actions", "cancel": "Cancel token" } + }, + "accessRedirect": { + "toast": "You don't have access to {{requested}} — showing {{shown}} instead." } } diff --git a/web/dash0/src/locales/es/org.json b/web/dash0/src/locales/es/org.json index 5b1b06ca6..4bfebac22 100644 --- a/web/dash0/src/locales/es/org.json +++ b/web/dash0/src/locales/es/org.json @@ -372,5 +372,8 @@ "actions": "Acciones", "cancel": "Cancelar token" } + }, + "accessRedirect": { + "toast": "No tienes acceso a {{requested}}: se muestra {{shown}} en su lugar." } } diff --git a/web/dash0/src/locales/fr/org.json b/web/dash0/src/locales/fr/org.json index 5d87c29ed..55f0b32b6 100644 --- a/web/dash0/src/locales/fr/org.json +++ b/web/dash0/src/locales/fr/org.json @@ -372,5 +372,8 @@ "actions": "Actions", "cancel": "Annuler le jeton" } + }, + "accessRedirect": { + "toast": "Vous n'avez pas accès à {{requested}} — voici {{shown}} à la place." } } diff --git a/web/dash0/src/routes/orgs/$org.tsx b/web/dash0/src/routes/orgs/$org.tsx index f4259f1d2..b3ad020ad 100644 --- a/web/dash0/src/routes/orgs/$org.tsx +++ b/web/dash0/src/routes/orgs/$org.tsx @@ -67,6 +67,8 @@ import { useFeedback } from "@/components/feedback/useFeedback"; import { LiveEventsProvider } from "@/contexts/LiveEventsContext"; import { isOrgPublicRoute } from "@/lib/org-public-routes"; import { demoFlagFromLocation } from "@/lib/demo"; +import { pickAccessibleOrg } from "@/lib/accessible-org"; +import { toast } from "sonner"; import { useTranslation } from "react-i18next"; /** Parses the `?from=` search param used by the notification detail route. */ @@ -992,6 +994,7 @@ function OrgLayout() { const location = useLocation(); const navigate = useNavigate(); const auth = useAuth(); + const { t } = useTranslation("org"); // Anchored to the org-level login/register routes — see isOrgPublicRoute for // why a bare `.endsWith("/register")` is wrong, and why the org param must // not be interpolated into the pattern here. @@ -1046,6 +1049,67 @@ function OrgLayout() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [needsOrgSwitch, org, orgSwitchFailed]); + // The complementary case (spec 2026-09-08-01 §C): the URL names an org this + // session is NOT a member of. Until now those "fell through to the normal + // 403 handling" — every child query answered 403, QueryErrorView rendered + // "Permission Denied", and its "Return to dashboard" button linked back to + // the very same org. A dead end, reachable from a bookmark to an org you + // were removed from, a link a colleague pasted from THEIR org, or a slug in + // an old email — even though the client already holds the list of orgs the + // user can use. + // + // Redirect to the ORG ROOT of the picked org, never the current sub-path: + // check / status-page / incident uids do not carry across organizations, so + // a preserved sub-path would just 404 one hop later. + // + // Super admins are untouched (pickAccessibleOrg step 1), and so is a member + // who merely lacks a ROLE for one page — that still gets Permission Denied + // from QueryErrorView. This removes only the non-member dead end. + const accessibleOrg = pickAccessibleOrg(org, { + org: auth.org, + organizations: auth.organizations, + isSuperAdmin: auth.user?.isSuperAdmin === true, + }); + const redirectingForOrgRef = useRef(null); + const needsAccessibleOrgRedirect = + auth.isAuthenticated && + !auth.isLoading && + !isLoginPage && + // The OAuth callback does its own hard redirect below; the session it is + // about to adopt is not the one `auth` currently describes. + !hasOAuthTokenInURL() && + accessibleOrg !== org; + + useEffect(() => { + if (!needsAccessibleOrgRedirect) { + redirectingForOrgRef.current = null; + return; + } + // At most one redirect per URL org — the same loop guard the switch effect + // above uses, and what makes a helper that disagreed with itself a bounded + // bug rather than an infinite ping-pong. + if (redirectingForOrgRef.current === org) return; + // A switcher UI is mid-`switchOrg()`: auth.org transiently disagrees with + // a URL its caller has not navigated away from yet. See + // isSwitchOrgInFlight's doc comment in AuthContext.tsx. + if (isSwitchOrgInFlight()) return; + redirectingForOrgRef.current = org; + + if (accessibleOrg === null) { + navigate({ to: "/no-org", replace: true }); + return; + } + toast.warning( + t("accessRedirect.toast", { requested: org, shown: accessibleOrg }), + ); + navigate({ + to: "/orgs/$org", + params: { org: accessibleOrg }, + replace: true, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [needsAccessibleOrgRedirect, org, accessibleOrg]); + // Handle OAuth callback tokens in URL useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -1109,6 +1173,20 @@ function OrgLayout() { ); } + // Same gate for the non-member redirect (spec 2026-09-08-01 §C). Without it + // the children mount and fire their org-scoped queries during the render + // that precedes the redirect effect — child effects run before the parent's + // — so the user would collect a burst of 403s (and a 4403 WS close) on the + // way OUT of an org they were never allowed into. No escape hatch is needed + // here: unlike a switch, this decision is local and cannot fail. + if (needsAccessibleOrgRedirect) { + return ( +
+ +
+ ); + } + return ( From ef1106c4b6a6a287eb6c644c463954c78846eecc Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Tue, 8 Sep 2026 07:37:22 +0200 Subject: [PATCH 06/10] chore: all checks passing for redirect to an accessible org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - npx tsc -b: clean - bun run test:unit: 1505 passed (92 files), incl. locale parity + translated defaults for the four new org.accessRedirect.toast keys - make build-dash0 + bun run lint: 42 errors, byte-for-byte the pre-existing base; the touched files show the same 2 errors / 12 warnings as their baseline versions, so no new finding was introduced - Playwright (side-car SP_RUNMODE=test server on :4010, postgres): accessible-org-redirect + demo-account + the 14 auth/org specs — 90 passed. The one failure, confirm-registration-no-org, is the documented "Registration is not enabled" environment limitation create-org.spec.ts already calls out, unrelated to this change. Co-Authored-By: Claude Opus 5 From 37627b8e9f69f4de7ff4af393a2ac7e519faebf8 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Tue, 8 Sep 2026 08:02:01 +0200 Subject: [PATCH 07/10] chore: all checks passing for redirect to an accessible org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the audit's blocking finding: a 2FA login flashed through /no-org. The new login-page effect (spec §B) picks the destination from auth.organizations. Every login shape carried that list except 2FA: completeLoginAfter2FA built its LoginResponse without the field, so applyLoginResponse fell back to /auth/me — and that await ends React's auto-batch, committing a render where the session is authenticated with an empty list. The effect read "no organization at all" and sent an ordinary TOTP user to /no-org, which has no "you do have orgs" guard, before the ordinary post-login navigation corrected the URL. Fixed at both layers: - Backend (root cause): completeLoginAfter2FA now returns Organizations from getOrganizationsForUser, exactly as the invitation-accept path does, in both its org and no-org branches. The list is decorative enough that a failure to read it is logged and swallowed rather than failing an otherwise valid login — the same call it already makes for last_active_at. The spec expected no backend change, but it also asked the implementer to verify this shape, and it did not hold. - Client (defence in depth): applyLoginResponse holds isLoading up across the /auth/me fallback, and the login effect gates on it. isLoading is the session's existing "still resolving" signal rather than a new global flag, and it is raised in the same batch as setUser so no render ever exposes an authenticated session with a half-built org list. That also makes the org layout's twin branch ($org.tsx), which already gated on !auth.isLoading, correct for this window for free — both call sites now key on one signal. Tests: login_organizations_test.go asserts the 2FA response carries the user's WHOLE list (two memberships, so an echo of the signed-into org would not pass), on both branches. e2e/login-2fa.spec.ts gains a test that records pushState/replaceState navigations — the existing tests wait for a URL merely not containing "/login", which /no-org satisfies, and a final-URL assertion cannot see a redirect that a later one corrects. Verified failing pre-fix with "/dash0/no-org" in the trail while the other three 2FA tests stayed green. Cosmetic: accessible-org-redirect.spec.ts's seed helper now skips through base.skip rather than mixing in the fixtures test object. Co-Authored-By: Claude Opus 5 --- .../handlers/auth/login_organizations_test.go | 72 +++++++++++++++++ server/internal/handlers/auth/service.go | 43 ++++++++--- web/dash0/e2e/accessible-org-redirect.spec.ts | 5 +- web/dash0/e2e/login-2fa.spec.ts | 77 +++++++++++++++++++ web/dash0/src/contexts/AuthContext.tsx | 16 ++++ web/dash0/src/routes/orgs/$org.tsx | 6 ++ web/dash0/src/routes/orgs/$org/login.tsx | 11 +++ 7 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 server/internal/handlers/auth/login_organizations_test.go diff --git a/server/internal/handlers/auth/login_organizations_test.go b/server/internal/handlers/auth/login_organizations_test.go new file mode 100644 index 000000000..d4925cb01 --- /dev/null +++ b/server/internal/handlers/auth/login_organizations_test.go @@ -0,0 +1,72 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/db/models" +) + +// slugsOf projects a login response's organization list down to slugs. +func slugsOf(summaries []OrganizationSummary) []string { + slugs := make([]string, 0, len(summaries)) + for _, summary := range summaries { + slugs = append(slugs, summary.Slug) + } + + return slugs +} + +// TestCompleteLoginAfter2FAReturnsOrganizations pins the login response SHAPE +// for the 2FA path. +// +// Every other completed login hands the caller its full organization list — +// completeLogin gets it from resolveOrgPreference (password, LDAP, OAuth, +// passkey), and the invitation accept re-reads it. 2FA returned none: the +// summaries resolveOrgPreference computed before the challenge were dropped +// with everything else the temp token does not carry. +// +// That is not cosmetic. The dashboard picks which org a fresh session may land +// on from this list, so an empty one reads as "this user belongs to no +// organization at all" — a TOTP user was bounced through the no-organization +// screen until a follow-up /auth/me landed and corrected it. +func TestCompleteLoginAfter2FAReturnsOrganizations(t *testing.T) { + t.Parallel() + + fixture, ctx := newLoginAuditFixture(t) + user := fixture.user(ctx, t, "totp-orgs@unknown.example") + + // A SECOND membership, so the assertion proves the response carries the + // user's whole list rather than merely echoing the org being signed into — + // which a response built from `orgSlug` alone would also satisfy. + second := models.NewOrganization("acmesecond", "Acme Second") + require.NoError(t, fixture.db.CreateOrganization(ctx, second)) + require.NoError(t, fixture.db.CreateOrganizationMember( + ctx, models.NewOrganizationMember(second.UID, user.UID, models.MemberRoleUser))) + + want := []string{fixture.org.Slug, second.Slug} + + t.Run("signing into a resolved org", func(t *testing.T) { + t.Parallel() + + resp, err := fixture.svc.completeLoginAfter2FA(ctx, user, fixture.org.Slug, + string(models.MemberRoleAdmin), + withSecondFactor(AuthMethodPassword, SecondFactorTOTP), Context{}) + require.NoError(t, err) + require.ElementsMatch(t, want, slugsOf(resp.Organizations)) + }) + + t.Run("recovery code, no org on the temp token", func(t *testing.T) { + t.Parallel() + + // The no-org branch mints an access token with an empty org claim. It + // must still describe the memberships, otherwise the client has + // nothing to offer the user but the org-less screen. + resp, err := fixture.svc.completeLoginAfter2FA(ctx, user, "", + string(models.MemberRoleAdmin), + withSecondFactor(AuthMethodPassword, SecondFactorRecoveryCode), Context{}) + require.NoError(t, err) + require.ElementsMatch(t, want, slugsOf(resp.Organizations)) + }) +} diff --git a/server/internal/handlers/auth/service.go b/server/internal/handlers/auth/service.go index 8927c4dcd..8a67aab1e 100644 --- a/server/internal/handlers/auth/service.go +++ b/server/internal/handlers/auth/service.go @@ -4270,6 +4270,27 @@ func (s *Service) completeLoginAfter2FA( userInfo := newUserInfo(user, role) + // Every OTHER login shape carries the caller's full organization list: + // completeLogin gets it from resolveOrgPreference (password, LDAP, OAuth, + // passkey) and the invitation accept re-reads it here. 2FA was the one + // path that returned none, because resolveOrgPreference ran before the + // challenge and its summaries were dropped along with everything else the + // temp token does not carry. + // + // That asymmetry is load-bearing on the client: the dashboard decides + // which org a fresh session may land on from this list, and an absent one + // reads as "this user belongs to no organization at all" until a + // follow-up /auth/me lands. One shape for every login path. + orgSummaries, orgsErr := s.getOrganizationsForUser(ctx, user.UID) + if orgsErr != nil { + // Decorative, and the client refetches from /auth/me when the payload + // carries none — never worth failing an otherwise valid login for. + slog.ErrorContext(ctx, "Failed to list organizations after 2FA login", + "error", orgsErr, "userUID", user.UID) + + orgSummaries = nil + } + // No org case if orgSlug == "" { accessToken, tokenErr := s.generateAccessToken(user.UID, "", role, "", user.Demo) @@ -4278,10 +4299,11 @@ func (s *Service) completeLoginAfter2FA( } return &LoginResponse{ - AccessToken: accessToken, - ExpiresIn: int(s.cfg.AccessTokenExpiry.Seconds()), - TokenType: tokenTypeBearer, - User: userInfo, + AccessToken: accessToken, + ExpiresIn: int(s.cfg.AccessTokenExpiry.Seconds()), + TokenType: tokenTypeBearer, + User: userInfo, + Organizations: orgSummaries, }, nil } @@ -4320,11 +4342,12 @@ func (s *Service) completeLoginAfter2FA( } return &LoginResponse{ - AccessToken: accessToken, - RefreshToken: refreshTokenValue, - ExpiresIn: int(s.cfg.AccessTokenExpiry.Seconds()), - TokenType: tokenTypeBearer, - User: userInfo, - Organization: newOrganizationInfo(org), + AccessToken: accessToken, + RefreshToken: refreshTokenValue, + ExpiresIn: int(s.cfg.AccessTokenExpiry.Seconds()), + TokenType: tokenTypeBearer, + User: userInfo, + Organization: newOrganizationInfo(org), + Organizations: orgSummaries, }, nil } diff --git a/web/dash0/e2e/accessible-org-redirect.spec.ts b/web/dash0/e2e/accessible-org-redirect.spec.ts index 76de81680..583a6d3f5 100644 --- a/web/dash0/e2e/accessible-org-redirect.spec.ts +++ b/web/dash0/e2e/accessible-org-redirect.spec.ts @@ -46,7 +46,10 @@ async function seedOrdinaryMember(page: Page): Promise { data: { email, password, name: "Accessible Org User" }, }); if (created.status() !== 201) { - test.skip( + // `base.skip`, not `test.skip`: this helper only ever runs inside a + // `base(...)` test, and a runtime skip must come from the same test object + // that declared the running test. + base.skip( true, `test user-seed endpoint unavailable (server not in SP_RUNMODE=test?): ${created.status()}`, ); diff --git a/web/dash0/e2e/login-2fa.spec.ts b/web/dash0/e2e/login-2fa.spec.ts index 37a3ba9be..ef404573a 100644 --- a/web/dash0/e2e/login-2fa.spec.ts +++ b/web/dash0/e2e/login-2fa.spec.ts @@ -146,4 +146,81 @@ test.describe("Login with 2FA", () => { await expect(error).not.toContainText(/temporary token/i); expect(page.url()).toContain("/login"); }); + + /** + * Records every path this context visits, INCLUDING the same-document + * pushState/replaceState navigations TanStack Router performs. + * + * A final-URL assertion cannot see the bug below: the wrong redirect is + * corrected a few milliseconds later, so the URL the test reads at the end + * is the right one either way. Only the trail shows the visit. + */ + async function recordVisitedPaths(page: import("@playwright/test").Page) { + await page.addInitScript(() => { + const store = window as unknown as { __visitedPaths?: string[] }; + store.__visitedPaths = [window.location.pathname]; + + const record = () => store.__visitedPaths?.push(window.location.pathname); + const push = history.pushState.bind(history); + const replace = history.replaceState.bind(history); + + history.pushState = (...args: Parameters) => { + push(...args); + record(); + }; + history.replaceState = (...args: Parameters) => { + replace(...args); + record(); + }; + }); + } + + // Spec 2026-09-08-01 §B added a login-page effect that resolves the + // destination through `pickAccessibleOrg`, and sends a session with NO + // organization to /no-org. 2FA was the one login shape whose response + // carried no `organizations` (completeLoginAfter2FA built its LoginResponse + // without the field), so `applyLoginResponse` had to await /auth/me — and + // that await commits a render where the session is authenticated with an + // empty list. The effect fired against exactly that render, read "no + // organization at all", and flashed an ordinary TOTP user through the + // org-less screen before the ordinary post-login navigation corrected it. + // + // The tests above cannot catch it: they wait for a URL merely NOT containing + // "/login", which /no-org satisfies. This one fails pre-fix. + test("a TOTP login never passes through /no-org on its way to the org", async ({ + page, + }) => { + const { email, orgSlug, secret } = await seedEnrolledUser(page); + + await recordVisitedPaths(page); + await loginToChallenge(page, orgSlug, email); + + const codeInput = page.getByTestId("2fa-login-code"); + await expect(codeInput).toBeVisible(); + await codeInput.fill(generateTotp(secret)); + await page.getByTestId("2fa-login-verify").click(); + + // The destination is the org the user actually belongs to, and it renders + // a working dashboard rather than another interstitial. + await page.waitForURL(new RegExp(`/orgs/${orgSlug}(/|$)`), { + timeout: 15000, + }); + await expect(page.getByTestId("sidebar-trigger")).toBeVisible({ + timeout: 20000, + }); + + const visited = await page.evaluate( + () => (window as unknown as { __visitedPaths?: string[] }).__visitedPaths ?? [], + ); + // Positive control for the recorder itself: if it caught nothing at all, + // the assertion below would be vacuous. + expect( + visited.some((path) => path.includes("/login")), + "the path recorder must have observed the login page it started on", + ).toBe(true); + expect( + visited.filter((path) => path.includes("/no-org")), + "a member with an organization must never be routed through /no-org", + ).toEqual([]); + }); }); diff --git a/web/dash0/src/contexts/AuthContext.tsx b/web/dash0/src/contexts/AuthContext.tsx index 6726ea24f..ae5fb4bd2 100644 --- a/web/dash0/src/contexts/AuthContext.tsx +++ b/web/dash0/src/contexts/AuthContext.tsx @@ -412,11 +412,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (orgs.length > 0) { setOrganizations(orgs); } else { + // The payload carried no list, so /auth/me has to fill it in — and that + // `await` ends React's auto-batch. Everything set above (user, org, + // isAuthenticated) therefore COMMITS a render before the list arrives, + // and in that render `organizations` is still the initial []. + // + // Consumers that decide where to send a fresh session from that list + // (pickAccessibleOrg, spec 2026-09-08-01) read the gap as "this user + // belongs to no organization at all" and bounce to /no-org. `isLoading` + // is the session's existing "still resolving" signal — the org layout + // already gates on it — so hold it up across the fetch instead of + // inventing a second flag. It is raised in the same batch as setUser, so + // no render ever sees an authenticated session with a half-built org + // list. + setIsLoading(true); try { const meData = await apiFetch(`/api/v1/auth/me`); setOrganizations(meData.organizations || []); } catch { setOrganizations([]); + } finally { + setIsLoading(false); } } diff --git a/web/dash0/src/routes/orgs/$org.tsx b/web/dash0/src/routes/orgs/$org.tsx index b3ad020ad..ec6b5aa45 100644 --- a/web/dash0/src/routes/orgs/$org.tsx +++ b/web/dash0/src/routes/orgs/$org.tsx @@ -1073,6 +1073,12 @@ function OrgLayout() { const redirectingForOrgRef = useRef(null); const needsAccessibleOrgRedirect = auth.isAuthenticated && + // Never on a session that is still resolving: an authenticated render can + // still carry an EMPTY organizations list while applyLoginResponse's + // /auth/me fallback is in flight, and pickAccessibleOrg reads that as "no + // organization at all". AuthContext holds isLoading up across that fetch + // precisely so this gate covers it — the login page's twin branch gates on + // the same flag. !auth.isLoading && !isLoginPage && // The OAuth callback does its own hard redirect below; the session it is diff --git a/web/dash0/src/routes/orgs/$org/login.tsx b/web/dash0/src/routes/orgs/$org/login.tsx index 71f213563..29c3dd574 100644 --- a/web/dash0/src/routes/orgs/$org/login.tsx +++ b/web/dash0/src/routes/orgs/$org/login.tsx @@ -403,6 +403,16 @@ function LoginPage() { useEffect(() => { if (demoOwnsRedirect) return; + // `authLoading` is the session's "still resolving" signal, and this effect + // must not act on a half-built one. applyLoginResponse raises it across the + // /auth/me it falls back to when a login payload carries no organization + // list; without the gate, the render committed in that window has + // isAuthenticated=true with an EMPTY list, pickAccessibleOrg reads it as + // "no organization at all", and a perfectly ordinary member is flashed + // through /no-org before routeResult corrects the URL. The org layout's + // twin branch ($org.tsx) already gates on the same flag — same signal, + // same treatment, both call sites. + if (authLoading) return; if (isAuthenticated && !showOrgPicker) { const accessibleOrg = pickAccessibleOrg(org, { org: sessionOrg, @@ -422,6 +432,7 @@ function LoginPage() { } }, [ demoOwnsRedirect, + authLoading, isAuthenticated, showOrgPicker, org, From f15827f1cc7f74f049940ecdda40848731c9a985 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Tue, 8 Sep 2026 08:03:00 +0200 Subject: [PATCH 08/10] docs: archive completed spec redirect to an accessible org Co-Authored-By: Claude Opus 5 --- .../2026/09}/2026-09-08-01-redirect-to-an-accessible-org.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename specs/{todos => done/2026/09}/2026-09-08-01-redirect-to-an-accessible-org.md (100%) diff --git a/specs/todos/2026-09-08-01-redirect-to-an-accessible-org.md b/specs/done/2026/09/2026-09-08-01-redirect-to-an-accessible-org.md similarity index 100% rename from specs/todos/2026-09-08-01-redirect-to-an-accessible-org.md rename to specs/done/2026/09/2026-09-08-01-redirect-to-an-accessible-org.md From 4d691bcceb97bff29627c16d2b4cb89050151266 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Tue, 8 Sep 2026 08:03:16 +0200 Subject: [PATCH 09/10] docs: changelog entries for the accessible-org redirect Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cb794650..1920989b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + + +### Bug Fixes + +* **dash0:** landing on an organization you cannot use now takes you to one you can, instead of a dead end. Following a link, a bookmark or an old email to an organization you are not a member of used to answer 403 on every request and show "Permission Denied" — whose only button linked back to the same organization you could not reach. You are now sent to your own organization (the one this browser used last, else your first membership) with a short note saying so, and to the "no organization" screen only if you genuinely have none. Administrators with cross-organization access are unaffected. +* **demo:** entering the live demo from another organization's login page no longer strands you in *that* organization. The "Try the live demo" button raced its own sign-in — whichever navigation committed last won, and the losing one left you on an organization the demo account is not a member of, staring at Permission Denied. A returning visitor who still held a demo session hit the same wall just by opening an ordinary login link, with no race involved. +* **auth:** signing in with a two-factor code now returns the same information as every other way of signing in. It was the one login path that omitted the caller's organization list, which briefly read as "this account belongs to no organization" and could flash the no-organization screen on the way to the dashboard. + ## [0.26.0](https://github.com/fclairamb/solidping/compare/v0.25.1...v0.26.0) (2026-09-08) From 44d39ec2d19aebb28c11b893c3256c2f80d059c8 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Tue, 8 Sep 2026 09:13:37 +0200 Subject: [PATCH 10/10] chore: all checks passing for redirect to an accessible org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrong-org live-socket regression test used "park a non-member on a foreign org" as its vehicle for getting a wrong-org token onto a page that dials the WebSocket. Spec 2026-09-08-01 §C deliberately makes that state unreachable — a non-member is now redirected to an org they can use — so the socket connected fine and the sidebar dot never reached the terminal "disabled" state. Re-point the test at the wrong-org state that IS still reachable, and was always the likelier production cause: a genuine *member* of the URL's org whose token is scoped to another of their orgs, for whom the automatic `switchOrg()` re-mint fails. $org.tsx drops its loading gate in exactly that case ("better to render and let per-request 403s surface than to hang"), so the page mounts holding the other org's token — the same (token-org × target-org) mismatch the server answers with a 4403 (backend twin: realtimews TestServe_TwoOrgMemberMatchesREST). The end-to-end coverage is unchanged: real cookie, real upgrade, real 4403, no hello, no subscribed, dot on "disabled". Added assertions pin the vehicle itself down — the switch-org call was attempted and refused, and the page stayed on the URL's org — so the test cannot degrade into passing vacuously on a page that never held a wrong-org token. Co-Authored-By: Claude Opus 5 --- web/dash0/e2e/live-updates-handshake.spec.ts | 109 ++++++++++++++----- 1 file changed, 84 insertions(+), 25 deletions(-) diff --git a/web/dash0/e2e/live-updates-handshake.spec.ts b/web/dash0/e2e/live-updates-handshake.spec.ts index 2a5cd29d2..d7ed1d9c4 100644 --- a/web/dash0/e2e/live-updates-handshake.spec.ts +++ b/web/dash0/e2e/live-updates-handshake.spec.ts @@ -185,12 +185,31 @@ test.describe("Live updates handshake", () => { test("a valid cookie for a DIFFERENT org is closed by the server with no hello and lands on the disabled live badge", async ({ page, }) => { - // Reproduce the incident's exact credential shape: a *fresh, valid* - // non-super-admin token scoped to another org. The seeded test user is a - // super-admin (claims role "superadmin"), which bypasses the WS org check, - // so it cannot reproduce the 4403 — mint a regular user + their own org - // instead. `POST /api/v1/test/users` is SP_RUNMODE=test only; skip cleanly - // when it is unavailable, mirroring create-org.spec.ts. + // Reproduce the incident's exact credential shape — a *fresh, valid* + // non-super-admin token scoped to another org, on a page the app parks you + // on — under two constraints: + // + // 1. The seeded test user is a super-admin (claims role "superadmin"), + // which bypasses the WS org check, so it cannot produce a 4403 at all. + // Mint a regular user instead. `POST /api/v1/test/users` is + // SP_RUNMODE=test only; skip cleanly when it is unavailable, mirroring + // create-org.spec.ts. + // 2. Parking a NON-member on a foreign org — this test's original vehicle + // — is no longer a reachable state: spec 2026-09-08-01 §C redirects + // that session to an org it can use, so the socket never dials with + // the wrong-org token. The incident's state survives where it is still + // genuinely reachable, and where it always was the more likely cause + // in production: a *member* of the URL's org whose session token is + // scoped to ANOTHER of their orgs, for whom the automatic re-mint + // (`switchOrg`) fails. `$org.tsx` deliberately drops its loading gate + // in that case ("better to render and let per-request 403s surface + // than to hang"), so the page mounts with a token scoped to org B + // while the URL says org A — precisely the (token-org × target-org) + // mismatch the server answers with a 4403, and the shape the backend + // pins down in realtimews' TestServe_TwoOrgMemberMatchesREST. + // + // So: one regular user, TWO orgs of their own, a token for the second, a + // failing switch-org, and a navigation to the first. const stamp = Date.now(); const email = `wrong-org-${stamp}@unknown.example`; const password = "Strong-Pass-123!"; @@ -206,9 +225,9 @@ test.describe("Live updates handshake", () => { ); } - // Log in (zero org), then create the user's own org — the create-org + // Log in (zero org), then create the user's two orgs. Each create-org // response mints a fresh org-scoped token with role "admin" (NOT - // super-admin), whose orgSlug claim is this new org. + // super-admin), whose orgSlug claim is that new org. const loginResp = await page.request.post( `${API_BASE}/api/v1/auth/login`, { data: { email, password } }, @@ -216,24 +235,52 @@ test.describe("Live updates handshake", () => { expect(loginResp.status()).toBe(200); const session = (await loginResp.json()) as { accessToken: string }; - const orgSlug = `wrongorg-${stamp.toString(36)}`; - const createOrgResp = await page.request.post(`${API_BASE}/api/v1/orgs`, { - headers: { Authorization: `Bearer ${session.accessToken}` }, - data: { name: `Wrong Org Co ${stamp}`, slug: orgSlug }, - }); - expect(createOrgResp.status()).toBe(201); - const orgSession = (await createOrgResp.json()) as { - accessToken: string; - refreshToken?: string; - expiresIn?: number; + const createOrg = async (slug: string, name: string) => { + const resp = await page.request.post(`${API_BASE}/api/v1/orgs`, { + headers: { Authorization: `Bearer ${session.accessToken}` }, + data: { name, slug }, + }); + expect(resp.status()).toBe(201); + return (await resp.json()) as { + accessToken: string; + refreshToken?: string; + expiresIn?: number; + }; }; - expect(orgSession.accessToken).toBeTruthy(); + + // Org A is the one the URL will name; org B is the one the token is scoped + // to. The user is a genuine member (owner) of both. + const orgA = `visitedorg-${stamp.toString(36)}`; + const orgB = `tokenorg-${stamp.toString(36)}`; + await createOrg(orgA, `Visited Org ${stamp}`); + const orgBSession = await createOrg(orgB, `Token Org ${stamp}`); + expect(orgBSession.accessToken).toBeTruthy(); + + // The org layout auto-re-mints the session for the URL's org + // (`needsOrgSwitch` → `auth.switchOrg`). Fail that call the way a transient + // backend hiccup would: the layout records the failure and renders anyway, + // leaving the page on org A holding org B's token. Without this the switch + // succeeds and the socket connects — so count the calls and assert the + // vehicle actually engaged. + let switchOrgAttempts = 0; + await page.route("**/api/v1/auth/switch-org", async (route) => { + switchOrgAttempts++; + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ + title: "Service unavailable", + code: "INTERNAL_ERROR", + detail: "switch-org is unavailable (simulated)", + }), + }); + }); // Seed the wrong-org session before the app loads: localStorage gates the // run() loop's dial (getToken must be non-null) and its pre-dial expiry // check, while the access_token COOKIE is what actually authenticates the // handshake now (HTTP-level auth). Both carry the org-B token so the socket - // dials /orgs/test/events/ws and is upgraded, then closed 4403. + // dials /orgs//events/ws and is upgraded, then closed 4403. await page.addInitScript( ({ accessToken, refreshToken, expiresIn }) => { localStorage.setItem("solidping_session_token", accessToken as string); @@ -249,19 +296,19 @@ test.describe("Live updates handshake", () => { } }, { - accessToken: orgSession.accessToken, - refreshToken: orgSession.refreshToken ?? "", - expiresIn: orgSession.expiresIn ?? 0, + accessToken: orgBSession.accessToken, + refreshToken: orgBSession.refreshToken ?? "", + expiresIn: orgBSession.expiresIn ?? 0, }, ); // The browser attaches this cookie to the same-origin WS handshake — it is // the credential the server validates before upgrading. await page.context().addCookies([ - { name: "access_token", value: orgSession.accessToken, url: API_BASE }, + { name: "access_token", value: orgBSession.accessToken, url: API_BASE }, ]); const hs = watchHandshake(page); - await page.goto("orgs/test/checks"); + await page.goto(`orgs/${orgA}/checks`); // The sidebar live-status dot must reach the terminal "disabled" state: // that is the client's reaction to a 4403 (or 4404) permanent close only — @@ -273,6 +320,18 @@ test.describe("Live updates handshake", () => { timeout: 20000, }); + // The vehicle really did what it claims: the app tried to re-mint for org + // A, was refused, and stayed put rather than redirecting elsewhere. If a + // future change makes the app navigate away (or makes the switch succeed), + // these fail loudly instead of letting the assertion above pass vacuously + // on a page that never held a wrong-org token. + expect( + switchOrgAttempts, + "the layout must have attempted (and failed) the org re-mint", + ).toBeGreaterThan(0); + expect(page.url()).toContain(`/orgs/${orgA}`); + expect(page.url()).not.toContain(`/orgs/${orgB}`); + // The server rejected the wrong-org socket after the upgrade (close 4403): // no `hello` ever arrived and no scope was subscribed. With HTTP-level auth // there is no in-band auth frame, so the client sent nothing on the wire.