import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { Button } from "@/client/components/ui/button"; import { Card } from "@/client/components/ui/card"; import { PageError } from "@/client/components/ui/page-error"; import { useSession } from "@/client/lib/auth-client"; import { apiJsonRequest } from "@/client/lib/http"; import { signInPath } from "@/client/lib/navigation"; import { fetchJson, queryKeys } from "@/client/lib/query"; interface ClientInfo { client_id: string; client_name: string | null; } // Human-language gloss for the OIDC scopes the consent page is likely to // surface. The protocol name stays visible (operators inspecting the URL // need it); the description is what a third-party user reads. Keep this // in sync with the `scopes` allowlist in `src/worker/auth/index.ts` — // any scope rendered here without a matching entry falls back to the // generic placeholder. const SCOPE_DESCRIPTIONS: Record = { openid: "Confirm your identity.", profile: "Your name and username.", email: "Your email address.", }; export const OauthConsentPage = () => { const navigate = useNavigate(); const [params] = useSearchParams(); const session = useSession(); const clientId = params.get("client_id") ?? ""; const scope = params.get("scope") ?? ""; // Better Auth's consent endpoint validates `oauth_query` as the full // signed query string from /authorize (sig + exp included). It's NOT a // single param of the same name on the URL — it's the whole search, // re-serialised so we don't lose any of the original ordering. const oauthQuery = params.toString(); // `null` when idle, `true` when the Authorize button is in flight, // `false` when Deny is in flight. Per-button keying so the spinner // matches the button the user actually clicked. const [pendingAccept, setPendingAccept] = useState(null); const submitting = pendingAccept !== null; // Synchronous lock against double-tap before React re-renders the // disabled state. Same-task repeat clicks no-op until the in-flight // request resolves; the `finally` block resets the lock so retry after // a completed failure is allowed. const inFlightRef = useRef(false); const [error, setError] = useState(null); const userId = session.data?.user.id ?? null; useEffect(() => { if (!session.isPending && !userId) { navigate(signInPath(window.location.pathname + window.location.search), { replace: true }); } }, [session.isPending, userId, navigate]); // Fetch the human-readable name for the client_id in the URL. Falling // back to the opaque id until the request resolves keeps the UI from // flashing two strings if the lookup is fast. const { data: clientInfo = null } = useQuery({ queryKey: queryKeys.consentClient(userId, clientId || null), queryFn: () => fetchJson(`/api/auth/oauth2/public-client?${new URLSearchParams({ client_id: clientId })}`), enabled: Boolean(userId && clientId), }); const respond = async (accept: boolean) => { if (inFlightRef.current) return; inFlightRef.current = true; setPendingAccept(accept); setError(null); try { const res = await apiJsonRequest( "/api/auth/oauth2/consent", { accept, scope, oauth_query: oauthQuery }, { method: "POST", }, ); const body = (await res.json().catch(() => null)) as { redirect?: boolean; url?: string; message?: string; } | null; if (body?.redirect && body.url) { window.location.assign(body.url); return; } if (!res.ok) { setError( typeof body?.message === "string" && body.message.length > 0 ? body.message : "Could not record consent.", ); } } catch (e) { setError(e instanceof Error ? e.message : "Could not record consent."); } finally { inFlightRef.current = false; setPendingAccept(null); } }; if (session.isPending || !session.data) return null; return (

Authorize access

{clientInfo?.client_name ?? clientId} wants to sign you in via tessera.

{clientInfo?.client_name ? ( // Always surface the client_id alongside the name — operators // running multiple RPs with similar names need a verifiable // identifier before granting tokens.

{clientId}

) : (
)} setError(null)} />

It will be able to:

    {scope .split(" ") .filter(Boolean) .map((s) => (
  • {SCOPE_DESCRIPTIONS[s] ?? "Grants the permission below."}{" "} {s}
  • ))}
); };