Compare commits
6 Commits
8d6f4a4c52
...
86a684790c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a684790c | ||
|
|
ff27493670 | ||
|
|
70129baad5 | ||
|
|
c373d82135 | ||
|
|
be0b13acbf | ||
|
|
e50f81f7f3 |
@@ -6,6 +6,9 @@ POSTGRES_DB=delivery_tracker
|
|||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET=your_random_jwt_secret_min_32_chars
|
JWT_SECRET=your_random_jwt_secret_min_32_chars
|
||||||
|
|
||||||
|
# Seed admin password
|
||||||
|
SEED_ADMIN_PASSWORD=your_secure_password_here
|
||||||
|
|
||||||
# Gitea Registry credentials for Watchtower
|
# Gitea Registry credentials for Watchtower
|
||||||
GITEA_REGISTRY=gitea.your-domain.com/yourusername
|
GITEA_REGISTRY=gitea.your-domain.com/yourusername
|
||||||
GITEA_USER=your_gitea_username
|
GITEA_USER=your_gitea_username
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,6 +17,8 @@ dist-ssr
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
backend/.env
|
||||||
|
backend/.env.local
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage
|
coverage
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/chedius/delivery-tracker/internal/auth"
|
||||||
db "github.com/chedius/delivery-tracker/internal/db/sqlc"
|
db "github.com/chedius/delivery-tracker/internal/db/sqlc"
|
||||||
"github.com/chedius/delivery-tracker/internal/delivery"
|
"github.com/chedius/delivery-tracker/internal/delivery"
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
@@ -15,6 +16,19 @@ import (
|
|||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func initAuth(queries *db.Queries) (*auth.Service, *auth.Handler) {
|
||||||
|
secret := []byte(os.Getenv("JWT_SECRET"))
|
||||||
|
expiry := 24 * time.Hour
|
||||||
|
|
||||||
|
if len(secret) == 0 {
|
||||||
|
log.Fatal("JWT_SECRET not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
service := auth.New(queries, secret, expiry)
|
||||||
|
handler := auth.NewHandler(service)
|
||||||
|
return service, handler
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
godotenv.Load()
|
godotenv.Load()
|
||||||
@@ -27,6 +41,7 @@ func main() {
|
|||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
queries := db.New(pool)
|
queries := db.New(pool)
|
||||||
|
_, authHandler := initAuth(queries)
|
||||||
h := delivery.NewHandler(queries)
|
h := delivery.NewHandler(queries)
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
@@ -44,13 +59,20 @@ func main() {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|
||||||
r.GET("/api/deliveries", h.GetDeliveries)
|
r.POST("/api/auth/register", authHandler.Register)
|
||||||
r.GET("/api/deliveries/:id", h.GetDeliveryByID)
|
r.POST("/api/auth/login", authHandler.Login)
|
||||||
r.GET("/api/deliveries/count", h.GetDeliveryCount)
|
|
||||||
r.POST("/api/deliveries", h.CreateDelivery)
|
authorized := r.Group("/api")
|
||||||
r.PATCH("/api/deliveries/:id", h.UpdateDelivery)
|
authorized.Use(auth.AuthMiddleware([]byte(os.Getenv("JWT_SECRET"))))
|
||||||
r.PATCH("/api/deliveries/:id/status", h.UpdateDeliveryStatus)
|
{
|
||||||
r.DELETE("/api/deliveries/:id", h.DeleteDelivery)
|
authorized.GET("/deliveries", h.GetDeliveries)
|
||||||
|
authorized.GET("/deliveries/:id", h.GetDeliveryByID)
|
||||||
|
authorized.GET("/deliveries/count", h.GetDeliveryCount)
|
||||||
|
authorized.POST("/deliveries", h.CreateDelivery)
|
||||||
|
authorized.PATCH("/deliveries/:id", h.UpdateDelivery)
|
||||||
|
authorized.PATCH("/deliveries/:id/status", h.UpdateDeliveryStatus)
|
||||||
|
authorized.DELETE("/deliveries/:id", h.DeleteDelivery)
|
||||||
|
}
|
||||||
|
|
||||||
r.Run(":8080")
|
r.Run(":8080")
|
||||||
}
|
}
|
||||||
|
|||||||
51
backend/cmd/seed/main.go
Normal file
51
backend/cmd/seed/main.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/chedius/delivery-tracker/internal/auth"
|
||||||
|
db "github.com/chedius/delivery-tracker/internal/db/sqlc"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
godotenv.Load()
|
||||||
|
|
||||||
|
dsn := os.Getenv("DATABASE_URL")
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("db connect: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
queries := db.New(pool)
|
||||||
|
|
||||||
|
_, err = queries.GetUserByUsername(ctx, "admin")
|
||||||
|
if err == nil {
|
||||||
|
log.Println("admin user already exists, skipping seed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := []byte(os.Getenv("JWT_SECRET"))
|
||||||
|
if len(secret) == 0 {
|
||||||
|
log.Fatalf("JWT_SECRET not set")
|
||||||
|
}
|
||||||
|
authService := auth.New(queries, secret, 0)
|
||||||
|
|
||||||
|
password := os.Getenv("SEED_ADMIN_PASSWORD")
|
||||||
|
if password == "" {
|
||||||
|
password = "admin123" // ⚠️ только для dev!
|
||||||
|
}
|
||||||
|
|
||||||
|
user, token, err := authService.Register(ctx, "admin", password)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("created admin user: id=%s, username=%s", user.ID, user.Username)
|
||||||
|
log.Printf("token: %s", token)
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
@@ -41,7 +43,7 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
golang.org/x/arch v0.23.0 // indirect
|
golang.org/x/arch v0.23.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
|||||||
12
backend/internal/auth/errors.go
Normal file
12
backend/internal/auth/errors.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
ErrUserExists = errors.New("user already exists")
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrPasswordMismatch = errors.New("passwords do not match")
|
||||||
|
ErrCredentialsEmpty = errors.New("username and password cannot be empty")
|
||||||
|
ErrPasswordTooShort = errors.New("password must be at least 6 characters long")
|
||||||
|
)
|
||||||
77
backend/internal/auth/handler.go
Normal file
77
backend/internal/auth/handler.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
authService *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(authService *Service) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
authService: authService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||||
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Login(c *gin.Context) {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.authService.Login(c.Request.Context(), req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case ErrUserNotFound, ErrInvalidCredentials:
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Register(c *gin.Context) {
|
||||||
|
var req RegisterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, token, err := h.authService.Register(c.Request.Context(), req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case ErrUserExists:
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "user already exists"})
|
||||||
|
case ErrPasswordTooShort:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "password too short"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"user": gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
},
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
}
|
||||||
50
backend/internal/auth/jwt.go
Normal file
50
backend/internal/auth/jwt.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateToken(userID uuid.UUID, secret []byte, expiry time.Duration) (string, error) {
|
||||||
|
if userID == uuid.Nil {
|
||||||
|
return "", errors.New("user ID cannot be nil")
|
||||||
|
}
|
||||||
|
if secret == nil {
|
||||||
|
return "", errors.New("JWT secret not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseToken(tokenString string, secret []byte) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return secret, nil
|
||||||
|
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("invalid token claims")
|
||||||
|
}
|
||||||
36
backend/internal/auth/middleware.go
Normal file
36
backend/internal/auth/middleware.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AuthMiddleware(secret []byte) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if tokenString == authHeader {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := ParseToken(tokenString, secret)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("userID", claims.UserID)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
98
backend/internal/auth/service.go
Normal file
98
backend/internal/auth/service.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sqlc "github.com/chedius/delivery-tracker/internal/db/sqlc"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
queries *sqlc.Queries
|
||||||
|
secret []byte
|
||||||
|
expiry time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
// Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(queries *sqlc.Queries, secret []byte, expiry time.Duration) *Service {
|
||||||
|
return &Service{queries, secret, expiry}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Register(ctx context.Context, username, password string) (User, string, error) {
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return User{}, "", ErrCredentialsEmpty
|
||||||
|
}
|
||||||
|
if _, err := s.queries.GetUserByUsername(ctx, username); err == nil {
|
||||||
|
return User{}, "", ErrUserExists
|
||||||
|
}
|
||||||
|
if len(password) < 6 {
|
||||||
|
return User{}, "", ErrPasswordTooShort
|
||||||
|
}
|
||||||
|
hashedPassword, err := s.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.queries.CreateUser(ctx, sqlc.CreateUserParams{
|
||||||
|
Username: username,
|
||||||
|
PasswordHash: hashedPassword,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return User{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := GenerateToken(uuid.UUID(user.ID.Bytes), s.secret, s.expiry)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return User{
|
||||||
|
ID: user.ID.String(),
|
||||||
|
Username: user.Username,
|
||||||
|
}, token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Login(ctx context.Context, username, password string) (string, error) { // returns JWT
|
||||||
|
user, err := s.queries.GetUserByUsername(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrUserNotFound
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.VerifyPassword(user.PasswordHash, password) {
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := GenerateToken(uuid.UUID(user.ID.Bytes), s.secret, s.expiry)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HashPassword(password string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
func (s *Service) VerifyPassword(hash, password string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
-- name: CreateUser :one
|
||||||
|
INSERT INTO users (username, password_hash)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetUserByUsername :one
|
||||||
|
SELECT * FROM users WHERE username = $1;
|
||||||
|
|
||||||
-- name: GetDeliveriesByDate :many
|
-- name: GetDeliveriesByDate :many
|
||||||
SELECT * FROM deliveries WHERE date = $1;
|
SELECT * FROM deliveries WHERE date = $1;
|
||||||
|
|
||||||
@@ -16,7 +24,9 @@ DELETE FROM deliveries WHERE id = $1;
|
|||||||
UPDATE deliveries SET date = $1, pickup_location = $2, product_name = $3, address = $4, phone = $5, additional_phone = $6, has_elevator = $7, comment = $8, updated_at = NOW() WHERE id = $9;
|
UPDATE deliveries SET date = $1, pickup_location = $2, product_name = $3, address = $4, phone = $5, additional_phone = $6, has_elevator = $7, comment = $8, updated_at = NOW() WHERE id = $9;
|
||||||
|
|
||||||
-- name: GetDeliveryCount :many
|
-- name: GetDeliveryCount :many
|
||||||
SELECT COUNT(*) as count, date FROM deliveries WHERE date >= DATE_TRUNC('month', CURRENT_DATE) GROUP BY date;
|
SELECT COUNT(*) as count, date FROM deliveries
|
||||||
|
WHERE date >= CURRENT_DATE AND date < CURRENT_DATE + INTERVAL '7 days'
|
||||||
|
GROUP BY date;
|
||||||
|
|
||||||
-- name: UpdateDeliveryStatus :exec
|
-- name: UpdateDeliveryStatus :exec
|
||||||
UPDATE deliveries SET status = $1, updated_at = NOW() WHERE id = $2;
|
UPDATE deliveries SET status = $1, updated_at = NOW() WHERE id = $2;
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import (
|
|||||||
|
|
||||||
type Querier interface {
|
type Querier interface {
|
||||||
CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (Delivery, error)
|
CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (Delivery, error)
|
||||||
|
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
|
||||||
DeleteDelivery(ctx context.Context, id pgtype.UUID) error
|
DeleteDelivery(ctx context.Context, id pgtype.UUID) error
|
||||||
GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]Delivery, error)
|
GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]Delivery, error)
|
||||||
GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error)
|
GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error)
|
||||||
GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error)
|
GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error)
|
||||||
|
GetUserByUsername(ctx context.Context, username string) (User, error)
|
||||||
UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error
|
UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error
|
||||||
UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error
|
UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,29 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createUser = `-- name: CreateUser :one
|
||||||
|
INSERT INTO users (username, password_hash)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING id, username, password_hash, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateUserParams struct {
|
||||||
|
Username string `db:"username" json:"username"`
|
||||||
|
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createUser, arg.Username, arg.PasswordHash)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Username,
|
||||||
|
&i.PasswordHash,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const deleteDelivery = `-- name: DeleteDelivery :exec
|
const deleteDelivery = `-- name: DeleteDelivery :exec
|
||||||
DELETE FROM deliveries WHERE id = $1
|
DELETE FROM deliveries WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -128,7 +151,9 @@ func (q *Queries) GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDeliveryCount = `-- name: GetDeliveryCount :many
|
const getDeliveryCount = `-- name: GetDeliveryCount :many
|
||||||
SELECT COUNT(*) as count, date FROM deliveries WHERE date >= DATE_TRUNC('month', CURRENT_DATE) GROUP BY date
|
SELECT COUNT(*) as count, date FROM deliveries
|
||||||
|
WHERE date >= CURRENT_DATE AND date < CURRENT_DATE + INTERVAL '7 days'
|
||||||
|
GROUP BY date
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetDeliveryCountRow struct {
|
type GetDeliveryCountRow struct {
|
||||||
@@ -156,6 +181,22 @@ func (q *Queries) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow,
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||||
|
SELECT id, username, password_hash, created_at FROM users WHERE username = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getUserByUsername, username)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Username,
|
||||||
|
&i.PasswordHash,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const updateDelivery = `-- name: UpdateDelivery :exec
|
const updateDelivery = `-- name: UpdateDelivery :exec
|
||||||
UPDATE deliveries SET date = $1, pickup_location = $2, product_name = $3, address = $4, phone = $5, additional_phone = $6, has_elevator = $7, comment = $8, updated_at = NOW() WHERE id = $9
|
UPDATE deliveries SET date = $1, pickup_location = $2, product_name = $3, address = $4, phone = $5, additional_phone = $6, has_elevator = $7, comment = $8, updated_at = NOW() WHERE id = $9
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# API Configuration
|
# API Configuration
|
||||||
# Leave empty to use proxy (recommended for local dev and production)
|
# Leave empty to use proxy (recommended for local dev and production)
|
||||||
# Or set full URL like http://localhost:8081 for direct API access
|
# Or set full URL like http://localhost:8081 for direct API access
|
||||||
VITE_API_URL=
|
VITE_API_URL=http://localhost:8080
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||||
import { Truck, Loader2 } from 'lucide-react';
|
import { Truck, Loader2, LogOut } from 'lucide-react';
|
||||||
import { DeliveryForm } from './components/delivery/DeliveryForm';
|
import { DeliveryForm } from './components/delivery/DeliveryForm';
|
||||||
|
import { LoginForm } from './components/auth/LoginForm';
|
||||||
import { ToastContainer } from './components/ui/Toast';
|
import { ToastContainer } from './components/ui/Toast';
|
||||||
|
import { Button } from './components/ui/Button';
|
||||||
import { useDeliveryStore } from './stores/deliveryStore';
|
import { useDeliveryStore } from './stores/deliveryStore';
|
||||||
|
import { useAuthStore } from './stores/authStore';
|
||||||
|
|
||||||
// Lazy load pages for code splitting
|
// Lazy load pages for code splitting
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
@@ -22,15 +25,21 @@ function App() {
|
|||||||
const [formDate, setFormDate] = useState<string>('');
|
const [formDate, setFormDate] = useState<string>('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const { isAuthenticated, isAuthChecking, restoreAuth, logout } = useAuthStore();
|
||||||
const addDelivery = useDeliveryStore(state => state.addDelivery);
|
const addDelivery = useDeliveryStore(state => state.addDelivery);
|
||||||
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
||||||
|
|
||||||
// Refresh counts when form closes
|
// Restore auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFormOpen) {
|
restoreAuth();
|
||||||
|
}, [restoreAuth]);
|
||||||
|
|
||||||
|
// Refresh counts when form closes (only when authenticated)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && !isFormOpen) {
|
||||||
fetchDeliveryCounts();
|
fetchDeliveryCounts();
|
||||||
}
|
}
|
||||||
}, [isFormOpen, fetchDeliveryCounts]);
|
}, [isAuthenticated, isFormOpen, fetchDeliveryCounts]);
|
||||||
|
|
||||||
const handleDateSelect = (date: string) => {
|
const handleDateSelect = (date: string) => {
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
@@ -67,6 +76,25 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show loading while checking auth
|
||||||
|
if (isAuthChecking) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#fbf8fb]">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-[#1B263B]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show login form if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoginForm />
|
||||||
|
<ToastContainer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#fbf8fb]">
|
<div className="min-h-screen bg-[#fbf8fb]">
|
||||||
<header className="sticky top-0 z-40 bg-[#1B263B] text-white shadow-md">
|
<header className="sticky top-0 z-40 bg-[#1B263B] text-white shadow-md">
|
||||||
@@ -78,8 +106,19 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="text-lg font-semibold hidden sm:block">Delivery Tracker</h1>
|
<h1 className="text-lg font-semibold hidden sm:block">Delivery Tracker</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-white/70">
|
<div className="flex items-center gap-4">
|
||||||
{view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`}
|
<div className="text-sm text-white/70">
|
||||||
|
{view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={logout}
|
||||||
|
className="text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<LogOut size={18} className="mr-1" />
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
7
frontend/src/api/auth.ts
Normal file
7
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { LoginRequest, LoginResponse } from '../types';
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (credentials: LoginRequest): Promise<LoginResponse> =>
|
||||||
|
api.post<LoginResponse>('/api/auth/login', credentials),
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useToastStore } from '../stores/toastStore';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
// Request deduplication cache
|
// Request deduplication cache
|
||||||
@@ -5,6 +7,20 @@ const pendingRequests = new Map<string, Promise<unknown>>();
|
|||||||
// Abort controllers for cancelling requests
|
// Abort controllers for cancelling requests
|
||||||
const abortControllers = new Map<string, AbortController>();
|
const abortControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
// Get token from localStorage
|
||||||
|
function getAuthToken(): string | null {
|
||||||
|
return localStorage.getItem('auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 401 unauthorized
|
||||||
|
function handleUnauthorized() {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('auth_user');
|
||||||
|
useToastStore.getState().addToast('Сессия истекла, войдите снова', 'error');
|
||||||
|
// Reload page to trigger auth check
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
details?: unknown;
|
details?: unknown;
|
||||||
@@ -48,16 +64,23 @@ async function fetchApi<T>(
|
|||||||
|
|
||||||
const requestPromise = (async (): Promise<T> => {
|
const requestPromise = (async (): Promise<T> => {
|
||||||
try {
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
// Handle 401 unauthorized
|
||||||
|
if (response.status === 401) {
|
||||||
|
handleUnauthorized();
|
||||||
|
throw new ApiError('Unauthorized', 401);
|
||||||
|
}
|
||||||
const errorData = await response.json().catch(() => null);
|
const errorData = await response.json().catch(() => null);
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
errorData?.error || `HTTP ${response.status}`,
|
errorData?.error || `HTTP ${response.status}`,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { api, ApiError, cancelAllRequests } from './client';
|
export { api, ApiError, cancelAllRequests } from './client';
|
||||||
export { deliveriesApi } from './deliveries';
|
export { deliveriesApi } from './deliveries';
|
||||||
|
export { authApi } from './auth';
|
||||||
export { frontendDateToBackend } from '../utils/date';
|
export { frontendDateToBackend } from '../utils/date';
|
||||||
|
|||||||
91
frontend/src/components/auth/LoginForm.tsx
Normal file
91
frontend/src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Lock, User, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
|
export const LoginForm = () => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const { login, isLoading } = useAuthStore();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!username.trim() || !password.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login({ username: username.trim(), password });
|
||||||
|
} catch {
|
||||||
|
// Error is handled by store (toast)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#fbf8fb] p-4">
|
||||||
|
<div className="w-full max-w-md bg-white rounded-xl shadow-lg p-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-[#1B263B] rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Lock className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#1b1b1d]">
|
||||||
|
Delivery Tracker
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#75777d] mt-2">
|
||||||
|
Войдите в систему
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[#75777d]">
|
||||||
|
<User size={20} />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Имя пользователя"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[#75777d]">
|
||||||
|
<Lock size={20} />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Пароль"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Вход...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Войти'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
frontend/src/components/auth/index.ts
Normal file
1
frontend/src/components/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { LoginForm } from './LoginForm';
|
||||||
79
frontend/src/stores/authStore.ts
Normal file
79
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { authApi } from '../api/auth';
|
||||||
|
import { useToastStore } from './toastStore';
|
||||||
|
import type { User, LoginRequest } from '../types';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthChecking: boolean;
|
||||||
|
login: (credentials: LoginRequest) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
restoreAuth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'auth_token';
|
||||||
|
const USER_KEY = 'auth_user';
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthChecking: true,
|
||||||
|
|
||||||
|
login: async (credentials: LoginRequest) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const response = await authApi.login(credentials);
|
||||||
|
const token = response.token;
|
||||||
|
|
||||||
|
// Extract user info from token payload (JWT)
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
const user: User = {
|
||||||
|
id: payload.sub || '',
|
||||||
|
username: credentials.username,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
|
||||||
|
set({ token, user, isAuthenticated: true, isLoading: false });
|
||||||
|
useToastStore.getState().addToast('Вход выполнен успешно', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
set({ isLoading: false });
|
||||||
|
const message = error instanceof Error ? error.message : 'Ошибка входа';
|
||||||
|
useToastStore.getState().addToast(message, 'error');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
set({ token: null, user: null, isAuthenticated: false });
|
||||||
|
useToastStore.getState().addToast('Вы вышли из системы', 'info');
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreAuth: () => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const userJson = localStorage.getItem(USER_KEY);
|
||||||
|
|
||||||
|
if (token && userJson) {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(userJson) as User;
|
||||||
|
set({ token, user, isAuthenticated: true, isAuthChecking: false });
|
||||||
|
} catch {
|
||||||
|
// Invalid stored data, clear it
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
set({ isAuthChecking: false });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set({ isAuthChecking: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -28,3 +28,18 @@ export const statusLabels: Record<DeliveryStatus, string> = {
|
|||||||
new: 'Новое',
|
new: 'Новое',
|
||||||
delivered: 'Доставлено',
|
delivered: 'Доставлено',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auth types
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default defineConfig({
|
|||||||
allowedHosts: ['delivery.loca.lt', '.loca.lt'],
|
allowedHosts: ['delivery.loca.lt', '.loca.lt'],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8081',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user