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:
2026-08-14 23:02:09 -07:00
parent bdd42e06f6
commit abeee0076b
15 changed files with 513 additions and 85 deletions
+54
View File
@@ -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`);
}
+135
View File
@@ -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>
+4
View File
@@ -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;