Build and browser-verify the tenant-picker frontend page
web/src/routes/select-tenant now calls enterprise-auth's existing
GET /auth/memberships / POST /auth/select-tenant protocol (built earlier
this phase, previously called only from Go tests) via
fetch(..., {credentials: 'include'}) -- new listMemberships/selectTenant
functions in $lib/api.ts, using a dedicated request helper that reads
plain-text error bodies (loginhandler's http.Error responses), unlike
every other request helper in that file which expects JSON.
Credentialed cross-origin fetch needed CORS enterprise-auth didn't have:
api/httpserver.WithCORS's wildcard-friendly default can't be combined
with a credentialed request at all (browsers refuse to honor
Access-Control-Allow-Origin: "*" on one) -- added WithCredentialedCORS
(literal origin, Access-Control-Allow-Credentials: true) alongside it,
wired into enterprise-auth via a new CORS_ALLOWED_ORIGIN config var
defaulting to POST_LOGIN_REDIRECT_URL (web's own origin, the same
default pattern SELECT_TENANT_REDIRECT_URL already used).
adapter-static's route crawler doesn't discover a page nothing links to
(this one is only ever reached via enterprise-auth's redirect) -- fixed
with select-tenant/+page.ts's `export const prerender = true`, the same
declaration every other route already has.
Genuinely verified in a real browser in this environment, not just
type-checked: a throwaway Node server standing in for enterprise-auth's
exact wire contract (including its plain-text error bodies), driven
through the full flow via mcp__claude-in-chrome -- cross-origin
pending-login cookie set, credentialed preflight + GET/POST round trip,
a real click choosing a tenant, the post-selection redirect, and the
missing/expired-cookie error path rendering the backend's actual
message. No Docker or live Postgres/IdP needed, since the point was
exercising web's own fetch/CORS/cookie wiring, not enterprise-auth's
internals (already covered by loginhandler's own tests).
This closes the tenant-picker as the last named gap in Phase 4. What's
left is the already-disclosed live-verification caveat shared by every
Postgres/ClickHouse-backed piece and both SSO protocols: none of this
has run against a real database, external IdP, or multi-container
deployment in this environment.
This commit is contained in:
@@ -38,6 +38,46 @@ docker build -f Dockerfile -t sentry-web . # context is web/, not the repo roo
|
||||
docker run -p 3000:3000 sentry-web
|
||||
```
|
||||
|
||||
## Tenant picker (Phase 4)
|
||||
|
||||
`src/routes/select-tenant` is the one route that isn't reachable by
|
||||
clicking around the app -- `enterprise-auth`'s `internal/loginhandler`
|
||||
redirects a browser here after an SSO login resolves to more than one
|
||||
`tenant_memberships` row (see that package's doc comment), carrying a
|
||||
short-lived `sentry_pending_login` cookie instead of a real session. The
|
||||
page calls `GET /auth/memberships` to list the choices, and
|
||||
`POST /auth/select-tenant` on a click, both via
|
||||
`fetch(..., {credentials: 'include'})` (`$lib/api.ts`'s
|
||||
`listMemberships`/`selectTenant`) so that cookie -- and, on success, the
|
||||
real session cookie the POST response sets -- actually cross the origin
|
||||
boundary between this app and `enterprise-auth`. `enterprise-auth`'s
|
||||
default `POST_LOGIN_REDIRECT_URL` (this app's own base URL) is also
|
||||
where `CORS_ALLOWED_ORIGIN` defaults to, and it has to be a literal
|
||||
origin, not `*` -- see `api/httpserver.WithCredentialedCORS`'s doc
|
||||
comment for why a credentialed `fetch` and a wildcard CORS origin can
|
||||
never be combined; `getAuthFeatures` above deliberately doesn't send
|
||||
credentials for exactly this reason, and is why it could stay on the
|
||||
plain `WithCORS` every other endpoint in this repo uses.
|
||||
|
||||
Like every other route (`export const prerender = true` in this route's
|
||||
own `+page.ts`), no server-side data loading -- the membership list and
|
||||
the tenant choice both come from client-side `fetch` calls the same way
|
||||
the root query page's does.
|
||||
|
||||
Verified in a real browser in this environment: a throwaway Node server
|
||||
standing in for `enterprise-auth` (implementing the exact
|
||||
`GET /auth/memberships`/`POST /auth/select-tenant` wire contract,
|
||||
including the plain-text `http.Error` bodies the real handler sends, not
|
||||
JSON) on a different origin/port than this app's dev server, driven
|
||||
through the full flow -- cross-origin pending-login cookie set, the
|
||||
credentialed preflight + `GET`/`POST` round trip, a real click choosing
|
||||
a tenant, and the post-selection redirect landing back on `/` -- plus
|
||||
the missing/expired-cookie error path separately. No Docker or live
|
||||
Postgres/IdP needed for this, since the whole point was exercising this
|
||||
app's own fetch/CORS/cookie wiring against a contract-accurate fake, not
|
||||
`enterprise-auth`'s internals (those are `enterprise/internal/
|
||||
loginhandler`'s own tests' job, already covered there).
|
||||
|
||||
## Why nginx, not distroless
|
||||
|
||||
The repo convention prefers distroless/scratch base images. Serving a
|
||||
|
||||
@@ -149,6 +149,60 @@ export async function getAuthFeatures(): Promise<AuthFeatures> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- tenant picker (Phase 4) -------------------------------------------
|
||||
//
|
||||
// The two calls below are the reason getAuthFeatures above doesn't send
|
||||
// credentials but these do: they carry the short-lived
|
||||
// sentry_pending_login cookie enterprise-auth's finishLogin sets when an
|
||||
// identity resolves to more than one tenant_memberships row (see
|
||||
// enterprise/internal/loginhandler's package doc comment), and
|
||||
// selectTenant's response sets the real session cookie. Both require
|
||||
// `credentials: 'include'`, which is exactly why enterprise-auth's CORS
|
||||
// (httpserver.WithCredentialedCORS) can't use getAuthFeatures'/api.ts's
|
||||
// other requests' wildcard-friendly posture -- browsers refuse to honor
|
||||
// Access-Control-Allow-Origin: "*" on a credentialed request at all, so
|
||||
// CORS_ALLOWED_ORIGIN has to name this page's real origin.
|
||||
|
||||
export type Membership = { tenant_id: string; tenant_display_name: string; role: string };
|
||||
|
||||
class TenantPickerError extends Error {}
|
||||
|
||||
// enterprise-auth's loginhandler responds to an error with plain
|
||||
// http.Error text (e.g. "no membership in the requested tenant"), not a
|
||||
// JSON {"error": "..."} body the way /api's queryapi/dashboards handlers
|
||||
// do -- requestFrom's JSON-body error parsing wouldn't surface that
|
||||
// message, so this reads the body as plain text instead.
|
||||
async function enterpriseAuthRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
if (!enterpriseAuthBase) {
|
||||
throw new TenantPickerError('enterprise-auth is not configured (VITE_ENTERPRISE_AUTH_BASE_URL unset)');
|
||||
}
|
||||
const res = await fetch(`${enterpriseAuthBase}${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new TenantPickerError(body || `request failed with status ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// listMemberships backs the tenant-picker page's initial load -- see
|
||||
// web/src/routes/select-tenant. A 400/401 (missing or expired pending
|
||||
// login) surfaces as a thrown TenantPickerError; the page's own error
|
||||
// state is what tells the user to start over at login.
|
||||
export function listMemberships(): Promise<Membership[]> {
|
||||
return enterpriseAuthRequest('/auth/memberships');
|
||||
}
|
||||
|
||||
export function selectTenant(tenantId: string): Promise<{ redirect_url: string }> {
|
||||
return enterpriseAuthRequest('/auth/select-tenant', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tenant_id: tenantId })
|
||||
});
|
||||
}
|
||||
|
||||
export function exportDashboard(id: string): Promise<Dashboard> {
|
||||
return request(`/dashboards/${id}/export`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
// Phase 4's tenant-picker page: enterprise-auth's finishLogin lands
|
||||
// the browser here (SELECT_TENANT_REDIRECT_URL, default
|
||||
// http://localhost:3000/select-tenant -- see enterprise/internal/
|
||||
// config's doc comment) after a login resolves to more than one
|
||||
// tenant_memberships row, carrying a short-lived sentry_pending_login
|
||||
// cookie instead of a real session. This page's whole job: show the
|
||||
// choices GET /auth/memberships returns, and turn a click into
|
||||
// POST /auth/select-tenant, which trades that cookie for a real
|
||||
// session and tells us where to go next.
|
||||
//
|
||||
// No +page.ts -- everything here is client-only (fetch with
|
||||
// credentials against a different origin), nothing to prerender or
|
||||
// load server-side, same reasoning as every other data-fetching route
|
||||
// in this app.
|
||||
import { listMemberships, selectTenant, type Membership } from '$lib/api';
|
||||
|
||||
let phase = $state<'loading' | 'ready' | 'error'>('loading');
|
||||
let memberships = $state<Membership[]>([]);
|
||||
let error = $state('');
|
||||
let selectingTenantId = $state('');
|
||||
|
||||
async function load() {
|
||||
phase = 'loading';
|
||||
error = '';
|
||||
try {
|
||||
memberships = await listMemberships();
|
||||
phase = 'ready';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
phase = 'error';
|
||||
}
|
||||
}
|
||||
load();
|
||||
|
||||
async function choose(tenantId: string) {
|
||||
selectingTenantId = tenantId;
|
||||
error = '';
|
||||
try {
|
||||
const { redirect_url } = await selectTenant(tenantId);
|
||||
// Full navigation, not SvelteKit's router: redirect_url is
|
||||
// enterprise-auth's postLoginRedirectURL, i.e. this app's own
|
||||
// base URL -- reloading picks up the real session cookie
|
||||
// POST /auth/select-tenant just set, which client-side
|
||||
// routing wouldn't need to know about but a fresh page load
|
||||
// makes unambiguous.
|
||||
window.location.href = redirect_url;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
selectingTenantId = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>Select a workspace</h1>
|
||||
|
||||
{#if phase === 'loading'}
|
||||
<p>Loading…</p>
|
||||
{:else if phase === 'error' && memberships.length === 0}
|
||||
<p class="error">{error}</p>
|
||||
<p class="note">Your login link may have expired. Start over by logging in again.</p>
|
||||
{:else}
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
<ul>
|
||||
{#each memberships as m (m.tenant_id)}
|
||||
<li>
|
||||
<button
|
||||
disabled={selectingTenantId !== ''}
|
||||
onclick={() => choose(m.tenant_id)}
|
||||
>
|
||||
<span class="name">{m.tenant_display_name}</span>
|
||||
<span class="role">{m.role}</span>
|
||||
{#if selectingTenantId === m.tenant_id}<span class="note">Signing in…</span>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 480px;
|
||||
margin: 3rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
border-color: #06c;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.role {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.error {
|
||||
color: #b00020;
|
||||
}
|
||||
.note {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
// Same shape as settings/+page.ts and dashboards/+page.ts: no route
|
||||
// params, data comes from a client-side fetch (here, credentialed —
|
||||
// see this route's +page.svelte and $lib/api.ts's listMemberships).
|
||||
export const prerender = true;
|
||||
Reference in New Issue
Block a user