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/*
tmp/
# Output of the go coverage tool, specifically when used with LiteIDE
*.out

View File

@ -1,11 +1,14 @@
# Project metadata
APP_NAME := hspguard
CMD_DIR := ./cmd/$(APP_NAME)
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 := ./...
GO_FILES := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
# Go tools
GO := go
@ -16,21 +19,20 @@ GOTEST := go test
# Build flags
LD_FLAGS := -s -w
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
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)
run:
$(GO) run $(CMD_DIR)
fmt:
$(GOFMT) -s -w $(GO_FILES)
$(GOFMT) -s -w .
lint:
$(GOLINT) run
@ -39,8 +41,7 @@ test:
$(GOTEST) -v $(PKG)
clean:
@rm -rf $(BIN_DIR)
@if [ -d "$(BIN_DIR)" ]; then rm -rf $(BIN_DIR); fi
mod:
$(GO) mod tidy

View File

@ -6,10 +6,9 @@ import (
"net/http"
"os"
"gitea.local/admin/hspguard/internal/apiservices"
"gitea.local/admin/hspguard/internal/admin"
"gitea.local/admin/hspguard/internal/auth"
"gitea.local/admin/hspguard/internal/config"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/oauth"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage"
@ -45,17 +44,7 @@ func (s *APIServer) Run() error {
oauthHandler := oauth.NewOAuthHandler(s.repo, s.cfg)
router.Route("/api/v1", func(r chi.Router) {
am := imiddleware.New(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 := user.NewUserHandler(s.repo, s.storage, s.cfg)
userHandler.RegisterRoutes(r)
authHandler := auth.NewAuthHandler(s.repo, s.cfg)
@ -63,8 +52,8 @@ func (s *APIServer) Run() error {
oauthHandler.RegisterRoutes(r)
apiServicesHandler := apiservices.New(s.repo, s.cfg)
apiServicesHandler.RegisterRoutes(r)
adminHandler := admin.New(s.repo, s.cfg)
adminHandler.RegisterRoutes(r)
})
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/storage"
"gitea.local/admin/hspguard/internal/user"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
)
@ -31,7 +31,7 @@ func main() {
ctx := context.Background()
conn, err := pgx.Connect(ctx, cfg.DatabaseURL)
conn, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalln("ERR: Failed to connect to db:", err)
return

2
go.mod
View File

@ -16,6 +16,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // 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/cpuid/v2 v2.2.10 // indirect
github.com/minio/crc64nvme v1.0.1 // indirect
@ -26,6 +27,7 @@ require (
github.com/tinylib/msgp v1.3.0 // indirect
golang.org/x/crypto 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/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 (
"encoding/json"
"log"
"net/http"
"gitea.local/admin/hspguard/internal/config"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type ApiServicesHandler struct {
type AdminHandler struct {
repo *repository.Queries
cfg *config.AppConfig
}
func New(repo *repository.Queries, cfg *config.AppConfig) *ApiServicesHandler {
return &ApiServicesHandler{
func New(repo *repository.Queries, cfg *config.AppConfig) *AdminHandler {
return &AdminHandler{
repo,
cfg,
}
}
func (h *ApiServicesHandler) RegisterRoutes(router chi.Router) {
router.Post("/api-services/create", h.Add)
func (h *AdminHandler) RegisterRoutes(router chi.Router) {
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 {
@ -35,24 +72,7 @@ type AddServiceRequest struct {
GrantTypes []string `json:"grant_types"`
}
func (h *ApiServicesHandler) Add(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
}
func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
var req AddServiceRequest
decoder := json.NewDecoder(r.Body)
@ -103,7 +123,7 @@ func (h *ApiServicesHandler) Add(w http.ResponseWriter, r *http.Request) {
service.ClientSecret = clientSecret
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)
}
}

View File

@ -3,11 +3,13 @@ package auth
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"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/types"
"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,
Subject: user.ID.String(),
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 {
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 {
return "", "", err
}
@ -66,9 +68,17 @@ func NewAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *AuthHandle
}
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
api.Get("/auth/profile", h.getProfile)
api.Post("/auth/login", h.login)
api.Post("/auth/refresh", h.refreshToken)
api.Route("/auth", func(r chi.Router) {
r.Group(func(protected chi.Router) {
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) {
@ -85,7 +95,7 @@ func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
}
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 {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
return
@ -184,6 +194,8 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("DEBUG: looking for user with following params: %#v\n", params)
user, err := h.repo.FindUserEmail(r.Context(), params.Email)
if err != nil {
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"
"strings"
"gitea.local/admin/hspguard/internal/auth"
"gitea.local/admin/hspguard/internal/config"
"gitea.local/admin/hspguard/internal/types"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
)
@ -16,7 +16,7 @@ type AuthMiddleware struct {
cfg *config.AppConfig
}
func New(cfg *config.AppConfig) *AuthMiddleware {
func NewAuthMiddleware(cfg *config.AppConfig) *AuthMiddleware {
return &AuthMiddleware{
cfg,
}
@ -37,9 +37,9 @@ func (m *AuthMiddleware) Runner(next http.Handler) http.Handler {
}
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 {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
web.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
return
}

View File

@ -9,7 +9,6 @@ import (
"strings"
"time"
"gitea.local/admin/hspguard/internal/auth"
"gitea.local/admin/hspguard/internal/config"
"gitea.local/admin/hspguard/internal/repository"
"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) {
r.Post("/oauth/token", h.tokenEndpoint)
func (h *OAuthHandler) RegisterRoutes(router chi.Router) {
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) {
pubKey, err := auth.ParseBase64PublicKey(h.cfg.Jwt.PublicKey)
pubKey, err := util.ParseBase64PublicKey(h.cfg.Jwt.PublicKey)
if err != nil {
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 {
web.Error(w, "failed to sign id token", http.StatusInternalServerError)
return

View File

@ -11,6 +11,8 @@ import (
"strings"
"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/storage"
"gitea.local/admin/hspguard/internal/util"
@ -24,18 +26,26 @@ import (
type UserHandler struct {
repo *repository.Queries
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{
repo: repo,
minio: minio,
repo,
minio,
cfg,
}
}
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.Put("/avatar", h.uploadAvatar)
api.Get("/avatar/{avatar}", h.getAvatar)
}

View File

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

View File

@ -36,4 +36,4 @@ WHERE client_id = $1;
UPDATE api_services
SET client_secret = $2,
updated_at = NOW()
WHERE client_id = $1;
WHERE client_id = $1;

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

View File

@ -1,12 +1,12 @@
import type { FC } from "react";
import { useBarItems } from "../tabs";
import { useBarItems } from "@/hooks/barItems";
import { Link } from "react-router";
const Sidebar: FC = () => {
const [barItems, isActive] = useBarItems();
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) => (
<Link to={item.pathname} key={item.tab}>
<div

View File

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

View File

@ -19,16 +19,17 @@ const DashboardLayout: FC = () => {
</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">
<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">
<TopBar />
</div>
<div className="p-4">
<Outlet />
</div>
<Outlet />
</div>
</div>
</CardContent>

View File

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

View File

@ -1,100 +1,132 @@
import type { FC } from "react";
import { useAdmin } from "@/store/admin";
import { useEffect, type FC } from "react";
import { Link } from "react-router";
const services = [
{
id: "1",
name: "User Service",
clientId: "user-svc-001",
isActive: true,
createdAt: "2024-09-15T10:20:30Z",
updatedAt: "2025-01-10T12:00:00Z",
},
{
id: "2",
name: "Billing Service",
clientId: "billing-svc-009",
isActive: false,
createdAt: "2024-10-01T08:45:10Z",
updatedAt: "2025-03-22T14:30:00Z",
},
{
id: "3",
name: "Analytics Service",
clientId: "analytics-svc-777",
isActive: true,
createdAt: "2024-11-25T16:00:00Z",
updatedAt: "2025-02-05T10:15:45Z",
},
{
id: "4",
name: "Email Service",
clientId: "email-svc-333",
isActive: false,
createdAt: "2023-07-10T13:00:00Z",
updatedAt: "2024-12-31T09:25:00Z",
},
];
// const services = [
// {
// id: "1",
// name: "User Service",
// clientId: "user-svc-001",
// isActive: true,
// createdAt: "2024-09-15T10:20:30Z",
// updatedAt: "2025-01-10T12:00:00Z",
// },
// {
// id: "2",
// name: "Billing Service",
// clientId: "billing-svc-009",
// isActive: false,
// createdAt: "2024-10-01T08:45:10Z",
// updatedAt: "2025-03-22T14:30:00Z",
// },
// {
// id: "3",
// name: "Analytics Service",
// clientId: "analytics-svc-777",
// isActive: true,
// createdAt: "2024-11-25T16:00:00Z",
// updatedAt: "2025-02-05T10:15:45Z",
// },
// {
// id: "4",
// name: "Email Service",
// clientId: "email-svc-333",
// isActive: false,
// createdAt: "2023-07-10T13:00:00Z",
// updatedAt: "2024-12-31T09:25:00Z",
// },
// ];
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 (
<div className="overflow-x-auto rounded shadow-md dark:shadow-gray-800">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<div className="relative overflow-x-auto flex flex-col w-full h-full">
<div className="p-4">
<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>
<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
</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
</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
</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
</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
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{services.map((service) => (
<tr
key={service.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800"
>
<td className="px-6 py-4 text-sm font-medium text-blue-600">
<Link
to={`/services/${service.id}`}
className="hover:underline hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{service.name}
</Link>
</td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
{service.clientId}
</td>
<td className="px-6 py-4 text-sm">
<span
className={`inline-block px-2 py-1 text-xs rounded-full font-semibold ${
service.isActive
? "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"
}`}
>
{service.isActive ? "Yes" : "No"}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{new Date(service.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{new Date(service.updatedAt).toLocaleString()}
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{!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
key={service.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800"
>
<td className="px-6 py-4 text-sm font-medium text-blue-600 border border-gray-700">
<Link
to={`/services/${service.id}`}
className="hover:underline hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{service.name}
</Link>
</td>
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300 border border-gray-700">
{service.client_id}
</td>
<td className="px-6 py-4 text-sm border border-gray-700">
<span
className={`inline-block px-2 py-1 text-xs rounded-full font-semibold ${
service.is_active
? "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"
}`}
>
{service.is_active ? "Yes" : "No"}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 border border-gray-700">
{new Date(service.created_at).toLocaleString()}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 border border-gray-700">
{new Date(service.updated_at).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>

View File

@ -7,7 +7,7 @@ const PersonalInfoPage: FC = () => {
const profile = useAuth((state) => state.profile);
return (
<>
<div className="p-4 sm:p-8">
<h1 className="dark:text-gray-200 text-gray-800 text-2xl">
Your profile info in Home services
</h1>
@ -61,7 +61,7 @@ const PersonalInfoPage: FC = () => {
</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;
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,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": ["src", "globals.d.ts"]
}