Skip to content
File

Blob: src/client/pages/auth-error.tsx

typescript96 lines
1import { useSearchParams } from "react-router";
2 
3import { EditorialMessage } from "@/client/components/editorial-message";
4 
5interface ErrorExplanation {
6 title: string;
7 body: string;
8 hint?: string;
9}
10 
11const 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 
58export 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};