feat: add login
This commit is contained in:
parent
5413244e60
commit
295f3ecd14
15 changed files with 559 additions and 13 deletions
45
app.go
45
app.go
|
@ -1,8 +1,10 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"git.wh64.net/devproje/kuma-archive/config"
|
||||
"git.wh64.net/devproje/kuma-archive/internal/routes"
|
||||
|
@ -11,6 +13,7 @@ import (
|
|||
"github.com/devproje/commando/option"
|
||||
"github.com/devproje/commando/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -40,6 +43,9 @@ func main() {
|
|||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// init auth module
|
||||
service.NewAuthService()
|
||||
|
||||
gin := gin.Default()
|
||||
routes.New(gin, ver, apiOnly)
|
||||
|
||||
|
@ -65,6 +71,45 @@ func main() {
|
|||
return nil
|
||||
})
|
||||
|
||||
command.ComplexRoot("account", "file server account manager", []commando.Node{
|
||||
command.Then("create", "create account", func(n *commando.Node) error {
|
||||
var username, password string
|
||||
|
||||
fmt.Print("new username: ")
|
||||
if _, err := fmt.Scanln(&username); err != nil {
|
||||
return fmt.Errorf("failed to read username: %v", err)
|
||||
}
|
||||
|
||||
fmt.Print("new password: ")
|
||||
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %v", err)
|
||||
}
|
||||
password = string(bytePassword)
|
||||
fmt.Println()
|
||||
|
||||
fmt.Print("type new password one more time: ")
|
||||
checkByte, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %v", err)
|
||||
}
|
||||
check := string(checkByte)
|
||||
fmt.Println()
|
||||
|
||||
if password != check {
|
||||
return errors.New("password check is not correct")
|
||||
}
|
||||
|
||||
auth := service.NewAuthService()
|
||||
if err := auth.Create(&service.Account{Username: username, Password: password}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Account for %s created successfully\n", username)
|
||||
return nil
|
||||
}),
|
||||
})
|
||||
|
||||
if err := command.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
1
go.mod
1
go.mod
|
@ -33,6 +33,7 @@ require (
|
|||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/net v0.37.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/term v0.30.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
|
2
go.sum
2
go.sum
|
@ -85,6 +85,8 @@ golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
|||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
|
|
|
@ -106,6 +106,38 @@ func New(app *gin.Engine, version *service.Version, apiOnly bool) {
|
|||
ctx.FileAttachment(data.Path, data.Name)
|
||||
})
|
||||
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", func(ctx *gin.Context) {
|
||||
auth := service.NewAuthService()
|
||||
username := ctx.PostForm("username")
|
||||
password := ctx.PostForm("password")
|
||||
|
||||
acc, err := auth.Read(username)
|
||||
if err != nil {
|
||||
ctx.JSON(401, gin.H{
|
||||
"ok": 0,
|
||||
"errno": "username or password not invalid",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := auth.Verify(username, password)
|
||||
if err != nil || !ok {
|
||||
ctx.JSON(401, gin.H{
|
||||
"ok": 0,
|
||||
"errno": "username or password not invalid",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(200, gin.H{
|
||||
"ok": 1,
|
||||
"token": auth.Token(acc.Username, acc.Password),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
api.GET("/version", func(ctx *gin.Context) {
|
||||
ctx.String(200, "%s", version.String())
|
||||
})
|
||||
|
|
168
internal/service/auth.go
Normal file
168
internal/service/auth.go
Normal file
|
@ -0,0 +1,168 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AuthService struct{}
|
||||
|
||||
type Account struct {
|
||||
Username string
|
||||
Password string
|
||||
Salt string
|
||||
}
|
||||
|
||||
func NewAuthService() *AuthService {
|
||||
return &AuthService{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
db, err := Open()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
stmt, err := db.Prepare(strings.TrimSpace(`
|
||||
create table Account(
|
||||
username varchar(25),
|
||||
password varchar(255),
|
||||
salt varchar(50),
|
||||
primary key (username)
|
||||
);
|
||||
`))
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
if _, err = stmt.Exec(); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) Create(data *Account) error {
|
||||
db, err := Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
stmt, err := db.Prepare("insert into Account values(?, ?, ?);")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
salt := genSalt()
|
||||
if _, err = stmt.Exec(data.Username, encrypt(data.Password, salt), salt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Read(username string) (*Account, error) {
|
||||
db, err := Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
stmt, err := db.Prepare("select * from Account where username = ?;")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
var account Account
|
||||
if err := stmt.QueryRow(username).Scan(&account.Username, &account.Password, &account.Salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Update(username, password string) error {
|
||||
db, err := Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
stmt, err := db.Prepare("update Account set password = ?, salt = ? where username = ?;")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
salt := genSalt()
|
||||
if _, err = stmt.Exec(encrypt(password, salt), salt, username); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Delete(username string) error {
|
||||
db, err := Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
stmt, err := db.Prepare("delete from Account where username = ?;")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
if _, err = stmt.Exec(username); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Verify(username, password string) (bool, error) {
|
||||
account, err := s.Read(username)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if encrypt(password, account.Salt) == account.Password {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Token(username, password string) string {
|
||||
raw := fmt.Sprintf("%s:%s", username, password)
|
||||
return base64.StdEncoding.EncodeToString([]byte(raw))
|
||||
}
|
||||
|
||||
func encrypt(password, salt string) string {
|
||||
hash := sha256.New()
|
||||
hash.Write([]byte(password + salt))
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
func genSalt() string {
|
||||
b := make([]byte, 16)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
|
@ -2,11 +2,12 @@ package service
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
|
||||
"git.wh64.net/devproje/kuma-archive/config"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func Open() (*sql.DB, error) {
|
||||
return sql.Open("sqlite3", (config.ROOT_DIRECTORY))
|
||||
return sql.Open("sqlite3", filepath.Join(config.ROOT_DIRECTORY, "data.db"))
|
||||
}
|
||||
|
|
27
src/App.scss
27
src/App.scss
|
@ -31,9 +31,36 @@
|
|||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
.link {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.login-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
display: flex;
|
||||
margin-left: 0.5rem;
|
||||
align-items: center;
|
||||
border-radius: 25px;
|
||||
padding: 0.45rem 1rem;
|
||||
justify-content: center;
|
||||
transition-duration: 300ms;
|
||||
background-color: var(--nav-color);
|
||||
|
||||
&:hover {
|
||||
transition-duration: 300ms;
|
||||
background-color: var(--nav-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
51
src/App.tsx
51
src/App.tsx
|
@ -9,18 +9,33 @@ import { BrowserRouter, Route, Routes, useLocation } from "react-router";
|
|||
import "./App.scss";
|
||||
import kuma from "./assets/kuma.png";
|
||||
import NotFound from "./components/notfound";
|
||||
import Login from "./components/login";
|
||||
import { useAuthStore } from "./store/auth";
|
||||
import Logout from "./components/logout";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path={"*"} element={<Dashboard />} />
|
||||
<Route path="/login" element={<Dashboard children={<Login />} />} />
|
||||
<Route path="/logout" element={<Logout />} />
|
||||
<Route path={"*"} element={<Dashboard children={<View />} />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
function Dashboard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="container-md ka-view">
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function View() {
|
||||
const path = usePath();
|
||||
const location = useLocation();
|
||||
const [load, setLoad] = useState(false);
|
||||
|
@ -39,16 +54,20 @@ function Dashboard() {
|
|||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container-md ka-view">
|
||||
<Header />
|
||||
{typeof path.data !== "undefined" ? path.data.is_dir ? <Directory /> : <FileView /> : <NotFound />}
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
if (typeof path.data === "undefined") {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
if (path.data.is_dir) {
|
||||
return <Directory />;
|
||||
}
|
||||
|
||||
return <FileView />;
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const auth = useAuthStore();
|
||||
|
||||
return (
|
||||
<nav className="ka-nav">
|
||||
<a className="title" href="/">
|
||||
|
@ -63,6 +82,20 @@ function Header() {
|
|||
<a className="link" href="https://projecttl.net">
|
||||
<DynamicIcon name="globe" size={15} />
|
||||
</a>
|
||||
|
||||
{!auth.token ? (
|
||||
<a className="login-btn" href="/login">
|
||||
Login
|
||||
</a>
|
||||
) : (
|
||||
<div className="login-info">
|
||||
<span>Logged in as Admin</span>
|
||||
<a className="login-btn" href="/logout">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
#download {
|
||||
margin: 1rem 0;
|
||||
|
||||
a {
|
||||
.download-btn {
|
||||
margin: 0 5px;
|
||||
span {
|
||||
margin-left: 5px;
|
||||
|
|
|
@ -107,7 +107,7 @@ function FileView() {
|
|||
{convert(path.data.total)}
|
||||
|
||||
<div id="download">
|
||||
<button className="primary" id="download" onClick={ev => {
|
||||
<button className="download-btn secondary" onClick={ev => {
|
||||
ev.preventDefault();
|
||||
const link = document.createElement("a");
|
||||
link.href = `/api/download${path.data?.path}`;
|
||||
|
|
100
src/components/login/index.tsx
Normal file
100
src/components/login/index.tsx
Normal file
|
@ -0,0 +1,100 @@
|
|||
import { useRef, useState } from "react";
|
||||
|
||||
import "./login.scss";
|
||||
import project from "../../assets/kuma.png";
|
||||
import { DynamicIcon } from "lucide-react/dynamic";
|
||||
import { AuthData, useAuthStore } from "../../store/auth";
|
||||
|
||||
function Login() {
|
||||
const [show, setShow] = useState(false);
|
||||
const auth = useAuthStore();
|
||||
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const errnoRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (auth.token !== null)
|
||||
document.location.href = "/";
|
||||
|
||||
return (
|
||||
<div className="ka-login">
|
||||
<form className="login-form">
|
||||
<div className="logo">
|
||||
<img src={project} />
|
||||
<h1>Kuma Archive Login</h1>
|
||||
</div>
|
||||
|
||||
<div className="input-area">
|
||||
<div className="input">
|
||||
<div className="input-icon">
|
||||
<DynamicIcon name="user" size={20} />
|
||||
</div>
|
||||
<input ref={usernameRef} placeholder="Username" type="text" required />
|
||||
<div className="dummy"></div>
|
||||
</div>
|
||||
<div className="input">
|
||||
<div className="input-icon">
|
||||
<DynamicIcon name="key-round" size={20} />
|
||||
</div>
|
||||
<input ref={passwordRef} placeholder="Password" type={show ? "text" : "password"} required />
|
||||
<a onClick={ev => {
|
||||
ev.preventDefault();
|
||||
setShow(!show);
|
||||
}}>
|
||||
<DynamicIcon name={show ? "eye-off" : "eye"} size={20} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="submit-area">
|
||||
<span className="errno" ref={errnoRef}></span>
|
||||
|
||||
<button type="submit" className="login-btn success" onClick={ev => {
|
||||
ev.preventDefault();
|
||||
const username = usernameRef.current?.value;
|
||||
const password = passwordRef.current?.value;
|
||||
|
||||
if (!errnoRef)
|
||||
return;
|
||||
|
||||
if (!username || !password) {
|
||||
alert("username or password is empty!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (username === "" || password === "") {
|
||||
alert("username or password is empty!");
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.append("username", username);
|
||||
form.append("password", password);
|
||||
|
||||
errnoRef.current!.innerText = "";
|
||||
|
||||
fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: form
|
||||
}).then((res) => {
|
||||
if (res.status !== 200) {
|
||||
errnoRef.current!.innerText = "username or password is not invalid";
|
||||
return;
|
||||
}
|
||||
|
||||
res.json().then((data: AuthData) => {
|
||||
auth.setToken(data.token);
|
||||
window.location.href = "/";
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<DynamicIcon name="log-in" size={20} />
|
||||
<span>Login</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
98
src/components/login/login.scss
Normal file
98
src/components/login/login.scss
Normal file
|
@ -0,0 +1,98 @@
|
|||
.ka-login {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin: 2rem 0;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.login-form {
|
||||
width: 400px;
|
||||
height: 500px;
|
||||
display: flex;
|
||||
padding: 4rem 2rem;
|
||||
border-radius: 15px;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background-color: var(--nav-color);
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-area {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
|
||||
input {
|
||||
border-radius: 0 25px 25px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
padding: 0 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 25px 0 0 25px;
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
margin-bottom: 5px;
|
||||
|
||||
input {
|
||||
padding: 0.25rem 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
a, .dummy {
|
||||
display: flex;
|
||||
min-width: 50px;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0 25px 25px 0;
|
||||
background-color: var(--background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submit-area {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.errno {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
src/components/logout/index.tsx
Normal file
11
src/components/logout/index.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { useAuthStore } from "../../store/auth";
|
||||
|
||||
function Logout() {
|
||||
const auth = useAuthStore();
|
||||
auth.clearToken();
|
||||
|
||||
document.location.href = "/";
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export default Logout;
|
|
@ -7,7 +7,7 @@ function NotFound() {
|
|||
<DynamicIcon className="icon" name="file-question" size={120} />
|
||||
<h1>404 Not Found</h1>
|
||||
|
||||
<button className="primary" onClick={ev => {
|
||||
<button className="secondary" onClick={ev => {
|
||||
ev.preventDefault();
|
||||
document.location.href = "/";
|
||||
}}>Back to home</button>
|
||||
|
|
28
src/store/auth.ts
Normal file
28
src/store/auth.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export interface AuthData {
|
||||
ok: number;
|
||||
token: string;
|
||||
}
|
||||
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
setToken: (token: string) => void;
|
||||
clearToken: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
setToken: (token) => set({ token }),
|
||||
clearToken: () => set({ token: null }),
|
||||
}),
|
||||
{
|
||||
name: "auth-storage"
|
||||
}
|
||||
)
|
||||
);
|
||||
|
Loading…
Reference in a new issue