Add basic authentication

This commit is contained in:
2026-05-02 14:59:05 +02:00
parent e1c6ea9e51
commit f1fd72520a
15 changed files with 369 additions and 66 deletions
+52
View File
@@ -0,0 +1,52 @@
package handlers
import (
"log"
"quay/app/repository"
"quay/internal/security"
"time"
"github.com/gofiber/fiber/v3"
)
type AuthHandler struct {
Repo repository.UserRepository
}
func NewAuthHandler(repo repository.UserRepository) *AuthHandler {
return &AuthHandler{Repo: repo}
}
type LoginRequest struct {
Name string `json:"name"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
}
func (h *AuthHandler) Login(c fiber.Ctx) error {
var req LoginRequest
if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
user, err := h.Repo.GetUserByName(req.Name)
if err != nil || user == nil {
log.Println("login: user lookup failed", err)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
}
if !security.CheckPasswordHash(req.Password, user.HashedPassword) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
}
token, err := security.GenerateToken(user.ID, user.Role, 24*time.Hour)
if err != nil {
log.Println("login: token generation failed", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"})
}
return c.JSON(LoginResponse{Token: token})
}
+32
View File
@@ -0,0 +1,32 @@
package middleware
import (
"strings"
"quay/internal/security"
"github.com/gofiber/fiber/v3"
)
func RequireAuth() fiber.Handler {
return func(c fiber.Ctx) error {
auth := c.Get("Authorization")
if auth == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing authorization header"})
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid authorization header"})
}
claims, err := security.ValidateToken(parts[1])
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token"})
}
c.Locals("user_id", claims.UserID)
c.Locals("role", claims.Role)
return c.Next()
}
}
+50 -31
View File
@@ -26,50 +26,69 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d
deploymentsHandler := handlers.NewDeploymentHandler(deploymentRepository)
api := app.Group("/api/v1", middleware.APIHostGuard(envCfg.DashboardHost))
api.Get("/health", handlers.HealthCheck)
api.Post("/deploy", deploySiteHandler.PostDeploy)
public := api.Group("")
public.Get("/health", handlers.HealthCheck)
authHandler := handlers.NewAuthHandler(userRepository)
public.Post("/login", authHandler.Login)
// Protected routes - require auth for everything by default
protected := api.Group("", middleware.RequireAuth())
protected.Post("/deploy", deploySiteHandler.PostDeploy)
// Sites
api.Get("/sites", siteHandler.GetSites)
api.Get("/sites/:id", siteHandler.GetSite)
api.Post("/sites", siteHandler.PostSite)
api.Put("/sites/:id", siteHandler.PutSite)
api.Delete("/sites/:id", siteHandler.DeleteSite)
api.Patch("/sites/:id/enabled", siteHandler.ToggleEnabled)
protected.Get("/sites", siteHandler.GetSites)
protected.Get("/sites/:id", siteHandler.GetSite)
protected.Post("/sites", siteHandler.PostSite)
protected.Put("/sites/:id", siteHandler.PutSite)
protected.Delete("/sites/:id", siteHandler.DeleteSite)
protected.Patch("/sites/:id/enabled", siteHandler.ToggleEnabled)
// Forward rules
api.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules)
api.Post("/sites/:id/forward-rules", siteHandler.PostForwardRule)
api.Get("/sites/:id/forward-rules/:ruleId", siteHandler.GetForwardRule)
api.Put("/sites/:id/forward-rules/:ruleId", siteHandler.PutForwardRule)
api.Delete("/sites/:id/forward-rules/:ruleId", siteHandler.DeleteForwardRule)
protected.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules)
protected.Post("/sites/:id/forward-rules", siteHandler.PostForwardRule)
protected.Get("/sites/:id/forward-rules/:ruleId", siteHandler.GetForwardRule)
protected.Put("/sites/:id/forward-rules/:ruleId", siteHandler.PutForwardRule)
protected.Delete("/sites/:id/forward-rules/:ruleId", siteHandler.DeleteForwardRule)
// Custom headers (header rules)
api.Get("/sites/:id/custom-headers", siteHandler.GetSiteCustomHeaders)
api.Post("/sites/:id/custom-headers", siteHandler.PostCustomHeaders)
api.Get("/sites/:id/custom-headers/:customHeaderId", siteHandler.GetCustomHeaders)
api.Put("/sites/:id/custom-headers/:customHeaderId", siteHandler.PutCustomHeaders)
api.Delete("/sites/:id/custom-headers/:customHeaderId", siteHandler.DeleteCustomHeaders)
protected.Get("/sites/:id/custom-headers", siteHandler.GetSiteCustomHeaders)
protected.Post("/sites/:id/custom-headers", siteHandler.PostCustomHeaders)
protected.Get("/sites/:id/custom-headers/:customHeaderId", siteHandler.GetCustomHeaders)
protected.Put("/sites/:id/custom-headers/:customHeaderId", siteHandler.PutCustomHeaders)
protected.Delete("/sites/:id/custom-headers/:customHeaderId", siteHandler.DeleteCustomHeaders)
// Headers
api.Get("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.GetCustomHeaderHeaders)
api.Post("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.PostHeader)
api.Get("/sites/:id/headers/:headerId", siteHandler.GetHeader)
api.Put("/sites/:id/headers/:headerId", siteHandler.PutHeader)
api.Delete("/sites/:id/headers/:headerId", siteHandler.DeleteHeader)
protected.Get("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.GetCustomHeaderHeaders)
protected.Post("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.PostHeader)
protected.Get("/sites/:id/headers/:headerId", siteHandler.GetHeader)
protected.Put("/sites/:id/headers/:headerId", siteHandler.PutHeader)
protected.Delete("/sites/:id/headers/:headerId", siteHandler.DeleteHeader)
// Deployments
api.Get("/deployments/:id", deploymentsHandler.GetDeployment)
api.Get("/sites/:id/deployments", deploymentsHandler.GetDeploymentsBySite)
protected.Get("/deployments/:id", deploymentsHandler.GetDeployment)
protected.Get("/sites/:id/deployments", deploymentsHandler.GetDeploymentsBySite)
// Users
api.Get("/users", userHandler.GetAllUsers)
api.Get("/users/:id", userHandler.GetUserById)
api.Get("/users/by-name/:name", userHandler.GetUserByName)
api.Post("/users", userHandler.CreateUser)
api.Put("/users/:id", userHandler.UpdateUser)
api.Delete("/users/:id", userHandler.DeleteUser)
protected.Get("/users", userHandler.GetAllUsers)
protected.Get("/users/:id", userHandler.GetUserById)
protected.Get("/users/by-name/:name", userHandler.GetUserByName)
// Allow creating the very first admin user without auth (bootstrap).
// If an admin already exists, require auth to create users.
if exists, err := userRepository.AdminUserExists(); err != nil {
// if we can't determine, be conservative and require auth
protected.Post("/users", userHandler.CreateUser)
} else if !exists {
public.Post("/users", userHandler.CreateUser)
} else {
protected.Post("/users", userHandler.CreateUser)
}
protected.Put("/users/:id", userHandler.UpdateUser)
protected.Delete("/users/:id", userHandler.DeleteUser)
api.Use(func(c fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
+3 -2
View File
@@ -5,9 +5,12 @@ go 1.25.0
require (
github.com/Flussen/swagger-fiber-v3 v1.0.1
github.com/gofiber/fiber/v3 v3.1.0
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.38
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.48.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -28,11 +31,9 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/swaggo/swag v1.16.4 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
+21
View File
@@ -10,7 +10,10 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -27,6 +30,8 @@ github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -38,6 +43,7 @@ github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxh
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -49,13 +55,19 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
@@ -64,11 +76,19 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -84,6 +104,7 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+66
View File
@@ -0,0 +1,66 @@
package security
import (
"errors"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
var ErrMissingSecret = errors.New("JWT secret not set")
type Claims struct {
UserID string `json:"sub"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func getSecret() (string, error) {
s := os.Getenv("JWT_SECRET")
if s == "" {
return "", ErrMissingSecret
}
return s, nil
}
func GenerateToken(userID, role string, ttl time.Duration) (string, error) {
secret, err := getSecret()
if err != nil {
return "", err
}
claims := Claims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ValidateToken(tokenString string) (*Claims, error) {
secret, err := getSecret()
if err != nil {
return nil, err
}
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
+18
View File
@@ -0,0 +1,18 @@
import { makeApiUrl } from '.';
export const login = async (name: string, password: string): Promise<string> => {
const response = await fetch(makeApiUrl('/login'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, password }),
});
if (!response.ok) {
throw new Error('Failed to login');
}
const data = await response.json();
return data.token;
};
+8 -8
View File
@@ -1,4 +1,4 @@
import { makeApiUrl } from '.';
import { fetchWithAuth } from '.';
import type {
CreateCustomHeadersRequest,
CreateHeaderRequest,
@@ -7,7 +7,7 @@ import type {
} from './types/site';
export const getCustomHeaders = async (siteId: string): Promise<CustomHeaders[]> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers`), {
const response = await fetchWithAuth(`/sites/${siteId}/custom-headers`, {
method: 'GET',
});
if (!response.ok) {
@@ -20,7 +20,7 @@ export const createCustomHeaders = async (
siteId: string,
data: CreateCustomHeadersRequest
): Promise<CustomHeaders> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers`), {
const response = await fetchWithAuth(`/sites/${siteId}/custom-headers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -38,7 +38,7 @@ export const updateCustomHeaders = async (
groupId: string,
data: CreateCustomHeadersRequest
): Promise<CustomHeaders> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}`), {
const response = await fetchWithAuth(`/sites/${siteId}/custom-headers/${groupId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -52,7 +52,7 @@ export const updateCustomHeaders = async (
};
export const deleteCustomHeaders = async (siteId: string, groupId: string): Promise<void> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}`), {
const response = await fetchWithAuth(`/sites/${siteId}/custom-headers/${groupId}`, {
method: 'DELETE',
});
if (!response.ok) {
@@ -65,7 +65,7 @@ export const createHeader = async (
groupId: string,
data: CreateHeaderRequest
): Promise<Header> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}/headers`), {
const response = await fetchWithAuth(`/sites/${siteId}/custom-headers/${groupId}/headers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -83,7 +83,7 @@ export const updateHeader = async (
headerId: string,
data: CreateHeaderRequest
): Promise<Header> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/headers/${headerId}`), {
const response = await fetchWithAuth(`/sites/${siteId}/headers/${headerId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -97,7 +97,7 @@ export const updateHeader = async (
};
export const deleteHeader = async (siteId: string, headerId: string): Promise<void> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/headers/${headerId}`), {
const response = await fetchWithAuth(`/sites/${siteId}/headers/${headerId}`, {
method: 'DELETE',
});
if (!response.ok) {
+2 -2
View File
@@ -1,11 +1,11 @@
import { makeApiUrl } from '.';
import { fetchWithAuth } from '.';
import type { Deployment } from './types/deployments';
export const getDeploymentsForSite = async (
siteId: string,
limit: number = 100
): Promise<Deployment[]> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/deployments?limit=${limit}`), {
const response = await fetchWithAuth(`/sites/${siteId}/deployments?limit=${limit}`, {
method: 'GET',
});
+5 -5
View File
@@ -1,8 +1,8 @@
import { makeApiUrl } from '.';
import { fetchWithAuth } from '.';
import type { CreateForwardRuleRequest, ForwardRule } from './types/site';
export const getForwardRules = async (siteId: string): Promise<ForwardRule[]> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules`), {
const response = await fetchWithAuth(`/sites/${siteId}/forward-rules`, {
method: 'GET',
});
if (!response.ok) {
@@ -15,7 +15,7 @@ export const createForwardRule = async (
siteId: string,
data: CreateForwardRuleRequest
): Promise<ForwardRule> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules`), {
const response = await fetchWithAuth(`/sites/${siteId}/forward-rules`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -33,7 +33,7 @@ export const updateForwardRule = async (
ruleId: string,
data: CreateForwardRuleRequest
): Promise<ForwardRule> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules/${ruleId}`), {
const response = await fetchWithAuth(`/sites/${siteId}/forward-rules/${ruleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -47,7 +47,7 @@ export const updateForwardRule = async (
};
export const deleteForwardRule = async (siteId: string, ruleId: string): Promise<void> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules/${ruleId}`), {
const response = await fetchWithAuth(`/sites/${siteId}/forward-rules/${ruleId}`, {
method: 'DELETE',
});
if (!response.ok) {
+17
View File
@@ -5,3 +5,20 @@ export const makeApiUrl = (endpoint: string) => {
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `${base}${path}`;
};
export const authHeaders = () => {
try {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
} catch (e) {
return {};
}
};
export const fetchWithAuth = (endpoint: string, options: RequestInit = {}) => {
const headers = {
...(options.headers || {}),
...authHeaders(),
} as Record<string, string>;
return fetch(makeApiUrl(endpoint), { ...options, headers });
};
+7 -7
View File
@@ -1,4 +1,4 @@
import { makeApiUrl } from '.';
import { fetchWithAuth } from '.';
import type {
CreateSiteRequest,
CreateSiteResponse,
@@ -8,7 +8,7 @@ import type {
} from './types/site';
export const getSites = async (): Promise<GetAllSitesResponse> => {
const response = await fetch(makeApiUrl('/sites'), {
const response = await fetchWithAuth('/sites', {
method: 'GET',
});
if (response.status === 404) {
@@ -21,7 +21,7 @@ export const getSites = async (): Promise<GetAllSitesResponse> => {
};
export const getSite = async (id: string): Promise<Site> => {
const response = await fetch(makeApiUrl(`/sites/${id}`), {
const response = await fetchWithAuth(`/sites/${id}`, {
method: 'GET',
});
if (!response.ok) {
@@ -31,7 +31,7 @@ export const getSite = async (id: string): Promise<Site> => {
};
export const createSite = async (data: CreateSiteRequest): Promise<CreateSiteResponse> => {
const response = await fetch(makeApiUrl('/sites'), {
const response = await fetchWithAuth('/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -45,7 +45,7 @@ export const createSite = async (data: CreateSiteRequest): Promise<CreateSiteRes
};
export const updateSite = async (id: string, data: Partial<CreateSiteRequest>): Promise<Site> => {
const response = await fetch(makeApiUrl(`/sites/${id}`), {
const response = await fetchWithAuth(`/sites/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -59,7 +59,7 @@ export const updateSite = async (id: string, data: Partial<CreateSiteRequest>):
};
export const toggleSiteEnabled = async (id: string): Promise<ToggleSiteEnabledResponse> => {
const response = await fetch(makeApiUrl(`/sites/${id}/enabled`), {
const response = await fetchWithAuth(`/sites/${id}/enabled`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
@@ -72,7 +72,7 @@ export const toggleSiteEnabled = async (id: string): Promise<ToggleSiteEnabledRe
};
export const deleteSite = async (id: string): Promise<void> => {
const response = await fetch(makeApiUrl(`/sites/${id}`), {
const response = await fetchWithAuth(`/sites/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
+6 -6
View File
@@ -1,8 +1,8 @@
import { makeApiUrl } from '.';
import { fetchWithAuth } from '.';
import type { CreateUserRequest, UpdateUserRequest, User } from './types/user';
export const getUsers = async (): Promise<User[]> => {
const response = await fetch(makeApiUrl('/users'), {
const response = await fetchWithAuth('/users', {
method: 'GET',
});
if (response.status === 404) {
@@ -15,7 +15,7 @@ export const getUsers = async (): Promise<User[]> => {
};
export const getUserById = async (id: string): Promise<User> => {
const response = await fetch(makeApiUrl(`/users/${id}`), {
const response = await fetchWithAuth(`/users/${id}`, {
method: 'GET',
});
if (!response.ok) {
@@ -25,7 +25,7 @@ export const getUserById = async (id: string): Promise<User> => {
};
export const createUser = async (data: CreateUserRequest): Promise<User> => {
const response = await fetch(makeApiUrl('/users'), {
const response = await fetchWithAuth('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -39,7 +39,7 @@ export const createUser = async (data: CreateUserRequest): Promise<User> => {
};
export const updateUser = async (id: string, data: Partial<UpdateUserRequest>): Promise<User> => {
const response = await fetch(makeApiUrl(`/users/${id}`), {
const response = await fetchWithAuth(`/users/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -53,7 +53,7 @@ export const updateUser = async (id: string, data: Partial<UpdateUserRequest>):
};
export const deleteUser = async (id: string): Promise<void> => {
const response = await fetch(makeApiUrl(`/users/${id}`), {
const response = await fetchWithAuth(`/users/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
+31 -5
View File
@@ -1,4 +1,4 @@
import { BrowserRouter, Route, Routes } from 'react-router';
import { BrowserRouter, Route, Routes, Navigate } from 'react-router';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MainPage from './pages/Main/Main';
@@ -8,6 +8,7 @@ import './index.css';
import NewSite from './pages/NewSite/NewSite';
import SiteOverview from './pages/SiteOverview/SiteOverview';
import Users from './pages/Users/Users';
import Login from './pages/Login/Login';
const queryClient = new QueryClient();
@@ -16,10 +17,35 @@ createRoot(document.getElementById('root')!).render(
<ThemeProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<MainPage />} />
<Route path="/sites/new" element={<NewSite />} />
<Route path="/sites/:id" element={<SiteOverview />} />
<Route path="/users" element={<Users />} />
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
!localStorage.getItem('token') ? <Navigate to="/login" /> : <MainPage />
}
/>
<Route
path="/sites/new"
element={
!localStorage.getItem('token') ? <Navigate to="/login" /> : <NewSite />
}
/>
<Route
path="/sites/:id"
element={
!localStorage.getItem('token') ? (
<Navigate to="/login" />
) : (
<SiteOverview />
)
}
/>
<Route
path="/users"
element={
!localStorage.getItem('token') ? <Navigate to="/login" /> : <Users />
}
/>
</Routes>
</BrowserRouter>
</ThemeProvider>
+51
View File
@@ -0,0 +1,51 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { login } from '../../api/auth.api';
import { Input } from '../../components/ui/input';
import { Button } from '../../components/ui/button';
const Login = () => {
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
const token = await login(name, password);
localStorage.setItem('token', token);
navigate('/');
} catch (err) {
setError('Invalid credentials');
}
};
return (
<div className="max-w-md mx-auto mt-20">
<h1 className="text-2xl font-semibold mb-4">Sign in</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm mb-1">Name</label>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div>
<label className="block text-sm mb-1">Password</label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div>
<Button type="submit">Sign in</Button>
</div>
</form>
</div>
);
};
export default Login;