Compare commits
267 Commits
3279f1fb90
...
93a5cd7c70
Author | SHA1 | Date | |
---|---|---|---|
93a5cd7c70 | |||
951de989af | |||
c5cf253a15 | |||
d70032e36d | |||
445ac50537 | |||
c13e564b01 | |||
5d3a77133d | |||
44592ebc08 | |||
1b941cb0c3 | |||
1cb520c2b6 | |||
b3fdd3bc18 | |||
9110db2f08 | |||
5972735102 | |||
14c69349cc | |||
3ceeab04e1 | |||
b7a67c208f | |||
cee885a84d | |||
4b496ea9bd | |||
570ae6ac8c | |||
f0d3a61e7b | |||
b09567620f | |||
2209846525 | |||
108ed61961 | |||
b73bfd590b | |||
19d56159ba | |||
b9ccf6adac | |||
9a0870dbbc | |||
70429f69a2 | |||
ad635008eb | |||
eacc8fdd89 | |||
d309fb3f57 | |||
f4fd993679 | |||
016879b53f | |||
70bba15cda | |||
57daf175ab | |||
0817a65272 | |||
13f9da1a67 | |||
83535acf1c | |||
441ce2daca | |||
f9848d2110 | |||
7f0511b0d4 | |||
a27f2ad593 | |||
715a984241 | |||
66e1756ade | |||
849403a137 | |||
8d15c9b8b2 | |||
87af1834cf | |||
357583f54d | |||
aa6de76ded | |||
ab3c2d1eb0 | |||
644cf2a358 | |||
5b1ed9925d | |||
4071a50a37 | |||
dd7c51efd8 | |||
8902f4d187 | |||
6666b20464 | |||
c5f288ba1e | |||
cc49ab1655 | |||
06c60b3491 | |||
b584a7b07f | |||
410e420a46 | |||
eeb0f6eac1 | |||
fb622f918a | |||
a50bad417f | |||
c395729446 | |||
eaa92d2fe4 | |||
a9e382d713 | |||
974244025e | |||
ae41076673 | |||
cc7f7f40c4 | |||
e2ae03f2a6 | |||
9319564dea | |||
83e3e5a2e9 | |||
2b40e4e922 | |||
ed33d03fda | |||
34c152a459 | |||
ad09e98bba | |||
d3fd5cba16 | |||
64dbb4368c | |||
cb3a6ddc58 | |||
e774f415d8 | |||
d5a22895e7 | |||
9983c51e3a | |||
6a1fc193f4 | |||
118877f727 | |||
7b8fe6baf2 | |||
e85b23b3e8 | |||
6164b77bee | |||
8b5a5744ab | |||
d9e9c5ab38 | |||
0dcef81b59 | |||
426b70a1de | |||
912973cdb5 | |||
e4ff799f05 | |||
320715f5aa | |||
a3b04b6243 | |||
f610d7480f | |||
11ac92a026 | |||
98ae3e06e9 | |||
a1146ce371 | |||
a67ec7e78c | |||
9895392b50 | |||
c6998f33e1 | |||
81659181e4 | |||
849b5935c2 | |||
c27d837ab0 | |||
92e9b87227 | |||
243b7cce33 | |||
76d960619f | |||
3e59c78287 | |||
6cd9da69ab | |||
29b97a87b3 | |||
8bc4603274 | |||
cc60a1ba86 | |||
89c7dc43e5 | |||
95c330568d | |||
900d314a95 | |||
0d8a3b1b39 | |||
4b7396c210 | |||
d4e2cbdd4f | |||
5024ac8151 | |||
3bf08c5933 | |||
b42da50306 | |||
0efc90567b | |||
a5466f1b10 | |||
8e946cbee5 | |||
a3a6b5e4d7 | |||
ad0a0f5626 | |||
2389058ddc | |||
ce44ef3e62 | |||
9ee30d1e23 | |||
886d0a7f5c | |||
cfdf419460 | |||
930e069aee | |||
1ef261660f | |||
5d9e5d27bf | |||
3f8a4024ce | |||
e8a74999c3 | |||
dc2ce1f349 | |||
bce775f692 | |||
45bce711f2 | |||
8ab2ddbe8e | |||
7f9b719b2b | |||
2b7f4995ef | |||
321f4087e1 | |||
9d19b470bc | |||
68493be36e | |||
f8772f8de2 | |||
d451331c66 | |||
485cfc2d12 | |||
944c650ab3 | |||
dfc5587608 | |||
96bdbfda95 | |||
05a234b7a5 | |||
2f58c01c24 | |||
e92dde20ca | |||
63437d6dc7 | |||
cef9dae4d3 | |||
0d7b1355d5 | |||
a213ea85d0 | |||
5c43f6d72a | |||
e49c0bbe45 | |||
413a11ee63 | |||
4a112318bd | |||
9897eb1f5d | |||
3fc7ceac23 | |||
aa48c21466 | |||
0ca2bb3f89 | |||
cd5adcdc3f | |||
62c90d0597 | |||
665d12a828 | |||
4d455fd62e | |||
348aacfde2 | |||
de17870bdb | |||
54581742dc | |||
639575dae0 | |||
800e1afbe5 | |||
8abc4396ac | |||
70f860824c | |||
3c5e31cbb2 | |||
3923b428a4 | |||
5b816c6873 | |||
66edadfeda | |||
b872722e07 | |||
091218b42d | |||
03697b2f67 | |||
8a28fca3d9 | |||
1ab4113040 | |||
013f300513 | |||
45e31b41ca | |||
182f30f1ba | |||
7c97ebd84f | |||
9fefe3ac71 | |||
ca3006c428 | |||
51b7e6b3f9 | |||
db2cb36f54 | |||
78e84567c7 | |||
0423b3803f | |||
60e317b9e4 | |||
aa18b9f3e2 | |||
d9ca1ce2b4 | |||
41c3dfdfe4 | |||
725cc74102 | |||
83c26bb94a
|
|||
6be3aa07a1
|
|||
54021c3021
|
|||
4b3a814d7e | |||
dd5c59afa8 | |||
0723a48ab0 | |||
ffefee930a | |||
a7ddd3d1ff | |||
21cedeabbd | |||
807d7538a0 | |||
8364a8e9ec | |||
56755ac531 | |||
e4d83e75a0 | |||
3dd91cf238 | |||
03d6730151 | |||
aa152a4127 | |||
a1ed1113d9 | |||
2187c873ee | |||
595015f324 | |||
8504f9c230 | |||
04db9b8ef2 | |||
e983719601 | |||
c5c55f72b1 | |||
c445756296 | |||
0166e62e98 | |||
8e22a3ac05 | |||
05ee30f6db | |||
dd8c453c54 | |||
52870cb541 | |||
14b37c2220 | |||
5604a824fe | |||
7d0ddd4d77 | |||
07b9b94143 | |||
b95dcc6230 | |||
e8bad71f21 | |||
4df7561dd3 | |||
8f753b2561 | |||
11748bb68e | |||
491c9a824d | |||
476b9a13d9 | |||
42665fffbb | |||
a157a3ec0e | |||
024d07fdd6 | |||
23845e25dd | |||
47209c311c | |||
2caef38ce6 | |||
e88980e64f | |||
159e4ad0e2 | |||
6e2d67ad24 | |||
d46e296ce1 | |||
e98806e96f | |||
0ab82e2503 | |||
34c1ce7652 | |||
428dc50aa1 | |||
2663264f50 | |||
ffba961d72 | |||
5a939c0771 | |||
87916f96fd | |||
c6c03e9cb6 | |||
6fd7171450 | |||
ae07d2d3d9 | |||
65545a0d71 | |||
d423d9ba62 | |||
d64c8479f8 |
63
.air.toml
Normal file
63
.air.toml
Normal 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
|
28
.env.example
28
.env.example
@ -1,22 +1,24 @@
|
||||
|
||||
PORT=3001
|
||||
HOST="127.0.0.1"
|
||||
GUARD_PORT=3001
|
||||
GUARD_HOST="127.0.0.1"
|
||||
GUARD_URI="http://localhost:3001"
|
||||
|
||||
DATABASE_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable"
|
||||
GUARD_DB_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable"
|
||||
|
||||
ADMIN_NAME="admin"
|
||||
ADMIN_EMAIL="admin@test.net"
|
||||
ADMIN_PASSWORD="secret"
|
||||
GUARD_REDIS_URL="redis://guard:guard@localhost:6379/0"
|
||||
|
||||
JWT_PRIVATE_KEY="ecdsa"
|
||||
JWT_PUBLIC_KEY="ecdsa"
|
||||
GUARD_ADMIN_NAME="admin"
|
||||
GUARD_ADMIN_EMAIL="admin@test.net"
|
||||
GUARD_ADMIN_PASSWORD="secret"
|
||||
|
||||
MINIO_ENDPOINT="localhost:9000"
|
||||
MINIO_ACCESS_KEY=""
|
||||
MINIO_SECRET_KEY=""
|
||||
GUARD_JWT_PRIVATE="rsa"
|
||||
GUARD_JWT_PUBLIC="rsa"
|
||||
GUARD_JWT_KID="my-rsa-key-1"
|
||||
|
||||
GUARD_MINIO_ENDPOINT="localhost:9000"
|
||||
GUARD_MINIO_ACCESS_KEY=""
|
||||
GUARD_MINIO_SECRET_KEY=""
|
||||
|
||||
GOOSE_DRIVER="postgres"
|
||||
GOOSE_DBSTRING=$DATABASE_URL
|
||||
GOOSE_MIGRATION_DIR="./migrations"
|
||||
|
||||
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -13,6 +13,8 @@
|
||||
|
||||
bin/*
|
||||
|
||||
tmp/
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
@ -29,4 +31,6 @@ go.work.sum
|
||||
# key files
|
||||
*.pem
|
||||
|
||||
NUL
|
||||
|
||||
dist/
|
||||
|
55
Dockerfile
Normal file
55
Dockerfile
Normal file
@ -0,0 +1,55 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM node:22 AS frontend-builder
|
||||
WORKDIR /app/web
|
||||
COPY web/ .
|
||||
RUN npm install && npm run build
|
||||
|
||||
# Stage 2: Build backend
|
||||
FROM golang:1.24 AS backend-builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
# Copy built frontend into Go embed path (adjust if needed)
|
||||
# COPY --from=frontend-builder /app/web/dist ./web/dist
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux make build
|
||||
|
||||
# Stage 3: Final image
|
||||
FROM debian:bookworm-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install CA certificates for HTTPS
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=backend-builder /app/bin/hspguard .
|
||||
COPY --from=frontend-builder /app/dist ./dist
|
||||
|
||||
COPY redis.conf /config/redis.conf
|
||||
|
||||
# Optional: copy default .env file if used
|
||||
# COPY .env .env
|
||||
|
||||
# Set environment variables (can be overridden at runtime)
|
||||
ENV ENV=production \
|
||||
GUARD_PORT=3001 \
|
||||
GUARD_HOST="127.0.0.1" \
|
||||
GUARD_URI="http://localhost:3001" \
|
||||
GUARD_DB_URL="postgres://user:user@localhost:5432/db?sslmode=disable" \
|
||||
GUARD_ADMIN_NAME="admin" \
|
||||
GUARD_ADMIN_EMAIL="admin@test.net" \
|
||||
GUARD_ADMIN_PASSWORD="secret" \
|
||||
GUARD_JWT_PRIVATE="rsa" \
|
||||
GUARD_JWT_PUBLIC="rsa" \
|
||||
GUARD_JWT_KID="my-rsa-key-1" \
|
||||
GUARD_MINIO_ENDPOINT="localhost:9000" \
|
||||
GUARD_MINIO_ACCESS_KEY="" \
|
||||
GUARD_MINIO_SECRET_KEY="" \
|
||||
GOOSE_DRIVER="postgres" \
|
||||
GOOSE_DBSTRING=$GUARD_DB_URL \
|
||||
GOOSE_MIGRATION_DIR="./migrations"
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["./hspguard"]
|
19
Makefile
19
Makefile
@ -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
|
||||
|
||||
|
@ -6,8 +6,11 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/admin"
|
||||
"gitea.local/admin/hspguard/internal/auth"
|
||||
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"gitea.local/admin/hspguard/internal/oauth"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/storage"
|
||||
"gitea.local/admin/hspguard/internal/user"
|
||||
@ -19,13 +22,17 @@ type APIServer struct {
|
||||
addr string
|
||||
repo *repository.Queries
|
||||
storage *storage.FileStorage
|
||||
cache *cache.Client
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage) *APIServer {
|
||||
func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage, cache *cache.Client, cfg *config.AppConfig) *APIServer {
|
||||
return &APIServer{
|
||||
addr: addr,
|
||||
repo: db,
|
||||
storage: minio,
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,16 +44,24 @@ func (s *APIServer) Run() error {
|
||||
// staticDir := http.Dir(filepath.Join(workDir, "static"))
|
||||
// FileServer(router, "/static", staticDir)
|
||||
|
||||
router.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(imiddleware.WithSkipper(imiddleware.AuthMiddleware, "/api/v1/login", "/api/v1/register"))
|
||||
oauthHandler := oauth.NewOAuthHandler(s.repo, s.cache, s.cfg)
|
||||
|
||||
userHandler := user.NewUserHandler(s.repo, s.storage)
|
||||
router.Route("/api/v1", func(r chi.Router) {
|
||||
userHandler := user.NewUserHandler(s.repo, s.storage, s.cfg)
|
||||
userHandler.RegisterRoutes(r)
|
||||
|
||||
authHandler := auth.NewAuthHandler(s.repo)
|
||||
authHandler := auth.NewAuthHandler(s.repo, s.cache, s.cfg)
|
||||
authHandler.RegisterRoutes(r)
|
||||
|
||||
oauthHandler.RegisterRoutes(r)
|
||||
|
||||
adminHandler := admin.New(s.repo, s.cfg)
|
||||
adminHandler.RegisterRoutes(r)
|
||||
})
|
||||
|
||||
router.Get("/.well-known/jwks.json", oauthHandler.WriteJWKS)
|
||||
router.Get("/.well-known/openid-configuration", oauthHandler.OpenIdConfiguration)
|
||||
|
||||
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
path := "./dist" + r.URL.Path
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
|
@ -7,23 +7,32 @@ import (
|
||||
"os"
|
||||
|
||||
"gitea.local/admin/hspguard/cmd/hspguard/api"
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"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"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := godotenv.Load()
|
||||
if err != nil && os.Getenv("ENV") != "production" {
|
||||
log.Fatalln("WARNING: .env file not found. Skipping...")
|
||||
}
|
||||
|
||||
var cfg config.AppConfig
|
||||
|
||||
err = config.LoadEnv(&cfg)
|
||||
if err != nil {
|
||||
log.Fatalln("ERR: Failed to load environment variables:", err)
|
||||
log.Fatal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
conn, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL"))
|
||||
conn, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalln("ERR: Failed to connect to db:", err)
|
||||
return
|
||||
@ -31,21 +40,13 @@ func main() {
|
||||
|
||||
repo := repository.New(conn)
|
||||
|
||||
fStorage := storage.New()
|
||||
fStorage := storage.New(&cfg)
|
||||
|
||||
user.EnsureAdminUser(ctx, repo)
|
||||
cache := cache.NewClient(&cfg)
|
||||
|
||||
host := os.Getenv("HOST")
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
user.EnsureAdminUser(ctx, &cfg, repo)
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "3000"
|
||||
}
|
||||
|
||||
server := api.NewAPIServer(fmt.Sprintf("%s:%s", host, port), repo, fStorage)
|
||||
server := api.NewAPIServer(fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), repo, fStorage, cache, &cfg)
|
||||
if err := server.Run(); err != nil {
|
||||
log.Fatalln("ERR: Failed to start server:", err)
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
@ -10,3 +9,17 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
cache:
|
||||
image: redis:7.2 # or newer
|
||||
container_name: guard-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
- ./redis.conf:/usr/local/etc/redis/redis.conf
|
||||
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
driver: local
|
||||
|
5
go.mod
5
go.mod
@ -11,21 +11,26 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
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
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.92 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/redis/go-redis/v9 v9.10.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
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
|
||||
)
|
||||
|
6
go.sum
6
go.sum
@ -1,6 +1,10 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
@ -38,6 +42,8 @@ github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1Gsh
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/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/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs=
|
||||
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
342
internal/admin/apiservices.go
Normal file
342
internal/admin/apiservices.go
Normal file
@ -0,0 +1,342 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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 ApiServiceDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
IconUrl *string `json:"icon_url"`
|
||||
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,
|
||||
Description: service.Description,
|
||||
IconUrl: service.IconUrl,
|
||||
RedirectUris: service.RedirectUris,
|
||||
Scopes: service.Scopes,
|
||||
GrantTypes: service.GrantTypes,
|
||||
CreatedAt: service.CreatedAt,
|
||||
UpdatedAt: service.UpdatedAt,
|
||||
IsActive: service.IsActive,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Items: apiServices,
|
||||
Count: len(apiServices),
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type AddServiceRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type ApiServiceCredentials struct {
|
||||
ClientId string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) AddApiService(w http.ResponseWriter, r *http.Request) {
|
||||
var req AddServiceRequest
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
web.Error(w, "failed to parse request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
web.Error(w, "name is required for an api service", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
clientId, err := util.GenerateClientID()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate client id", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
clientSecret, err := util.GenerateClientSecret()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate client secret", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
hashSecret, err := util.HashPassword(clientSecret)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to create client secret", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
params := repository.CreateApiServiceParams{
|
||||
ClientID: clientId,
|
||||
ClientSecret: hashSecret,
|
||||
Name: req.Name,
|
||||
RedirectUris: req.RedirectUris,
|
||||
Scopes: req.Scopes,
|
||||
GrantTypes: req.GrantTypes,
|
||||
IsActive: req.IsActive,
|
||||
}
|
||||
|
||||
if req.Description != "" {
|
||||
params.Description = &req.Description
|
||||
}
|
||||
|
||||
service, err := h.repo.CreateApiService(r.Context(), params)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to create new api service", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
service.ClientSecret = clientSecret
|
||||
|
||||
type Response struct {
|
||||
Service ApiServiceDTO `json:"service"`
|
||||
Credentials ApiServiceCredentials `json:"credentials"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Service: NewApiServiceDTO(service),
|
||||
Credentials: ApiServiceCredentials{
|
||||
ClientId: service.ClientID,
|
||||
ClientSecret: service.ClientSecret,
|
||||
},
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetApiService(w http.ResponseWriter, r *http.Request) {
|
||||
serviceId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(serviceId)
|
||||
if err != nil {
|
||||
web.Error(w, "service id provided is not a valid uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
|
||||
if err != nil {
|
||||
web.Error(w, "service with provided id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(NewApiServiceDTO(service)); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetApiServiceCID(w http.ResponseWriter, r *http.Request) {
|
||||
clientId := chi.URLParam(r, "client_id")
|
||||
|
||||
service, err := h.repo.GetApiServiceCID(r.Context(), clientId)
|
||||
if err != nil {
|
||||
web.Error(w, "service with provided client id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(NewApiServiceDTO(service)); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RegenerateApiServiceSecret(w http.ResponseWriter, r *http.Request) {
|
||||
serviceId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(serviceId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
|
||||
if err != nil {
|
||||
web.Error(w, "service with provided id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
clientSecret, err := util.GenerateClientSecret()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate client secret", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateClientSecret(r.Context(), repository.UpdateClientSecretParams{
|
||||
ClientID: service.ClientID,
|
||||
ClientSecret: clientSecret,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to update client secret for service", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(ApiServiceCredentials{
|
||||
ClientId: service.ClientID,
|
||||
ClientSecret: clientSecret,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to send credentials", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateApiServiceRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateApiService(w http.ResponseWriter, r *http.Request) {
|
||||
serviceId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(serviceId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateApiServiceRequest
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
web.Error(w, "missing required fields to update service", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
web.Error(w, "service name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Scopes) == 0 {
|
||||
web.Error(w, "at least 1 scope is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
|
||||
if err != nil {
|
||||
web.Error(w, "service with provided id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.repo.UpdateApiService(r.Context(), repository.UpdateApiServiceParams{
|
||||
ClientID: service.ClientID,
|
||||
Name: req.Name,
|
||||
Description: &req.Description,
|
||||
RedirectUris: req.RedirectUris,
|
||||
Scopes: req.Scopes,
|
||||
GrantTypes: req.GrantTypes,
|
||||
})
|
||||
if err != nil {
|
||||
web.Error(w, "failed to update api service", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(NewApiServiceDTO(updated)); err != nil {
|
||||
web.Error(w, "failed to send updated api service", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ToggleApiService(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
|
||||
serviceId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(serviceId)
|
||||
if err != nil {
|
||||
web.Error(w, "provided service id is not valid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service, err := h.repo.GetApiServiceId(r.Context(), parsed)
|
||||
if err != nil {
|
||||
web.Error(w, "service with provided id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if service.IsActive {
|
||||
log.Println("INFO: Service is active. Deactivating...")
|
||||
err = h.repo.DeactivateApiService(r.Context(), service.ClientID)
|
||||
} else {
|
||||
log.Println("INFO: Service is inactive. Activating...")
|
||||
err = h.repo.ActivateApiService(r.Context(), service.ClientID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to toggle api service (cid: %s): %v\n", service.ClientID, err)
|
||||
web.Error(w, "failed to toggle api service", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
41
internal/admin/routes.go
Normal file
41
internal/admin/routes.go
Normal file
@ -0,0 +1,41 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
repo *repository.Queries
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func New(repo *repository.Queries, cfg *config.AppConfig) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
repo,
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
|
||||
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.Get("/api-services/{id}", h.GetApiService)
|
||||
r.Post("/api-services", h.AddApiService)
|
||||
r.Patch("/api-services/{id}", h.RegenerateApiServiceSecret)
|
||||
r.Put("/api-services/{id}", h.UpdateApiService)
|
||||
r.Patch("/api-services/toggle/{id}", h.ToggleApiService)
|
||||
|
||||
r.Get("/users", h.GetUsers)
|
||||
r.Post("/users", h.CreateUser)
|
||||
r.Get("/users/{id}", h.GetUser)
|
||||
})
|
||||
|
||||
router.Get("/api-services/client/{client_id}", h.GetApiServiceCID)
|
||||
}
|
165
internal/admin/users.go
Normal file
165
internal/admin/users.go
Normal file
@ -0,0 +1,165 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) GetUsers(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, "failed to get access information", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.repo.FindAdminUsers(r.Context(), &user.ID)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to query users from db:", err)
|
||||
web.Error(w, "failed to get all users", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []types.UserDTO `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
var items []types.UserDTO
|
||||
|
||||
for _, user := range users {
|
||||
items = append(items, types.NewUserDTO(&user))
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(&Response{
|
||||
Items: items,
|
||||
Count: len(items),
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to send response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
userId := chi.URLParam(r, "id")
|
||||
parsed, err := uuid.Parse(userId)
|
||||
if err != nil {
|
||||
web.Error(w, "user id provided is not a valid uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), parsed)
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided id not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(types.NewUserDTO(&user)); err != nil {
|
||||
web.Error(w, "failed to encode user dto", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateUser(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, "failed to get access information", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateUserRequest
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
web.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email == "" {
|
||||
web.Error(w, "email is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.FullName == "" {
|
||||
web.Error(w, "full name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Password == "" {
|
||||
web.Error(w, "password is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.repo.FindUserEmail(r.Context(), req.Email)
|
||||
if err == nil {
|
||||
web.Error(w, "user with provided email already exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := util.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to hash password for new user:", err)
|
||||
web.Error(w, "failed to create user account", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
params := repository.InsertUserParams{
|
||||
Email: req.Email,
|
||||
FullName: req.FullName,
|
||||
PasswordHash: hash,
|
||||
IsAdmin: false,
|
||||
CreatedBy: &user.ID,
|
||||
}
|
||||
|
||||
log.Println("INFO: params for user creation:", params)
|
||||
|
||||
id, err := h.repo.InsertUser(r.Context(), params)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to insert user into database:", err)
|
||||
web.Error(w, "failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
if err := encoder.Encode(Response{
|
||||
ID: id.String(),
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
@ -1,91 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func parseBase64PrivateKey(envVar string) (*ecdsa.PrivateKey, error) {
|
||||
b64 := os.Getenv(envVar)
|
||||
if b64 == "" {
|
||||
return nil, fmt.Errorf("env var %s is empty", envVar)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
|
||||
}
|
||||
|
||||
return x509.ParseECPrivateKey(decoded)
|
||||
}
|
||||
|
||||
func parseBase64PublicKey(envVar string) (*ecdsa.PublicKey, error) {
|
||||
b64 := os.Getenv(envVar)
|
||||
if b64 == "" {
|
||||
return nil, fmt.Errorf("env var %s is empty", envVar)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
|
||||
}
|
||||
|
||||
pubInterface, err := x509.ParsePKIXPublicKey(decoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public key: %v", err)
|
||||
}
|
||||
|
||||
pubKey, ok := pubInterface.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not an ECDSA public key")
|
||||
}
|
||||
|
||||
return pubKey, nil
|
||||
}
|
||||
|
||||
func SignJwtToken(claims jwt.Claims) (string, error) {
|
||||
privateKey, err := parseBase64PrivateKey("JWT_PRIVATE_KEY")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
s, err := token.SignedString(privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func VerifyToken(token string) (*jwt.Token, *types.UserClaims, error) {
|
||||
publicKey, err := parseBase64PublicKey("JWT_PUBLIC_KEY")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
claims := &types.UserClaims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
if !parsed.Valid {
|
||||
return nil, nil, fmt.Errorf("token is not valid")
|
||||
}
|
||||
|
||||
return parsed, claims, nil
|
||||
}
|
||||
|
81
internal/auth/login.go
Normal file
81
internal/auth/login.go
Normal file
@ -0,0 +1,81 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
)
|
||||
|
||||
type LoginParams struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||
var params LoginParams
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(¶ms); err != nil {
|
||||
web.Error(w, "failed to parse request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if params.Email == "" || params.Password == "" {
|
||||
web.Error(w, "missing required fields", http.StatusBadRequest)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if !util.VerifyPassword(params.Password, user.PasswordHash) {
|
||||
web.Error(w, "username or/and password are incorrect", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
access, refresh, err := h.signTokens(&user)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateLastLogin(r.Context(), user.ID); err != nil {
|
||||
web.Error(w, "failed to update user's last login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
type Response struct {
|
||||
AccessToken string `json:"access"`
|
||||
RefreshToken string `json:"refresh"`
|
||||
// fields required for UI in account selector, e.g. email, full name and avatar
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Id string `json:"id"`
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
// Avatar
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
Id: user.ID.String(),
|
||||
ProfilePicture: user.ProfilePicture,
|
||||
// Avatar
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
31
internal/auth/profile.go
Normal file
31
internal/auth/profile.go
Normal file
@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) getProfile(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
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(types.NewUserDTO(&user)); err != nil {
|
||||
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
82
internal/auth/refresh.go
Normal file
82
internal/auth/refresh.go
Normal file
@ -0,0 +1,82 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
web.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, "Bearer ")
|
||||
if len(parts) != 2 {
|
||||
web.Error(w, "invalid auth header format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
var userClaims types.UserClaims
|
||||
|
||||
token, err := util.VerifyToken(tokenStr, h.cfg.Jwt.PublicKey, &userClaims)
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
expire, err := userClaims.GetExpirationTime()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(expire.Time) {
|
||||
web.Error(w, "token is expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := uuid.Parse(userClaims.Subject)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to parsej user id from token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), userId)
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
access, refresh, err := h.signTokens(&user)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
AccessToken string `json:"access"`
|
||||
RefreshToken string `json:"refresh"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
@ -1,146 +1,81 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"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"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
repo *repository.Queries
|
||||
repo *repository.Queries
|
||||
cache *cache.Client
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func NewAuthHandler(repo *repository.Queries) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
|
||||
api.Get("/profile", h.getProfile)
|
||||
api.Post("/login", h.login)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getProfile(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 err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"full_name": user.FullName,
|
||||
"email": user.Email,
|
||||
"phoneNumber": user.PhoneNumber,
|
||||
"isAdmin": user.IsAdmin,
|
||||
"last_login": user.LastLogin,
|
||||
"profile_picture": user.ProfilePicture.String,
|
||||
"updated_at": user.UpdatedAt,
|
||||
"created_at": user.CreatedAt,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type LoginParams struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||
var params LoginParams
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(¶ms); err != nil {
|
||||
web.Error(w, "failed to parse request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if params.Email == "" || params.Password == "" {
|
||||
web.Error(w, "missing required fields", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserEmail(r.Context(), params.Email)
|
||||
if err != nil {
|
||||
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !util.VerifyPassword(params.Password, user.PasswordHash) {
|
||||
web.Error(w, "username or/and password are incorrect", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) {
|
||||
accessClaims := types.UserClaims{
|
||||
UserEmail: user.Email,
|
||||
IsAdmin: user.IsAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "hspguard",
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: user.ID.String(),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
},
|
||||
}
|
||||
|
||||
accessToken, err := SignJwtToken(accessClaims)
|
||||
accessToken, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
web.Error(w, fmt.Sprintf("failed to generate access token: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
refreshClaims := types.UserClaims{
|
||||
UserEmail: user.Email,
|
||||
IsAdmin: user.IsAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "hspguard",
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: user.ID.String(),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
|
||||
},
|
||||
}
|
||||
|
||||
refreshToken, err := SignJwtToken(refreshClaims)
|
||||
refreshToken, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
web.Error(w, fmt.Sprintf("failed to generate refresh token: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
return accessToken, refreshToken, nil
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
AccessToken string `json:"access"`
|
||||
RefreshToken string `json:"refresh"`
|
||||
// fields required for UI in account selector, e.g. email, full name and avatar
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Id string `json:"id"`
|
||||
ProfilePicture string `json:"profile_picture"`
|
||||
// Avatar
|
||||
}
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
Id: user.ID.String(),
|
||||
ProfilePicture: user.ProfilePicture.String,
|
||||
// Avatar
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
func NewAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
repo,
|
||||
cache,
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
|
||||
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)
|
||||
protected.Post("/email", h.requestEmailOtp)
|
||||
protected.Post("/email/otp", h.confirmOtp)
|
||||
protected.Post("/verify", h.finishVerification)
|
||||
})
|
||||
|
||||
r.Post("/login", h.login)
|
||||
r.Post("/refresh", h.refreshToken)
|
||||
})
|
||||
}
|
||||
|
126
internal/auth/verify.go
Normal file
126
internal/auth/verify.go
Normal file
@ -0,0 +1,126 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *AuthHandler) requestEmailOtp(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.EmailVerified {
|
||||
web.Error(w, "email is already verified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
number := rand.Intn(1000000) // 0 to 999999
|
||||
padded := fmt.Sprintf("%06d", number) // Always 6 characters
|
||||
|
||||
if _, err := h.cache.Set(r.Context(), fmt.Sprintf("otp-%s", user.ID.String()), padded, 5*time.Minute).Result(); err != nil {
|
||||
log.Println("ERR: Failed to save OTP in cache:", err)
|
||||
web.Error(w, "failed to generate otp", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("INFO: Saved OTP %s\n", padded)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
type ConfirmOtpRequest struct {
|
||||
OTP string `json:"otp"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) confirmOtp(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.EmailVerified {
|
||||
web.Error(w, "email is already verified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req ConfirmOtpRequest
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
web.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
val, err := h.cache.Get(r.Context(), fmt.Sprintf("otp-%s", user.ID.String())).Result()
|
||||
if err != nil {
|
||||
web.Error(w, "otp verification session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("INFO: Comparing OTP %s == %s\n", req.OTP, val)
|
||||
|
||||
if req.OTP == val {
|
||||
err := h.repo.UserVerifyEmail(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to update email_verified:", err)
|
||||
web.Error(w, "failed to verify email", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
web.Error(w, "otp verification failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) finishVerification(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.EmailVerified || !user.AvatarVerified {
|
||||
web.Error(w, "finish other verification steps before final verify", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UserVerifyComplete(r.Context(), user.ID); err != nil {
|
||||
log.Println("ERR: Failed to update verified on user:", err)
|
||||
web.Error(w, "failed to verify user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
84
internal/cache/mod.go
vendored
Normal file
84
internal/cache/mod.go
vendored
Normal file
@ -0,0 +1,84 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
rClient *redis.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg *config.AppConfig) *Client {
|
||||
opts, err := redis.ParseURL(cfg.RedisURL)
|
||||
if err != nil {
|
||||
log.Fatalln("ERR: Failed to get redis options:", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
client := redis.NewClient(opts)
|
||||
|
||||
return &Client{
|
||||
rClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
type OAuthCode struct {
|
||||
ClientID string `json:"client_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
type SaveAuthCodeParams struct {
|
||||
AuthCode string
|
||||
UserID string
|
||||
ClientID string
|
||||
Nonce string
|
||||
}
|
||||
|
||||
func (c *Client) Set(ctx context.Context, key string, value any, expiration time.Duration) *redis.StatusCmd {
|
||||
return c.rClient.Set(ctx, key, value, expiration)
|
||||
}
|
||||
|
||||
func (c *Client) SaveAuthCode(ctx context.Context, params *SaveAuthCodeParams) error {
|
||||
code := OAuthCode{
|
||||
ClientID: params.ClientID,
|
||||
UserID: params.UserID,
|
||||
Nonce: params.Nonce,
|
||||
}
|
||||
row, err := json.Marshal(&code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Set(ctx, fmt.Sprintf("oauth.%s", params.AuthCode), string(row), 5*time.Minute).Err()
|
||||
}
|
||||
|
||||
func (c *Client) GetAuthCode(ctx context.Context, authCode string) (*OAuthCode, error) {
|
||||
row, err := c.Get(ctx, fmt.Sprintf("oauth.%s", authCode)).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(row) == 0 {
|
||||
return nil, fmt.Errorf("no auth params found under %s", authCode)
|
||||
}
|
||||
|
||||
var parsed OAuthCode
|
||||
|
||||
if err := json.Unmarshal([]byte(row), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func (c *Client) Get(ctx context.Context, key string) *redis.StringCmd {
|
||||
return c.rClient.Get(ctx, key)
|
||||
}
|
7
internal/config/admin.go
Normal file
7
internal/config/admin.go
Normal file
@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
type AdminConfig struct {
|
||||
Name string `env:"GUARD_ADMIN_NAME" default:"Admin"`
|
||||
Email string `env:"GUARD_ADMIN_EMAIL" required:"true"`
|
||||
Password string `env:"GUARD_ADMIN_PASSWORD" required:"true"`
|
||||
}
|
7
internal/config/jwt.go
Normal file
7
internal/config/jwt.go
Normal file
@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
type JwtConfig struct {
|
||||
PrivateKey string `env:"GUARD_JWT_PRIVATE" required:"true"`
|
||||
PublicKey string `env:"GUARD_JWT_PUBLIC" required:"true"`
|
||||
KID string `env:"GUARD_JWT_KID" default:"guard-rsa"`
|
||||
}
|
7
internal/config/minio.go
Normal file
7
internal/config/minio.go
Normal file
@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
type MinioConfig struct {
|
||||
Endpoint string `env:"GUARD_MINIO_ENDPOINT" default:"localhost:9000"`
|
||||
AccessKey string `env:"GUARD_MINIO_ACCESS_KEY" required:"true"`
|
||||
SecretKey string `env:"GUARD_MINIO_SECRET_KEY" required:"true"`
|
||||
}
|
100
internal/config/mod.go
Normal file
100
internal/config/mod.go
Normal file
@ -0,0 +1,100 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AppConfig struct {
|
||||
Port string `env:"GUARD_PORT" default:"3001"`
|
||||
Host string `env:"GUARD_HOST" default:"127.0.0.1"`
|
||||
Uri string `env:"GUARD_URI" default:"http://127.0.0.1:3001"`
|
||||
DatabaseURL string `env:"GUARD_DB_URL" required:"true"`
|
||||
RedisURL string `env:"GUARD_REDIS_URL" default:"redis://localhost:6379/0"`
|
||||
Admin AdminConfig
|
||||
Jwt JwtConfig
|
||||
Minio MinioConfig
|
||||
}
|
||||
|
||||
func LoadEnv(target any) error {
|
||||
v := reflect.ValueOf(target)
|
||||
if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct {
|
||||
return &InvalidTargetError{}
|
||||
}
|
||||
return loadStruct(v.Elem(), "")
|
||||
}
|
||||
|
||||
type InvalidTargetError struct{}
|
||||
|
||||
func (e *InvalidTargetError) Error() string {
|
||||
return "target must be a pointer to a struct"
|
||||
}
|
||||
|
||||
var ErrMissingRequiredEnv = errors.New("missing required environment variable")
|
||||
|
||||
func loadStruct(v reflect.Value, prefix string) error {
|
||||
t := v.Type()
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
valueField := v.Field(i)
|
||||
|
||||
if !valueField.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
envKey := field.Tag.Get("env")
|
||||
if envKey == "" {
|
||||
envKey = strings.ToUpper(prefix + "_" + field.Name)
|
||||
envKey = strings.TrimPrefix(envKey, "_")
|
||||
}
|
||||
|
||||
required := field.Tag.Get("required") == "true"
|
||||
defaultVal := field.Tag.Get("default")
|
||||
|
||||
if field.Type.Kind() == reflect.Struct {
|
||||
err := loadStruct(valueField, envKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
envVal := os.Getenv(envKey)
|
||||
|
||||
if envVal == "" {
|
||||
if defaultVal != "" {
|
||||
envVal = defaultVal
|
||||
} else if required {
|
||||
return fmt.Errorf("%w: %s", ErrMissingRequiredEnv, envKey)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
switch field.Type.Kind() {
|
||||
case reflect.String:
|
||||
valueField.SetString(envVal)
|
||||
case reflect.Int, reflect.Int64:
|
||||
i, err := strconv.ParseInt(envVal, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid int for %s: %w", envKey, err)
|
||||
}
|
||||
valueField.SetInt(i)
|
||||
case reflect.Bool:
|
||||
b, err := strconv.ParseBool(envVal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid bool for %s: %w", envKey, err)
|
||||
}
|
||||
valueField.SetBool(b)
|
||||
default:
|
||||
return fmt.Errorf("unsupported type for field %s", field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
47
internal/middleware/admin.go
Normal file
47
internal/middleware/admin.go
Normal 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)
|
||||
})
|
||||
}
|
@ -6,12 +6,23 @@ 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"
|
||||
)
|
||||
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
type AuthMiddleware struct {
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(cfg *config.AppConfig) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) Runner(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
@ -26,9 +37,11 @@ func AuthMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
token, userClaims, err := auth.VerifyToken(tokenStr)
|
||||
var userClaims types.UserClaims
|
||||
|
||||
token, err := util.VerifyToken(tokenStr, m.cfg.Jwt.PublicKey, &userClaims)
|
||||
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
|
||||
}
|
||||
|
||||
|
74
internal/oauth/authorize.go
Normal file
74
internal/oauth/authorize.go
Normal file
@ -0,0 +1,74 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
)
|
||||
|
||||
// client_id=gitea-client&redirect_uri=https://git.adalspace.com/user/oauth2/Home%20Guard/callback&response_type=code&scope=openid&state=4c3b4a25-9cf9-4b18-afc0-270e1078eb40
|
||||
func (h *OAuthHandler) AuthorizeClient(w http.ResponseWriter, r *http.Request) {
|
||||
redirectUri := r.URL.Query().Get("redirect_uri")
|
||||
if redirectUri == "" {
|
||||
web.Error(w, "redirect_uri is missing in request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
clientId := r.URL.Query().Get("client_id")
|
||||
if clientId == "" {
|
||||
uri := fmt.Sprintf("%s?error=invalid_request&error_description=ClientID+is+missing", redirectUri)
|
||||
if state != "" {
|
||||
uri += "&state=" + state
|
||||
}
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := h.repo.GetApiServiceCID(r.Context(), clientId)
|
||||
if err != nil {
|
||||
uri := fmt.Sprintf("%s?error=access_denied&error_description=Service+not+authorized", redirectUri)
|
||||
if state != "" {
|
||||
uri += "&state=" + state
|
||||
}
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !client.IsActive {
|
||||
uri := fmt.Sprintf("%s?error=temporarily_unavailable&error_description=Service+not+active", redirectUri)
|
||||
if state != "" {
|
||||
uri += "&state=" + state
|
||||
}
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
scopes := strings.SplitSeq(strings.TrimSpace(r.URL.Query().Get("scope")), " ")
|
||||
|
||||
for scope := range scopes {
|
||||
if !slices.Contains(client.Scopes, scope) {
|
||||
uri := fmt.Sprintf("%s?error=invalid_scope&error_description=Scope+%s+is+not+allowed", redirectUri, strings.ReplaceAll(scope, " ", "+"))
|
||||
if state != "" {
|
||||
uri += "&state=" + state
|
||||
}
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(client.RedirectUris, redirectUri) {
|
||||
uri := fmt.Sprintf("%s?error=invalid_request&error_description=Redirect+URI+is+not+allowed", redirectUri)
|
||||
if state != "" {
|
||||
uri += "&state=" + state
|
||||
}
|
||||
http.Redirect(w, r, uri, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/auth?%s", r.URL.Query().Encode()), http.StatusFound)
|
||||
}
|
79
internal/oauth/code.go
Normal file
79
internal/oauth/code.go
Normal file
@ -0,0 +1,79 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *OAuthHandler) getAuthCode(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
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Nonce string `json:"nonce"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
var req Request
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
web.Error(w, "nonce field is required in request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, 32)
|
||||
_, err = rand.Read(buf)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to generate auth code:", err)
|
||||
web.Error(w, "failed to create authorization code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
authCode := base64.RawURLEncoding.EncodeToString(buf)
|
||||
|
||||
params := cache.SaveAuthCodeParams{
|
||||
AuthCode: authCode,
|
||||
UserID: user.ID.String(),
|
||||
ClientID: req.ClientID,
|
||||
Nonce: req.Nonce,
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: Saving auth code session with params: %#v\n", params)
|
||||
|
||||
if err := h.cache.SaveAuthCode(r.Context(), ¶ms); err != nil {
|
||||
log.Println("ERR: Failed to save auth code in redis:", err)
|
||||
web.Error(w, "failed to generate auth code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Code: authCode,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
36
internal/oauth/jwks.go
Normal file
36
internal/oauth/jwks.go
Normal file
@ -0,0 +1,36 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
)
|
||||
|
||||
func (h *OAuthHandler) WriteJWKS(w http.ResponseWriter, r *http.Request) {
|
||||
pubKey, err := util.ParseBase64PublicKey(h.cfg.Jwt.PublicKey)
|
||||
if err != nil {
|
||||
web.Error(w, "failed to parse public key", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
n := base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes())
|
||||
e := base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}) // 65537 = 0x010001
|
||||
|
||||
jwks := map[string]interface{}{
|
||||
"keys": []map[string]string{
|
||||
{
|
||||
"kty": "RSA",
|
||||
"kid": "my-rsa-key-1",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"n": n,
|
||||
"e": e,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(jwks)
|
||||
}
|
34
internal/oauth/openid.go
Normal file
34
internal/oauth/openid.go
Normal file
@ -0,0 +1,34 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
)
|
||||
|
||||
func (h *OAuthHandler) OpenIdConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||
type Response struct {
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
Issuer string `json:"issuer"`
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
TokenEndpoint: h.cfg.Uri + "/api/v1/oauth/token",
|
||||
AuthorizationEndpoint: h.cfg.Uri + "/api/v1/oauth/authorize",
|
||||
JwksURI: h.cfg.Uri + "/.well-known/jwks.json",
|
||||
Issuer: h.cfg.Uri,
|
||||
EndSessionEndpoint: h.cfg.Uri + "/api/v1/oauth/logout",
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
36
internal/oauth/routes.go
Normal file
36
internal/oauth/routes.go
Normal file
@ -0,0 +1,36 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"gitea.local/admin/hspguard/internal/cache"
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type OAuthHandler struct {
|
||||
repo *repository.Queries
|
||||
cache *cache.Client
|
||||
cfg *config.AppConfig
|
||||
}
|
||||
|
||||
func NewOAuthHandler(repo *repository.Queries, cache *cache.Client, cfg *config.AppConfig) *OAuthHandler {
|
||||
return &OAuthHandler{
|
||||
repo,
|
||||
cache,
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Route("/oauth", func(r chi.Router) {
|
||||
r.Group(func(protected chi.Router) {
|
||||
authMiddleware := imiddleware.NewAuthMiddleware(h.cfg)
|
||||
protected.Use(authMiddleware.Runner)
|
||||
|
||||
protected.Post("/code", h.getAuthCode)
|
||||
})
|
||||
r.Get("/authorize", h.AuthorizeClient)
|
||||
r.Post("/token", h.tokenEndpoint)
|
||||
})
|
||||
}
|
288
internal/oauth/token.go
Normal file
288
internal/oauth/token.go
Normal file
@ -0,0 +1,288 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/types"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ApiToken struct {
|
||||
Token string
|
||||
Expiration float64
|
||||
}
|
||||
|
||||
type ApiTokens struct {
|
||||
ID ApiToken
|
||||
Access ApiToken
|
||||
Refresh ApiToken
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) signApiTokens(user *repository.User, apiService *repository.ApiService, nonce *string) (*ApiTokens, error) {
|
||||
accessExpiresIn := 15 * time.Minute
|
||||
accessExpiresAt := time.Now().Add(accessExpiresIn)
|
||||
|
||||
accessClaims := types.ApiClaims{
|
||||
Permissions: []string{},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: apiService.ClientID,
|
||||
Audience: jwt.ClaimStrings{apiService.ClientID},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(accessExpiresAt),
|
||||
},
|
||||
}
|
||||
|
||||
access, err := util.SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var roles = []string{"user"}
|
||||
|
||||
if user.IsAdmin {
|
||||
roles = append(roles, "admin")
|
||||
}
|
||||
|
||||
idExpiresIn := 15 * time.Minute
|
||||
idExpiresAt := time.Now().Add(idExpiresIn)
|
||||
|
||||
idClaims := types.IdTokenClaims{
|
||||
Email: user.Email,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Name: user.FullName,
|
||||
Picture: user.ProfilePicture,
|
||||
Nonce: nonce,
|
||||
Roles: roles,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: user.ID.String(),
|
||||
Audience: jwt.ClaimStrings{apiService.ClientID},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(idExpiresAt),
|
||||
},
|
||||
}
|
||||
|
||||
idToken, err := util.SignJwtToken(idClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshExpiresIn := 24 * time.Hour
|
||||
refreshExpiresAt := time.Now().Add(refreshExpiresIn)
|
||||
|
||||
refreshClaims := types.ApiRefreshClaims{
|
||||
UserID: user.ID.String(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: h.cfg.Uri,
|
||||
Subject: apiService.ClientID,
|
||||
Audience: jwt.ClaimStrings{apiService.ClientID},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExpiresAt),
|
||||
},
|
||||
}
|
||||
|
||||
refresh, err := util.SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ApiTokens{
|
||||
ID: ApiToken{
|
||||
Token: idToken,
|
||||
Expiration: idExpiresIn.Seconds(),
|
||||
},
|
||||
Access: ApiToken{
|
||||
Token: access,
|
||||
Expiration: accessExpiresIn.Seconds(),
|
||||
},
|
||||
Refresh: ApiToken{
|
||||
Token: refresh,
|
||||
Expiration: refreshExpiresIn.Seconds(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("[OAUTH] New request to token endpoint")
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Basic ") {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode credentials
|
||||
payload, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic "))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid auth encoding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var clientId string
|
||||
var clientSecret string
|
||||
|
||||
parts := strings.SplitN(string(payload), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
http.Error(w, "Unauthorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
clientId = parts[0]
|
||||
clientSecret = parts[1]
|
||||
|
||||
log.Printf("Some client is trying to exchange code with id: %s and secret: %s\n", clientId, clientSecret)
|
||||
|
||||
// Parse the form data
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
grantType := r.FormValue("grant_type")
|
||||
redirectUri := r.FormValue("redirect_uri")
|
||||
|
||||
log.Printf("Redirect URI is %s\n", redirectUri)
|
||||
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
code := r.FormValue("code")
|
||||
|
||||
fmt.Printf("Code received: %s\n", code)
|
||||
|
||||
session, err := h.cache.GetAuthCode(r.Context(), code)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Failed to find session under the code %s: %v\n", code, err)
|
||||
web.Error(w, "no session found under this auth code", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: Fetched code session: %#v\n", session)
|
||||
|
||||
apiService, err := h.repo.GetApiServiceCID(r.Context(), session.ClientID)
|
||||
if err != nil {
|
||||
log.Printf("ERR: Could not find API service with client %s: %v\n", session.ClientID, err)
|
||||
web.Error(w, "service is not registered", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if session.ClientID != clientId {
|
||||
web.Error(w, "invalid auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(session.UserID))
|
||||
if err != nil {
|
||||
web.Error(w, "requested user not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.signApiTokens(&user, &apiService, &session.Nonce)
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to sign api tokens:", err)
|
||||
web.Error(w, "failed to sign tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
IdToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Email string `json:"email"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
// TODO: add scope (RFC 8693 $2)
|
||||
}
|
||||
|
||||
response := Response{
|
||||
IdToken: tokens.ID.Token,
|
||||
TokenType: "Bearer",
|
||||
AccessToken: tokens.Access.Token,
|
||||
RefreshToken: tokens.Refresh.Token,
|
||||
ExpiresIn: tokens.Access.Expiration,
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
log.Printf("sending following response: %#v\n", response)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
encoder := json.NewEncoder(w)
|
||||
if err := encoder.Encode(response); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
case "refresh_token":
|
||||
refreshToken := r.FormValue("refresh_token")
|
||||
|
||||
var claims types.ApiRefreshClaims
|
||||
|
||||
token, err := util.VerifyToken(refreshToken, h.cfg.Jwt.PublicKey, &claims)
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
expire, err := claims.GetExpirationTime()
|
||||
if err != nil {
|
||||
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(expire.Time) {
|
||||
web.Error(w, "token is expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(claims.UserID)
|
||||
if err != nil {
|
||||
web.Error(w, "invalid user credentials in refresh token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, err := h.repo.FindUserId(r.Context(), userID)
|
||||
|
||||
apiService, err := h.repo.GetApiServiceCID(r.Context(), claims.Subject)
|
||||
if err != nil {
|
||||
web.Error(w, "api service is not registered", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.signApiTokens(&user, &apiService, nil)
|
||||
|
||||
type Response struct {
|
||||
IdToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
response := Response{
|
||||
IdToken: tokens.ID.Token,
|
||||
TokenType: "Bearer",
|
||||
AccessToken: tokens.Access.Token,
|
||||
RefreshToken: tokens.Refresh.Token,
|
||||
ExpiresIn: tokens.Access.Expiration,
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: refresh - sending following response: %#v\n", response)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
encoder := json.NewEncoder(w)
|
||||
if err := encoder.Encode(response); err != nil {
|
||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
default:
|
||||
web.Error(w, "unsupported grant type", http.StatusBadRequest)
|
||||
}
|
||||
}
|
241
internal/repository/api_services.sql.go
Normal file
241
internal/repository/api_services.sql.go
Normal file
@ -0,0 +1,241 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: api_services.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const activateApiService = `-- name: ActivateApiService :exec
|
||||
UPDATE api_services
|
||||
SET is_active = true,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ActivateApiService(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.Exec(ctx, activateApiService, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const createApiService = `-- name: CreateApiService :one
|
||||
INSERT INTO api_services (
|
||||
client_id, client_secret, name, description, redirect_uris, scopes, grant_types, is_active
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8
|
||||
) RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url
|
||||
`
|
||||
|
||||
type CreateApiServiceParams struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateApiService(ctx context.Context, arg CreateApiServiceParams) (ApiService, error) {
|
||||
row := q.db.QueryRow(ctx, createApiService,
|
||||
arg.ClientID,
|
||||
arg.ClientSecret,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.RedirectUris,
|
||||
arg.Scopes,
|
||||
arg.GrantTypes,
|
||||
arg.IsActive,
|
||||
)
|
||||
var i ApiService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ClientID,
|
||||
&i.ClientSecret,
|
||||
&i.Name,
|
||||
&i.RedirectUris,
|
||||
&i.Scopes,
|
||||
&i.GrantTypes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deactivateApiService = `-- name: DeactivateApiService :exec
|
||||
UPDATE api_services
|
||||
SET is_active = false,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeactivateApiService(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.Exec(ctx, deactivateApiService, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getApiServiceCID = `-- name: GetApiServiceCID :one
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
|
||||
WHERE client_id = $1
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetApiServiceCID(ctx context.Context, clientID string) (ApiService, error) {
|
||||
row := q.db.QueryRow(ctx, getApiServiceCID, clientID)
|
||||
var i ApiService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ClientID,
|
||||
&i.ClientSecret,
|
||||
&i.Name,
|
||||
&i.RedirectUris,
|
||||
&i.Scopes,
|
||||
&i.GrantTypes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getApiServiceId = `-- name: GetApiServiceId :one
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetApiServiceId(ctx context.Context, id uuid.UUID) (ApiService, error) {
|
||||
row := q.db.QueryRow(ctx, getApiServiceId, id)
|
||||
var i ApiService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ClientID,
|
||||
&i.ClientSecret,
|
||||
&i.Name,
|
||||
&i.RedirectUris,
|
||||
&i.Scopes,
|
||||
&i.GrantTypes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listApiServices = `-- name: ListApiServices :many
|
||||
SELECT id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url FROM api_services
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListApiServices(ctx context.Context) ([]ApiService, error) {
|
||||
rows, err := q.db.Query(ctx, listApiServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ApiService
|
||||
for rows.Next() {
|
||||
var i ApiService
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ClientID,
|
||||
&i.ClientSecret,
|
||||
&i.Name,
|
||||
&i.RedirectUris,
|
||||
&i.Scopes,
|
||||
&i.GrantTypes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateApiService = `-- name: UpdateApiService :one
|
||||
UPDATE api_services
|
||||
SET
|
||||
name = $2,
|
||||
description = $3,
|
||||
redirect_uris = $4,
|
||||
scopes = $5,
|
||||
grant_types = $6,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1
|
||||
RETURNING id, client_id, client_secret, name, redirect_uris, scopes, grant_types, created_at, updated_at, is_active, description, icon_url
|
||||
`
|
||||
|
||||
type UpdateApiServiceParams struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
RedirectUris []string `json:"redirect_uris"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateApiService(ctx context.Context, arg UpdateApiServiceParams) (ApiService, error) {
|
||||
row := q.db.QueryRow(ctx, updateApiService,
|
||||
arg.ClientID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.RedirectUris,
|
||||
arg.Scopes,
|
||||
arg.GrantTypes,
|
||||
)
|
||||
var i ApiService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ClientID,
|
||||
&i.ClientSecret,
|
||||
&i.Name,
|
||||
&i.RedirectUris,
|
||||
&i.Scopes,
|
||||
&i.GrantTypes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.IsActive,
|
||||
&i.Description,
|
||||
&i.IconUrl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateClientSecret = `-- name: UpdateClientSecret :exec
|
||||
UPDATE api_services
|
||||
SET client_secret = $2,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1
|
||||
`
|
||||
|
||||
type UpdateClientSecretParams struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateClientSecret(ctx context.Context, arg UpdateClientSecretParams) error {
|
||||
_, err := q.db.Exec(ctx, updateClientSecret, arg.ClientID, arg.ClientSecret)
|
||||
return err
|
||||
}
|
@ -5,19 +5,39 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
LastLogin pgtype.Timestamptz `json:"last_login"`
|
||||
PhoneNumber pgtype.Text `json:"phone_number"`
|
||||
ProfilePicture pgtype.Text `json:"profile_picture"`
|
||||
type ApiService struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
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"`
|
||||
Description *string `json:"description"`
|
||||
IconUrl *string `json:"icon_url"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt *time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
LastLogin *time.Time `json:"last_login"`
|
||||
PhoneNumber *string `json:"phone_number"`
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
CreatedBy *uuid.UUID `json:"created_by"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
AvatarVerified bool `json:"avatar_verified"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
@ -9,11 +9,49 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const findAdminUsers = `-- name: FindAdminUsers :many
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE created_by = $1
|
||||
`
|
||||
|
||||
func (q *Queries) FindAdminUsers(ctx context.Context, createdBy *uuid.UUID) ([]User, error) {
|
||||
rows, err := q.db.Query(ctx, findAdminUsers, createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []User
|
||||
for rows.Next() {
|
||||
var i User
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.FullName,
|
||||
&i.PasswordHash,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LastLogin,
|
||||
&i.PhoneNumber,
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const findAllUsers = `-- name: FindAllUsers :many
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users
|
||||
`
|
||||
|
||||
func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
|
||||
@ -36,6 +74,10 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
|
||||
&i.LastLogin,
|
||||
&i.PhoneNumber,
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -48,7 +90,7 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
|
||||
}
|
||||
|
||||
const findUserEmail = `-- name: FindUserEmail :one
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users WHERE email = $1 LIMIT 1
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE email = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error) {
|
||||
@ -65,12 +107,16 @@ func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error)
|
||||
&i.LastLogin,
|
||||
&i.PhoneNumber,
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const findUserId = `-- name: FindUserId :one
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users WHERE id = $1 LIMIT 1
|
||||
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture, created_by, email_verified, avatar_verified, verified FROM users WHERE id = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
@ -87,24 +133,29 @@ func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
&i.LastLogin,
|
||||
&i.PhoneNumber,
|
||||
&i.ProfilePicture,
|
||||
&i.CreatedBy,
|
||||
&i.EmailVerified,
|
||||
&i.AvatarVerified,
|
||||
&i.Verified,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertUser = `-- name: InsertUser :one
|
||||
INSERT INTO users (
|
||||
email, full_name, password_hash, is_admin
|
||||
email, full_name, password_hash, is_admin, created_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type InsertUserParams struct {
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedBy *uuid.UUID `json:"created_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (uuid.UUID, error) {
|
||||
@ -113,12 +164,24 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (uuid.UU
|
||||
arg.FullName,
|
||||
arg.PasswordHash,
|
||||
arg.IsAdmin,
|
||||
arg.CreatedBy,
|
||||
)
|
||||
var id uuid.UUID
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const updateLastLogin = `-- name: UpdateLastLogin :exec
|
||||
UPDATE users
|
||||
SET last_login = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, updateLastLogin, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateProfilePicture = `-- name: UpdateProfilePicture :exec
|
||||
UPDATE users
|
||||
SET profile_picture = $1
|
||||
@ -126,11 +189,44 @@ WHERE id = $2
|
||||
`
|
||||
|
||||
type UpdateProfilePictureParams struct {
|
||||
ProfilePicture pgtype.Text `json:"profile_picture"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateProfilePicture(ctx context.Context, arg UpdateProfilePictureParams) error {
|
||||
_, err := q.db.Exec(ctx, updateProfilePicture, arg.ProfilePicture, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const userVerifyAvatar = `-- name: UserVerifyAvatar :exec
|
||||
UPDATE users
|
||||
SET avatar_verified = true
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UserVerifyAvatar(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, userVerifyAvatar, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const userVerifyComplete = `-- name: UserVerifyComplete :exec
|
||||
UPDATE users
|
||||
SET verified = true
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UserVerifyComplete(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, userVerifyComplete, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const userVerifyEmail = `-- name: UserVerifyEmail :exec
|
||||
UPDATE users
|
||||
SET email_verified = true
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UserVerifyEmail(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, userVerifyEmail, id)
|
||||
return err
|
||||
}
|
||||
|
@ -5,8 +5,8 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
@ -15,27 +15,9 @@ type FileStorage struct {
|
||||
client *minio.Client
|
||||
}
|
||||
|
||||
func New() *FileStorage {
|
||||
endpoint := os.Getenv("MINIO_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
log.Fatalln("MINIO_ENDPOINT env var is required")
|
||||
return nil
|
||||
}
|
||||
|
||||
accessKey := os.Getenv("MINIO_ACCESS_KEY")
|
||||
if accessKey == "" {
|
||||
log.Fatalln("MINIO_ACCESS_KEY env var is required")
|
||||
return nil
|
||||
}
|
||||
|
||||
secretKey := os.Getenv("MINIO_SECRET_KEY")
|
||||
if secretKey == "" {
|
||||
log.Fatalln("MINIO_SECRET_KEY env var is required")
|
||||
return nil
|
||||
}
|
||||
|
||||
client, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
func New(cfg *config.AppConfig) *FileStorage {
|
||||
client, err := minio.New(cfg.Minio.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.Minio.AccessKey, cfg.Minio.SecretKey, ""),
|
||||
Secure: false,
|
||||
})
|
||||
if err != nil {
|
||||
@ -52,6 +34,10 @@ func (fs *FileStorage) PutObject(ctx context.Context, bucketName string, objectN
|
||||
return fs.client.PutObject(ctx, bucketName, objectName, reader, size, opts)
|
||||
}
|
||||
|
||||
func (fs *FileStorage) GetObject(ctx context.Context, bucketName string, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) {
|
||||
return fs.client.GetObject(ctx, bucketName, objectName, opts)
|
||||
}
|
||||
|
||||
func (fs *FileStorage) EndpointURL() *url.URL {
|
||||
return fs.client.EndpointURL()
|
||||
}
|
||||
|
@ -4,16 +4,30 @@ import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
type UserClaims struct {
|
||||
UserEmail string `json:"user_email"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type IdTokenClaims struct {
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture *string `json:"picture"`
|
||||
Nonce *string `json:"nonce"`
|
||||
Roles []string `json:"roles"`
|
||||
// TODO: add given_name, family_name, locale...
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type ApiClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
// Permissions are guard's defined permissions
|
||||
// Examples:
|
||||
// 1. User MetaData (specifically some fields like email, profile picture and name)
|
||||
// 2. Actions on User, e.g. home permissions fetching, notifications emitting
|
||||
// FIXME: correct permissions
|
||||
Permissions []string `json:"permissions"`
|
||||
// Subject is an API ID defined in guard's DB after registration
|
||||
jwt.RegisteredClaims
|
||||
// Subject = ClientID
|
||||
}
|
||||
|
||||
type ApiRefreshClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
// Subject = ClientID
|
||||
}
|
||||
|
40
internal/types/user.go
Normal file
40
internal/types/user.go
Normal file
@ -0,0 +1,40 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt *time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
LastLogin *time.Time `json:"last_login"`
|
||||
PhoneNumber *string `json:"phone_number"`
|
||||
ProfilePicture *string `json:"profile_picture"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
AvatarVerified bool `json:"avatar_verified"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
func NewUserDTO(row *repository.User) UserDTO {
|
||||
return UserDTO{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
FullName: row.FullName,
|
||||
IsAdmin: row.IsAdmin,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
LastLogin: row.LastLogin,
|
||||
PhoneNumber: row.PhoneNumber,
|
||||
ProfilePicture: row.ProfilePicture,
|
||||
EmailVerified: row.EmailVerified,
|
||||
AvatarVerified: row.AvatarVerified,
|
||||
Verified: row.Verified,
|
||||
}
|
||||
}
|
@ -4,33 +4,21 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gitea.local/admin/hspguard/internal/config"
|
||||
"gitea.local/admin/hspguard/internal/repository"
|
||||
"gitea.local/admin/hspguard/internal/util"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func EnsureAdminUser(ctx context.Context, repo *repository.Queries) {
|
||||
adminName := os.Getenv("ADMIN_NAME")
|
||||
if adminName == "" {
|
||||
adminName = "admin"
|
||||
}
|
||||
|
||||
adminEmail := os.Getenv("ADMIN_EMAIL")
|
||||
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
||||
|
||||
if adminEmail == "" {
|
||||
log.Fatalln("ERR: ADMIN_EMAIL env variable is required")
|
||||
}
|
||||
|
||||
_, err := repo.FindUserEmail(ctx, adminEmail)
|
||||
func EnsureAdminUser(ctx context.Context, cfg *config.AppConfig, repo *repository.Queries) {
|
||||
_, err := repo.FindUserEmail(ctx, cfg.Admin.Email)
|
||||
if err != nil {
|
||||
if adminPassword == "" {
|
||||
if cfg.Admin.Password == "" {
|
||||
log.Fatalln("ERR: ADMIN_PASSWORD env variable is required")
|
||||
}
|
||||
|
||||
if _, err := createAdmin(ctx, adminName, adminEmail, adminPassword, repo); err != nil {
|
||||
if _, err := createAdmin(ctx, cfg.Admin.Name, cfg.Admin.Email, cfg.Admin.Password, repo); err != nil {
|
||||
log.Fatalln("ERR: Failed to create admin account:", err)
|
||||
}
|
||||
}
|
||||
|
@ -4,36 +4,48 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"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"
|
||||
"gitea.local/admin/hspguard/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
type RegisterParams struct {
|
||||
@ -86,6 +98,8 @@ func (h *UserHandler) register(w http.ResponseWriter, r *http.Request) {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{
|
||||
Id: id.String(),
|
||||
}); err != nil {
|
||||
@ -93,6 +107,28 @@ func (h *UserHandler) register(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *UserHandler) getAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
avatarObject := chi.URLParam(r, "avatar")
|
||||
|
||||
object, err := h.minio.GetObject(r.Context(), "guard-storage", avatarObject, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
web.Error(w, "avatar not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer object.Close()
|
||||
|
||||
stat, err := object.Stat()
|
||||
if err != nil {
|
||||
log.Printf("ERR: failed to get object stats: %v\n", err)
|
||||
web.Error(w, "failed to get avatar", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", stat.ContentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.Copy(w, object)
|
||||
}
|
||||
|
||||
func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := util.GetRequestUserId(r.Context())
|
||||
if !ok {
|
||||
@ -130,23 +166,29 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
ContentType: header.Header.Get("Content-Type"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("ERR: Failed to put object:", err)
|
||||
web.Error(w, "failed to upload image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
imageURL := fmt.Sprintf("http://%s/%s/%s", h.minio.EndpointURL().Host, "guard-storage", uploadInfo.Key)
|
||||
imgURI := fmt.Sprintf("%s/api/v1/avatar/%s", h.cfg.Uri, uploadInfo.Key)
|
||||
|
||||
if err := h.repo.UpdateProfilePicture(r.Context(), repository.UpdateProfilePictureParams{
|
||||
ProfilePicture: pgtype.Text{
|
||||
String: imageURL,
|
||||
Valid: true,
|
||||
},
|
||||
ID: user.ID,
|
||||
ProfilePicture: &imgURI,
|
||||
ID: user.ID,
|
||||
}); err != nil {
|
||||
web.Error(w, "failed to update profile picture", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.AvatarVerified {
|
||||
if err := h.repo.UserVerifyAvatar(r.Context(), user.ID); err != nil {
|
||||
log.Println("ERR: Failed to update avatar_verified:", err)
|
||||
web.Error(w, "failed to verify avatar", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@ -155,7 +197,9 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
if err := encoder.Encode(Response{URL: imageURL}); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := encoder.Encode(Response{URL: fmt.Sprintf("%s/avatar/%s", h.cfg.Uri, uploadInfo.Key)}); err != nil {
|
||||
web.Error(w, "failed to write response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
9
internal/util/client.go
Normal file
9
internal/util/client.go
Normal file
@ -0,0 +1,9 @@
|
||||
package util
|
||||
|
||||
func GenerateClientID() (string, error) {
|
||||
return generateRandomStringURLSafe(16)
|
||||
}
|
||||
|
||||
func GenerateClientSecret() (string, error) {
|
||||
return generateRandomStringURLSafe(32)
|
||||
}
|
17
internal/util/generate.go
Normal file
17
internal/util/generate.go
Normal file
@ -0,0 +1,17 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// generateRandomStringURLSafe generates a base64 URL-safe random string of n bytes.
|
||||
func generateRandomStringURLSafe(n int) (string, error) {
|
||||
bytes := make([]byte, n)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
81
internal/util/jwt.go
Normal file
81
internal/util/jwt.go
Normal file
@ -0,0 +1,81 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func ParseBase64PrivateKey(b64 string) (*rsa.PrivateKey, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
|
||||
}
|
||||
|
||||
key, err := x509.ParsePKCS8PrivateKey(decoded)
|
||||
return key.(*rsa.PrivateKey), err
|
||||
}
|
||||
|
||||
func ParseBase64PublicKey(b64 string) (*rsa.PublicKey, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64 key: %v", err)
|
||||
}
|
||||
|
||||
pubInterface, err := x509.ParsePKIXPublicKey(decoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public key: %v", err)
|
||||
}
|
||||
|
||||
pubKey, ok := pubInterface.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not an RSA public key")
|
||||
}
|
||||
|
||||
return pubKey, nil
|
||||
}
|
||||
|
||||
func SignJwtToken(claims jwt.Claims, key string) (string, error) {
|
||||
privateKey, err := ParseBase64PrivateKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
|
||||
token.Header["kid"] = "my-rsa-key-1"
|
||||
|
||||
s, err := token.SignedString(privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func VerifyToken(token string, key string, claims jwt.Claims) (*jwt.Token, error) {
|
||||
publicKey, err := ParseBase64PublicKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
if !parsed.Valid {
|
||||
return nil, fmt.Errorf("token is not valid")
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
27
migrations/00004_create_services_table.sql
Normal file
27
migrations/00004_create_services_table.sql
Normal file
@ -0,0 +1,27 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE api_services (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Unique identifier
|
||||
|
||||
-- OIDC-required fields
|
||||
client_id TEXT UNIQUE NOT NULL,
|
||||
client_secret TEXT NOT NULL, -- Store as hashed value
|
||||
|
||||
-- Metadata
|
||||
name TEXT NOT NULL,
|
||||
|
||||
redirect_uris TEXT[] DEFAULT '{}',
|
||||
scopes TEXT[] DEFAULT '{openid}',
|
||||
grant_types TEXT[] DEFAULT '{authorization_code}',
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
is_active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE api_services;
|
||||
-- +goose StatementEnd
|
11
migrations/00005_add_description_to_api_service.sql
Normal file
11
migrations/00005_add_description_to_api_service.sql
Normal file
@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE api_services ADD description TEXT DEFAULT '';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE api_services
|
||||
DROP COLUMN description;
|
||||
|
||||
-- +goose StatementEnd
|
12
migrations/00006_add_user_creator.sql
Normal file
12
migrations/00006_add_user_creator.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
ADD COLUMN created_by UUID REFERENCES users (id) ON DELETE SET NULL;
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
DROP COLUMN created_by;
|
||||
|
||||
-- +goose StatementEnd
|
12
migrations/00007_add_user_verification.sql
Normal file
12
migrations/00007_add_user_verification.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
DROP COLUMN email_verified;
|
||||
|
||||
-- +goose StatementEnd
|
12
migrations/00008_add_verification_levels.sql
Normal file
12
migrations/00008_add_verification_levels.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
ADD COLUMN avatar_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
DROP COLUMN avatar_verified;
|
||||
|
||||
-- +goose StatementEnd
|
12
migrations/00009_add_complete_verify.sql
Normal file
12
migrations/00009_add_complete_verify.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
ADD COLUMN verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
DROP COLUMN verified;
|
||||
|
||||
-- +goose StatementEnd
|
12
migrations/00010_add_api_service_icon_url.sql
Normal file
12
migrations/00010_add_api_service_icon_url.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE api_services
|
||||
ADD COLUMN icon_url TEXT DEFAULT NULL;
|
||||
|
||||
-- +goose StatementEnd
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE api_services
|
||||
DROP COLUMN icon_url;
|
||||
|
||||
-- +goose StatementEnd
|
51
queries/api_services.sql
Normal file
51
queries/api_services.sql
Normal file
@ -0,0 +1,51 @@
|
||||
-- name: CreateApiService :one
|
||||
INSERT INTO api_services (
|
||||
client_id, client_secret, name, description, redirect_uris, scopes, grant_types, is_active
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8
|
||||
) RETURNING *;
|
||||
|
||||
-- name: GetApiServiceCID :one
|
||||
SELECT * FROM api_services
|
||||
WHERE client_id = $1
|
||||
AND is_active = true
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetApiServiceId :one
|
||||
SELECT * FROM api_services
|
||||
WHERE id = $1
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ListApiServices :many
|
||||
SELECT * FROM api_services
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateApiService :one
|
||||
UPDATE api_services
|
||||
SET
|
||||
name = $2,
|
||||
description = $3,
|
||||
redirect_uris = $4,
|
||||
scopes = $5,
|
||||
grant_types = $6,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeactivateApiService :exec
|
||||
UPDATE api_services
|
||||
SET is_active = false,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1;
|
||||
|
||||
-- name: ActivateApiService :exec
|
||||
UPDATE api_services
|
||||
SET is_active = true,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1;
|
||||
|
||||
-- name: UpdateClientSecret :exec
|
||||
UPDATE api_services
|
||||
SET client_secret = $2,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $1;
|
@ -1,11 +1,14 @@
|
||||
-- name: FindAllUsers :many
|
||||
SELECT * FROM users;
|
||||
|
||||
-- name: FindAdminUsers :many
|
||||
SELECT * FROM users WHERE created_by = $1;
|
||||
|
||||
-- name: InsertUser :one
|
||||
INSERT INTO users (
|
||||
email, full_name, password_hash, is_admin
|
||||
email, full_name, password_hash, is_admin, created_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
RETURNING id;
|
||||
|
||||
@ -19,3 +22,23 @@ SELECT * FROM users WHERE id = $1 LIMIT 1;
|
||||
UPDATE users
|
||||
SET profile_picture = $1
|
||||
WHERE id = $2;
|
||||
|
||||
-- name: UserVerifyEmail :exec
|
||||
UPDATE users
|
||||
SET email_verified = true
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UserVerifyAvatar :exec
|
||||
UPDATE users
|
||||
SET avatar_verified = true
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UserVerifyComplete :exec
|
||||
UPDATE users
|
||||
SET verified = true
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateLastLogin :exec
|
||||
UPDATE users
|
||||
SET last_login = NOW()
|
||||
WHERE id = $1;
|
||||
|
4
redis.conf
Normal file
4
redis.conf
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
# Enable ACL
|
||||
user default off
|
||||
user guard on >guard allcommands allkeys
|
@ -1,16 +1,17 @@
|
||||
# Generate private key
|
||||
openssl ecparam -genkey -name prime256v1 -noout -out ec256-private.pem
|
||||
# Generate 2048-bit RSA private key (suppress output)
|
||||
openssl genpkey -algorithm RSA -out rsa-private.pem -pkeyopt rsa_keygen_bits:2048 *> $null
|
||||
|
||||
# Extract public key
|
||||
openssl ec -in ec256-private.pem -pubout -out ec256-public.pem
|
||||
# Extract the public key from the private key (suppress output)
|
||||
openssl rsa -in rsa-private.pem -pubout -out rsa-public.pem *> $null
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Private Key (DER base64):"
|
||||
openssl ec -in ec256-private.pem -outform DER | openssl base64 -A
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "--------------------------------"
|
||||
Write-Host ""
|
||||
# Base64 encode private key (DER format, for JWT_PRIVATE_KEY)
|
||||
Write-Host -NoNewline 'JWT_PRIVATE_KEY="'
|
||||
openssl pkcs8 -topk8 -nocrypt -in rsa-private.pem -outform DER 2>$null | openssl base64 -A
|
||||
Write-Host '"'
|
||||
|
||||
Write-Host "Public Key (DER base64):"
|
||||
openssl ec -in ec256-private.pem -pubout -outform DER | openssl base64 -A
|
||||
# Base64 encode public key (DER format, for JWT_PUBLIC_KEY)
|
||||
Write-Host -NoNewline 'JWT_PUBLIC_KEY="'
|
||||
openssl rsa -in rsa-private.pem -pubout -outform DER 2>$null | openssl base64 -A
|
||||
Write-Host '"'
|
@ -1,26 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Generate private key
|
||||
# openssl ecparam -genkey -name prime256v1 -noout -out ec256-private.pem
|
||||
# openssl ec -in ec256-private.pem -outform DER | base64 -w 0
|
||||
# Generate 2048-bit RSA private key (suppress all output)
|
||||
openssl genpkey -algorithm RSA -out rsa-private.pem -pkeyopt rsa_keygen_bits:2048 >/dev/null 2>&1
|
||||
|
||||
# Extract public key
|
||||
# openssl ec -in ec256-private.pem -pubout -out ec256-public.pem
|
||||
# openssl ec -in ec256-private.pem -pubout -outform DER | base64 -w 0
|
||||
|
||||
# Generate private key
|
||||
openssl ecparam -genkey -name prime256v1 -noout -out ec256-private.pem
|
||||
|
||||
# Extract public key
|
||||
openssl ec -in ec256-private.pem -pubout -out ec256-public.pem
|
||||
# Extract the public key from the private key (suppress all output)
|
||||
openssl rsa -in rsa-private.pem -pubout -out rsa-public.pem >/dev/null 2>&1
|
||||
|
||||
echo ""
|
||||
echo "Private Key (DER base64):"
|
||||
openssl ec -in ec256-private.pem -outform DER | base64 -w 0
|
||||
|
||||
echo "
|
||||
--------------------------------"
|
||||
# Base64 encode private key (for JWT_PRIVATE_KEY)
|
||||
echo -n 'JWT_PRIVATE_KEY="'
|
||||
openssl pkcs8 -topk8 -nocrypt -in rsa-private.pem -outform DER 2>/dev/null | base64 -w 0
|
||||
echo '"'
|
||||
|
||||
echo ""
|
||||
echo "Public Key (DER base64):"
|
||||
openssl ec -in ec256-private.pem -pubout -outform DER | base64 -w 0
|
||||
# Base64 encode public key (for JWT_PUBLIC_KEY)
|
||||
echo -n 'JWT_PUBLIC_KEY="'
|
||||
openssl rsa -in rsa-private.pem -pubout -outform DER 2>/dev/null | base64 -w 0
|
||||
echo '"'
|
92
sqlc.yaml
92
sqlc.yaml
@ -1,4 +1,3 @@
|
||||
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
@ -14,8 +13,95 @@ sql:
|
||||
- db_type: "uuid"
|
||||
go_type:
|
||||
import: "github.com/google/uuid"
|
||||
type: "UUID"
|
||||
- db_type: "timestamptz"
|
||||
type: UUID
|
||||
- db_type: "uuid"
|
||||
nullable: true
|
||||
go_type:
|
||||
import: "github.com/google/uuid"
|
||||
type: UUID
|
||||
pointer: true
|
||||
# ───── bool ──────────────────────────────────────────
|
||||
- db_type: "pg_catalog.bool" # or just "bool"
|
||||
go_type: { type: "bool" }
|
||||
- db_type: "bool" # or just "bool"
|
||||
go_type: { type: "bool" }
|
||||
|
||||
- db_type: "pg_catalog.bool"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "bool"
|
||||
pointer: true # ⇒ *bool for NULLable columns
|
||||
|
||||
- db_type: "bool"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "bool"
|
||||
pointer: true # ⇒ *bool for NULLable columns
|
||||
|
||||
# ───── text ──────────────────────────────────────────
|
||||
- db_type: "pg_catalog.text"
|
||||
go_type: { type: "string" }
|
||||
- db_type: "text" # or just "bool"
|
||||
go_type: { type: "string" }
|
||||
|
||||
- db_type: "pg_catalog.text"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true # ⇒ *bool for NULLable columns
|
||||
|
||||
- db_type: "text"
|
||||
nullable: true
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true # ⇒ *bool for NULLable columns
|
||||
|
||||
# ───── timestamp (WITHOUT TZ) ────────────────────────
|
||||
- db_type: "pg_catalog.timestamp" # or "timestamp"
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
|
||||
- db_type: "timestamp" # or "timestamp"
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
|
||||
- db_type: "pg_catalog.timestamp"
|
||||
nullable: true
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
pointer: true
|
||||
|
||||
- db_type: "timestamp"
|
||||
nullable: true
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
pointer: true
|
||||
|
||||
# ───── timestamptz (WITH TZ) ─────────────────────────
|
||||
- db_type: "pg_catalog.timestamptz" # or "timestamptz"
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
|
||||
- db_type: "timestamptz" # or "timestamptz"
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
|
||||
- db_type: "pg_catalog.timestamptz"
|
||||
nullable: true
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
pointer: true
|
||||
|
||||
- db_type: "timestamptz"
|
||||
nullable: true
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
pointer: true
|
||||
|
5
web/.prettierignore
Normal file
5
web/.prettierignore
Normal file
@ -0,0 +1,5 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
node_modules
|
||||
public
|
1
web/.prettierrc
Normal file
1
web/.prettierrc
Normal file
@ -0,0 +1 @@
|
||||
{}
|
@ -24,31 +24,31 @@ export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
import reactX from "eslint-plugin-react-x";
|
||||
import reactDom from "eslint-plugin-react-dom";
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
"react-x": reactX,
|
||||
"react-dom": reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactX.configs["recommended-typescript"].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
});
|
||||
```
|
||||
|
@ -1,28 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
6
web/globals.d.ts
vendored
Normal file
6
web/globals.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
interface Window {
|
||||
guard: {
|
||||
refreshing: boolean;
|
||||
refreshQueue: ((token: string | null) => void)[];
|
||||
};
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@ -8,6 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="portal-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
659
web/package-lock.json
generated
659
web/package-lock.json
generated
@ -8,16 +8,19 @@
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"axios": "^1.9.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.6.0",
|
||||
"tailwindcss": "^4.1.7"
|
||||
"react-jwt": "^1.3.0",
|
||||
"react-router": "^7.6.1",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
@ -30,6 +33,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"path": "^0.12.7",
|
||||
"prettier": "3.5.3",
|
||||
"sass": "^1.89.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
@ -53,6 +57,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
@ -108,6 +113,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz",
|
||||
"integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.1",
|
||||
@ -141,6 +147,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
|
||||
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.27.1",
|
||||
@ -182,6 +189,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -191,6 +199,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -224,6 +233,7 @@
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz",
|
||||
"integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.27.1"
|
||||
@ -267,19 +277,11 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
|
||||
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@ -294,6 +296,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz",
|
||||
"integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@ -312,6 +315,7 @@
|
||||
"version": "11.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
||||
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@ -321,6 +325,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
|
||||
"integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
@ -330,126 +335,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.16.7",
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/serialize": "^1.3.3",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"convert-source-map": "^1.5.0",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"find-root": "^1.1.0",
|
||||
"source-map": "^0.5.7",
|
||||
"stylis": "4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/cache": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
|
||||
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/sheet": "^1.4.0",
|
||||
"@emotion/utils": "^1.4.2",
|
||||
"@emotion/weak-memoize": "^0.4.0",
|
||||
"stylis": "4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/memoize": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
|
||||
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/react": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/serialize": "^1.3.3",
|
||||
"@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
|
||||
"@emotion/utils": "^1.4.2",
|
||||
"@emotion/weak-memoize": "^0.4.0",
|
||||
"hoist-non-react-statics": "^3.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/serialize": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
|
||||
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/unitless": "^0.10.0",
|
||||
"@emotion/utils": "^1.4.2",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/sheet": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
|
||||
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/unitless": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
|
||||
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
|
||||
"integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
||||
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/weak-memoize": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
|
||||
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
|
||||
@ -2063,17 +1948,11 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz",
|
||||
"integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@ -2401,19 +2280,21 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/babel-plugin-macros": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
||||
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
|
||||
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"cosmiconfig": "^7.0.0",
|
||||
"resolve": "^1.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6"
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
@ -2480,10 +2361,24 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@ -2572,6 +2467,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@ -2595,31 +2502,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/parse-json": "^4.0.0",
|
||||
"import-fresh": "^3.2.1",
|
||||
"parse-json": "^5.0.0",
|
||||
"path-type": "^4.0.0",
|
||||
"yaml": "^1.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig/node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -2639,12 +2521,14 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@ -2665,6 +2549,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||
@ -2678,6 +2571,20 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.155",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
|
||||
@ -2698,13 +2605,49 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.2.1"
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
@ -2761,6 +2704,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@ -3034,12 +2978,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-root": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@ -3078,6 +3016,41 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@ -3111,6 +3084,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@ -3137,6 +3147,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@ -3160,6 +3182,33 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@ -3172,14 +3221,11 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
@ -3202,6 +3248,7 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parent-module": "^1.0.0",
|
||||
@ -3231,27 +3278,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@ -3305,6 +3331,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@ -3324,6 +3351,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jsesc": "bin/jsesc"
|
||||
@ -3339,12 +3367,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-parse-even-better-errors": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@ -3633,12 +3655,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@ -3690,6 +3706,15 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@ -3714,6 +3739,27 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@ -3767,6 +3813,7 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@ -3872,6 +3919,7 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"callsites": "^3.0.0"
|
||||
@ -3880,24 +3928,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"error-ex": "^1.3.1",
|
||||
"json-parse-even-better-errors": "^2.3.0",
|
||||
"lines-and-columns": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path": {
|
||||
"version": "0.12.7",
|
||||
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
|
||||
@ -3929,21 +3959,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -4001,6 +4016,22 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
|
||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
@ -4011,6 +4042,12 @@
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@ -4088,11 +4125,20 @@
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
"node_modules/react-jwt": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-jwt/-/react-jwt-1.3.0.tgz",
|
||||
"integrity": "sha512-aC+X6q8pi63zoO7A060/4mfF5jM6Ay+4YyY4QgdD8dDOqp89sPcg0IhWEHyPACnVETMjBWzmxMPgIPosQNeYyw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "^2.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
@ -4105,9 +4151,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz",
|
||||
"integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==",
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.1.tgz",
|
||||
"integrity": "sha512-hPJXXxHJZEsPFNVbtATH7+MMX43UDeOauz+EAU4cgqTn7ojdI9qQORqS8Z0qmDlL1TclO/6jLRYUEtbWidtdHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@ -4140,30 +4186,11 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.0",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"resolve": "bin/resolve"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@ -4309,15 +4336,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -4340,12 +4358,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@ -4359,18 +4371,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
|
||||
@ -4754,6 +4754,35 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz",
|
||||
"integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,21 +5,25 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build --watch",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:watch": "tsc -b && vite build --watch",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"axios": "^1.9.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.6.0",
|
||||
"tailwindcss": "^4.1.7"
|
||||
"react-jwt": "^1.3.0",
|
||||
"react-router": "^7.6.1",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
@ -32,6 +36,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"path": "^0.12.7",
|
||||
"prettier": "3.5.3",
|
||||
"sass": "^1.89.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 6.9 MiB After Width: | Height: | Size: 59 KiB |
BIN
web/public/favicon.png
Normal file
BIN
web/public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.5 KiB |
156
web/src/App.tsx
156
web/src/App.tsx
@ -1,53 +1,145 @@
|
||||
import { useEffect, type FC } from "react";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import { type FC } from "react";
|
||||
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";
|
||||
|
||||
import IndexPage from "./pages/Index";
|
||||
import LoginPage from "./pages/Login";
|
||||
import RegisterPage from "./pages/Register";
|
||||
import { useDbContext } from "./context/db/db";
|
||||
import { openDB } from "idb";
|
||||
import AgreementPage from "./pages/Agreement";
|
||||
import AuthorizePage from "./pages/Authorize";
|
||||
import AuthenticatePage from "./pages/Authenticate";
|
||||
import AuthLayout from "./layout/AuthLayout";
|
||||
import DashboardLayout from "./layout/DashboardLayout";
|
||||
import PersonalInfoPage from "./pages/PersonalInfo";
|
||||
import ApiServicesPage from "./pages/Admin/ApiServices";
|
||||
import AdminLayout from "./layout/AdminLayout";
|
||||
import ApiServiceCreatePage from "./pages/Admin/ApiServices/Create";
|
||||
import ViewApiServicePage from "./pages/Admin/ApiServices/View";
|
||||
import NotAllowedPage from "./pages/NotAllowed";
|
||||
import NotFoundPage from "./pages/NotFound";
|
||||
import ApiServiceEditPage from "./pages/Admin/ApiServices/Update";
|
||||
import AdminUsersPage from "./pages/Admin/Users";
|
||||
import AdminViewUserPage from "./pages/Admin/Users/View";
|
||||
import AdminCreateUserPage from "./pages/Admin/Users/Create";
|
||||
import VerificationLayout from "./layout/VerificationLayout";
|
||||
import VerifyStartPage from "./pages/Verify";
|
||||
import VerifyEmailPage from "./pages/Verify/Email";
|
||||
import VerifyEmailOtpPage from "./pages/Verify/Email/OTP";
|
||||
import VerifyAvatarPage from "./pages/Verify/Avatar";
|
||||
import VerifyReviewPage from "./pages/Verify/Review";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <IndexPage />,
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
element: <DashboardLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <IndexPage />,
|
||||
},
|
||||
{
|
||||
path: "personal-info",
|
||||
element: <PersonalInfoPage />,
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
element: <AdminLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/admin/api-services" />,
|
||||
},
|
||||
{
|
||||
path: "api-services",
|
||||
children: [
|
||||
{ index: true, element: <ApiServicesPage /> },
|
||||
{ path: "create", element: <ApiServiceCreatePage /> },
|
||||
{
|
||||
path: "view/:serviceId",
|
||||
element: <ViewApiServicePage />,
|
||||
},
|
||||
{
|
||||
path: "edit/:serviceId",
|
||||
element: <ApiServiceEditPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
children: [
|
||||
{ index: true, element: <AdminUsersPage /> },
|
||||
{ path: "create", element: <AdminCreateUserPage /> },
|
||||
{
|
||||
path: "view/:userId",
|
||||
element: <AdminViewUserPage />,
|
||||
},
|
||||
// {
|
||||
// path: "edit/:serviceId",
|
||||
// element: <ApiServiceEditPage />,
|
||||
// },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/agreement",
|
||||
element: <AgreementPage />,
|
||||
path: "/verify",
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{
|
||||
path: "/verify",
|
||||
element: <VerificationLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <VerifyStartPage />,
|
||||
},
|
||||
{
|
||||
path: "email",
|
||||
element: <VerifyEmailPage />,
|
||||
},
|
||||
{
|
||||
path: "email/otp",
|
||||
element: <VerifyEmailOtpPage />,
|
||||
},
|
||||
{
|
||||
path: "avatar",
|
||||
element: <VerifyAvatarPage />,
|
||||
},
|
||||
{
|
||||
path: "review",
|
||||
element: <VerifyReviewPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
path: "/auth",
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{ index: true, element: <AuthorizePage /> },
|
||||
{ path: "login", element: <LoginPage /> },
|
||||
{ path: "register", element: <RegisterPage /> },
|
||||
{ path: "authenticate", element: <AuthenticatePage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/register",
|
||||
element: <RegisterPage />,
|
||||
path: "/not-allowed",
|
||||
element: <NotAllowedPage />,
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <NotFoundPage />,
|
||||
},
|
||||
]);
|
||||
|
||||
const App: FC = () => {
|
||||
const { db, setDb } = useDbContext();
|
||||
|
||||
useEffect(() => {
|
||||
const openConnection = async () => {
|
||||
const dbPromise = openDB("guard-local", 3, {
|
||||
upgrade: (db) => {
|
||||
if (!db.objectStoreNames.contains("accounts")) {
|
||||
db.createObjectStore("accounts", { keyPath: "accountId" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const conn = await dbPromise;
|
||||
|
||||
setDb(conn);
|
||||
};
|
||||
|
||||
openConnection();
|
||||
}, [db, setDb]);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
|
104
web/src/api/admin/apiServices.ts
Normal file
104
web/src/api/admin/apiServices.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import type { ApiService, ApiServiceCredentials } 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;
|
||||
};
|
||||
|
||||
export interface CreateApiServiceRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
redirect_uris: string[];
|
||||
scopes: string[];
|
||||
grant_types: string[];
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface CreateApiServiceResponse {
|
||||
service: ApiService;
|
||||
credentials: ApiServiceCredentials;
|
||||
}
|
||||
|
||||
export const postApiService = async (
|
||||
req: CreateApiServiceRequest,
|
||||
): Promise<CreateApiServiceResponse> => {
|
||||
const response = await axios.post<CreateApiServiceResponse>(
|
||||
"/api/v1/admin/api-services",
|
||||
req,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getApiService = async (id: string): Promise<ApiService> => {
|
||||
const response = await axios.get<ApiService>(
|
||||
`/api/v1/admin/api-services/${id}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getApiServiceCID = async (
|
||||
clientId: string,
|
||||
): Promise<ApiService> => {
|
||||
const response = await axios.get<ApiService>(
|
||||
`/api/v1/api-services/client/${clientId}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const patchToggleApiService = async (id: string): Promise<void> => {
|
||||
const response = await axios.patch(`/api/v1/admin/api-services/toggle/${id}`);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface UpdateApiServiceRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
redirect_uris: string[];
|
||||
scopes: string[];
|
||||
grant_types: string[];
|
||||
}
|
||||
|
||||
export type UpdateApiServiceResponse = ApiService;
|
||||
|
||||
export const putApiService = async (
|
||||
serviceId: string,
|
||||
req: UpdateApiServiceRequest,
|
||||
): Promise<UpdateApiServiceResponse> => {
|
||||
const response = await axios.put<UpdateApiServiceResponse>(
|
||||
`/api/v1/admin/api-services/${serviceId}`,
|
||||
req,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
56
web/src/api/admin/users.ts
Normal file
56
web/src/api/admin/users.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import type { UserProfile } from "@/types";
|
||||
import { axios, handleApiError } from "..";
|
||||
|
||||
export interface FetchUsersResponse {
|
||||
items: UserProfile[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const adminGetUsersApi = async (): Promise<FetchUsersResponse> => {
|
||||
const response = await axios.get<FetchUsersResponse>("/api/v1/admin/users");
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export type FetchUserResponse = UserProfile;
|
||||
|
||||
export const adminGetUserApi = async (
|
||||
id: string,
|
||||
): Promise<FetchUserResponse> => {
|
||||
const response = await axios.get<FetchUserResponse>(
|
||||
`/api/v1/admin/users/${id}`,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface CreateUserRequest {
|
||||
email: string;
|
||||
full_name: string;
|
||||
password: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const postUser = async (
|
||||
req: CreateUserRequest,
|
||||
): Promise<CreateUserResponse> => {
|
||||
const response = await axios.post<CreateUserResponse>(
|
||||
"/api/v1/admin/users",
|
||||
req,
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
17
web/src/api/avatar.ts
Normal file
17
web/src/api/avatar.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { axios, handleApiError } from ".";
|
||||
|
||||
export const uploadAvatarApi = async (imageFile: File): Promise<string> => {
|
||||
const formData = new FormData();
|
||||
formData.append("image", imageFile);
|
||||
|
||||
const response = await axios.put("/api/v1/avatar", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
29
web/src/api/code.ts
Normal file
29
web/src/api/code.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { handleApiError, axios } from ".";
|
||||
|
||||
export interface CodeResponse {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export const codeApi = async (
|
||||
accessToken: string,
|
||||
nonce: string,
|
||||
clientId: string,
|
||||
) => {
|
||||
const response = await axios.post(
|
||||
"/api/v1/oauth/code",
|
||||
{ nonce, client_id: clientId },
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
const data: CodeResponse = response.data;
|
||||
|
||||
return data;
|
||||
};
|
@ -1,20 +1,121 @@
|
||||
export const handleApiError = async (response: Response) => {
|
||||
try {
|
||||
const json = await response.json();
|
||||
console.log({ json });
|
||||
const text = json.error ?? "unexpected error happpened";
|
||||
return new Error(text[0].toUpperCase() + text.slice(1));
|
||||
} catch (err) {
|
||||
try {
|
||||
console.log(err);
|
||||
const text = await response.text();
|
||||
if (text.length > 0) {
|
||||
return new Error(text[0].toUpperCase() + text.slice(1));
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
import { deleteAccount, updateAccountTokens } from "@/repository/account";
|
||||
import { useDbStore } from "@/store/db";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import Axios, { type AxiosResponse } from "axios";
|
||||
import { refreshTokenApi } from "./refresh";
|
||||
|
||||
import { isExpired } from "react-jwt";
|
||||
|
||||
export const axios = Axios.create({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
let refreshQueue: ((token: string | null) => void)[] = [];
|
||||
|
||||
const waitForTokenRefresh = () => {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
refreshQueue.push((token: string | null) => resolve(token));
|
||||
});
|
||||
};
|
||||
|
||||
const processRefreshQueue = async (token: string | null) => {
|
||||
refreshQueue.forEach((resolve) => resolve(token));
|
||||
refreshQueue = [];
|
||||
};
|
||||
|
||||
const logout = async (accountId: string) => {
|
||||
const db = useDbStore.getState().db;
|
||||
const requireSignIn = useAuth.getState().requireSignIn;
|
||||
|
||||
if (db) {
|
||||
await deleteAccount(db, accountId);
|
||||
}
|
||||
requireSignIn?.();
|
||||
};
|
||||
|
||||
const refreshToken = async (
|
||||
accountId: string,
|
||||
refreshToken: string,
|
||||
): Promise<{ access: string; refresh: string }> => {
|
||||
const db = useDbStore.getState().db;
|
||||
const loadAccounts = useAuth.getState().loadAccounts;
|
||||
|
||||
if (!db) {
|
||||
console.log("No database connection available.");
|
||||
throw new Error("No database connection available.");
|
||||
}
|
||||
|
||||
return new Error("Unexpected error happened");
|
||||
try {
|
||||
const response = await refreshTokenApi(refreshToken);
|
||||
|
||||
await updateAccountTokens(db, {
|
||||
accountId: accountId,
|
||||
access: response.access,
|
||||
refresh: response.refresh,
|
||||
});
|
||||
|
||||
processRefreshQueue(response.access);
|
||||
|
||||
return { access: response.access, refresh: response.refresh };
|
||||
} catch (err) {
|
||||
console.error("Token refresh failed:", err);
|
||||
processRefreshQueue(null);
|
||||
throw err;
|
||||
} finally {
|
||||
localStorage.removeItem("refreshing");
|
||||
loadAccounts?.();
|
||||
window.guard.refreshing = false;
|
||||
}
|
||||
};
|
||||
|
||||
axios.interceptors.request.use(
|
||||
async (request) => {
|
||||
const account = useAuth.getState().activeAccount;
|
||||
let token: string | null = account?.access ?? null;
|
||||
|
||||
if (!token || !isExpired(token)) {
|
||||
request.headers["Authorization"] = `Bearer ${token}`;
|
||||
return request;
|
||||
}
|
||||
|
||||
if (!window.guard.refreshing) {
|
||||
console.log(`request to ${request.url} is refreshing token`);
|
||||
window.guard.refreshing = true;
|
||||
try {
|
||||
const { access } = await refreshToken(
|
||||
account!.accountId,
|
||||
account!.refresh,
|
||||
);
|
||||
token = access;
|
||||
} catch (err) {
|
||||
console.error("Token refresh failed:", err);
|
||||
await logout(account!.accountId);
|
||||
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) {
|
||||
// logout should be triggered by main process (refreshToken)
|
||||
// await logout(account!.accountId);
|
||||
throw new Error("No token available");
|
||||
}
|
||||
|
||||
request.headers["Authorization"] = `Bearer ${token}`;
|
||||
return request;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
export const handleApiError = async (response: AxiosResponse) => {
|
||||
const text =
|
||||
response.data?.error ||
|
||||
response.data?.toString?.() ||
|
||||
"unexpected error happened";
|
||||
return new Error(text[0].toUpperCase() + text.slice(1));
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
import axios from "axios";
|
||||
import { handleApiError } from ".";
|
||||
|
||||
export interface LoginRequest {
|
||||
@ -15,21 +16,15 @@ export interface LoginResponse {
|
||||
}
|
||||
|
||||
export const loginApi = async (req: LoginRequest) => {
|
||||
const response = await fetch("/api/v1/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
const response = await axios.post("/api/v1/auth/login", {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
});
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
const data: LoginResponse = await response.json();
|
||||
const data: LoginResponse = response.data;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
15
web/src/api/profile.ts
Normal file
15
web/src/api/profile.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { UserProfile } from "@/types";
|
||||
import { axios, handleApiError } from ".";
|
||||
|
||||
export type FetchProfileResponse = UserProfile;
|
||||
|
||||
export const fetchProfileApi = async () => {
|
||||
const response = await axios.get("/api/v1/auth/profile");
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
const data: FetchProfileResponse = response.data;
|
||||
|
||||
return data;
|
||||
};
|
27
web/src/api/refresh.ts
Normal file
27
web/src/api/refresh.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import axios from "axios";
|
||||
import { handleApiError } from ".";
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
}
|
||||
|
||||
export const refreshTokenApi = async (refreshToken: string) => {
|
||||
const response = await axios.post(
|
||||
"/api/v1/auth/refresh",
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${refreshToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
const data: RefreshTokenResponse = response.data;
|
||||
|
||||
return data;
|
||||
};
|
34
web/src/api/verify.ts
Normal file
34
web/src/api/verify.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { axios, handleApiError } from ".";
|
||||
|
||||
export const requestEmailOtpApi = async (): Promise<void> => {
|
||||
const response = await axios.post("/api/v1/auth/email");
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface ConfirmEmailRequest {
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export const confirmEmailApi = async (
|
||||
req: ConfirmEmailRequest,
|
||||
): Promise<void> => {
|
||||
const response = await axios.post("/api/v1/auth/email/otp", req);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const finishVerificationApi = async (): Promise<void> => {
|
||||
const response = await axios.post("/api/v1/auth/verify");
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
return response.data;
|
||||
};
|
25
web/src/components/Home/Sidebar/index.tsx
Normal file
25
web/src/components/Home/Sidebar/index.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import type { FC } from "react";
|
||||
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 border-r border-gray-300 dark:border-gray-700 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
|
||||
className={`dark:text-gray-200 transition-colors text-sm cursor-pointer p-4 rounded-lg flex flex-row items-center gap-3${
|
||||
isActive(item) ? " bg-gray-200 dark:bg-gray-900" : ""
|
||||
}`}
|
||||
>
|
||||
{item.icon} {item.title}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
27
web/src/components/Home/TopBar/index.tsx
Normal file
27
web/src/components/Home/TopBar/index.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { type FC } from "react";
|
||||
import { useBarItems } from "@/hooks/barItems";
|
||||
import { Link } from "react-router";
|
||||
|
||||
const TopBar: FC = () => {
|
||||
const [barItems, isActive] = useBarItems();
|
||||
|
||||
return (
|
||||
<div className="sm:hidden flex w-full overflow-x-auto sm:overflow-x-visible max-w-full min-w-full sm:justify-center sm:space-x-4 no-scrollbar shadow-md shadow-gray-300 dark:shadow-gray-700 dark:bg-black/70 bg-white/70">
|
||||
{barItems.map((item) => (
|
||||
<Link to={item.pathname} key={item.tab}>
|
||||
<div
|
||||
className={`flex-shrink-0 transition-all border-b-4 px-4 py-2 min-w-[120px] sm:min-w-0 sm:flex-1 flex items-center justify-center cursor-pointer select-none whitespace-nowrap text-sm font-medium ${
|
||||
isActive(item)
|
||||
? " border-b-4 border-b-blue-500 text-blue-500"
|
||||
: " border-b-transparent text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopBar;
|
35
web/src/components/ui/breadcrumbs.tsx
Normal file
35
web/src/components/ui/breadcrumbs.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import type { FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface BreadItem {
|
||||
href?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface IBreadcrumbsProps {
|
||||
items: BreadItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Breadcrumbs: FC<IBreadcrumbsProps> = ({ items, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={`${className ? `${className} ` : ""} flex flex-row p-1 sm:p-3 gap-3 items-center text-gray-800 dark:text-gray-200 text-sm sm:text-base`}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<>
|
||||
{item.href ? (
|
||||
<Link to={item.href}>
|
||||
<p className="text-blue-500">{item.label}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p>{item.label}</p>
|
||||
)}
|
||||
{index + 1 < items.length && <p className="text-gray-500">/</p>}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Breadcrumbs;
|
@ -9,7 +9,7 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
loading?: boolean;
|
||||
variant?: "contained" | "outlined" | "text";
|
||||
variant?: "contained" | "outlined" | "text" | "icon";
|
||||
}
|
||||
|
||||
export const Button: FC<ButtonProps> = ({
|
||||
@ -22,11 +22,13 @@ export const Button: FC<ButtonProps> = ({
|
||||
const appearance = useMemo(() => {
|
||||
switch (variant) {
|
||||
case "contained":
|
||||
return "bg-blue-600 text-white hover:bg-blue-700";
|
||||
return "px-4 py-2 bg-blue-600 text-white hover:bg-blue-700";
|
||||
case "outlined":
|
||||
return "border border-blue-600 text-blue-600 hover:text-blue-700 font-medium";
|
||||
return "px-4 py-2 border border-blue-600 text-blue-600 hover:text-blue-700 font-medium";
|
||||
case "text":
|
||||
return "text-blue-600 hover:text-blue-700 font-medium";
|
||||
return "py-2 px-4 text-blue-600 hover:text-blue-700 font-medium";
|
||||
case "icon":
|
||||
return "py-0 px-0 text-gray-400 hover:text-gray-600";
|
||||
}
|
||||
|
||||
return "";
|
||||
@ -34,7 +36,7 @@ export const Button: FC<ButtonProps> = ({
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`cursor-pointer py-2 px-4 rounded-md transition-colors ${appearance} ${
|
||||
className={`${appearance} cursor-pointer rounded-md transition-colors ${
|
||||
className || ""
|
||||
}${
|
||||
loading
|
||||
|
@ -3,16 +3,29 @@ import type { FC, ReactNode } from "react";
|
||||
interface ComponentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
spacing?: boolean;
|
||||
}
|
||||
|
||||
export const Card: FC<ComponentProps> = ({ children, className }) => {
|
||||
return (
|
||||
<div className={`bg-white sm:rounded-lg shadow-md ${className || ""}`}>
|
||||
<div
|
||||
className={`bg-white sm:rounded-lg overflow-hidden shadow-md ${
|
||||
className || ""
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function CardContent({ children, className }: ComponentProps) {
|
||||
return <div className={`p-4 ${className || ""}`}>{children}</div>;
|
||||
export function CardContent({
|
||||
children,
|
||||
className,
|
||||
spacing = true,
|
||||
}: ComponentProps) {
|
||||
return (
|
||||
<div className={`${spacing ? "p-4 " : ""}${className || ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ export const Input: FC<InputProps> = ({ className, ...props }) => {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={`w-full border border-gray-300 dark:border-gray-600 dark:placeholder-gray-600 dark:text-gray-100 rounded-md px-3 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
className={`w-full border border-gray-300 dark:border-gray-700 dark:placeholder-gray-600 dark:text-gray-100 rounded-md px-3 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
className || ""
|
||||
}`}
|
||||
/>
|
||||
|
91
web/src/components/ui/stepper.tsx
Normal file
91
web/src/components/ui/stepper.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
type Step = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
type StepperProps = {
|
||||
steps: Step[];
|
||||
currentStep: string;
|
||||
};
|
||||
|
||||
export const Stepper: React.FC<StepperProps> = ({ steps, currentStep }) => {
|
||||
const stepIndex = useMemo(
|
||||
() => steps.findIndex((s) => s.id === currentStep),
|
||||
[currentStep, steps],
|
||||
);
|
||||
|
||||
const percent = useMemo(() => {
|
||||
return steps.length === 1
|
||||
? 100
|
||||
: Math.round((stepIndex / (steps.length - 1)) * 100);
|
||||
}, [stepIndex, steps.length]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center w-full max-w-2xl mx-auto mb-5 sm:mb-8 gap-5 relative">
|
||||
{steps.map((step, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`sm:flex p-4 pb-0 sm:p-0 flex-1 items-center ${idx < stepIndex ? "opacity-70" : ""} ${idx === stepIndex ? "flex" : "hidden"}`}
|
||||
>
|
||||
{/* Step circle */}
|
||||
<div
|
||||
className={`relative z-10 flex items-center justify-center w-12 h-12 min-w-12 sm:w-10 sm:h-10 sm:min-w-10 rounded-full
|
||||
${
|
||||
idx < stepIndex
|
||||
? "bg-blue-400 text-white"
|
||||
: idx === stepIndex
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-100 dark:bg-gray-800/60 text-gray-500"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{idx < stepIndex ? (
|
||||
// Check icon for completed steps
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
(step.icon ?? <span className="font-bold">{idx + 1}</span>)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step label */}
|
||||
<div className="flex flex-col ml-2 mr-2 sm:ml-4 sm:mr-4">
|
||||
<span className="text-base text-gray-700 dark:text-gray-200 sm:text-sm font-medium">
|
||||
{step.label}
|
||||
</span>
|
||||
{step.description && (
|
||||
<span className="text-sm sm:text-xs text-gray-500 dark:text-gray-400">
|
||||
{step.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* {idx < steps.length - 1 && (
|
||||
<div className="flex-1 h-1 mx-2 min-w sm:mx-4 rounded bg-gray-300 dark:bg-gray-600" />
|
||||
)} */}
|
||||
</div>
|
||||
))}
|
||||
<div className="sm:hidden relative h-1 w-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-blue-500 transition-all ease-in duration-500"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,16 +0,0 @@
|
||||
import type { IDBPDatabase } from "idb";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface DbContextValues {
|
||||
db: IDBPDatabase | null;
|
||||
connected: boolean;
|
||||
setDb: (db: IDBPDatabase) => void;
|
||||
}
|
||||
|
||||
export const DbContext = createContext<DbContextValues>({
|
||||
db: null,
|
||||
connected: false,
|
||||
setDb: () => {},
|
||||
});
|
||||
|
||||
export const useDbContext = () => useContext(DbContext);
|
@ -1,25 +0,0 @@
|
||||
import { useCallback, useState, type FC, type ReactNode } from "react";
|
||||
import { DbContext } from "./db";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
|
||||
interface IDBProvider {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const DbProvider: FC<IDBProvider> = ({ children }) => {
|
||||
const [db, _setDb] = useState<IDBPDatabase | null>(null);
|
||||
|
||||
const setDb = useCallback((db: IDBPDatabase) => _setDb(db), []);
|
||||
|
||||
return (
|
||||
<DbContext.Provider
|
||||
value={{
|
||||
db,
|
||||
connected: Boolean(db),
|
||||
setDb,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DbContext.Provider>
|
||||
);
|
||||
};
|
@ -1,65 +1,34 @@
|
||||
import { useDbContext } from "@/context/db/db";
|
||||
import { type LocalAccount, useAccountRepo } from "@/repository/account";
|
||||
import { CirclePlus, User } from "lucide-react";
|
||||
import { useEffect, useState, type FC } from "react";
|
||||
import { type LocalAccount } from "@/repository/account";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { CirclePlus } from "lucide-react";
|
||||
import { useCallback, type FC } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import Avatar from "../Avatar";
|
||||
|
||||
const AccountList: FC = () => {
|
||||
const [accounts, setAccounts] = useState<LocalAccount[]>([]);
|
||||
const accounts = useAuth((state) => state.accounts);
|
||||
const updateActiveAccount = useAuth((state) => state.updateActiveAccount);
|
||||
|
||||
const repo = useAccountRepo();
|
||||
const { connected } = useDbContext();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) repo.loadAll().then(setAccounts);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [connected]);
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<div className="p-5 flex-1 h-full flex-full flex items-center justify-center">
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-12 h-12 text-gray-200 dark:text-gray-600 animate-spin fill-blue-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const handleAccountSelect = useCallback(
|
||||
(account: LocalAccount) => {
|
||||
updateActiveAccount(account);
|
||||
},
|
||||
[updateActiveAccount],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.accountId}
|
||||
onClick={() => handleAccountSelect(account)}
|
||||
className="flex flex-row items-center p-4 border-gray-200 dark:border-gray-700/65 border-b border-r-0 border-l-0 select-none cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/10 transition-colors mb-0"
|
||||
>
|
||||
<div>
|
||||
<div className="rounded-full w-10 h-10 overflow-hidden bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-200 mr-3 ring ring-gray-400 dark:ring dark:ring-gray-500">
|
||||
{account.profilePicture ? (
|
||||
<img
|
||||
src={account.profilePicture}
|
||||
className="w-full h-full flex-1"
|
||||
alt="profile"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<User />
|
||||
</div>
|
||||
)}
|
||||
<Avatar iconSize={8} avatarId={account.profilePicture ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@ -72,16 +41,18 @@ const AccountList: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-row items-center p-4 border-gray-200 dark:border-gray-700/65 border-b border-r-0 border-l-0 select-none cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/10 transition-colors mb-0">
|
||||
<div>
|
||||
<div className="rounded-full p-2 text-gray-900 dark:text-gray-200 mr-3">
|
||||
<CirclePlus />
|
||||
<Link to="/auth/login" state={location.state}>
|
||||
<div className="flex flex-row items-center p-4 border-gray-200 dark:border-gray-700/65 border-b border-r-0 border-l-0 select-none cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/10 transition-colors mb-0">
|
||||
<div>
|
||||
<div className="rounded-full p-2 text-gray-900 dark:text-gray-200 mr-3">
|
||||
<CirclePlus />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base text-gray-900 dark:text-gray-200">
|
||||
Add new account
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-base text-gray-900 dark:text-gray-200">
|
||||
Add new account
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
65
web/src/feature/ApiServiceCredentialsModal/index.tsx
Normal file
65
web/src/feature/ApiServiceCredentialsModal/index.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X } from "lucide-react";
|
||||
import { useApiServices } from "@/store/admin/apiServices";
|
||||
import type { ApiServiceCredentials } from "@/types";
|
||||
|
||||
const download = (credentials: ApiServiceCredentials) => {
|
||||
const jsonStr = JSON.stringify(credentials, null, 2);
|
||||
const blob = new Blob([jsonStr], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `credentials.json`;
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const ApiServiceCredentialsModal = () => {
|
||||
const credentials = useApiServices((state) => state.createdCredentials);
|
||||
const resetCredentials = useApiServices((state) => state.resetCredentials);
|
||||
|
||||
const portalRoot = document.getElementById("portal-root");
|
||||
if (!portalRoot || !credentials) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed z-50 inset-0 flex items-center justify-center bg-black/30 dark:bg-white/30 px-5">
|
||||
<div className="rounded-2xl flex flex-col items-stretch bg-white dark:bg-black min-w-[300px] max-w-md w-full">
|
||||
<div className="flex flex-row items-center justify-between p-4 border-b dark:border-gray-800 border-gray-300">
|
||||
<p className="text-gray-800 dark:text-gray-200">New Credentials</p>
|
||||
<Button variant="icon" onClick={resetCredentials}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Client ID
|
||||
</p>
|
||||
<Input value={credentials.client_id} disabled />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Client Secret
|
||||
</p>
|
||||
<Input value={credentials.client_secret} disabled />
|
||||
</div>
|
||||
<div className="mt-4 flex flex-row items-center justify-between">
|
||||
<Button variant="contained" onClick={resetCredentials}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => download(credentials)}>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
portalRoot,
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiServiceCredentialsModal;
|
44
web/src/feature/ApiServiceUpdatedModal/index.tsx
Normal file
44
web/src/feature/ApiServiceUpdatedModal/index.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CircleCheckBig, X } from "lucide-react";
|
||||
import { useApiServices } from "@/store/admin/apiServices";
|
||||
|
||||
const ApiServiceUpdatedModal = () => {
|
||||
const resetUpdated = useApiServices((state) => state.resetUpdated);
|
||||
|
||||
const portalRoot = document.getElementById("portal-root");
|
||||
if (!portalRoot) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed z-50 inset-0 flex items-center justify-center bg-black/30 dark:bg-white/30 px-5">
|
||||
<div className="rounded-2xl flex flex-col items-stretch bg-white dark:bg-black min-w-[300px] max-w-md w-full">
|
||||
<div className="flex flex-row items-center justify-between p-4 border-b dark:border-gray-800 border-gray-300">
|
||||
<p className="text-gray-800 dark:text-gray-200">Service Updated</p>
|
||||
<Button variant="icon" onClick={resetUpdated}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex flex-col items-center p-4 text-green-400 gap-3">
|
||||
<CircleCheckBig size={64} />
|
||||
<h2 className="text-gray-800 dark:text-gray-200 text-xl">
|
||||
Service has updated successfully!
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-4 w-full">
|
||||
<Button
|
||||
variant="outlined"
|
||||
className="w-full"
|
||||
onClick={resetUpdated}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
portalRoot,
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiServiceUpdatedModal;
|
36
web/src/feature/Avatar/index.tsx
Normal file
36
web/src/feature/Avatar/index.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { User } from "lucide-react";
|
||||
import { useMemo, type FC } from "react";
|
||||
|
||||
export interface AvatarProps {
|
||||
iconSize?: number;
|
||||
className?: string;
|
||||
avatarId?: string;
|
||||
}
|
||||
|
||||
const Avatar: FC<AvatarProps> = ({ iconSize = 32, className, avatarId }) => {
|
||||
const profile = useAuth((state) => state.profile);
|
||||
|
||||
const avatar = useMemo(
|
||||
() => (avatarId !== undefined ? avatarId : profile?.profile_picture),
|
||||
[avatarId, profile?.profile_picture],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden bg-gray-100 rounded-full ring ring-gray-400 dark:ring dark:ring-gray-500 flex items-center justify-center ${className}`}
|
||||
>
|
||||
{avatar ? (
|
||||
<img
|
||||
src={avatar}
|
||||
className="w-full h-full flex-1 object-cover"
|
||||
alt="profile"
|
||||
/>
|
||||
) : (
|
||||
<User size={iconSize} className="text-gray-800" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Avatar;
|
68
web/src/hooks/barItems.tsx
Normal file
68
web/src/hooks/barItems.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { Blocks, Home, Settings2, User, Users } from "lucide-react";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
export interface BarItem {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
tab: string;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
export const useBarItems = (): [BarItem[], (item: BarItem) => boolean] => {
|
||||
const profile = useAuth((state) => state.profile);
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = useCallback(
|
||||
(item: BarItem) => {
|
||||
if (item.pathname === "/") return location.pathname === item.pathname;
|
||||
return location.pathname.startsWith(item.pathname);
|
||||
},
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
if (!profile) {
|
||||
return [[], isActive];
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
{
|
||||
icon: <Home />,
|
||||
title: "Home",
|
||||
tab: "home",
|
||||
pathname: "/",
|
||||
},
|
||||
{
|
||||
icon: <User />,
|
||||
title: "Personal Info",
|
||||
tab: "personal-info",
|
||||
pathname: "/personal-info",
|
||||
},
|
||||
{
|
||||
icon: <Settings2 />,
|
||||
title: "Data & Personalization",
|
||||
tab: "data-personalization",
|
||||
pathname: "/data-personalize",
|
||||
},
|
||||
...(profile.is_admin
|
||||
? [
|
||||
{
|
||||
icon: <Blocks />,
|
||||
title: "API Services",
|
||||
tab: "admin.api-services",
|
||||
pathname: "/admin/api-services",
|
||||
},
|
||||
{
|
||||
icon: <Users />,
|
||||
title: "Users",
|
||||
tab: "admin.users",
|
||||
pathname: "/admin/users",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
isActive,
|
||||
];
|
||||
};
|
@ -1,6 +1,16 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer utilities {
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: "Inter", Arial, Helvetica, sans-serif;
|
||||
|
42
web/src/layout/AdminLayout.tsx
Normal file
42
web/src/layout/AdminLayout.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useAuth } from "@/store/auth";
|
||||
import type { FC } from "react";
|
||||
import { Navigate, Outlet } from "react-router";
|
||||
|
||||
const AdminLayout: FC = () => {
|
||||
const profile = useAuth((state) => state.profile);
|
||||
|
||||
if (!profile) {
|
||||
<div className="w-screen h-screen flex flex-1 items-center justify-center flex-col">
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-10 h-10 text-gray-400 animate-spin fill-white"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p className="text-gray-200 dark:text-gray-400 mt-4 text-lg">
|
||||
Loading...
|
||||
</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (!profile?.is_admin) {
|
||||
return <Navigate to="/not-allowed" />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
168
web/src/layout/AuthLayout.tsx
Normal file
168
web/src/layout/AuthLayout.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { useDbStore } from "@/store/db";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "react-router";
|
||||
import BackgroundLayout from "./BackgroundLayout";
|
||||
import { useOAuth } from "@/store/oauth";
|
||||
|
||||
const AuthLayout = () => {
|
||||
const { connecting, db, connect } = useDbStore();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const parseSearchParams = useOAuth((state) => state.parseSearchParams);
|
||||
|
||||
const dbConnected = useMemo(() => !!db, [db]);
|
||||
|
||||
const loadingAccounts = useAuth((state) => state.loadingAccounts);
|
||||
const loadAccounts = useAuth((state) => state.loadAccounts);
|
||||
const hasLoadedAccounts = useAuth((state) => state.hasLoadedAccounts);
|
||||
|
||||
const activeAccount = useAuth((state) => state.activeAccount);
|
||||
|
||||
const hasAccounts = useAuth((state) => state.accounts.length > 0);
|
||||
|
||||
const authenticating = useAuth((state) => state.authenticating);
|
||||
const authenticate = useAuth((state) => state.authenticate);
|
||||
const hasAuthenticated = useAuth((state) => state.hasAuthenticated);
|
||||
|
||||
const authProfile = useAuth((s) => s.profile);
|
||||
|
||||
const signInRequired = useAuth((state) => state.signInRequired);
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isAuthPage = useMemo(() => {
|
||||
const pathname = location.pathname.replace(/\/$/i, "");
|
||||
return pathname !== "/auth" && pathname.startsWith("/auth");
|
||||
}, [location.pathname]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
if (isAuthPage) {
|
||||
return connecting;
|
||||
}
|
||||
return (
|
||||
!hasAuthenticated ||
|
||||
!hasLoadedAccounts ||
|
||||
loadingAccounts ||
|
||||
authenticating ||
|
||||
connecting
|
||||
);
|
||||
}, [
|
||||
isAuthPage,
|
||||
hasAuthenticated,
|
||||
hasLoadedAccounts,
|
||||
loadingAccounts,
|
||||
authenticating,
|
||||
connecting,
|
||||
]);
|
||||
|
||||
const verificationRequired = useMemo(() => {
|
||||
return (
|
||||
authProfile?.email_verified === false ||
|
||||
authProfile?.avatar_verified === false ||
|
||||
authProfile?.verified === false
|
||||
);
|
||||
}, [
|
||||
authProfile?.avatar_verified,
|
||||
authProfile?.email_verified,
|
||||
authProfile?.verified,
|
||||
]);
|
||||
|
||||
// OAuth
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
"parsing url search params:",
|
||||
Object.fromEntries(searchParams.entries()),
|
||||
);
|
||||
parseSearchParams(searchParams);
|
||||
}, [parseSearchParams, searchParams]);
|
||||
|
||||
// Database
|
||||
useEffect(() => {
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
// Account Manager
|
||||
useEffect(() => {
|
||||
if (dbConnected) {
|
||||
loadAccounts();
|
||||
}
|
||||
}, [dbConnected, loadAccounts]);
|
||||
|
||||
// Fetch Profile
|
||||
useEffect(() => {
|
||||
if (dbConnected && !loadingAccounts && activeAccount) {
|
||||
authenticate();
|
||||
}
|
||||
}, [activeAccount, dbConnected, authenticate, loadingAccounts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!signInRequired && isAuthPage) {
|
||||
const to = location.state?.from ?? "/";
|
||||
navigate(to, { state: { reset: true } });
|
||||
}
|
||||
}, [isAuthPage, location.state?.from, navigate, signInRequired]);
|
||||
|
||||
if (signInRequired && !isAuthPage) {
|
||||
return (
|
||||
<Navigate
|
||||
to={hasAccounts ? "/auth/authenticate" : "/auth/login"}
|
||||
state={{ from: location.pathname }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<BackgroundLayout>
|
||||
<div className="w-screen h-screen flex flex-1 items-center justify-center flex-col">
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-10 h-10 text-gray-400 animate-spin fill-white"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p className="text-gray-200 dark:text-gray-400 mt-4 text-lg">
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
</BackgroundLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!signInRequired &&
|
||||
verificationRequired &&
|
||||
!location.pathname.startsWith("/verify")
|
||||
) {
|
||||
return <Navigate to="/verify" state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BackgroundLayout>
|
||||
<Outlet />
|
||||
</BackgroundLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
17
web/src/layout/BackgroundLayout.tsx
Normal file
17
web/src/layout/BackgroundLayout.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { type FC, type ReactNode } from "react";
|
||||
|
||||
export interface IBackgroundLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BackgroundLayout: FC<IBackgroundLayoutProps> = ({ children }) => {
|
||||
return (
|
||||
// <div className="relative min-h-screen bg-[url(/overlay.jpg)] bg-[#f8f9fb] dark:bg-gradient-to-br from-[#101112] to-[#041758]">
|
||||
// <div className="relative min-h-screen bg-cover bg-center bg-[url(/overlay.jpg)] bg-[#f8f9fb] dark:bg-[#101112] dark:bg-[url(/background-dark.png)]">
|
||||
<div className="relative min-h-screen bg-cover bg-center bg-[#f8f9fb] dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackgroundLayout;
|
42
web/src/layout/DashboardLayout.tsx
Normal file
42
web/src/layout/DashboardLayout.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import Sidebar from "@/components/Home/Sidebar";
|
||||
import TopBar from "@/components/Home/TopBar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { type FC } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
const DashboardLayout: FC = () => {
|
||||
return (
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen">
|
||||
<Card className="min-h-screen w-full min-w-full h-screen max-h-screen shadow-lg bg-white/85 dark:bg-black/85 backdrop-blur-md sm:rounded-none overflow-y-auto sm:overflow-hidden">
|
||||
<div className="flex flex-col w-full h-full flex-1 items-center sm:pt-0">
|
||||
<div className="flex w-full sm:w-auto p-4 sm:p-0 flex-row items-center sm:absolute sm:left-4 sm:top-4">
|
||||
<img src="/icon.png" alt="icon" className="w-6 h-6" />
|
||||
|
||||
<div className="ml-2">
|
||||
<p className="text-sm text-gray-600 text-left dark:text-gray-500">
|
||||
Home Guard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="max-w-full flex-1 sm:overflow-y-auto sm:max-h-screen">
|
||||
<div className="flex flex-col w-full items-center gap-2">
|
||||
<TopBar />
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardLayout;
|
86
web/src/layout/VerificationLayout.tsx
Normal file
86
web/src/layout/VerificationLayout.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { Stepper } from "@/components/ui/stepper";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useVerify } from "@/store/verify";
|
||||
import { Eye, MailCheck, ScanFace } from "lucide-react";
|
||||
import { useEffect, type FC } from "react";
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from "react-router";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: "email",
|
||||
icon: <MailCheck size={18} />,
|
||||
label: "Verify Email",
|
||||
description: "Confirm your address",
|
||||
},
|
||||
{
|
||||
id: "avatar",
|
||||
icon: <ScanFace size={20} />,
|
||||
label: "Profile Picture",
|
||||
description: "Add profile image",
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
icon: <Eye size={20} />,
|
||||
label: "Done",
|
||||
description: "Review & Quit",
|
||||
},
|
||||
];
|
||||
|
||||
const VerificationLayout: FC = () => {
|
||||
const location = useLocation();
|
||||
const profile = useAuth((s) => s.profile);
|
||||
|
||||
const step = useVerify((s) => s.step);
|
||||
const loadStep = useVerify((s) => s.loadStep);
|
||||
|
||||
const redirect = useVerify((s) => s.redirect);
|
||||
const setRedirect = useVerify((s) => s.setRedirect);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) loadStep(profile);
|
||||
}, [loadStep, profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.from) {
|
||||
setRedirect(location.state.from);
|
||||
}
|
||||
}, [location.state?.from, setRedirect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === false) {
|
||||
navigate(redirect ?? "/", { state: { reset: true } });
|
||||
}
|
||||
}, [navigate, redirect, step]);
|
||||
|
||||
if (
|
||||
step === "email" &&
|
||||
!location.pathname.startsWith("/verify/email") &&
|
||||
location.pathname.replace(/\/$/i, "") !== "/verify"
|
||||
) {
|
||||
return <Navigate to="/verify/email" />;
|
||||
}
|
||||
|
||||
if (step === "avatar" && !location.pathname.startsWith("/verify/avatar")) {
|
||||
return <Navigate to="/verify/avatar" />;
|
||||
}
|
||||
|
||||
if (step === "review" && !location.pathname.startsWith("/verify/review")) {
|
||||
return <Navigate to="/verify/review" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen max-h-screen overflow-y-auto flex flex-col items-center sm:justify-center bg-white/50 dark:bg-black/50">
|
||||
<div className="w-full h-full sm:w-auto sm:h-auto">
|
||||
{location.pathname.replace(/\/$/i, "") !== "/verify" &&
|
||||
typeof step === "string" && (
|
||||
<Stepper steps={steps} currentStep={step} />
|
||||
)}
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationLayout;
|
@ -2,12 +2,14 @@ import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
import "./index.css";
|
||||
import { DbProvider } from "./context/db/provider";
|
||||
|
||||
if (typeof window.guard !== "object") {
|
||||
window.guard = {
|
||||
refreshing: false,
|
||||
refreshQueue: [],
|
||||
};
|
||||
}
|
||||
|
||||
const root = document.getElementById("root")!;
|
||||
|
||||
createRoot(root).render(
|
||||
<DbProvider>
|
||||
<App />
|
||||
</DbProvider>
|
||||
);
|
||||
createRoot(root).render(<App />);
|
||||
|
192
web/src/pages/Admin/ApiServices/Create/index.tsx
Normal file
192
web/src/pages/Admin/ApiServices/Create/index.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import ApiServiceCredentialsModal from "@/feature/ApiServiceCredentialsModal";
|
||||
import { useApiServices } from "@/store/admin/apiServices";
|
||||
import { useCallback, type FC } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
description: string;
|
||||
redirectUris: string;
|
||||
scopes: string;
|
||||
grantTypes: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const ApiServiceCreatePage: FC = () => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
defaultValues: {
|
||||
enabled: true,
|
||||
scopes: "openid",
|
||||
},
|
||||
});
|
||||
|
||||
const createApiService = useApiServices((state) => state.create);
|
||||
|
||||
const credentials = useApiServices((state) => state.createdCredentials);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormData) => {
|
||||
console.log("Form submitted:", data);
|
||||
createApiService({
|
||||
name: data.name,
|
||||
description: data.description ?? "",
|
||||
redirect_uris: data.redirectUris.trim().split("\n"),
|
||||
scopes: data.scopes.trim().split(" "),
|
||||
grant_types: data.grantTypes
|
||||
? data.grantTypes.trim().split(" ")
|
||||
: ["authorization_code"],
|
||||
is_active: data.enabled,
|
||||
});
|
||||
},
|
||||
[createApiService],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{credentials !== null && <ApiServiceCredentialsModal />}
|
||||
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ href: "/admin", label: "Admin" },
|
||||
{ href: "/admin/api-services", label: "API Services" },
|
||||
{ label: "Create new API Service" },
|
||||
]}
|
||||
/>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* Service Information */}
|
||||
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
|
||||
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
|
||||
<h2 className="text-gray-800 dark:text-gray-200">
|
||||
Service Information
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">Name</p>
|
||||
<Input
|
||||
placeholder="Display Name"
|
||||
{...register("name", { required: "Name is required" })}
|
||||
/>
|
||||
{errors.name && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.name.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Description
|
||||
</p>
|
||||
<textarea
|
||||
{...register("description", {
|
||||
required: "Description is required",
|
||||
})}
|
||||
className="dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded placeholder:text-gray-600 text-sm p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Some description goes here..."
|
||||
></textarea>
|
||||
{errors.description && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.description.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OpenID Connect */}
|
||||
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
|
||||
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
|
||||
<h2 className="text-gray-800 dark:text-gray-200">OpenID Connect</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Redirect URIs
|
||||
</p>
|
||||
<textarea
|
||||
{...register("redirectUris", {
|
||||
required: "At least one URI is required",
|
||||
})}
|
||||
className="dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded placeholder:text-gray-600 text-sm p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter multiple URIs separated with new line"
|
||||
></textarea>
|
||||
{errors.redirectUris && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.redirectUris.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">Scopes</p>
|
||||
<Input
|
||||
placeholder="Scopes separated with space"
|
||||
{...register("scopes", { required: "Scopes are required" })}
|
||||
/>
|
||||
{errors.scopes && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.scopes.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Grant Types
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Leave empty for 'authorization_code'"
|
||||
{...register("grantTypes")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final Section */}
|
||||
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
|
||||
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
|
||||
<h2 className="text-gray-800 dark:text-gray-200">
|
||||
Final Customization & Submit
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<label className="inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
{...register("enabled")}
|
||||
/>
|
||||
<div className="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"></div>
|
||||
<span className="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Enabled by default
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-2 mt-4">
|
||||
<Button type="submit">Create</Button>
|
||||
<Link to="/admin/api-services">
|
||||
<Button
|
||||
variant="text"
|
||||
className="text-red-400 hover:text-red-500"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiServiceCreatePage;
|
199
web/src/pages/Admin/ApiServices/Update/index.tsx
Normal file
199
web/src/pages/Admin/ApiServices/Update/index.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import ApiServiceUpdatedModal from "@/feature/ApiServiceUpdatedModal";
|
||||
import { useApiServices } from "@/store/admin/apiServices";
|
||||
import { useCallback, useEffect, type FC } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useParams } from "react-router";
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
description: string;
|
||||
redirectUris: string;
|
||||
scopes: string;
|
||||
grantTypes: string;
|
||||
}
|
||||
|
||||
const ApiServiceEditPage: FC = () => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
defaultValues: {
|
||||
scopes: "openid",
|
||||
},
|
||||
});
|
||||
|
||||
const { serviceId } = useParams();
|
||||
const apiService = useApiServices((state) => state.view);
|
||||
|
||||
const loadService = useApiServices((state) => state.fetchSingle);
|
||||
|
||||
const updateApiService = useApiServices((state) => state.update);
|
||||
const updating = useApiServices((state) => state.updating);
|
||||
const updated = useApiServices((state) => state.updated);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormData) => {
|
||||
console.log("Form submitted:", data);
|
||||
updateApiService({
|
||||
name: data.name,
|
||||
description: data.description ?? "",
|
||||
redirect_uris: data.redirectUris.trim().split("\n"),
|
||||
scopes: data.scopes.trim().split(" "),
|
||||
grant_types: data.grantTypes
|
||||
? data.grantTypes.trim().split(" ")
|
||||
: ["authorization_code"],
|
||||
});
|
||||
},
|
||||
[updateApiService],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof serviceId === "string") loadService(serviceId);
|
||||
}, [loadService, serviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiService != null) {
|
||||
setValue("name", apiService.name);
|
||||
setValue("description", apiService.description);
|
||||
setValue("redirectUris", apiService.redirect_uris.join("\n"));
|
||||
setValue("scopes", apiService.scopes.join(" "));
|
||||
setValue("grantTypes", apiService.grant_types.join(" "));
|
||||
}
|
||||
}, [apiService, setValue]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{updated && <ApiServiceUpdatedModal />}
|
||||
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ href: "/admin", label: "Admin" },
|
||||
{ href: "/admin/api-services", label: "API Services" },
|
||||
{ label: "Create new API Service" },
|
||||
]}
|
||||
/>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* Service Information */}
|
||||
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
|
||||
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
|
||||
<h2 className="text-gray-800 dark:text-gray-200">
|
||||
Service Information
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">Name</p>
|
||||
<Input
|
||||
placeholder="Display Name"
|
||||
{...register("name", { required: "Name is required" })}
|
||||
/>
|
||||
{errors.name && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.name.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Description
|
||||
</p>
|
||||
<textarea
|
||||
{...register("description", {
|
||||
required: "Description is required",
|
||||
})}
|
||||
className="dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded placeholder:text-gray-600 text-sm p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Some description goes here..."
|
||||
></textarea>
|
||||
{errors.description && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.description.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OpenID Connect */}
|
||||
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
|
||||
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
|
||||
<h2 className="text-gray-800 dark:text-gray-200">OpenID Connect</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Redirect URIs
|
||||
</p>
|
||||
<textarea
|
||||
{...register("redirectUris", {
|
||||
required: "At least one URI is required",
|
||||
})}
|
||||
className="dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded placeholder:text-gray-600 text-sm p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter multiple URIs separated with new line"
|
||||
></textarea>
|
||||
{errors.redirectUris && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.redirectUris.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">Scopes</p>
|
||||
<Input
|
||||
placeholder="Scopes separated with space"
|
||||
{...register("scopes", { required: "Scopes are required" })}
|
||||
/>
|
||||
{errors.scopes && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors.scopes.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Grant Types
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Leave empty for 'authorization_code'"
|
||||
{...register("grantTypes")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final Section */}
|
||||
<div className="border dark:border-gray-800 border-gray-300 rounded mt-4 flex flex-col">
|
||||
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
|
||||
<h2 className="text-gray-800 dark:text-gray-200">
|
||||
Approve & Submit
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-row items-center justify-between gap-2 mt-4">
|
||||
<Button type="submit" loading={updating}>
|
||||
Update
|
||||
</Button>
|
||||
<Link to="/admin/api-services">
|
||||
<Button
|
||||
variant="text"
|
||||
className="text-red-400 hover:text-red-500"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiServiceEditPage;
|
205
web/src/pages/Admin/ApiServices/View/index.tsx
Normal file
205
web/src/pages/Admin/ApiServices/View/index.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import Breadcrumbs from "@/components/ui/breadcrumbs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useApiServices } from "@/store/admin/apiServices";
|
||||
import { useEffect, type FC } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
|
||||
const InfoCard = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="border dark:border-gray-800 border-gray-300 rounded mb-4">
|
||||
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
|
||||
<h2 className="text-gray-800 dark:text-gray-200 font-semibold text-lg">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ViewApiServicePage: FC = () => {
|
||||
const { serviceId } = useParams();
|
||||
const apiService = useApiServices((state) => state.view);
|
||||
// const loading = useApiServices((state) => state.fetchingApiService);
|
||||
|
||||
const loadService = useApiServices((state) => state.fetchSingle);
|
||||
|
||||
const toggling = useApiServices((state) => state.toggling);
|
||||
const toggle = useApiServices((state) => state.toggle);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof serviceId === "string") loadService(serviceId);
|
||||
}, [loadService, serviceId]);
|
||||
|
||||
if (!apiService) {
|
||||
return (
|
||||
<div className="p-4 flex items-center justify-center h-[60vh]">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent mx-auto mb-3" />
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Loading API Service...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dark:text-gray-200 text-gray-800 p-4">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ href: "/admin", label: "Admin" },
|
||||
{ href: "/admin/api-services", label: "API Services" },
|
||||
{ label: "View API Service" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="sm:p-4 pt-4">
|
||||
{/* 📋 Main Details */}
|
||||
<InfoCard title="API Service Details">
|
||||
<div className="flex flex-col gap-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Name:
|
||||
</span>{" "}
|
||||
{apiService.name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Description:
|
||||
</span>{" "}
|
||||
{apiService.description}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Redirect URIs:
|
||||
</span>
|
||||
<ul className="list-disc list-inside ml-4 mt-1">
|
||||
{apiService.redirect_uris.map((uri) => (
|
||||
<li key={uri}>{uri}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Scopes:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{apiService.scopes.map((scope) => (
|
||||
<span
|
||||
key={scope}
|
||||
className="bg-blue-100 dark:bg-blue-800/30 text-blue-700 dark:text-blue-300 text-xs font-medium px-2 py-1 rounded"
|
||||
>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Grant Types:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{apiService.grant_types.map((grant) => (
|
||||
<span
|
||||
key={grant}
|
||||
className="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-300 text-xs font-medium px-2 py-1 rounded"
|
||||
>
|
||||
{grant}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Created At:
|
||||
</span>{" "}
|
||||
{new Date(apiService.created_at).toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Updated At:
|
||||
</span>{" "}
|
||||
{new Date(apiService.updated_at).toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Status:
|
||||
</span>{" "}
|
||||
<span
|
||||
className={`font-semibold px-2 py-1 rounded ${
|
||||
apiService.is_active
|
||||
? "bg-green-200 text-green-800 dark:bg-green-700/20 dark:text-green-300"
|
||||
: "bg-red-200 text-red-800 dark:bg-red-700/20 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{apiService.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
{/* 🔐 Credentials */}
|
||||
<InfoCard title="Client Credentials">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Client ID:
|
||||
</span>
|
||||
</p>
|
||||
<Input value={apiService.client_id} disabled />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Client Secret:
|
||||
</span>
|
||||
</p>
|
||||
<Input value="***************" disabled />
|
||||
</div>
|
||||
|
||||
<Button variant="outlined" onClick={() => {}}>
|
||||
Regenerate Credentials
|
||||
</Button>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
{/* 🚀 Actions */}
|
||||
<div className="flex flex-wrap gap-4 mt-6 justify-between items-center">
|
||||
<Link to="/admin/api-services">
|
||||
<Button variant="outlined">Back</Button>
|
||||
</Link>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<Button
|
||||
variant="text"
|
||||
className={
|
||||
apiService.is_active
|
||||
? "text-red-400 hover:text-red-500"
|
||||
: "text-green-400 hover:text-green-500"
|
||||
}
|
||||
onClick={toggle}
|
||||
loading={toggling}
|
||||
>
|
||||
{apiService.is_active ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Link
|
||||
to={`/admin/api-services/edit/${serviceId}`}
|
||||
className="hover:underline hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<Button variant="contained">Edit</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewApiServicePage;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user