feat: add authentication

This commit is contained in:
2025-03-01 23:22:18 +07:00
parent b53859ae23
commit f8e65ccc0e
21 changed files with 375 additions and 27 deletions
+13 -2
View File
@@ -1,8 +1,9 @@
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { lazy } from "react";
import { lazy, Suspense } from "react";
import AuthLayout from "@/components/layouts/auth-layout";
import MainLayout from "@/components/layouts/main-layout";
const LoginPage = lazy(() => import("@/pages/auth/login"));
const ClusterPage = lazy(() => import("@/pages/cluster/page"));
const HomePage = lazy(() => import("@/pages/home/page"));
const BucketsPage = lazy(() => import("@/pages/buckets/page"));
@@ -13,6 +14,12 @@ const router = createBrowserRouter([
{
path: "/auth",
Component: AuthLayout,
children: [
{
path: "login",
Component: LoginPage,
},
],
},
{
path: "/",
@@ -42,7 +49,11 @@ const router = createBrowserRouter([
]);
const Router = () => {
return <RouterProvider router={router} />;
return (
<Suspense>
<RouterProvider router={router} />
</Suspense>
);
};
export default Router;
+47 -14
View File
@@ -4,6 +4,7 @@ import {
HardDrive,
KeySquare,
LayoutDashboard,
LogOut,
Palette,
} from "lucide-react";
import { Dropdown, Menu } from "react-daisyui";
@@ -12,6 +13,10 @@ import Button from "../ui/button";
import { themes } from "@/app/themes";
import appStore from "@/stores/app-store";
import garageLogo from "@/assets/garage-logo.svg";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
const pages = [
{ icon: LayoutDashboard, title: "Dashboard", path: "/", exact: true },
@@ -22,6 +27,7 @@ const pages = [
const Sidebar = () => {
const { pathname } = useLocation();
const auth = useAuth();
return (
<aside className="bg-base-100 border-r border-base-300/30 w-[80%] md:w-[250px] flex flex-col items-stretch overflow-hidden h-full">
@@ -57,23 +63,50 @@ const Sidebar = () => {
})}
</Menu>
<Dropdown className="my-2 mx-4" vertical="top">
<Dropdown.Toggle button={false}>
<Button icon={Palette} color="ghost">
Theme
</Button>
</Dropdown.Toggle>
<div className="py-2 px-4 flex items-center gap-2">
<Dropdown vertical="top">
<Dropdown.Toggle button={false}>
<Button icon={Palette} color="ghost">
{!auth.isEnabled ? "Theme" : null}
</Button>
</Dropdown.Toggle>
<Dropdown.Menu className="max-h-[200px] overflow-y-auto">
{themes.map((theme) => (
<Dropdown.Item key={theme} onClick={() => appStore.setTheme(theme)}>
{ucfirst(theme)}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
<Dropdown.Menu className="max-h-[500px] overflow-y-auto">
{themes.map((theme) => (
<Dropdown.Item
key={theme}
onClick={() => appStore.setTheme(theme)}
>
{ucfirst(theme)}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
{auth.isEnabled ? <LogoutButton /> : null}
</div>
</aside>
);
};
const LogoutButton = () => {
const queryClient = useQueryClient();
const logout = useMutation({
mutationFn: () => api.post("/auth/logout"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
onError: (err) => {
toast.error(err?.message || "Unknown error");
},
});
return (
<Button className="flex-1" icon={LogOut} onClick={() => logout.mutate()}>
Logout
</Button>
);
};
export default Sidebar;
+18 -1
View File
@@ -1,5 +1,22 @@
import { useAuth } from "@/hooks/useAuth";
import { Navigate, Outlet } from "react-router-dom";
const AuthLayout = () => {
return <div>AuthLayout</div>;
const auth = useAuth();
if (auth.isLoading) {
return null;
}
if (auth.isAuthenticated) {
return <Navigate to="/" replace />;
}
return (
<div className="min-h-svh flex items-center justify-center">
<Outlet />
</div>
);
};
export default AuthLayout;
+11 -1
View File
@@ -1,15 +1,17 @@
import { PageContext } from "@/context/page-context";
import { Suspense, useContext, useEffect } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import Sidebar from "../containers/sidebar";
import { ArrowLeft, MenuIcon } from "lucide-react";
import Button from "../ui/button";
import { useDisclosure } from "@/hooks/useDisclosure";
import { Drawer } from "react-daisyui";
import { useAuth } from "@/hooks/useAuth";
const MainLayout = () => {
const sidebar = useDisclosure();
const { pathname } = useLocation();
const auth = useAuth();
useEffect(() => {
if (sidebar.isOpen) {
@@ -17,6 +19,14 @@ const MainLayout = () => {
}
}, [pathname]);
if (auth.isLoading) {
return null;
}
if (!auth.isAuthenticated) {
return <Navigate to="/auth/login" />;
}
return (
<Drawer
open={sidebar.isOpen}
+1
View File
@@ -4,6 +4,7 @@ import { Button as BaseButton } from "react-daisyui";
import { Link } from "react-router-dom";
type ButtonProps = ComponentPropsWithoutRef<typeof BaseButton> & {
type?: HTMLButtonElement["type"];
href?: string;
target?: "_blank" | "_self" | "_parent" | "_top";
icon?: LucideIcon;
+20
View File
@@ -0,0 +1,20 @@
import api from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
type AuthResponse = {
enabled: boolean;
authenticated: boolean;
};
export const useAuth = () => {
const { data, isLoading } = useQuery({
queryKey: ["auth"],
queryFn: () => api.get<AuthResponse>("/auth/status"),
retry: false,
});
return {
isLoading,
isEnabled: data?.enabled,
isAuthenticated: data?.authenticated,
};
};
+1
View File
@@ -27,6 +27,7 @@ const api = {
const res = await fetch(_url, {
...options,
credentials: "include",
headers: { ...headers, ...(options?.headers || {}) },
});
+21
View File
@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { loginSchema } from "./schema";
import api from "@/lib/api";
import { toast } from "sonner";
export const useLogin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: z.infer<typeof loginSchema>) => {
return api.post("/auth/login", { body });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
onError: (err) => {
toast.error(err?.message || "Unknown error");
},
});
};
+54
View File
@@ -0,0 +1,54 @@
import Button from "@/components/ui/button";
import { zodResolver } from "@hookform/resolvers/zod";
import { Card } from "react-daisyui";
import { useForm } from "react-hook-form";
import { loginSchema } from "./schema";
import { InputField } from "@/components/ui/input";
import { useLogin } from "./hooks";
export default function LoginPage() {
const form = useForm({
resolver: zodResolver(loginSchema),
defaultValues: { username: "", password: "" },
});
const login = useLogin();
return (
<form onSubmit={form.handleSubmit((v) => login.mutate(v))}>
<Card className="w-full max-w-md" bordered>
<Card.Body>
<Card.Title tag="h2">Login</Card.Title>
<p className="text-base-content/60">
Enter username and password below to log in to your account
</p>
<InputField
form={form}
name="username"
title="Username"
placeholder="Enter your username"
/>
<InputField
form={form}
name="password"
title="Password"
type="password"
placeholder="Enter your password"
/>
<Card.Actions className="mt-4">
<Button
type="submit"
color="primary"
className="w-full md:w-auto min-w-[100px]"
loading={login.isPending}
>
Login
</Button>
</Card.Actions>
</Card.Body>
</Card>
</form>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { z } from "zod";
export const loginSchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
});