File
Blob: src/client/pages/auth-error.tsx
| 1 | import { useSearchParams } from "react-router"; |
| 2 | |
| 3 | import { EditorialMessage } from "@/client/components/editorial-message"; |
| 4 | |
| 5 | interface ErrorExplanation { |
| 6 | title: string; |
| 7 | body: string; |
| 8 | hint?: string; |
| 9 | } |
| 10 | |
| 11 | const explainError = (code: string | null): ErrorExplanation => { |
| 12 | switch (code) { |
| 13 | case "state_mismatch": |
| 14 | case "invalid_state": |
| 15 | return { |
| 16 | title: "The state didn't match.", |
| 17 | body: "The authentication response carried a different state than the one we set when you started. This usually means the request took too long to complete, you opened the callback in a different browser, or a third party tampered with the redirect.", |
| 18 | hint: "Start over from the sign-in page. If it keeps happening, clear cookies for this site and try again.", |
| 19 | }; |
| 20 | case "account_not_linked": |
| 21 | case "social_account_not_linked": |
| 22 | case "signup_disabled": |
| 23 | case "user_not_found": |
| 24 | return { |
| 25 | title: "That identity isn't linked.", |
| 26 | body: "tessera is invite-only and won't create a new account from a GitHub or Google sign-in. The email returned by the provider is not associated with any tessera user.", |
| 27 | hint: "If you already have a tessera account, sign in with email and password first, then link the social provider from your account page.", |
| 28 | }; |
| 29 | case "callback_url_mismatch": |
| 30 | case "invalid_redirect_uri": |
| 31 | return { |
| 32 | title: "The redirect URI is unregistered.", |
| 33 | body: "The provider returned the callback to a URL that isn't on the registered list for this OAuth client.", |
| 34 | hint: "If you're the operator, register the URL via the admin clients page (or update the OAuth app on the provider's side to use one that is registered).", |
| 35 | }; |
| 36 | case "invalid_token": |
| 37 | case "expired_token": |
| 38 | return { |
| 39 | title: "The token is no longer valid.", |
| 40 | body: "The credential or code you presented has expired or has already been used. Codes from /authorize are single-use and short-lived.", |
| 41 | hint: "Start over from the sign-in page.", |
| 42 | }; |
| 43 | case "rate_limited": |
| 44 | return { |
| 45 | title: "Too many attempts.", |
| 46 | body: "You hit the per-IP rate limit for this endpoint. tessera throttles sign-in and invite acceptance to slow brute-force attempts.", |
| 47 | hint: "Wait a minute and try again.", |
| 48 | }; |
| 49 | default: |
| 50 | return { |
| 51 | title: "We couldn't admit you.", |
| 52 | body: "tessera received an authentication response that didn't validate. The original sign-in request may have expired, or the upstream provider returned an error we don't recognize.", |
| 53 | hint: "Start over from the sign-in page.", |
| 54 | }; |
| 55 | } |
| 56 | }; |
| 57 | |
| 58 | export const AuthErrorPage = () => { |
| 59 | const [params] = useSearchParams(); |
| 60 | const code = params.get("error"); |
| 61 | const description = params.get("error_description"); |
| 62 | const explanation = explainError(code); |
| 63 | |
| 64 | return ( |
| 65 | <> |
| 66 | <EditorialMessage |
| 67 | eyebrow="Authentication did not complete" |
| 68 | title={explanation.title} |
| 69 | body={explanation.body} |
| 70 | hint={explanation.hint} |
| 71 | ctaTo="/sign-in" |
| 72 | ctaLabel="Back to sign in" |
| 73 | /> |
| 74 | {code || description ? ( |
| 75 | <div className="mx-auto -mt-8 mb-16 max-w-2xl"> |
| 76 | <div aria-hidden="true" className="mb-8 h-px w-12 bg-zinc-800/80" /> |
| 77 | <dl className="grid grid-cols-[max-content_minmax(0,1fr)] items-baseline gap-x-8 gap-y-3"> |
| 78 | {code ? ( |
| 79 | <> |
| 80 | <dt className="text-[11px] font-medium uppercase tracking-[0.18em] text-zinc-500">Error</dt> |
| 81 | <dd className="font-mono text-sm text-zinc-300">{code}</dd> |
| 82 | </> |
| 83 | ) : null} |
| 84 | {description ? ( |
| 85 | <> |
| 86 | <dt className="text-[11px] font-medium uppercase tracking-[0.18em] text-zinc-500">Detail</dt> |
| 87 | <dd className="font-mono text-sm text-zinc-400">{description}</dd> |
| 88 | </> |
| 89 | ) : null} |
| 90 | </dl> |
| 91 | </div> |
| 92 | ) : null} |
| 93 | </> |
| 94 | ); |
| 95 | }; |