Compare commits

...

6 Commits

Author SHA1 Message Date
Egor Pozharov
86a684790c update envs and urls 2026-04-16 13:10:58 +06:00
Egor Pozharov
ff27493670 fix JWT token expiry from 24 minutes to 24 hours 2026-04-16 13:00:48 +06:00
Egor Pozharov
70129baad5 change delivery count query to show next 7 days instead of current month 2026-04-16 13:00:32 +06:00
Egor Pozharov
c373d82135 add authentication with login form and token management 2026-04-16 12:47:42 +06:00
Egor Pozharov
be0b13acbf add auth module 2026-04-15 19:17:10 +06:00
Egor Pozharov
e50f81f7f3 update .gitignore 2026-04-15 19:16:22 +06:00
24 changed files with 682 additions and 18 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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
View 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)
}

View File

@@ -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

View File

@@ -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=

View 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")
)

View 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,
})
}

View 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")
}

View 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()
}
}

View 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
}

View File

@@ -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;

View File

@@ -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
} }

View File

@@ -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
` `

View File

@@ -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

View File

@@ -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
View 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),
};

View File

@@ -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}`,

View File

@@ -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';

View 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>
);
};

View File

@@ -0,0 +1 @@
export { LoginForm } from './LoginForm';

View 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 });
}
},
}));

View File

@@ -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;
}

View File

@@ -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,
}, },
}, },