mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-10-29 21:25: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