File
Blob: src/client/pages/login-page.tsx
| 1 | import { LogIn } from "lucide-react"; |
| 2 | import { Navigate, useLocation, useSearchParams } from "react-router-dom"; |
| 3 | import { useAuth } from "@/client/auth"; |
| 4 | import { Button, Card, ErrorBanner, PageHeader } from "@/client/components/ui"; |
| 5 | |
| 6 | const DEFAULT_RETURN_TO = "/app/projects"; |
| 7 | |
| 8 | const oidcErrorMessages: Record<string, string> = { |
| 9 | identity_conflict: |
| 10 | "tessera returned an identity that cannot be safely linked to an anvil account. Ask an operator to review the account binding.", |
| 11 | oidc_provider_error: "tessera could not complete sign-in. Try again or ask an operator to check the OIDC provider.", |
| 12 | oidc_session_expired: "The sign-in session expired. Start a new tessera sign-in.", |
| 13 | oidc_unverified_email: "tessera did not return a verified email address for this account.", |
| 14 | tessera_email_conflict: |
| 15 | "tessera returned an email address already assigned to another anvil account. Ask an operator to resolve the conflict.", |
| 16 | user_disabled: "This anvil account is disabled.", |
| 17 | }; |
| 18 | |
| 19 | const sanitizeReturnTo = (value: string | null): string => { |
| 20 | if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/api/")) { |
| 21 | return DEFAULT_RETURN_TO; |
| 22 | } |
| 23 | |
| 24 | return value === "/app/login" || value.startsWith("/app/login?") ? DEFAULT_RETURN_TO : value; |
| 25 | }; |
| 26 | |
| 27 | const getOidcErrorMessage = (code: string | null): string | null => { |
| 28 | if (!code) return null; |
| 29 | return oidcErrorMessages[code] ?? "Sign-in could not be completed. Start a new tessera sign-in."; |
| 30 | }; |
| 31 | |
| 32 | export const LoginPage = () => { |
| 33 | const { isAuthenticated, isInitializing } = useAuth(); |
| 34 | const location = useLocation(); |
| 35 | const [searchParams] = useSearchParams(); |
| 36 | const state = location.state as { returnTo?: unknown } | null; |
| 37 | const stateReturnTo = typeof state?.returnTo === "string" ? state.returnTo : null; |
| 38 | const returnTo = sanitizeReturnTo(searchParams.get("return_to") ?? stateReturnTo); |
| 39 | const errorMessage = getOidcErrorMessage(searchParams.get("error")); |
| 40 | |
| 41 | if (!isInitializing && isAuthenticated) { |
| 42 | return <Navigate to={returnTo} replace />; |
| 43 | } |
| 44 | |
| 45 | const startOidc = () => { |
| 46 | window.location.assign(`/api/public/oidc/start?return_to=${encodeURIComponent(returnTo)}`); |
| 47 | }; |
| 48 | |
| 49 | return ( |
| 50 | <div className="mx-auto max-w-3xl animate-slide-up space-y-6"> |
| 51 | <PageHeader title="Sign in" description="Use your tessera account to open an anvil session." /> |
| 52 | |
| 53 | <Card> |
| 54 | <div className="space-y-5"> |
| 55 | {errorMessage ? <ErrorBanner message={errorMessage} /> : null} |
| 56 | |
| 57 | <div> |
| 58 | <Button |
| 59 | variant="primary" |
| 60 | type="button" |
| 61 | disabled={isInitializing} |
| 62 | icon={<LogIn className="h-4 w-4" aria-hidden="true" />} |
| 63 | onClick={startOidc} |
| 64 | > |
| 65 | Sign in with tessera |
| 66 | </Button> |
| 67 | <p className="mt-3 text-sm leading-6 text-zinc-500"> |
| 68 | You will be redirected to tessera and returned here after OIDC verification. |
| 69 | </p> |
| 70 | </div> |
| 71 | </div> |
| 72 | </Card> |
| 73 | </div> |
| 74 | ); |
| 75 | }; |