"use client"; import { Suspense, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { authApi, isApiError } from "@/lib/api"; import { API_BASE } from "@/lib/constants"; import { authStore, useAuthStore } from "@/lib/auth-store"; import { toast } from "@/stores/ui-store"; import { Field, FormError, Input, SubmitButton } from "@/components/ui/form"; /** * The API origin (scheme://host) — e.g. `http://localhost:4000` — derived * from `API_BASE` (which is the full `…/api/v1` URL). Used to allow the * OAuth login-resume redirect (the API's `/oauth/authorize` is cross-origin, * so its `next` is absolute). */ const API_ORIGIN = (() => { try { return new URL(API_BASE).origin; } catch { return ""; } })(); /** * Resolve the post-login destination. Open-redirect safe: a `next` is used * only if it is RELATIVE (a path on this origin) or under the KNOWN API * origin (the OAuth flow). Any other absolute URL (a third-party `http://…`) * is dropped to `/w/default` — the same guard as before, extended to permit * exactly the cross-origin API-resume the OAuth `?next=` needs. */ function resolveNext(next: string): string { if (!next) return "/w/default"; if (next.startsWith("/")) return next; if (API_ORIGIN && next.startsWith(API_ORIGIN)) return next; return "/w/default"; } const loginSchema = z.object({ email: z.string().email("Enter a valid email address"), password: z.string().min(1, "Password is required") }); type LoginValues = z.infer; function LoginPage() { const router = useRouter(); const search = useSearchParams(); const user = useAuthStore((s) => s.user); const next = search.get("next") ?? ""; // TASK-0131: the SSO protocol endpoints (ACS / OIDC callback) redirect // failures here as `/login?error=sso_[&org=]` — a friendly // banner for the IdP failure (bad signature, expired, replayed, …) plus a // direct retry into the org's SSO flow. const ssoError = search.get("error") ?? ""; const ssoOrgSlug = search.get("org") ?? ""; const ssoErrorBanner = ssoError.startsWith("sso_") ? ssoError : ""; const ssoErrorText = (() => { const t = ssoError.replace(/^sso_(oidc_)?/, ""); const map: Record = { bad_signature: "Your identity provider's response was not validly signed.", expired: "The sign-in from your identity provider has expired.", replayed: "That sign-in response was already used.", audience_mismatch: "The sign-in response was addressed to the wrong service.", destination_mismatch: "The sign-in response was for the wrong destination.", issuer_mismatch: "The sign-in response came from an unexpected identity provider.", status_not_success: "Your identity provider reported an error.", missing_nameid: "Your identity provider did not return an identifier.", cert_mismatch: "The identity provider's certificate no longer matches.", unknown_org: "We could not find the organization for that sign-in.", no_sso_provider: "This organization no longer has single sign-on configured.", suspended: "Your account is suspended.", unknown_email: "Your email is not recognized and provisioning is disabled.", jit_disabled: "Account provisioning is disabled for this organization.", sso_required: "This organization requires SSO sign-in.", state_invalid: "The SSO session state is invalid or expired — try again.", state_replayed: "That SSO response was already used.", missing_code: "Your identity provider did not return an authorization code.", token_exchange_failed: "We could not exchange the SSO code for a token.", invalid_id_token: "The SSO token could not be verified.", unsupported_alg: "The SSO token uses an unsupported signature algorithm.", unknown_key: "The SSO token was signed with an unknown key.", invalid_signature: "The SSO token signature is invalid.", not_yet_valid: "The SSO token is not valid yet.", nonce_mismatch: "The SSO token does not match this sign-in attempt.", missing_claims: "Your identity provider did not return the required claims.", sso_misconfigured: "Single sign-on is not fully configured for this organization." }; return ( map[t] ?? "Single sign-on did not complete. Please try again, or contact your organization administrator." ); })(); const [serverError, setServerError] = useState(null); // If we already have a session (cookie), skip straight in. useEffect(() => { if (user) { router.replace(resolveNext(next)); } }, [user, next, router]); const { register, handleSubmit, watch, formState: { errors, isSubmitting } } = useForm({ resolver: zodResolver(loginSchema), defaultValues: { email: "", password: "" } }); // TASK-0131: live SSO discovery. As the user types a valid-looking email, // we ask the ANONYMOUS `/auth/sso/options` endpoint (minimal-info — org // slugs only, never the provider type/id) whether that email's orgs have // SSO. We render one "Sign in with SSO" button per org. A short debounce // keeps this off the hot path of typing. const email = watch("email") ?? ""; const [ssoOrgs, setSsoOrgs] = useState>([]); useEffect(() => { const looksLikeEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()); if (!looksLikeEmail) { setSsoOrgs([]); return; } let cancelled = false; const timer = setTimeout(async () => { try { const res = await authApi.ssoOptions(email.trim()); if (!cancelled) setSsoOrgs(res.orgs ?? []); } catch { // Discovery is best-effort — a network hiccup just hides the button. if (!cancelled) setSsoOrgs([]); } }, 350); return () => { cancelled = true; clearTimeout(timer); }; }, [email]); /** The SSO start URL is an API-route redirect; a full-window navigation. */ const startSso = (startUrl: string) => { window.location.assign(startUrl); }; /** Protocol-agnostic start dispatcher for an org slug (API route). */ const ssoStartFor = (slug: string): string => { const known = ssoOrgs.find((o) => o.slug === slug); return known?.startUrl ?? `${API_ORIGIN}/sso/start/${slug}`; }; // Whether the last login attempt was refused because the org enforces SSO // (403 reason `sso_required`) — drives the "Continue with SSO" banner. const [ssoRequired, setSsoRequired] = useState<{ orgs: string[]; startUrl?: string } | null>(null); const onSubmit = async (values: LoginValues) => { setServerError(null); setSsoRequired(null); try { // POST /auth/login → API sets the httpOnly factory_refresh cookie // (source of truth) and returns the short-lived access token (kept in // JS memory only — never localStorage, security-architecture.md). const result = await authApi.login(values); authStore.setUser(result.user, result.accessToken); toast("success", "Welcome back", result.user.name ? `Signed in as ${result.user.name}` : undefined); router.replace(resolveNext(next)); } catch (err) { // Enforce mode: the API refuses a password login when ANY of the // user's orgs has an enforced SSO provider. The details carry the // offending org slugs; we surface a "Continue with SSO" path. if (isApiError(err) && err.status === 403) { const details = Array.isArray(err.details) ? (err.details as Array>) : []; const ssoDetail = details.find((d) => d && d.reason === "sso_required"); if (ssoDetail) { const orgs = (ssoDetail.orgs as string[]) ?? []; // Prefer a start URL the discovery already resolved for the first // offending org; fall back to the protocol-agnostic start // dispatcher (API origin + slug — the server 302s by protocol). const known = ssoOrgs.find((o) => orgs.includes(o.slug)); setSsoRequired({ orgs, startUrl: known?.startUrl ?? (orgs[0] ? ssoStartFor(orgs[0]) : undefined) }); return; } } const message = err instanceof Error ? err.message : "Sign in failed. Please try again."; setServerError(message); } }; return (

Sign in

Enter your workspace credentials.

{ssoErrorBanner && (

Single sign-in failed

{ssoErrorText}

{ssoOrgSlug && ( )}
)} {ssoRequired && (

Sign in with SSO required

{ssoRequired.orgs.length > 0 ? `Your organization${ssoRequired.orgs.length > 1 ? "s" : ""} (${ssoRequired.orgs.join(", ")}) requires SSO sign-in.` : "Your organization requires SSO sign-in."}

{ssoRequired.startUrl && ( )}
)}
Forgot password?
Sign in {ssoOrgs.length > 0 && (
{ssoOrgs.length > 1 && (

{ssoOrgs.length} organizations offer SSO sign-in for this email.

)} {ssoOrgs.map((org) => ( ))}
)}

No account?{" "} Create one

Demo: register any account on the dev instance — the first user becomes workspace owner.

Curious how this is built?{" "} Browse the source {" "} (read-only).

); } /** * The page reads `useSearchParams()` (the `?next=` + SSO error params), which * forces a client-side bailout during static generation unless the component * is wrapped in a `` boundary. Without it `next build` fails with * "useSearchParams() should be wrapped in a suspense boundary at page * /login". The inner component is unchanged; this only adds the boundary. */ export default function LoginPageShell() { return ( ); }