feat: add totp logic and ui

This commit is contained in:
Stavros
2025-03-06 19:22:12 +02:00
parent 61f4848f20
commit bd7a140676
11 changed files with 278 additions and 51 deletions

View File

@@ -0,0 +1,40 @@
import { Button, PinInput } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod";
const schema = z.object({
code: z.string(),
});
type FormValues = z.infer<typeof schema>;
interface TotpFormProps {
onSubmit: (values: FormValues) => void;
isLoading: boolean;
}
export const TotpForm = (props: TotpFormProps) => {
const { onSubmit, isLoading } = props;
const form = useForm({
mode: "uncontrolled",
initialValues: {
code: "",
},
validate: zodResolver(schema),
});
return (
<form onSubmit={form.onSubmit(onSubmit)}>
<PinInput
length={6}
type={"number"}
placeholder=""
{...form.getInputProps("code")}
/>
<Button type="submit" mt="xl" loading={isLoading} fullWidth>
Verify
</Button>
</form>
);
};

View File

@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import React, { createContext, useContext } from "react";
import { UserContextSchemaType } from "../schemas/user-context-schema";
import axios from "axios";
import { UserContextSchemaType } from "../schemas/user-context-schema";
const UserContext = createContext<UserContextSchemaType | null>(null);
@@ -15,7 +15,7 @@ export const UserContextProvider = ({
isLoading,
error,
} = useQuery({
queryKey: ["isLoggedIn"],
queryKey: ["userContext"],
queryFn: async () => {
const res = await axios.get("/api/status");
return res.data;

View File

@@ -15,6 +15,7 @@ import { ContinuePage } from "./pages/continue-page.tsx";
import { NotFoundPage } from "./pages/not-found-page.tsx";
import { UnauthorizedPage } from "./pages/unauthorized-page.tsx";
import { InternalServerError } from "./pages/internal-server-error.tsx";
import { TotpPage } from "./pages/totp-page.tsx";
const queryClient = new QueryClient({
defaultOptions: {
@@ -34,6 +35,7 @@ createRoot(document.getElementById("root")!).render(
<Routes>
<Route path="/" element={<App />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/totp" element={<TotpPage />} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/continue" element={<ContinuePage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />

View File

@@ -5,10 +5,10 @@ import axios from "axios";
import { useUserContext } from "../context/user-context";
import { Navigate } from "react-router";
import { Layout } from "../components/layouts/layout";
import { isQueryValid } from "../utils/utils";
import { OAuthButtons } from "../components/auth/oauth-buttons";
import { LoginFormValues } from "../schemas/login-schema";
import { LoginForm } from "../components/auth/login-forn";
import { isQueryValid } from "../utils/utils";
export const LoginPage = () => {
const queryString = window.location.search;
@@ -37,18 +37,25 @@ export const LoginPage = () => {
color: "red",
});
},
onSuccess: () => {
onSuccess: async (data) => {
if (data.data.totpPending) {
window.location.replace(`/totp?redirect_uri=${redirectUri}`);
return;
}
notifications.show({
title: "Logged in",
message: "Welcome back!",
color: "green",
});
setTimeout(() => {
if (!isQueryValid(redirectUri)) {
window.location.replace("/");
} else {
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
return;
}
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
}, 500);
},
});

View File

@@ -0,0 +1,62 @@
import { Navigate } from "react-router";
import { useUserContext } from "../context/user-context";
import { Title, Paper, Text } from "@mantine/core";
import { Layout } from "../components/layouts/layout";
import { TotpForm } from "../components/auth/totp-form";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { notifications } from "@mantine/notifications";
export const TotpPage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri") ?? "";
const { totpPending, isLoggedIn, title } = useUserContext();
if (isLoggedIn) {
return <Navigate to={`/logout`} />;
}
if (!totpPending) {
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
}
const totpMutation = useMutation({
mutationFn: async (totp: { code: string }) => {
await axios.post("/api/totp", totp);
},
onError: () => {
notifications.show({
title: "Failed to verify code",
message: "Please try again",
color: "red",
});
},
onSuccess: () => {
notifications.show({
title: "Verified",
message: "Redirecting to your app",
color: "green",
});
setTimeout(() => {
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
}, 500);
},
});
return (
<Layout>
<Title ta="center">{title}</Title>
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
<Text size="lg" fw={500} mb="md" ta="center">
Enter your TOTP code
</Text>
<TotpForm
isLoading={totpMutation.isLoading}
onSubmit={(values) => totpMutation.mutate(values)}
/>
</Paper>
</Layout>
);
};

View File

@@ -9,6 +9,7 @@ export const userContextSchema = z.object({
disableContinue: z.boolean(),
title: z.string(),
genericName: z.string(),
totpPending: z.boolean(),
});
export type UserContextSchemaType = z.infer<typeof userContextSchema>;