From 4333509033bb9834f803ece43077689bdf2bc648 Mon Sep 17 00:00:00 2001 From: No767 <73260931+No767@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:49:30 -0700 Subject: [PATCH] finished --- src/lib/ory.ts | 20 +- src/routeTree.gen.ts | 21 ++ src/routes/dashboard/settings.tsx | 10 +- src/routes/login.tsx | 53 +++- src/routes/recovery.tsx | 500 ++++++++++++++++++++++++++++++ 5 files changed, 597 insertions(+), 7 deletions(-) create mode 100644 src/routes/recovery.tsx diff --git a/src/lib/ory.ts b/src/lib/ory.ts index e226f0ce..89a6d65f 100644 --- a/src/lib/ory.ts +++ b/src/lib/ory.ts @@ -2,7 +2,10 @@ import axios from "axios"; /// Types and Interfaces -type SubmitBody = Partial & { error?: { id?: string }; redirect_browser_to?: string }; +export type SubmitBody = Partial & { + error?: { details?: { redirect_browser_to?: string }; id?: string }; + redirect_browser_to?: string; +}; export type FlowType = "login" | "registration" | "recovery" | "verification" | "settings"; export type SubmitResponse = @@ -10,7 +13,7 @@ export type SubmitResponse = | { flow: Flow; kind: "validation" } | { kind: "redirect"; url: string } | { kind: "refresh"; url: string } - | { kind: "expired" }; + | { kind: "expired"; reason?: string }; export interface Message { id: number; @@ -42,7 +45,7 @@ export interface Flow { export const ORY_URL = (import.meta.env.VITE_ORY_URL as string | undefined) ?? "http://localhost:4433"; -const ACCEPT_JSON = { headers: { Accept: "application/json" } }; +export const ACCEPT_JSON = { headers: { Accept: "application/json" } }; /// Helper functions @@ -58,6 +61,13 @@ export function csrfToken(flow: Flow): string { return (node?.attributes.value as string | undefined) ?? ""; } +export function isSessionAlreadyAvailable(error: unknown): boolean { + return ( + axios.isAxiosError(error) && + error.response?.data.error?.id === "session_already_available" + ); +} + export async function oryInit( type: FlowType, opts: { flowId?: string; returnTo?: string }, @@ -95,5 +105,7 @@ export async function orySubmit( ? { kind: "refresh", url: data.redirect_browser_to } : { kind: "redirect", url: data.redirect_browser_to }; } - return status === 400 && flow ? { flow, kind: "validation" } : { kind: "expired" }; + return status === 400 && flow + ? { flow, kind: "validation" } + : { kind: "expired", reason: data.error?.id }; } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index a9005edd..31294db7 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as ErrorRouteImport } from './routes/error' import { Route as EventsRouteImport } from './routes/events' import { Route as LoginRouteImport } from './routes/login' import { Route as ProjectsRouteImport } from './routes/projects' +import { Route as RecoveryRouteImport } from './routes/recovery' import { Route as RegisterRouteImport } from './routes/register' import { Route as SigsRouteImport } from './routes/sigs' import { Route as DashboardIndexRouteImport } from './routes/dashboard/index' @@ -66,6 +67,11 @@ const ProjectsRoute = ProjectsRouteImport.update({ path: '/projects', getParentRoute: () => rootRouteImport, } as any) +const RecoveryRoute = RecoveryRouteImport.update({ + id: '/recovery', + path: '/recovery', + getParentRoute: () => rootRouteImport, +} as any) const RegisterRoute = RegisterRouteImport.update({ id: '/register', path: '/register', @@ -146,6 +152,7 @@ export interface FileRoutesByFullPath { '/events': typeof EventsRoute '/login': typeof LoginRoute '/projects': typeof ProjectsRoute + '/recovery': typeof RecoveryRoute '/register': typeof RegisterRoute '/sigs': typeof SigsRoute '/dashboard/events': typeof DashboardEventsRoute @@ -168,6 +175,7 @@ export interface FileRoutesByTo { '/events': typeof EventsRoute '/login': typeof LoginRoute '/projects': typeof ProjectsRoute + '/recovery': typeof RecoveryRoute '/register': typeof RegisterRoute '/sigs': typeof SigsRoute '/dashboard/events': typeof DashboardEventsRoute @@ -192,6 +200,7 @@ export interface FileRoutesById { '/events': typeof EventsRoute '/login': typeof LoginRoute '/projects': typeof ProjectsRoute + '/recovery': typeof RecoveryRoute '/register': typeof RegisterRoute '/sigs': typeof SigsRoute '/dashboard/events': typeof DashboardEventsRoute @@ -217,6 +226,7 @@ export interface FileRouteTypes { | '/events' | '/login' | '/projects' + | '/recovery' | '/register' | '/sigs' | '/dashboard/events' @@ -239,6 +249,7 @@ export interface FileRouteTypes { | '/events' | '/login' | '/projects' + | '/recovery' | '/register' | '/sigs' | '/dashboard/events' @@ -262,6 +273,7 @@ export interface FileRouteTypes { | '/events' | '/login' | '/projects' + | '/recovery' | '/register' | '/sigs' | '/dashboard/events' @@ -286,6 +298,7 @@ export interface RootRouteChildren { EventsRoute: typeof EventsRoute LoginRoute: typeof LoginRoute ProjectsRoute: typeof ProjectsRoute + RecoveryRoute: typeof RecoveryRoute RegisterRoute: typeof RegisterRoute SigsRoute: typeof SigsRoute ProjectProjectIdRoute: typeof ProjectProjectIdRoute @@ -342,6 +355,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectsRouteImport parentRoute: typeof rootRouteImport } + '/recovery': { + id: '/recovery' + path: '/recovery' + fullPath: '/recovery' + preLoaderRoute: typeof RecoveryRouteImport + parentRoute: typeof rootRouteImport + } '/register': { id: '/register' path: '/register' @@ -484,6 +504,7 @@ const rootRouteChildren: RootRouteChildren = { EventsRoute: EventsRoute, LoginRoute: LoginRoute, ProjectsRoute: ProjectsRoute, + RecoveryRoute: RecoveryRoute, RegisterRoute: RegisterRoute, SigsRoute: SigsRoute, ProjectProjectIdRoute: ProjectProjectIdRoute, diff --git a/src/routes/dashboard/settings.tsx b/src/routes/dashboard/settings.tsx index 3d879c37..4ec46f8b 100644 --- a/src/routes/dashboard/settings.tsx +++ b/src/routes/dashboard/settings.tsx @@ -1,7 +1,15 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/dashboard/settings")({ - beforeLoad: () => { + validateSearch: (search: Record): { flow?: string } => ({ + flow: search.flow as string | undefined, + }), + beforeLoad: ({ search }) => { + // For some reason Kratos sends a new recovery link to this route. There is literally + // no way to fix it. So we have to just quickly redirect them to the correct location + if (search.flow !== undefined) { + redirect({ to: "/recovery", search: { settings: search.flow }, replace: true, throw: true }); + } redirect({ to: "/dashboard", search: { settings: "profile" }, diff --git a/src/routes/login.tsx b/src/routes/login.tsx index df70dfad..fa789898 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -1,10 +1,12 @@ import { SiGoogle } from "@icons-pack/react-simple-icons"; import { useForm } from "@tanstack/react-form"; +import { useMutation } from "@tanstack/react-query"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import axios from "axios"; import { REGEXP_ONLY_DIGITS } from "input-otp"; import { Building2, ShieldCheck } from "lucide-react"; import { type ChangeEvent, type SyntheticEvent, useCallback, useState } from "react"; +import { toast } from "sonner"; import { z } from "zod"; import { Button } from "@/components/ui/button"; @@ -12,7 +14,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input"; import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; import { Label } from "@/components/ui/label"; -import { type Flow, type SubmitResponse, ORY_URL, csrfToken, oryInit, orySubmit } from "@/lib/ory"; +import { + type Flow, + type SubmitResponse, + ORY_URL, + csrfToken, + isSessionAlreadyAvailable, + oryInit, + orySubmit, +} from "@/lib/ory"; export const Route = createFileRoute("/login")({ validateSearch: (search: Record): { flow?: string; return_to?: string } => ({ @@ -130,6 +140,35 @@ function Login() { }, }); + const { mutate: sendRecoveryLink, isPending: sendingRecovery } = useMutation({ + mutationFn: async (email: string) => { + const recoveryFlow = await oryInit("recovery", {}); + return await orySubmit(recoveryFlow.ui.action, { + method: "link", + csrf_token: csrfToken(recoveryFlow), + email, + }); + }, + onSuccess: (result) => { + if (result.kind === "success") { + toast.success("Check your email (including your spam folder)"); + return; + } + toast.error( + (result.kind === "validation" + ? result.flow.ui.messages?.find((message) => message.type === "error")?.text + : undefined) ?? "We couldn't send a reset link. Please try again.", + ); + }, + onError: (error) => { + toast.error( + isSessionAlreadyAvailable(error) + ? "You're already signed in — change your password from account settings." + : "We couldn't send a reset link. Please try again.", + ); + }, + }); + const handleText = useCallback( (event: ChangeEvent) => { const { name, value } = event.target; @@ -137,6 +176,14 @@ function Login() { }, [form], ); + const handleForgotPassword = useCallback(() => { + const email = form.getFieldValue("email"); + if (!loginSchema.shape.email.safeParse(email).success) { + toast.error("Enter your email address first"); + return; + } + sendRecoveryLink(email); + }, [form, sendRecoveryLink]); const handleSubmit = useCallback( (event: SyntheticEvent) => { event.preventDefault(); @@ -318,8 +365,10 @@ function Login() { variant="link" size="xs" className="h-auto px-0 font-semibold" + disabled={sendingRecovery} + onClick={handleForgotPassword} > - Forgot your password? + {sendingRecovery ? "Sending…" : "Forgot your password?"} ): { flow?: string; settings?: string } => ({ + flow: search.flow as string | undefined, + settings: search.settings as string | undefined, + }), + loaderDeps: ({ search }) => ({ flowId: search.flow, settingsFlowId: search.settings }), + loader: async ({ context: { queryClient }, deps }) => { + if (deps.settingsFlowId !== undefined) { + const { status, data } = await axios.get( + `${ORY_URL}/self-service/settings/flows`, + { ...ACCEPT_JSON, params: { id: deps.settingsFlowId }, validateStatus: () => true }, + ); + + if (status === 200) { + queryClient.setQueryData(settingsFlowQueryOptions(deps.settingsFlowId).queryKey, data); + return; + } + + const stepUp = data.error?.details?.redirect_browser_to; + if (stepUp === undefined) return redirect({ to: "/login", search: {} }); + + const returnTo = new URL("/recovery", globalThis.location.origin); + returnTo.searchParams.set("settings", deps.settingsFlowId); + + const challenge = new URL(stepUp); + challenge.searchParams.set("return_to", returnTo.href); + return redirect({ href: challenge.href }); + } + + if (deps.flowId === undefined) { + const fresh = await axios.get(`${ORY_URL}/self-service/recovery/browser`, { + ...ACCEPT_JSON, + validateStatus: () => true, + }); + if (fresh.status !== 200) + return redirect({ to: "/dashboard", search: { settings: "profile" } }); + return redirect({ to: "/recovery", search: { flow: fresh.data.id } }); + } + + const { status, data } = await axios.get(`${ORY_URL}/self-service/recovery/flows`, { + ...ACCEPT_JSON, + params: { id: deps.flowId }, + validateStatus: () => true, + }); + if (status !== 200) return redirect({ to: "/recovery", search: {} }); + queryClient.setQueryData(recoveryFlowQueryOptions(deps.flowId).queryKey, data); + }, + component: Recovery, +}); + +/// Types and Interfaces + +interface RequestLinkInput { + flow: Flow; + email: string; +} + +interface ChangePasswordInput { + flow: Flow; + password: string; +} + +/// Constants - stable/empty data + +type EmailFormValues = z.infer; +const BLANK_EMAIL_FORM: EmailFormValues = { + email: "", +}; + +type PasswordFormValues = z.infer; +const BLANK_PASSWORD_FORM: PasswordFormValues = { + password: "", + confirmPassword: "", +}; + +const BLANK_LOGIN_SEARCH = {}; + +/// Constants - regular + +const FIELD_ERROR_CLASS = "text-[12px] font-semibold text-[#e13737] dark:text-[#ff6b6b]"; +const INPUT_CLASS = "h-11 border-muted-foreground/45"; +const PASSWORD_INPUT_CLASS = "h-11 border-muted-foreground/45 pr-11"; +const REVEAL_BUTTON_CLASS = + "absolute top-0 right-0 size-11 text-muted-foreground hover:bg-transparent hover:text-foreground"; +const MESSAGE_BOX_CLASS = "flex flex-col gap-1 rounded-lg px-3 py-2"; + +/// Zod schemas + +const emailSchema = z.object({ + email: z.email("Enter a valid email."), +}); + +const passwordSchema = z + .object({ + password: z.string().min(12, "Use at least 12 characters."), + confirmPassword: z.string().min(1, "Re-enter your new password."), + }) + .refine((values) => values.password === values.confirmPassword, { + message: "Both passwords must match.", + path: ["confirmPassword"], + }); + +/// Tanstack Query options + +const recoveryFlowQueryOptions = (flowId: string | undefined) => + queryOptions({ + queryKey: ["ory", "recovery", flowId], + queryFn: () => oryInit("recovery", { flowId }), + enabled: flowId !== undefined, + staleTime: Number.POSITIVE_INFINITY, + retry: false, + }); + +const settingsFlowQueryOptions = (flowId: string | undefined) => + queryOptions({ + queryKey: ["ory", "settings", flowId], + queryFn: () => oryInit("settings", { flowId }), + enabled: flowId !== undefined, + staleTime: Number.POSITIVE_INFINITY, + retry: false, + }); + +/// Route + +function Recovery() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { flow: flowId, settings: settingsFlowId } = Route.useSearch(); + + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const { data: recoveryFlow } = useQuery(recoveryFlowQueryOptions(flowId)); + const settingsQuery = useQuery(settingsFlowQueryOptions(settingsFlowId)); + const settingsFlow = settingsQuery.data; + + const { mutate: requestLink, isPending: sendingLink } = useMutation({ + mutationFn: async ({ flow, email }: RequestLinkInput) => + await orySubmit(flow.ui.action, { + method: "link", + email, + csrf_token: csrfToken(flow), + }), + onSuccess: async (result) => { + if ((result.kind === "success" || result.kind === "validation") && result.flow) { + queryClient.setQueryData(recoveryFlowQueryOptions(flowId).queryKey, result.flow); + return; + } + + if (result.kind === "expired" && result.reason === "session_already_available") { + toast.info("You're already signed in — change your password from account settings."); + await navigate({ to: "/dashboard", search: { settings: "profile" } }); + return; + } + toast.error("That reset request expired. Please try again."); + }, + onError: () => toast.error("Couldn't send the reset link. Please try again."), + }); + + const emailForm = useForm({ + defaultValues: BLANK_EMAIL_FORM, + validators: { onChange: emailSchema }, + onSubmit: ({ value }) => { + if (recoveryFlow) requestLink({ flow: recoveryFlow, email: value.email }); + }, + }); + + const { mutate: changePassword, isPending: savingPassword } = useMutation({ + mutationFn: async ({ flow, password }: ChangePasswordInput) => + await orySubmit(flow.ui.action, { + method: "password", + password, + csrf_token: csrfToken(flow), + }), + onSuccess: async (result) => { + if (result.kind === "success") { + toast.success("Password updated"); + await navigate({ to: "/dashboard" }); + return; + } + if (result.kind === "validation") { + queryClient.setQueryData(settingsFlowQueryOptions(settingsFlowId).queryKey, result.flow); + return; + } + toast.error("That reset link expired. Request a new one to set a password."); + await navigate({ to: "/recovery", search: {} }); + }, + onError: () => toast.error("Couldn't update your password. Please try again."), + }); + + const passwordForm = useForm({ + defaultValues: BLANK_PASSWORD_FORM, + validators: { onChange: passwordSchema }, + onSubmit: ({ value }) => { + if (settingsFlow) changePassword({ flow: settingsFlow, password: value.password }); + }, + }); + + const handleEmailText = useCallback( + (event: ChangeEvent) => { + emailForm.setFieldValue("email", event.target.value); + }, + [emailForm], + ); + const handleEmailSubmit = useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + void emailForm.handleSubmit(); + }, + [emailForm], + ); + const handlePasswordText = useCallback( + (event: ChangeEvent) => { + const { name, value } = event.target; + passwordForm.setFieldValue(name as "confirmPassword" | "password", value); + }, + [passwordForm], + ); + const handlePasswordSubmit = useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + void passwordForm.handleSubmit(); + }, + [passwordForm], + ); + const toggleShowPassword = useCallback(() => { + setShowPassword((previous) => !previous); + }, []); + const toggleShowConfirmPassword = useCallback(() => { + setShowConfirmPassword((previous) => !previous); + }, []); + + const onPasswordStep = settingsFlowId !== undefined; + const linkErrors = recoveryFlow?.ui.messages?.filter((message) => message.type === "error") ?? []; + const linkNotices = + recoveryFlow?.ui.messages?.filter((message) => message.type !== "error") ?? []; + const passwordErrors = + settingsFlow?.ui.nodes + .filter((node) => node.attributes.name === "password") + .flatMap((node) => node.messages) + .filter((message) => message.type === "error") ?? []; + + return ( +
+ + +
+ {onPasswordStep ? ( + + ) : ( + + )} +
+ + {onPasswordStep ? "Set a new password" : "Reset your password"} + + + {onPasswordStep + ? "Pick something you haven't used here before." + : "We'll email you a link to set a new one. It expires in 60 minutes."} + +
+ + {!onPasswordStep && ( +
+ {linkErrors.length > 0 && ( +
+ {linkErrors.map((message) => ( +

+ {message.text} +

+ ))} +
+ )} + + {linkNotices.length > 0 && ( +
+ {linkNotices.map((message) => ( +

+ {message.text} +

+ ))} +
+ )} + + + {(field) => ( +
+ + + {field.state.meta.isTouched && !field.state.meta.isValid && ( + + {field.state.meta.errors[0]?.message} + + )} +
+ )} +
+ + + {(state) => ( + + )} + +
+ )} + + {onPasswordStep && settingsQuery.isError && ( +
+
+

+ That reset link is no longer valid. Request a new one to continue. +

+
+ +
+ )} + + {onPasswordStep && !settingsQuery.isError && !settingsFlow && ( +
+ {Array.from({ length: 2 }, (_, index) => ( + + ))} +
+ )} + + {onPasswordStep && settingsFlow && ( +
+ {passwordErrors.length > 0 && ( +
+ {passwordErrors.map((message) => ( +

+ {message.text} +

+ ))} +
+ )} + + + {(field) => ( +
+ +
+ + +
+ {field.state.meta.isTouched && !field.state.meta.isValid && ( + + {field.state.meta.errors[0]?.message} + + )} +
+ )} +
+ + + {(field) => ( +
+ +
+ + +
+ {field.state.meta.isTouched && !field.state.meta.isValid && ( + + {field.state.meta.errors[0]?.message} + + )} +
+ )} +
+ + + {(state) => ( + + )} + +
+ )} + +

+ Remembered it?{" "} + + Back to sign in + +

+
+
+
+ ); +}