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_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=gitea.your-domain.com/yourusername
|
||||
GITEA_USER=your_gitea_username
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,6 +17,8 @@ dist-ssr
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
backend/.env
|
||||
backend/.env.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/chedius/delivery-tracker/internal/auth"
|
||||
db "github.com/chedius/delivery-tracker/internal/db/sqlc"
|
||||
"github.com/chedius/delivery-tracker/internal/delivery"
|
||||
"github.com/gin-contrib/cors"
|
||||
@@ -15,6 +16,19 @@ import (
|
||||
"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() {
|
||||
ctx := context.Background()
|
||||
godotenv.Load()
|
||||
@@ -27,6 +41,7 @@ func main() {
|
||||
defer pool.Close()
|
||||
|
||||
queries := db.New(pool)
|
||||
_, authHandler := initAuth(queries)
|
||||
h := delivery.NewHandler(queries)
|
||||
|
||||
r := gin.Default()
|
||||
@@ -44,13 +59,20 @@ func main() {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
r.GET("/api/deliveries", h.GetDeliveries)
|
||||
r.GET("/api/deliveries/:id", h.GetDeliveryByID)
|
||||
r.GET("/api/deliveries/count", h.GetDeliveryCount)
|
||||
r.POST("/api/deliveries", h.CreateDelivery)
|
||||
r.PATCH("/api/deliveries/:id", h.UpdateDelivery)
|
||||
r.PATCH("/api/deliveries/:id/status", h.UpdateDeliveryStatus)
|
||||
r.DELETE("/api/deliveries/:id", h.DeleteDelivery)
|
||||
r.POST("/api/auth/register", authHandler.Register)
|
||||
r.POST("/api/auth/login", authHandler.Login)
|
||||
|
||||
authorized := r.Group("/api")
|
||||
authorized.Use(auth.AuthMiddleware([]byte(os.Getenv("JWT_SECRET"))))
|
||||
{
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
require github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
@@ -41,7 +43,7 @@ require (
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.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/sync v0.20.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-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
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
|
||||
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;
|
||||
|
||||
-- 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
|
||||
UPDATE deliveries SET status = $1, updated_at = NOW() WHERE id = $2;
|
||||
|
||||
@@ -12,10 +12,12 @@ import (
|
||||
|
||||
type Querier interface {
|
||||
CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (Delivery, error)
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
|
||||
DeleteDelivery(ctx context.Context, id pgtype.UUID) error
|
||||
GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]Delivery, error)
|
||||
GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error)
|
||||
GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (User, error)
|
||||
UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error
|
||||
UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error
|
||||
}
|
||||
|
||||
@@ -57,6 +57,29 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
|
||||
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
|
||||
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
|
||||
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 {
|
||||
@@ -156,6 +181,22 @@ func (q *Queries) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow,
|
||||
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
|
||||
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
|
||||
# Leave empty to use proxy (recommended for local dev and production)
|
||||
# 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 { Truck, Loader2 } from 'lucide-react';
|
||||
import { Truck, Loader2, LogOut } from 'lucide-react';
|
||||
import { DeliveryForm } from './components/delivery/DeliveryForm';
|
||||
import { LoginForm } from './components/auth/LoginForm';
|
||||
import { ToastContainer } from './components/ui/Toast';
|
||||
import { Button } from './components/ui/Button';
|
||||
import { useDeliveryStore } from './stores/deliveryStore';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
@@ -22,15 +25,21 @@ function App() {
|
||||
const [formDate, setFormDate] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { isAuthenticated, isAuthChecking, restoreAuth, logout } = useAuthStore();
|
||||
const addDelivery = useDeliveryStore(state => state.addDelivery);
|
||||
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
||||
|
||||
// Refresh counts when form closes
|
||||
// Restore auth on mount
|
||||
useEffect(() => {
|
||||
if (!isFormOpen) {
|
||||
restoreAuth();
|
||||
}, [restoreAuth]);
|
||||
|
||||
// Refresh counts when form closes (only when authenticated)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !isFormOpen) {
|
||||
fetchDeliveryCounts();
|
||||
}
|
||||
}, [isFormOpen, fetchDeliveryCounts]);
|
||||
}, [isAuthenticated, isFormOpen, fetchDeliveryCounts]);
|
||||
|
||||
const handleDateSelect = (date: string) => {
|
||||
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 (
|
||||
<div className="min-h-screen bg-[#fbf8fb]">
|
||||
<header className="sticky top-0 z-40 bg-[#1B263B] text-white shadow-md">
|
||||
@@ -78,8 +106,19 @@ function App() {
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold hidden sm:block">Delivery Tracker</h1>
|
||||
</div>
|
||||
<div className="text-sm text-white/70">
|
||||
{view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`}
|
||||
<div className="flex items-center gap-4">
|
||||
<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>
|
||||
|
||||
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 || '';
|
||||
|
||||
// Request deduplication cache
|
||||
@@ -5,6 +7,20 @@ const pendingRequests = new Map<string, Promise<unknown>>();
|
||||
// Abort controllers for cancelling requests
|
||||
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 {
|
||||
status: number;
|
||||
details?: unknown;
|
||||
@@ -48,16 +64,23 @@ async function fetchApi<T>(
|
||||
|
||||
const requestPromise = (async (): Promise<T> => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle 401 unauthorized
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized();
|
||||
throw new ApiError('Unauthorized', 401);
|
||||
}
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new ApiError(
|
||||
errorData?.error || `HTTP ${response.status}`,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { api, ApiError, cancelAllRequests } from './client';
|
||||
export { deliveriesApi } from './deliveries';
|
||||
export { authApi } from './auth';
|
||||
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: 'Новое',
|
||||
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'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8081',
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user