feat: finalize username login

This commit is contained in:
Stavros
2025-05-09 22:55:52 +03:00
parent 41c63e5b49
commit 6453edede6
15 changed files with 217 additions and 55 deletions

View File

@@ -19,12 +19,14 @@
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.503.0", "lucide-react": "^0.503.0",
"next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.56.3", "react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.5.3", "react-router": "^7.5.3",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"zod": "^3.24.4", "zod": "^3.24.4",
@@ -785,6 +787,8 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -903,6 +907,8 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sonner": ["sonner@2.0.3", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],

View File

@@ -25,12 +25,14 @@
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.503.0", "lucide-react": "^0.503.0",
"next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.56.3", "react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.5.3", "react-router": "^7.5.3",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"zod": "^3.24.4" "zod": "^3.24.4"

View File

@@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { z } from "zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { import {
@@ -12,26 +11,21 @@ import {
FormMessage, FormMessage,
} from "../ui/form"; } from "../ui/form";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { loginSchema, LoginSchema } from "@/schemas/login-schema";
export const LoginForm = () => { interface Props {
onSubmit: (data: LoginSchema) => void;
loading?: boolean;
}
export const LoginForm = (props: Props) => {
const { onSubmit, loading } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const schema = z.object({ const form = useForm<LoginSchema>({
username: z.string(), resolver: zodResolver(loginSchema),
password: z.string(),
}); });
type LoginFormType = z.infer<typeof schema>;
const form = useForm<LoginFormType>({
resolver: zodResolver(schema),
});
const onSubmit = (data: LoginFormType) => {
// Handle login logic here
console.log("Login data:", data);
};
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}> <form onSubmit={form.handleSubmit(onSubmit)}>
@@ -42,7 +36,11 @@ export const LoginForm = () => {
<FormItem className="mb-4"> <FormItem className="mb-4">
<FormLabel>{t("loginUsername")}</FormLabel> <FormLabel>{t("loginUsername")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder={t("loginUsername")} {...field} /> <Input
placeholder={t("loginUsername")}
disabled={loading}
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -66,6 +64,7 @@ export const LoginForm = () => {
<Input <Input
placeholder={t("loginPassword")} placeholder={t("loginPassword")}
type="password" type="password"
disabled={loading}
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -73,7 +72,7 @@ export const LoginForm = () => {
</FormItem> </FormItem>
)} )}
/> />
<Button className="w-full" type="submit"> <Button className="w-full" type="submit" loading={loading}>
{t("loginSubmit")} {t("loginSubmit")}
</Button> </Button>
</form> </form>

View File

@@ -1,4 +1,3 @@
import { z } from "zod";
import { Form, FormControl, FormField, FormItem } from "../ui/form"; import { Form, FormControl, FormField, FormItem } from "../ui/form";
import { import {
InputOTP, InputOTP,
@@ -8,23 +7,19 @@ import {
} from "../ui/input-otp"; } from "../ui/input-otp";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { totpSchema, TotpSchema } from "@/schemas/totp-schema";
interface Props { interface Props {
formId: string; formId: string;
onSubmit: (code: FormValues) => void; onSubmit: (code: TotpSchema) => void;
loading?: boolean;
} }
const schema = z.object({
code: z.string(),
});
export type FormValues = z.infer<typeof schema>;
export const TotpForm = (props: Props) => { export const TotpForm = (props: Props) => {
const { formId, onSubmit } = props; const { formId, onSubmit, loading } = props;
const form = useForm<FormValues>({ const form = useForm<TotpSchema>({
resolver: zodResolver(schema), resolver: zodResolver(totpSchema),
}); });
return ( return (
@@ -36,7 +31,7 @@ export const TotpForm = (props: Props) => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<InputOTP maxLength={6} {...field}> <InputOTP maxLength={6} disabled={loading} {...field}>
<InputOTPGroup> <InputOTPGroup>
<InputOTPSlot index={0} /> <InputOTPSlot index={0} />
<InputOTPSlot index={1} /> <InputOTPSlot index={1} />

View File

@@ -3,6 +3,7 @@ import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -42,13 +43,28 @@ function Button({
variant, variant,
size, size,
asChild = false, asChild = false,
loading = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean; asChild?: boolean;
loading?: boolean;
}) { }) {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
if (loading) {
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
disabled
{...props}
>
<Loader2 className="animate-spin" />
</Comp>
);
}
return ( return (
<Comp <Comp
data-slot="button" data-slot="button"

View File

@@ -0,0 +1,23 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -15,6 +15,7 @@ import { UnauthorizedPage } from "./pages/unauthorized-page.tsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppContextProvider } from "./context/app-context.tsx"; import { AppContextProvider } from "./context/app-context.tsx";
import { UserContextProvider } from "./context/user-context.tsx"; import { UserContextProvider } from "./context/user-context.tsx";
import { Toaster } from "@/components/ui/sonner";
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@@ -52,6 +53,11 @@ const router = createBrowserRouter([
element: <UnauthorizedPage />, element: <UnauthorizedPage />,
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
}, },
{
path: "/error",
element: <ErrorPage />,
errorElement: <ErrorPage />,
},
{ {
path: "*", path: "*",
element: <NotFoundPage />, element: <NotFoundPage />,
@@ -68,6 +74,7 @@ createRoot(document.getElementById("root")!).render(
<UserContextProvider> <UserContextProvider>
<Layout> <Layout>
<RouterProvider router={router} /> <RouterProvider router={router} />
<Toaster />
</Layout> </Layout>
</UserContextProvider> </UserContextProvider>
</AppContextProvider> </AppContextProvider>

View File

@@ -20,24 +20,24 @@ export const ContinuePage = () => {
const { domain, disableContinue } = useAppContext(); const { domain, disableContinue } = useAppContext();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
if (!isLoggedIn) { if (!isLoggedIn) {
return <Navigate to="/login" />; return <Navigate to="/login" />;
} }
if (!redirectURI) { if (!redirectURI) {
return <Navigate to="/" />; return <Navigate to="/logout" />;
} }
if (!isValidUrl(redirectURI)) { if (!isValidUrl(redirectURI)) {
return <Navigate to="/" />; return <Navigate to="/logout" />;
} }
if (disableContinue) { if (disableContinue) {
window.location.href = redirectURI; window.location.href = redirectURI;
} }
const navigate = useNavigate();
const url = new URL(redirectURI); const url = new URL(redirectURI);
if (!url.hostname.includes(domain)) { if (!url.hostname.includes(domain)) {
@@ -60,12 +60,12 @@ export const ContinuePage = () => {
</CardHeader> </CardHeader>
<CardFooter className="flex flex-col items-stretch gap-2"> <CardFooter className="flex flex-col items-stretch gap-2">
<Button <Button
onClick={() => window.location.replace(redirectURI)} onClick={() => (window.location.href = redirectURI)}
variant="destructive" variant="destructive"
> >
{t("continueTitle")} {t("continueTitle")}
</Button> </Button>
<Button onClick={() => navigate("/")} variant="outline"> <Button onClick={() => navigate("/logout")} variant="outline">
{t("cancelTitle")} {t("cancelTitle")}
</Button> </Button>
</CardFooter> </CardFooter>
@@ -92,12 +92,12 @@ export const ContinuePage = () => {
</CardHeader> </CardHeader>
<CardFooter className="flex flex-col items-stretch gap-2"> <CardFooter className="flex flex-col items-stretch gap-2">
<Button <Button
onClick={() => window.location.replace(redirectURI)} onClick={() => (window.location.href = redirectURI)}
variant="warning" variant="warning"
> >
{t("continueTitle")} {t("continueTitle")}
</Button> </Button>
<Button onClick={() => navigate("/")} variant="outline"> <Button onClick={() => navigate("/logout")} variant="outline">
{t("cancelTitle")} {t("cancelTitle")}
</Button> </Button>
</CardFooter> </CardFooter>
@@ -112,7 +112,7 @@ export const ContinuePage = () => {
<CardDescription>{t("continueSubtitle")}</CardDescription> <CardDescription>{t("continueSubtitle")}</CardDescription>
</CardHeader> </CardHeader>
<CardFooter className="flex flex-col items-stretch"> <CardFooter className="flex flex-col items-stretch">
<Button onClick={() => window.location.replace(redirectURI)}> <Button onClick={() => (window.location.href = redirectURI)}>
{t("continueTitle")} {t("continueTitle")}
</Button> </Button>
</CardFooter> </CardFooter>

View File

@@ -12,19 +12,57 @@ import {
import { OAuthButton } from "@/components/ui/oauth-button"; import { OAuthButton } from "@/components/ui/oauth-button";
import { SeperatorWithChildren } from "@/components/ui/separator"; import { SeperatorWithChildren } from "@/components/ui/separator";
import { useAppContext } from "@/context/app-context"; import { useAppContext } from "@/context/app-context";
import { useUserContext } from "@/context/user-context";
import { LoginSchema } from "@/schemas/login-schema";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Navigate } from "react-router";
import { toast } from "sonner";
export const LoginPage = () => { export const LoginPage = () => {
const { configuredProviders, title } = useAppContext(); const searchParams = new URLSearchParams(window.location.search);
const redirectUri = searchParams.get("redirect_uri");
console.log("Configured providers:", configuredProviders); const { isLoggedIn } = useUserContext();
const { configuredProviders, title } = useAppContext();
const { t } = useTranslation(); const { t } = useTranslation();
if (isLoggedIn) {
return <Navigate to="/logout" />;
}
const oauthConfigured = const oauthConfigured =
configuredProviders.filter((provider) => provider !== "username").length > configuredProviders.filter((provider) => provider !== "username").length >
0; 0;
const userAuthConfigured = configuredProviders.includes("username"); const userAuthConfigured = configuredProviders.includes("username");
const loginMutation = useMutation({
mutationFn: (values: LoginSchema) => axios.post("/api/login", values),
mutationKey: ["login"],
onSuccess: (data) => {
if (data.data.totpPending) {
window.location.replace(`/totp?redirect_uri=${redirectUri}`);
return;
}
toast.success(t("loginSuccessTitle"), {
description: t("loginSuccessSubtitle"),
});
setTimeout(() => {
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
}, 500);
},
onError: (error: Error) => {
toast.error(t("loginFailTitle"), {
description: error.message.includes("429")
? t("loginFailRateLimit")
: t("loginFailSubtitle"),
});
},
});
return ( return (
<Card className="min-w-xs sm:min-w-sm"> <Card className="min-w-xs sm:min-w-sm">
<CardHeader> <CardHeader>
@@ -64,7 +102,12 @@ export const LoginPage = () => {
{userAuthConfigured && oauthConfigured && ( {userAuthConfigured && oauthConfigured && (
<SeperatorWithChildren>{t("loginDivider")}</SeperatorWithChildren> <SeperatorWithChildren>{t("loginDivider")}</SeperatorWithChildren>
)} )}
{userAuthConfigured && <LoginForm />} {userAuthConfigured && (
<LoginForm
onSubmit={(values) => loginMutation.mutate(values)}
loading={loginMutation.isPending}
/>
)}
{configuredProviders.length == 0 && ( {configuredProviders.length == 0 && (
<h3 className="text-center text-xl text-red-600"> <h3 className="text-center text-xl text-red-600">
{t("failedToFetchProvidersTitle")} {t("failedToFetchProvidersTitle")}

View File

@@ -9,8 +9,11 @@ import {
import { useAppContext } from "@/context/app-context"; import { useAppContext } from "@/context/app-context";
import { useUserContext } from "@/context/user-context"; import { useUserContext } from "@/context/user-context";
import { capitalize } from "@/lib/utils"; import { capitalize } from "@/lib/utils";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Navigate } from "react-router"; import { Navigate } from "react-router";
import { toast } from "sonner";
export const LogoutPage = () => { export const LogoutPage = () => {
const { provider, username, email, isLoggedIn } = useUserContext(); const { provider, username, email, isLoggedIn } = useUserContext();
@@ -21,6 +24,25 @@ export const LogoutPage = () => {
return <Navigate to="/login" />; return <Navigate to="/login" />;
} }
const logoutMutation = useMutation({
mutationFn: () => axios.post("/api/logout"),
mutationKey: ["logout"],
onSuccess: () => {
toast.success(t("logoutSuccessTitle"), {
description: t("logoutSuccessSubtitle"),
});
setTimeout(async () => {
window.location.replace("/login");
}, 500);
},
onError: () => {
toast.error(t("logoutFailTitle"), {
description: t("logoutFailSubtitle"),
});
},
});
return ( return (
<Card className="min-w-xs sm:min-w-sm"> <Card className="min-w-xs sm:min-w-sm">
<CardHeader> <CardHeader>
@@ -47,14 +69,19 @@ export const LogoutPage = () => {
code: <code />, code: <code />,
}} }}
values={{ values={{
username: username, username,
}} }}
/> />
)} )}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardFooter className="flex flex-col items-stretch"> <CardFooter className="flex flex-col items-stretch">
<Button>{t("logoutTitle")}</Button> <Button
loading={logoutMutation.isPending}
onClick={() => logoutMutation.mutate()}
>
{t("logoutTitle")}
</Button>
</CardFooter> </CardFooter>
</Card> </Card>
); );

View File

@@ -1,4 +1,4 @@
import { FormValues, TotpForm } from "@/components/auth/totp-form"; import { TotpForm } from "@/components/auth/totp-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -8,16 +8,40 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { TotpSchema } from "@/schemas/totp-schema";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { useId } from "react"; import { useId } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { toast } from "sonner";
export const TotpPage = () => { export const TotpPage = () => {
const searchParams = new URLSearchParams(window.location.search);
const redirectUri = searchParams.get("redirect_uri");
const { t } = useTranslation(); const { t } = useTranslation();
const formId = useId(); const formId = useId();
const navigate = useNavigate();
const onSubmit = (data: FormValues) => { const totpMutation = useMutation({
console.log("TOTP data:", data); mutationFn: (values: TotpSchema) => axios.post("/api/totp", values),
}; mutationKey: ["totp"],
onSuccess: () => {
toast.success(t("totpSuccessTitle"), {
description: t("totpSuccessSubtitle"),
});
setTimeout(() => {
navigate(`/continue?redirect_uri=${redirectUri}`);
}, 500);
},
onError: () => {
toast.error(t("totpFailTitle"), {
description: t("totpFailSubtitle"),
});
},
});
return ( return (
<Card className="min-w-xs sm:min-w-sm"> <Card className="min-w-xs sm:min-w-sm">
@@ -26,10 +50,14 @@ export const TotpPage = () => {
<CardDescription>{t("totpSubtitle")}</CardDescription> <CardDescription>{t("totpSubtitle")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col items-center"> <CardContent className="flex flex-col items-center">
<TotpForm formId={formId} onSubmit={onSubmit} /> <TotpForm
formId={formId}
onSubmit={(values) => totpMutation.mutate(values)}
loading={totpMutation.isPending}
/>
</CardContent> </CardContent>
<CardFooter className="flex flex-col items-stretch"> <CardFooter className="flex flex-col items-stretch">
<Button form={formId} type="submit"> <Button form={formId} type="submit" loading={totpMutation.isPending}>
{t("continueTitle")} {t("continueTitle")}
</Button> </Button>
</CardFooter> </CardFooter>

View File

@@ -16,12 +16,13 @@ export const UnauthorizedPage = () => {
const groupErr = searchParams.get("groupErr"); const groupErr = searchParams.get("groupErr");
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
if (!username) { if (!username) {
return <Navigate to="/" />; return <Navigate to="/" />;
} }
const navigate = useNavigate();
let i18nKey = "unaothorizedLoginSubtitle"; let i18nKey = "unaothorizedLoginSubtitle";
if (resource) { if (resource) {
@@ -44,8 +45,8 @@ export const UnauthorizedPage = () => {
code: <code />, code: <code />,
}} }}
values={{ values={{
username: username, username,
resource: resource, resource,
}} }}
/> />
</CardDescription> </CardDescription>

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const loginSchema = z.object({
username: z.string(),
password: z.string(),
});
export type LoginSchema = z.infer<typeof loginSchema>;

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
export const totpSchema = z.object({
code: z.string(),
});
export type TotpSchema = z.infer<typeof totpSchema>;

View File

@@ -179,7 +179,7 @@ func (h *Handlers) AuthHandler(c *gin.Context) {
} }
// We are using caddy/traefik so redirect // We are using caddy/traefik so redirect
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode())) c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
return return
} }
@@ -227,7 +227,7 @@ func (h *Handlers) AuthHandler(c *gin.Context) {
log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login") log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login")
// Redirect to login // Redirect to login
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", h.Config.AppURL, queries.Encode())) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", h.Config.AppURL, queries.Encode()))
} }
func (h *Handlers) LoginHandler(c *gin.Context) { func (h *Handlers) LoginHandler(c *gin.Context) {