Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/lib/ory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ import axios from "axios";

/// Types and Interfaces

type SubmitBody = Partial<Flow> & { error?: { id?: string }; redirect_browser_to?: string };
export type SubmitBody = Partial<Flow> & {
error?: { details?: { redirect_browser_to?: string }; id?: string };
redirect_browser_to?: string;
};
export type FlowType = "login" | "registration" | "recovery" | "verification" | "settings";

export type SubmitResponse =
| { flow?: Flow; kind: "success" }
| { flow: Flow; kind: "validation" }
| { kind: "redirect"; url: string }
| { kind: "refresh"; url: string }
| { kind: "expired" };
| { kind: "expired"; reason?: string };

export interface Message {
id: number;
Expand Down Expand Up @@ -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

Expand All @@ -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<SubmitBody>(error) &&
error.response?.data.error?.id === "session_already_available"
);
}

export async function oryInit(
type: FlowType,
opts: { flowId?: string; returnTo?: string },
Expand Down Expand Up @@ -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 };
}
21 changes: 21 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -217,6 +226,7 @@ export interface FileRouteTypes {
| '/events'
| '/login'
| '/projects'
| '/recovery'
| '/register'
| '/sigs'
| '/dashboard/events'
Expand All @@ -239,6 +249,7 @@ export interface FileRouteTypes {
| '/events'
| '/login'
| '/projects'
| '/recovery'
| '/register'
| '/sigs'
| '/dashboard/events'
Expand All @@ -262,6 +273,7 @@ export interface FileRouteTypes {
| '/events'
| '/login'
| '/projects'
| '/recovery'
| '/register'
| '/sigs'
| '/dashboard/events'
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -484,6 +504,7 @@ const rootRouteChildren: RootRouteChildren = {
EventsRoute: EventsRoute,
LoginRoute: LoginRoute,
ProjectsRoute: ProjectsRoute,
RecoveryRoute: RecoveryRoute,
RegisterRoute: RegisterRoute,
SigsRoute: SigsRoute,
ProjectProjectIdRoute: ProjectProjectIdRoute,
Expand Down
10 changes: 9 additions & 1 deletion src/routes/dashboard/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { createFileRoute, redirect } from "@tanstack/react-router";

export const Route = createFileRoute("/dashboard/settings")({
beforeLoad: () => {
validateSearch: (search: Record<string, unknown>): { 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" },
Expand Down
53 changes: 51 additions & 2 deletions src/routes/login.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
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";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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<string, unknown>): { flow?: string; return_to?: string } => ({
Expand Down Expand Up @@ -130,13 +140,50 @@ 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<HTMLInputElement>) => {
const { name, value } = event.target;
form.setFieldValue(name as "email" | "password", value);
},
[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<HTMLFormElement>) => {
event.preventDefault();
Expand Down Expand Up @@ -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?"}
</Button>
</div>
<Input
Expand Down
Loading