mirror of
				https://github.com/steveiliop56/tinyauth.git
				synced 2025-10-30 21:55:43 +00:00 
			
		
		
		
	feat: finalize username login
This commit is contained in:
		| @@ -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=="], | ||||||
|   | |||||||
| @@ -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" | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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} /> | ||||||
|   | |||||||
| @@ -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" | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								frontend/src/components/ui/sonner.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/src/components/ui/sonner.tsx
									
									
									
									
									
										Normal 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 } | ||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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")} | ||||||
|   | |||||||
| @@ -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> | ||||||
|   ); |   ); | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
							
								
								
									
										8
									
								
								frontend/src/schemas/login-schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/src/schemas/login-schema.ts
									
									
									
									
									
										Normal 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>; | ||||||
							
								
								
									
										7
									
								
								frontend/src/schemas/totp-schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/schemas/totp-schema.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | import { z } from "zod"; | ||||||
|  |  | ||||||
|  | export const totpSchema = z.object({ | ||||||
|  |   code: z.string(), | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export type TotpSchema = z.infer<typeof totpSchema>; | ||||||
| @@ -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) { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Stavros
					Stavros