mirror of
https://github.com/khairul169/garage-webui.git
synced 2025-04-27 22:39:31 +07:00
feat: add authentication
This commit is contained in:
parent
b53859ae23
commit
f8e65ccc0e
19
README.md
19
README.md
@ -144,6 +144,25 @@ However, if it fails to load, you can set these environment variables instead:
|
||||
- `S3_REGION`: S3 Region.
|
||||
- `S3_ENDPOINT_URL`: S3 Endpoint url.
|
||||
|
||||
### Authentication
|
||||
|
||||
Enable authentication by setting `AUTH_USER_PASS` environment variable. Generate the username and password hash using the following command:
|
||||
|
||||
```bash
|
||||
htpasswd -nbBC 10 "YOUR_USERNAME" "YOUR_PASSWORD"
|
||||
```
|
||||
|
||||
> If command 'htpasswd' is not found, install `apache2-utils` using your package manager.
|
||||
|
||||
Then update your `docker-compose.yml`:
|
||||
|
||||
```yml
|
||||
webui:
|
||||
....
|
||||
environment:
|
||||
AUTH_USER_PASS: "username:$2y$10$DSTi9o..."
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
Once your instance of Garage Web UI is started, you can open the web UI at http://your-ip:3909. You can place it behind a reverse proxy to secure it with SSL.
|
||||
|
@ -1,6 +1,8 @@
|
||||
module khairul169/garage-webui
|
||||
|
||||
go 1.22.5
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.4
|
||||
@ -13,6 +15,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alexedwards/scs/v2 v2.8.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect
|
||||
@ -21,4 +24,5 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect
|
||||
golang.org/x/crypto v0.35.0
|
||||
)
|
||||
|
@ -1,3 +1,5 @@
|
||||
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
|
||||
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 h1:70PVAiL15/aBMh5LThwgXdSQorVr91L127ttckI9QQU=
|
||||
@ -42,6 +44,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
@ -12,15 +12,18 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize app
|
||||
godotenv.Load()
|
||||
utils.InitCacheManager()
|
||||
sessionMgr := utils.InitSessionManager()
|
||||
|
||||
if err := utils.Garage.LoadConfig(); err != nil {
|
||||
log.Println("Cannot load garage config!", err)
|
||||
}
|
||||
|
||||
http.Handle("/api/", http.StripPrefix("/api", router.HandleApiRouter()))
|
||||
ui.ServeUI()
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/", http.StripPrefix("/api", router.HandleApiRouter()))
|
||||
ui.ServeUI(mux)
|
||||
|
||||
host := utils.GetEnv("HOST", "0.0.0.0")
|
||||
port := utils.GetEnv("PORT", "3909")
|
||||
@ -28,7 +31,7 @@ func main() {
|
||||
addr := fmt.Sprintf("%s:%s", host, port)
|
||||
log.Printf("Starting server on http://%s", addr)
|
||||
|
||||
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||
if err := http.ListenAndServe(addr, sessionMgr.LoadAndSave(mux)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
27
backend/middleware/auth.go
Normal file
27
backend/middleware/auth.go
Normal file
@ -0,0 +1,27 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"khairul169/garage-webui/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
authData := utils.GetEnv("AUTH_USER_PASS", "")
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := utils.Session.Get(r, "authenticated")
|
||||
|
||||
if authData == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if auth == nil || !auth.(bool) {
|
||||
utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
64
backend/router/auth.go
Normal file
64
backend/router/auth.go
Normal file
@ -0,0 +1,64 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"khairul169/garage-webui/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type Auth struct{}
|
||||
|
||||
func (c *Auth) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
utils.ResponseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
userPass := strings.Split(utils.GetEnv("AUTH_USER_PASS", ""), ":")
|
||||
if len(userPass) < 2 {
|
||||
utils.ResponseErrorStatus(w, errors.New("AUTH_USER_PASS not set"), 500)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(body.Username) != userPass[0] || bcrypt.CompareHashAndPassword([]byte(userPass[1]), []byte(body.Password)) != nil {
|
||||
utils.ResponseErrorStatus(w, errors.New("invalid username or password"), 401)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Session.Set(r, "authenticated", true)
|
||||
utils.ResponseSuccess(w, map[string]bool{
|
||||
"authenticated": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Auth) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
utils.Session.Clear(r)
|
||||
utils.ResponseSuccess(w, true)
|
||||
}
|
||||
|
||||
func (c *Auth) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
isAuthenticated := true
|
||||
authSession := utils.Session.Get(r, "authenticated")
|
||||
enabled := false
|
||||
|
||||
if utils.GetEnv("AUTH_USER_PASS", "") != "" {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
if authSession != nil && authSession.(bool) {
|
||||
isAuthenticated = true
|
||||
}
|
||||
|
||||
utils.ResponseSuccess(w, map[string]bool{
|
||||
"enabled": enabled,
|
||||
"authenticated": isAuthenticated,
|
||||
})
|
||||
}
|
@ -1,9 +1,19 @@
|
||||
package router
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"khairul169/garage-webui/middleware"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func HandleApiRouter() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
auth := &Auth{}
|
||||
mux.HandleFunc("POST /auth/login", auth.Login)
|
||||
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("POST /auth/logout", auth.Logout)
|
||||
router.HandleFunc("GET /auth/status", auth.GetStatus)
|
||||
|
||||
config := &Config{}
|
||||
router.HandleFunc("GET /config", config.GetAll)
|
||||
@ -17,7 +27,9 @@ func HandleApiRouter() *http.ServeMux {
|
||||
router.HandleFunc("PUT /browse/{bucket}/{key...}", browse.PutObject)
|
||||
router.HandleFunc("DELETE /browse/{bucket}/{key...}", browse.DeleteObject)
|
||||
|
||||
// Proxy request to garage api endpoint
|
||||
router.HandleFunc("/", ProxyHandler)
|
||||
|
||||
return router
|
||||
mux.Handle("/", middleware.AuthMiddleware(router))
|
||||
return mux
|
||||
}
|
||||
|
@ -3,4 +3,6 @@
|
||||
|
||||
package ui
|
||||
|
||||
func ServeUI() {}
|
||||
import "net/http"
|
||||
|
||||
func ServeUI(mux *http.ServeMux) {}
|
||||
|
@ -13,11 +13,11 @@ import (
|
||||
//go:embed dist
|
||||
var embeddedFs embed.FS
|
||||
|
||||
func ServeUI() {
|
||||
func ServeUI(mux *http.ServeMux) {
|
||||
distFs, _ := fs.Sub(embeddedFs, "dist")
|
||||
fileServer := http.FileServer(http.FS(distFs))
|
||||
|
||||
http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_path := path.Clean(r.URL.Path)[1:]
|
||||
|
||||
// Rewrite non-existing paths to index.html
|
||||
|
33
backend/utils/session.go
Normal file
33
backend/utils/session.go
Normal file
@ -0,0 +1,33 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
)
|
||||
|
||||
type SessionManager struct {
|
||||
mgr *scs.SessionManager
|
||||
}
|
||||
|
||||
var Session *SessionManager
|
||||
|
||||
func InitSessionManager() *scs.SessionManager {
|
||||
sessMgr := scs.New()
|
||||
sessMgr.Lifetime = 24 * time.Hour
|
||||
Session = &SessionManager{mgr: sessMgr}
|
||||
return sessMgr
|
||||
}
|
||||
|
||||
func (s *SessionManager) Get(r *http.Request, key string) interface{} {
|
||||
return s.mgr.Get(r.Context(), key)
|
||||
}
|
||||
|
||||
func (s *SessionManager) Set(r *http.Request, key string, value interface{}) {
|
||||
s.mgr.Put(r.Context(), key, value)
|
||||
}
|
||||
|
||||
func (s *SessionManager) Clear(r *http.Request) error {
|
||||
return s.mgr.Clear(r.Context())
|
||||
}
|
@ -47,5 +47,11 @@
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@swc/core",
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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
src/hooks/useAuth.ts
Normal file
20
src/hooks/useAuth.ts
Normal 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,
|
||||
};
|
||||
};
|
@ -27,6 +27,7 @@ const api = {
|
||||
|
||||
const res = await fetch(_url, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
headers: { ...headers, ...(options?.headers || {}) },
|
||||
});
|
||||
|
||||
|
21
src/pages/auth/hooks.ts
Normal file
21
src/pages/auth/hooks.ts
Normal 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
src/pages/auth/login.tsx
Normal file
54
src/pages/auth/login.tsx
Normal 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
src/pages/auth/schema.ts
Normal file
6
src/pages/auth/schema.ts
Normal 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"),
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user