Compare commits

..

19 Commits

Author SHA1 Message Date
800e1afbe5 feat: API service type 2025-05-30 21:28:06 +02:00
8abc4396ac feat: integrate admin store in api services tab 2025-05-30 21:27:59 +02:00
70f860824c feat: .air.toml file for ai 2025-05-30 21:27:46 +02:00
3c5e31cbb2 feat: api services DTO 2025-05-30 21:27:10 +02:00
3923b428a4 feat: admin zustand store 2025-05-30 21:27:02 +02:00
5b816c6873 feat: remove default inner padding for dashboard pages 2025-05-30 21:26:43 +02:00
66edadfeda feat: make hooks folder 2025-05-30 21:26:15 +02:00
b872722e07 fix: import bar items hook 2025-05-30 21:26:05 +02:00
091218b42d feat: better sidebar 2025-05-30 21:25:55 +02:00
03697b2f67 fix: window scoped state for token refresh 2025-05-30 21:25:39 +02:00
8a28fca3d9 feat: api for fetching api services 2025-05-30 21:25:15 +02:00
1ab4113040 feat: return all fields from api_service objects 2025-05-30 21:25:03 +02:00
013f300513 feat: adjust routes and make use of middlewares in each one 2025-05-30 21:24:48 +02:00
45e31b41ca feat: install pgxpool 2025-05-30 21:24:20 +02:00
182f30f1ba feat: use pgxpool instead of row connection 2025-05-30 21:24:07 +02:00
7c97ebd84f feat: let routes decide about middlewares to be used 2025-05-30 21:23:33 +02:00
9fefe3ac71 feat: makefile adjust for windows 2025-05-30 21:23:16 +02:00
ca3006c428 feat: ignore tmp/ folder 2025-05-30 21:23:02 +02:00
51b7e6b3f9 feat: admin routes + better auth routing 2025-05-30 18:17:12 +02:00
28 changed files with 454 additions and 167 deletions

63
.air.toml Normal file
View File

@ -0,0 +1,63 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "bin\\hspguard"
cmd = "make build"
delay = 1000
exclude_dir = [
"assets",
"tmp",
"vendor",
"testdata",
"dist",
"migrations",
"queries",
"scripts",
"templates",
"web",
]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

2
.gitignore vendored
View File

@ -13,6 +13,8 @@
bin/* bin/*
tmp/
# Output of the go coverage tool, specifically when used with LiteIDE # Output of the go coverage tool, specifically when used with LiteIDE
*.out *.out

View File

@ -1,11 +1,14 @@
# Project metadata # Project metadata
APP_NAME := hspguard APP_NAME := hspguard
CMD_DIR := ./cmd/$(APP_NAME) CMD_DIR := ./cmd/$(APP_NAME)
BIN_DIR := ./bin BIN_DIR := ./bin
BIN_PATH := $(BIN_DIR)/$(APP_NAME)
# Detect platform and add .exe suffix on Windows
OS := $(shell go env GOOS)
EXT := $(if $(filter windows,$(OS)),.exe,)
BIN_PATH := $(BIN_DIR)/$(APP_NAME)$(EXT)
PKG := ./... PKG := ./...
GO_FILES := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
# Go tools # Go tools
GO := go GO := go
@ -16,21 +19,20 @@ GOTEST := go test
# Build flags # Build flags
LD_FLAGS := -s -w LD_FLAGS := -s -w
BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") GIT_COMMIT := $(shell git rev-parse --short HEAD 2>NUL || echo unknown)
.PHONY: all build clean fmt lint run .PHONY: all build clean fmt lint run test mod
all: build all: build
build: build:
@mkdir -p $(BIN_DIR)
$(GO) build -ldflags "-X main.buildTime=$(BUILD_TIME) -X main.commitHash=$(GIT_COMMIT) $(LD_FLAGS)" -o $(BIN_PATH) $(CMD_DIR) $(GO) build -ldflags "-X main.buildTime=$(BUILD_TIME) -X main.commitHash=$(GIT_COMMIT) $(LD_FLAGS)" -o $(BIN_PATH) $(CMD_DIR)
run: run:
$(GO) run $(CMD_DIR) $(GO) run $(CMD_DIR)
fmt: fmt:
$(GOFMT) -s -w $(GO_FILES) $(GOFMT) -s -w .
lint: lint:
$(GOLINT) run $(GOLINT) run
@ -39,8 +41,7 @@ test:
$(GOTEST) -v $(PKG) $(GOTEST) -v $(PKG)
clean: clean:
@rm -rf $(BIN_DIR) @if [ -d "$(BIN_DIR)" ]; then rm -rf $(BIN_DIR); fi
mod: mod:
$(GO) mod tidy $(GO) mod tidy

View File

@ -6,10 +6,9 @@ import (
"net/http" "net/http"
"os" "os"
"gitea.local/admin/hspguard/internal/apiservices" "gitea.local/admin/hspguard/internal/admin"
"gitea.local/admin/hspguard/internal/auth" "gitea.local/admin/hspguard/internal/auth"
"gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/config"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/oauth" "gitea.local/admin/hspguard/internal/oauth"
"gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage" "gitea.local/admin/hspguard/internal/storage"
@ -45,17 +44,7 @@ func (s *APIServer) Run() error {
oauthHandler := oauth.NewOAuthHandler(s.repo, s.cfg) oauthHandler := oauth.NewOAuthHandler(s.repo, s.cfg)
router.Route("/api/v1", func(r chi.Router) { router.Route("/api/v1", func(r chi.Router) {
am := imiddleware.New(s.cfg) userHandler := user.NewUserHandler(s.repo, s.storage, s.cfg)
r.Use(imiddleware.WithSkipper(
am.Runner,
"/api/v1/auth/login",
"/api/v1/register",
"/api/v1/auth/refresh",
"/api/v1/oauth/token",
"/api/v1/avatar",
))
userHandler := user.NewUserHandler(s.repo, s.storage)
userHandler.RegisterRoutes(r) userHandler.RegisterRoutes(r)
authHandler := auth.NewAuthHandler(s.repo, s.cfg) authHandler := auth.NewAuthHandler(s.repo, s.cfg)
@ -63,8 +52,8 @@ func (s *APIServer) Run() error {
oauthHandler.RegisterRoutes(r) oauthHandler.RegisterRoutes(r)
apiServicesHandler := apiservices.New(s.repo, s.cfg) adminHandler := admin.New(s.repo, s.cfg)
apiServicesHandler.RegisterRoutes(r) adminHandler.RegisterRoutes(r)
}) })
router.Get("/.well-known/jwks.json", oauthHandler.WriteJWKS) router.Get("/.well-known/jwks.json", oauthHandler.WriteJWKS)

View File

@ -10,7 +10,7 @@ import (
"gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage" "gitea.local/admin/hspguard/internal/storage"
"gitea.local/admin/hspguard/internal/user" "gitea.local/admin/hspguard/internal/user"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
@ -31,7 +31,7 @@ func main() {
ctx := context.Background() ctx := context.Background()
conn, err := pgx.Connect(ctx, cfg.DatabaseURL) conn, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil { if err != nil {
log.Fatalln("ERR: Failed to connect to db:", err) log.Fatalln("ERR: Failed to connect to db:", err)
return return

2
go.mod
View File

@ -16,6 +16,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/minio/crc64nvme v1.0.1 // indirect github.com/minio/crc64nvme v1.0.1 // indirect
@ -26,6 +27,7 @@ require (
github.com/tinylib/msgp v1.3.0 // indirect github.com/tinylib/msgp v1.3.0 // indirect
golang.org/x/crypto v0.38.0 // indirect golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.38.0 // indirect golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.25.0 // indirect
) )

View File

@ -0,0 +1,34 @@
package admin
import (
"time"
"gitea.local/admin/hspguard/internal/repository"
"github.com/google/uuid"
)
type ApiServiceDTO struct {
ID uuid.UUID `json:"id"`
ClientID string `json:"client_id"`
Name string `json:"name"`
RedirectUris []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
IsActive bool `json:"is_active"`
}
func NewApiServiceDTO(service repository.ApiService) ApiServiceDTO {
return ApiServiceDTO{
ID: service.ID,
ClientID: service.ClientID,
Name: service.Name,
RedirectUris: service.RedirectUris,
Scopes: service.Scopes,
GrantTypes: service.GrantTypes,
CreatedAt: service.CreatedAt,
UpdatedAt: service.UpdatedAt,
IsActive: false,
}
}

View File

@ -1,31 +1,68 @@
package apiservices package admin
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/config"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/util" "gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web" "gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid"
) )
type ApiServicesHandler struct { type AdminHandler struct {
repo *repository.Queries repo *repository.Queries
cfg *config.AppConfig cfg *config.AppConfig
} }
func New(repo *repository.Queries, cfg *config.AppConfig) *ApiServicesHandler { func New(repo *repository.Queries, cfg *config.AppConfig) *AdminHandler {
return &ApiServicesHandler{ return &AdminHandler{
repo, repo,
cfg, cfg,
} }
} }
func (h *ApiServicesHandler) RegisterRoutes(router chi.Router) { func (h *AdminHandler) RegisterRoutes(router chi.Router) {
router.Post("/api-services/create", h.Add) router.Route("/admin", func(r chi.Router) {
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
adminMiddleware := imiddleware.NewAdminMiddleware(h.repo)
r.Use(authMiddleware.Runner, adminMiddleware.Runner)
r.Get("/api-services", h.GetApiServices)
r.Post("/api-services", h.AddApiService)
})
}
func (h *AdminHandler) GetApiServices(w http.ResponseWriter, r *http.Request) {
services, err := h.repo.ListApiServices(r.Context())
if err != nil {
log.Println("ERR: Failed to list api services from db:", err)
web.Error(w, "failed to get api services", http.StatusInternalServerError)
return
}
apiServices := make([]ApiServiceDTO, 0)
for _, service := range services {
apiServices = append(apiServices, NewApiServiceDTO(service))
}
type Response struct {
Items []ApiServiceDTO `json:"items"`
Count int `json:"count"`
}
encoder := json.NewEncoder(w)
if err := encoder.Encode(Response{
Items: apiServices,
Count: len(apiServices),
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
} }
type AddServiceRequest struct { type AddServiceRequest struct {
@ -35,24 +72,7 @@ type AddServiceRequest struct {
GrantTypes []string `json:"grant_types"` GrantTypes []string `json:"grant_types"`
} }
func (h *ApiServicesHandler) Add(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
if !user.IsAdmin {
web.Error(w, "you cannot create api services", http.StatusForbidden)
return
}
var req AddServiceRequest var req AddServiceRequest
decoder := json.NewDecoder(r.Body) decoder := json.NewDecoder(r.Body)
@ -103,7 +123,7 @@ func (h *ApiServicesHandler) Add(w http.ResponseWriter, r *http.Request) {
service.ClientSecret = clientSecret service.ClientSecret = clientSecret
encoder := json.NewEncoder(w) encoder := json.NewEncoder(w)
if err := encoder.Encode(service); err != nil { if err := encoder.Encode(NewApiServiceDTO(service)); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError) web.Error(w, "failed to encode response", http.StatusInternalServerError)
} }
} }

View File

@ -3,11 +3,13 @@ package auth
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/config"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/types" "gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util" "gitea.local/admin/hspguard/internal/util"
@ -30,11 +32,11 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
Issuer: h.cfg.Jwt.Issuer, Issuer: h.cfg.Jwt.Issuer,
Subject: user.ID.String(), Subject: user.ID.String(),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Second)),
}, },
} }
accessToken, err := SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey) accessToken, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@ -50,7 +52,7 @@ func (h *AuthHandler) signTokens(user *repository.User) (string, string, error)
}, },
} }
refreshToken, err := SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey) refreshToken, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@ -66,9 +68,17 @@ func NewAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *AuthHandle
} }
func (h *AuthHandler) RegisterRoutes(api chi.Router) { func (h *AuthHandler) RegisterRoutes(api chi.Router) {
api.Get("/auth/profile", h.getProfile) api.Route("/auth", func(r chi.Router) {
api.Post("/auth/login", h.login) r.Group(func(protected chi.Router) {
api.Post("/auth/refresh", h.refreshToken) authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
protected.Use(authMiddleware.Runner)
protected.Get("/profile", h.getProfile)
})
r.Post("/login", h.login)
r.Post("/refresh", h.refreshToken)
})
} }
func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
@ -85,7 +95,7 @@ func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
} }
tokenStr := parts[1] tokenStr := parts[1]
token, userClaims, err := VerifyToken(tokenStr, h.cfg.Jwt.PublicKey) token, userClaims, err := util.VerifyToken(tokenStr, h.cfg.Jwt.PublicKey)
if err != nil || !token.Valid { if err != nil || !token.Valid {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized) http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
return return
@ -184,6 +194,8 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("DEBUG: looking for user with following params: %#v\n", params)
user, err := h.repo.FindUserEmail(r.Context(), params.Email) user, err := h.repo.FindUserEmail(r.Context(), params.Email)
if err != nil { if err != nil {
web.Error(w, "user with provided email does not exists", http.StatusBadRequest) web.Error(w, "user with provided email does not exists", http.StatusBadRequest)

View File

@ -0,0 +1,47 @@
package middleware
import (
"log"
"net/http"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/google/uuid"
)
type AdminMiddleware struct {
repo *repository.Queries
}
func NewAdminMiddleware(repo *repository.Queries) *AdminMiddleware {
return &AdminMiddleware{
repo,
}
}
func (m *AdminMiddleware) Runner(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
log.Println("ERR: Could not get user id from request")
web.Error(w, "not authenticated", http.StatusUnauthorized)
return
}
user, err := m.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
log.Println("ERR: User with provided id does not exist:", userId)
web.Error(w, "not authenticated", http.StatusUnauthorized)
return
}
if !user.IsAdmin {
log.Println("INFO: User is not admin")
web.Error(w, "no priviligies to access this resource", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

View File

@ -6,9 +6,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"gitea.local/admin/hspguard/internal/auth"
"gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/config"
"gitea.local/admin/hspguard/internal/types" "gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web" "gitea.local/admin/hspguard/internal/web"
) )
@ -16,7 +16,7 @@ type AuthMiddleware struct {
cfg *config.AppConfig cfg *config.AppConfig
} }
func New(cfg *config.AppConfig) *AuthMiddleware { func NewAuthMiddleware(cfg *config.AppConfig) *AuthMiddleware {
return &AuthMiddleware{ return &AuthMiddleware{
cfg, cfg,
} }
@ -37,9 +37,9 @@ func (m *AuthMiddleware) Runner(next http.Handler) http.Handler {
} }
tokenStr := parts[1] tokenStr := parts[1]
token, userClaims, err := auth.VerifyToken(tokenStr, m.cfg.Jwt.PublicKey) token, userClaims, err := util.VerifyToken(tokenStr, m.cfg.Jwt.PublicKey)
if err != nil || !token.Valid { if err != nil || !token.Valid {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized) web.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
return return
} }

View File

@ -9,7 +9,6 @@ import (
"strings" "strings"
"time" "time"
"gitea.local/admin/hspguard/internal/auth"
"gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/config"
"gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/types" "gitea.local/admin/hspguard/internal/types"
@ -32,14 +31,16 @@ func NewOAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *OAuthHand
} }
} }
func (h *OAuthHandler) RegisterRoutes(r chi.Router) { func (h *OAuthHandler) RegisterRoutes(router chi.Router) {
r.Post("/oauth/token", h.tokenEndpoint) router.Route("/oauth", func(r chi.Router) {
r.Post("/token", h.tokenEndpoint)
r.Post("/oauth/code", h.getAuthCode) r.Post("/code", h.getAuthCode)
})
} }
func (h *OAuthHandler) WriteJWKS(w http.ResponseWriter, r *http.Request) { func (h *OAuthHandler) WriteJWKS(w http.ResponseWriter, r *http.Request) {
pubKey, err := auth.ParseBase64PublicKey(h.cfg.Jwt.PublicKey) pubKey, err := util.ParseBase64PublicKey(h.cfg.Jwt.PublicKey)
if err != nil { if err != nil {
web.Error(w, "failed to parse public key", http.StatusInternalServerError) web.Error(w, "failed to parse public key", http.StatusInternalServerError)
} }
@ -207,7 +208,7 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
}, },
} }
idToken, err := auth.SignJwtToken(claims, h.cfg.Jwt.PrivateKey) idToken, err := util.SignJwtToken(claims, h.cfg.Jwt.PrivateKey)
if err != nil { if err != nil {
web.Error(w, "failed to sign id token", http.StatusInternalServerError) web.Error(w, "failed to sign id token", http.StatusInternalServerError)
return return

View File

@ -11,6 +11,8 @@ import (
"strings" "strings"
"time" "time"
"gitea.local/admin/hspguard/internal/config"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage" "gitea.local/admin/hspguard/internal/storage"
"gitea.local/admin/hspguard/internal/util" "gitea.local/admin/hspguard/internal/util"
@ -24,18 +26,26 @@ import (
type UserHandler struct { type UserHandler struct {
repo *repository.Queries repo *repository.Queries
minio *storage.FileStorage minio *storage.FileStorage
cfg *config.AppConfig
} }
func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage) *UserHandler { func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage, cfg *config.AppConfig) *UserHandler {
return &UserHandler{ return &UserHandler{
repo: repo, repo,
minio: minio, minio,
cfg,
} }
} }
func (h *UserHandler) RegisterRoutes(api chi.Router) { func (h *UserHandler) RegisterRoutes(api chi.Router) {
api.Group(func(protected chi.Router) {
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
protected.Use(authMiddleware.Runner)
protected.Put("/avatar", h.uploadAvatar)
})
api.Post("/register", h.register) api.Post("/register", h.register)
api.Put("/avatar", h.uploadAvatar)
api.Get("/avatar/{avatar}", h.getAvatar) api.Get("/avatar/{avatar}", h.getAvatar)
} }

View File

@ -1,4 +1,4 @@
package auth package util
import ( import (
"crypto/rsa" "crypto/rsa"

6
web/globals.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
interface Window {
guard: {
refreshing: boolean;
refreshQueue: ((token: string | null) => void)[];
};
}

View File

@ -0,0 +1,18 @@
import type { ApiService } from "@/types";
import { axios, handleApiError } from "..";
export interface FetchApiServicesResponse {
items: ApiService[];
count: number;
}
export const getApiServices = async (): Promise<FetchApiServicesResponse> => {
const response = await axios.get<FetchApiServicesResponse>(
"/api/v1/admin/api-services",
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};

View File

@ -12,7 +12,6 @@ export const axios = Axios.create({
}, },
}); });
let isRefreshing = false;
let refreshQueue: ((token: string | null) => void)[] = []; let refreshQueue: ((token: string | null) => void)[] = [];
const waitForTokenRefresh = () => { const waitForTokenRefresh = () => {
@ -60,7 +59,7 @@ const refreshToken = async (
} finally { } finally {
localStorage.removeItem("refreshing"); localStorage.removeItem("refreshing");
loadAccounts?.(); loadAccounts?.();
isRefreshing = false; window.guard.refreshing = false;
} }
}; };
@ -74,8 +73,9 @@ axios.interceptors.request.use(
return request; return request;
} }
if (!isRefreshing) { if (!window.guard.refreshing) {
isRefreshing = true; console.log(`request to ${request.url} is refreshing token`);
window.guard.refreshing = true;
try { try {
const { access } = await refreshToken( const { access } = await refreshToken(
account!.accountId, account!.accountId,
@ -87,7 +87,9 @@ axios.interceptors.request.use(
throw err; throw err;
} }
} else { } else {
console.log(`request to ${request.url} is waiting for token`);
token = await waitForTokenRefresh(); token = await waitForTokenRefresh();
console.log(`request to ${request.url} waited for token:`, token);
} }
if (!token) { if (!token) {

View File

@ -1,12 +1,12 @@
import type { FC } from "react"; import type { FC } from "react";
import { useBarItems } from "../tabs"; import { useBarItems } from "@/hooks/barItems";
import { Link } from "react-router"; import { Link } from "react-router";
const Sidebar: FC = () => { const Sidebar: FC = () => {
const [barItems, isActive] = useBarItems(); const [barItems, isActive] = useBarItems();
return ( return (
<div className="hidden sm:flex flex-col gap-2 items-stretch min-w-80 w-80 p-5 pt-18 min-h-screen select-none bg-white/65 dark:bg-black/65 shadow-lg shadow-gray-300 dark:shadow-gray-700"> <div className="hidden sm:flex flex-col gap-2 items-stretch border-r border-gray-700 dark:border-gray-800 min-w-80 w-80 p-5 pt-18 min-h-screen select-none">
{barItems.map((item) => ( {barItems.map((item) => (
<Link to={item.pathname} key={item.tab}> <Link to={item.pathname} key={item.tab}>
<div <div

View File

@ -1,5 +1,5 @@
import { type FC } from "react"; import { type FC } from "react";
import { useBarItems } from "../tabs"; import { useBarItems } from "@/hooks/barItems";
import { Link } from "react-router"; import { Link } from "react-router";
const TopBar: FC = () => { const TopBar: FC = () => {

View File

@ -19,18 +19,19 @@ const DashboardLayout: FC = () => {
</div> </div>
</div> </div>
<CardContent className="w-full space-y-4 flex-1" spacing={false}> <CardContent
className="w-full space-y-4 flex-1 bg-black/5 dark:bg-white/5"
spacing={false}
>
<div className="flex flex-row"> <div className="flex flex-row">
<Sidebar /> <Sidebar />
<div className="sm:p-4 max-w-full flex-1"> <div className="max-w-full flex-1">
<div className="flex flex-col w-full items-center gap-2"> <div className="flex flex-col w-full items-center gap-2">
<TopBar /> <TopBar />
</div> </div>
<div className="p-4">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
</div>
</CardContent> </CardContent>
</div> </div>
</Card> </Card>

View File

@ -4,6 +4,13 @@ import App from "./App";
import "./index.css"; import "./index.css";
import { OAuthProvider } from "./context/oauth/provider"; import { OAuthProvider } from "./context/oauth/provider";
if (typeof window.guard !== "object") {
window.guard = {
refreshing: false,
refreshQueue: [],
};
}
const root = document.getElementById("root")!; const root = document.getElementById("root")!;
createRoot(root).render( createRoot(root).render(

View File

@ -1,71 +1,102 @@
import type { FC } from "react"; import { useAdmin } from "@/store/admin";
import { useEffect, type FC } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
const services = [ // const services = [
{ // {
id: "1", // id: "1",
name: "User Service", // name: "User Service",
clientId: "user-svc-001", // clientId: "user-svc-001",
isActive: true, // isActive: true,
createdAt: "2024-09-15T10:20:30Z", // createdAt: "2024-09-15T10:20:30Z",
updatedAt: "2025-01-10T12:00:00Z", // updatedAt: "2025-01-10T12:00:00Z",
}, // },
{ // {
id: "2", // id: "2",
name: "Billing Service", // name: "Billing Service",
clientId: "billing-svc-009", // clientId: "billing-svc-009",
isActive: false, // isActive: false,
createdAt: "2024-10-01T08:45:10Z", // createdAt: "2024-10-01T08:45:10Z",
updatedAt: "2025-03-22T14:30:00Z", // updatedAt: "2025-03-22T14:30:00Z",
}, // },
{ // {
id: "3", // id: "3",
name: "Analytics Service", // name: "Analytics Service",
clientId: "analytics-svc-777", // clientId: "analytics-svc-777",
isActive: true, // isActive: true,
createdAt: "2024-11-25T16:00:00Z", // createdAt: "2024-11-25T16:00:00Z",
updatedAt: "2025-02-05T10:15:45Z", // updatedAt: "2025-02-05T10:15:45Z",
}, // },
{ // {
id: "4", // id: "4",
name: "Email Service", // name: "Email Service",
clientId: "email-svc-333", // clientId: "email-svc-333",
isActive: false, // isActive: false,
createdAt: "2023-07-10T13:00:00Z", // createdAt: "2023-07-10T13:00:00Z",
updatedAt: "2024-12-31T09:25:00Z", // updatedAt: "2024-12-31T09:25:00Z",
}, // },
]; // ];
const ApiServicesPage: FC = () => { const ApiServicesPage: FC = () => {
const apiServices = useAdmin((state) => state.apiServices);
const loading = useAdmin((state) => state.loadingApiServices);
const fetchApiServices = useAdmin((state) => state.fetchApiServices);
useEffect(() => {
fetchApiServices();
}, [fetchApiServices]);
return ( return (
<div className="overflow-x-auto rounded shadow-md dark:shadow-gray-800"> <div className="relative overflow-x-auto flex flex-col w-full h-full">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <div className="p-4">
<thead className="bg-gray-50 dark:bg-gray-800"> <p className="text-gray-800 dark:text-gray-300">Search...</p>
</div>
{loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm">
<div className="text-gray-800 dark:text-gray-200 font-medium">
Loading...
</div>
</div>
)}
<table className="min-w-full flex-1 border-l-0 border border-gray-700 dark:border-gray-800 border-collapse divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-black/5 dark:bg-white/5">
<tr> <tr>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-200"> <th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-700 dark:border-gray-800">
Name Name
</th> </th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-200"> <th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-700 dark:border-gray-800">
Client ID Client ID
</th> </th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-200"> <th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-700 dark:border-gray-800">
Is Active Is Active
</th> </th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-200"> <th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-700 dark:border-gray-800">
Created At Created At
</th> </th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-200"> <th className="px-6 py-3 text-left text-sm font-semibold text-gray-700 dark:text-white/70 border border-l-0 border-gray-700 dark:border-gray-800">
Updated At Updated At
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{services.map((service) => ( {!loading && apiServices.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
>
No services found.
</td>
</tr>
) : (
apiServices.map((service) => (
<tr <tr
key={service.id} key={service.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800" className="hover:bg-gray-50 dark:hover:bg-gray-800"
> >
<td className="px-6 py-4 text-sm font-medium text-blue-600"> <td className="px-6 py-4 text-sm font-medium text-blue-600 border border-gray-700">
<Link <Link
to={`/services/${service.id}`} to={`/services/${service.id}`}
className="hover:underline hover:text-gray-600 dark:hover:text-gray-300 transition-colors" className="hover:underline hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
@ -73,28 +104,29 @@ const ApiServicesPage: FC = () => {
{service.name} {service.name}
</Link> </Link>
</td> </td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300"> <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300 border border-gray-700">
{service.clientId} {service.client_id}
</td> </td>
<td className="px-6 py-4 text-sm"> <td className="px-6 py-4 text-sm border border-gray-700">
<span <span
className={`inline-block px-2 py-1 text-xs rounded-full font-semibold ${ className={`inline-block px-2 py-1 text-xs rounded-full font-semibold ${
service.isActive service.is_active
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300" ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300" : "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
}`} }`}
> >
{service.isActive ? "Yes" : "No"} {service.is_active ? "Yes" : "No"}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400"> <td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 border border-gray-700">
{new Date(service.createdAt).toLocaleString()} {new Date(service.created_at).toLocaleString()}
</td> </td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400"> <td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 border border-gray-700">
{new Date(service.updatedAt).toLocaleString()} {new Date(service.updated_at).toLocaleString()}
</td> </td>
</tr> </tr>
))} ))
)}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -7,7 +7,7 @@ const PersonalInfoPage: FC = () => {
const profile = useAuth((state) => state.profile); const profile = useAuth((state) => state.profile);
return ( return (
<> <div className="p-4 sm:p-8">
<h1 className="dark:text-gray-200 text-gray-800 text-2xl"> <h1 className="dark:text-gray-200 text-gray-800 text-2xl">
Your profile info in Home services Your profile info in Home services
</h1> </h1>
@ -61,7 +61,7 @@ const PersonalInfoPage: FC = () => {
</div> </div>
</div> </div>
</div> </div>
</> </div>
); );
}; };

28
web/src/store/admin.ts Normal file
View File

@ -0,0 +1,28 @@
import { getApiServices } from "@/api/admin/apiServices";
import type { ApiService } from "@/types";
import { create } from "zustand";
interface IAdminState {
apiServices: ApiService[];
loadingApiServices: boolean;
fetchApiServices: () => Promise<void>;
}
export const useAdmin = create<IAdminState>((set) => ({
apiServices: [],
loadingApiServices: false,
fetchApiServices: async () => {
set({ loadingApiServices: true });
try {
const response = await getApiServices();
set({ apiServices: response.items });
} catch (err) {
console.log("ERR: Failed to fetch services:", err);
} finally {
set({ loadingApiServices: false });
}
},
}));

View File

@ -9,3 +9,15 @@ export interface UserProfile {
updated_at: string; updated_at: string;
created_at: string; created_at: string;
} }
export interface ApiService {
id: string;
client_id: string;
name: string;
redirect_uris: string[];
scopes: string[];
grant_types: string[];
created_at: string;
updated_at: string;
is_active: boolean;
}

View File

@ -25,5 +25,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"] "include": ["src", "globals.d.ts"]
} }