SSO UI (Phase 14 — TASK-0131)
The interaction contract for SSO in
apps/web: the login-page SSO surface (discovery, enforce banner, error banner) and the org-admin SSO tabs. The API contract lives in ../api/sso.md (authoritative). Component map:
app/(auth)/login/page.tsx— the SSO parts of the login screen: email-based discovery buttons, thesso_requiredbanner, the?error=sso_…bannercomponents/admin/SsoTabs.tsx— the self-contained org-admin SSO panel (create/edit provider, test-connection, enforce toggle, danger zone)app/org/[orgId]/admin/integrations/page.tsx— standalone admin route that rendersSsoTabs(session-gated; TASK-0133's org admin console will host the same component)lib/api.ts—authApi.ssoOptions(email)(discovery) +ssoApi(provider CRUD / enforce / test)
1. Login page — SSO discovery
The login form takes an email first. Once the field looks like an email, a debounced (350 ms) anonymous lookup runs:
GET /api/v1/auth/sso/options?email=… → { orgs: [{ slug, ssoAvailable, startUrl }] }
For every org the email already belongs to that has SSO configured, a button renders under the form:
[ Sign in with SSO — acme ] (one per org; data-testid="sso-start")
Clicking it navigates (full page load) to the org's start URL
(<SSO_ORIGIN>/sso/start/<slug>) — the public dispatcher that begins the
SAML or OIDC handshake. This is a window.location.assign, not a fetch:
the IdP round trip happens in the top frame, and the resulting session
cookie lands on the shared localhost/production domain.
What discovery does NOT show: a brand-new (not-yet-provisioned) email
gets no buttons — the endpoint is minimal-info on purpose (anti-
enumeration: it only reveals that SSO exists for orgs the email already
belongs to). First-time users of an SSO org enter through the link their
org gave them (/sso/start/<slug> — e.g. "Sign in with Factory" in the
org's help docs). Both paths are the same URL the buttons navigate to.
2. Login page — enforce banner (sso_required)
If the org enforces SSO, a password login attempt returns
403 { error.details[0].reason === "sso_required", orgs: [slug, …] }.
The login page turns that into an amber alert:
This organization requires SSO sign-in.
[ Continue with SSO ] (data-testid="sso-continue")
The button targets the start URL of the first enforcing org (falling back
to <SSO_ORIGIN>/sso/start/<slug> when the API didn't include one). The
password fields remain visible — the message explains the password path is
disabled, it doesn't hide the form.
3. Login page — SSO failure banner
When a handshake fails, the API redirects the browser to
/login?error=sso_<reason>[&org=<slug>]. The page maps <reason> to a
human sentence (e.g. sso_bad_signature → "The identity provider's
response could not be verified — contact your organization's admin.",
sso_replayed → "That sign-in response was already used …",
sso_expired → "The sign-in session from the identity provider expired …")
and renders:
[red banner] <human reason>
[ Try again ] (data-testid="sso-retry")
Try again re-navigates to the org's start URL (SAML path only — it needs
the org param). Unknown sso_* reasons fall back to a generic
"sign-in through SSO failed" message. Non-SSO ?error= values are ignored
by this banner.
4. Org admin — SSO tabs (SsoTabs)
Route: /org/[orgId]/admin/integrations (standalone today; TASK-0133's
org admin console mounts the same component). Session-gated (redirects to
/login?next=… when unauthenticated); the API enforces org owner on
every mutation (the org-admin role gate arrives with TASK-0133).
4.1 Empty state
"No SSO provider configured yet." + [ Configure SSO ].
4.2 Create / edit form
A provider is single-per-org. The form:
- Provider type —
SAML 2.0/OIDCtoggle (disabled in edit mode; an org can't switch type in place — remove + re-create). - Display name — free text.
- SAML fields — SP entity ID (defaults to
<origin>/saml/metadata/<org-slug>), IdP SSO URL, ACS URL (defaults to<origin>/saml/acs), NameID / email attribute overrides, optional IdP metadata URL (the test-connection target) and optional pinned IdP entity ID (Issuer). On create there is a certificate paste area (PEM); on edit it becomes a rotation field ("paste the new cert — the old one stays valid asprevious"). - OIDC fields — issuer, client id, authorization endpoint, token
endpoint, redirect URI (defaults to
<origin>/sso/oidc/<orgId>/callback), optional JWKS URI, scope (defaultopenid email profile), and the client-secret paste (create / rotate).
Secrets are reveal-once: the cert PEM / client secret are POSTed, encrypted server-side (AES-256-GCM), and never returned — the summary view shows a "Configured" badge, not the value. Save is disabled until the required fields (and, on create, the secret) are present.
4.3 Summary + actions
Once a provider exists: a masked summary (name, type badge, Enforce ON/OFF badge, cert/secret "Configured", config values) with Edit, Test connection, the enforce toggle, and a Danger zone (Remove SSO provider).
- Test connection → POSTs the probe (SAML: fetch the IdP metadata URL
and validate the descriptor; OIDC: fetch
/.well-known/openid- configuration+ the JWKS) and renders a green "Connected" / red "Failed — <reason>" panel (data-testid="sso-test-result"). - Enforce toggle (
role="switch",data-testid="sso-enforce-toggle") → confirm dialog ("Password logins for every member of this org will be blocked — including the owner") → PATCH enforce. The header badge flips to Enforce ON (amber). Turning it off has its own, gentler dialog. - Remove (danger zone) → confirm dialog → DELETE. Identities are kept; users with a password can sign in again, passwordless JIT users are locked out until they re-sign-in through another path.
4.4 Accessibility
- All form inputs are real labelled
<input>s (the type toggle is arole="group"ofaria-pressedbuttons — a<label>must never wrap non-input controls). - The enforce toggle is a
role="switch"; dialogs are the shared focus-trappedConfirmDialog(aria-modal, labelled). - Test results use
role="status"(announced, non-modal); toasts are the usualui-storetoasts. - Everything is keyboard-reachable: the standalone route has no sidebar trap, and the confirm flow is button → button.
5. E2E (what's covered)
tests/e2e/specs/sso.spec.ts (per-run mock SAML IdP + mock OIDC issuer in
tests/e2e/mocks/): the full browser SAML round trip (login page → start
dispatcher → IdP auto-approve → ACS → lands in the org's workspace, JIT
user created passwordless + verified, second login reuses the user), the
ACS tamper matrix (expired / non-success / cert mismatch / replay /
unconfigured signer), the OIDC round trip (authorize → callback → session
cookie), enforce mode (403 + sso_required, non-org users unaffected,
lockout-recovery path), and the admin UI flow (configure via the form,
test-connection green, enforce badge).