diff --git a/cmd/hspguard/api/api.go b/cmd/hspguard/api/api.go
index 4fddfed..b33b07f 100644
--- a/cmd/hspguard/api/api.go
+++ b/cmd/hspguard/api/api.go
@@ -46,7 +46,7 @@ func (s *APIServer) Run() error {
router.Route("/api/v1", func(r chi.Router) {
am := imiddleware.New(s.cfg)
- r.Use(imiddleware.WithSkipper(am.Runner, "/api/v1/login", "/api/v1/register", "/api/v1/oauth/token"))
+ r.Use(imiddleware.WithSkipper(am.Runner, "/api/v1/auth/login", "/api/v1/auth/register", "/api/v1/auth/refresh", "/api/v1/oauth/token"))
userHandler := user.NewUserHandler(s.repo, s.storage)
userHandler.RegisterRoutes(r)
diff --git a/internal/auth/routes.go b/internal/auth/routes.go
index 585da09..124ad6a 100644
--- a/internal/auth/routes.go
+++ b/internal/auth/routes.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "strings"
"time"
"gitea.local/admin/hspguard/internal/config"
@@ -21,6 +22,42 @@ type AuthHandler struct {
cfg *config.AppConfig
}
+func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) {
+ accessClaims := types.UserClaims{
+ UserEmail: user.Email,
+ IsAdmin: user.IsAdmin,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Issuer: h.cfg.Jwt.Issuer,
+ Subject: user.ID.String(),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
+ },
+ }
+
+ accessToken, err := SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
+ if err != nil {
+ return "", "", err
+ }
+
+ refreshClaims := types.UserClaims{
+ UserEmail: user.Email,
+ IsAdmin: user.IsAdmin,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Issuer: h.cfg.Jwt.Issuer,
+ Subject: user.ID.String(),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
+ },
+ }
+
+ refreshToken, err := SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
+ if err != nil {
+ return "", "", err
+ }
+
+ return accessToken, refreshToken, nil
+}
+
func NewAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *AuthHandler {
return &AuthHandler{
repo,
@@ -29,8 +66,73 @@ func NewAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *AuthHandle
}
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
- api.Get("/profile", h.getProfile)
- api.Post("/login", h.login)
+ api.Get("/auth/profile", h.getProfile)
+ api.Post("/auth/login", h.login)
+ api.Post("/auth/refresh", h.refreshToken)
+}
+
+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]
+ token, userClaims, err := VerifyToken(tokenStr, h.cfg.Jwt.PublicKey)
+ 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)
+
+ if err := encoder.Encode(Response{
+ AccessToken: access,
+ RefreshToken: refresh,
+ }); err != nil {
+ web.Error(w, "failed to encode response", http.StatusInternalServerError)
+ }
}
func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
@@ -49,6 +151,7 @@ func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
+ "id": user.ID.String(),
"full_name": user.FullName,
"email": user.Email,
"phone_number": user.PhoneNumber,
@@ -92,37 +195,9 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
return
}
- accessClaims := types.UserClaims{
- UserEmail: user.Email,
- IsAdmin: user.IsAdmin,
- RegisteredClaims: jwt.RegisteredClaims{
- Issuer: h.cfg.Jwt.Issuer,
- Subject: user.ID.String(),
- IssuedAt: jwt.NewNumericDate(time.Now()),
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
- },
- }
-
- accessToken, err := SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
+ access, refresh, err := h.signTokens(&user)
if err != nil {
- web.Error(w, fmt.Sprintf("failed to generate access token: %v", err), http.StatusBadRequest)
- return
- }
-
- refreshClaims := types.UserClaims{
- UserEmail: user.Email,
- IsAdmin: user.IsAdmin,
- RegisteredClaims: jwt.RegisteredClaims{
- Issuer: h.cfg.Jwt.Issuer,
- Subject: user.ID.String(),
- IssuedAt: jwt.NewNumericDate(time.Now()),
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
- },
- }
-
- refreshToken, err := SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
- if err != nil {
- web.Error(w, fmt.Sprintf("failed to generate refresh token: %v", err), http.StatusBadRequest)
+ web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
return
}
@@ -142,8 +217,8 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := encoder.Encode(Response{
- AccessToken: accessToken,
- RefreshToken: refreshToken,
+ AccessToken: access,
+ RefreshToken: refresh,
FullName: user.FullName,
Email: user.Email,
Id: user.ID.String(),
diff --git a/web/package-lock.json b/web/package-lock.json
index c5f2d93..2863354 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.7",
+ "axios": "^1.9.0",
"lucide-react": "^0.511.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
@@ -2402,6 +2403,23 @@
"dev": true,
"license": "Python-2.0"
},
+ "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": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.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",
@@ -2481,6 +2499,19 @@
"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",
@@ -2573,6 +2604,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",
@@ -2666,6 +2709,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",
@@ -2679,6 +2731,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",
@@ -2708,6 +2774,51 @@
"is-arrayish": "^0.2.1"
}
},
+ "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": {
+ "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": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
@@ -3079,6 +3190,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",
@@ -3112,6 +3258,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",
@@ -3138,6 +3321,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",
@@ -3161,6 +3356,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",
@@ -3691,6 +3913,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",
@@ -3715,6 +3946,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",
@@ -4012,6 +4264,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",
diff --git a/web/package.json b/web/package.json
index 36d9363..983ec79 100644
--- a/web/package.json
+++ b/web/package.json
@@ -12,6 +12,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.7",
+ "axios": "^1.9.0",
"lucide-react": "^0.511.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
diff --git a/web/src/App.tsx b/web/src/App.tsx
index b793775..f338e6a 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,58 +1,28 @@
-import { useEffect, type FC } from "react";
+import { type FC } from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import IndexPage from "./pages/Index";
import LoginPage from "./pages/Login";
import RegisterPage from "./pages/Register";
-import { useDbContext } from "./context/db";
-import { openDB } from "idb";
import AgreementPage from "./pages/Agreement";
import OAuthAuthorizePage from "./pages/OAuthAuthorize";
+import AuthLayout from "./layout/AuthLayout";
const router = createBrowserRouter([
{
path: "/",
- element: ,
- },
- {
- path: "/agreement",
- element: ,
- },
- {
- path: "/login",
- element: ,
- },
- {
- path: "/register",
- element: ,
- },
- {
- path: "/authorize",
- element: ,
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: "agreement", element: },
+ { path: "login", element: },
+ { path: "register", element: },
+ { path: "authorize", element: },
+ ],
},
]);
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 ;
};
diff --git a/web/src/api/code.ts b/web/src/api/code.ts
index 5bb8a50..9251425 100644
--- a/web/src/api/code.ts
+++ b/web/src/api/code.ts
@@ -1,25 +1,25 @@
-import { handleApiError } from ".";
+import { handleApiError, axios } from ".";
export interface CodeResponse {
code: string;
}
export const codeApi = async (accessToken: string, nonce: string) => {
- const response = await fetch("/api/v1/oauth/code", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${accessToken}`,
- },
- body: JSON.stringify({
- nonce,
- }),
- });
+ const response = await axios.post(
+ "/api/v1/oauth/code",
+ { nonce },
+ {
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
- const data: CodeResponse = await response.json();
+ const data: CodeResponse = response.data;
return data;
};
diff --git a/web/src/api/index.ts b/web/src/api/index.ts
index e8c9123..f740036 100644
--- a/web/src/api/index.ts
+++ b/web/src/api/index.ts
@@ -1,20 +1,74 @@
-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";
- return new Error("Unexpected error happened");
+export const axios = Axios.create({
+ headers: {
+ "Content-Type": "application/json",
+ },
+});
+
+axios.interceptors.request.use(
+ (request) => {
+ const account = useAuth.getState().activeAccount;
+ if (account?.access) {
+ request.headers["Authorization"] = `Bearer ${account.access}`;
+ }
+
+ return request;
+ },
+ (error) => {
+ return Promise.reject(error);
+ }
+);
+
+axios.interceptors.response.use(
+ (response) => response,
+ async (error) => {
+ const originalRequest = error.config;
+
+ if (error.response.status === 401 && !originalRequest._retry) {
+ originalRequest._retry = true;
+
+ const account = useAuth.getState().activeAccount;
+ if (!account?.refresh) {
+ return Promise.reject(new Error("Unauthorized. No refresh token"));
+ }
+
+ const db = useDbStore.getState().db;
+ if (!db) {
+ return Promise.reject(new Error("No database connection"));
+ }
+
+ try {
+ const response = await refreshTokenApi(account.refresh);
+
+ updateAccountTokens(db, {
+ accountId: account.accountId,
+ access: response.access,
+ refresh: response.refresh,
+ });
+ } catch (err) {
+ console.error("token refresh failed:", err);
+ await deleteAccount(db, account.accountId);
+ const loadAccounts = useAuth.getState().loadAccounts;
+ loadAccounts?.();
+ const requireSignIn = useAuth.getState().requireSignIn;
+ requireSignIn?.();
+ return Promise.reject(err);
+ }
+ }
+
+ return 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));
};
diff --git a/web/src/api/login.ts b/web/src/api/login.ts
index d541e9b..27b6789 100644
--- a/web/src/api/login.ts
+++ b/web/src/api/login.ts
@@ -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;
};
diff --git a/web/src/api/profile.ts b/web/src/api/profile.ts
index 2bd2608..91e159c 100644
--- a/web/src/api/profile.ts
+++ b/web/src/api/profile.ts
@@ -1,29 +1,15 @@
-import { handleApiError } from ".";
+import type { UserProfile } from "@/types";
+import { axios, handleApiError } from ".";
-export interface FetchProfileResponse {
- full_name: string;
- email: string;
- phone_number: string;
- isAdmin: boolean;
- last_login: string;
- profile_picture: string | null;
- updated_at: string;
- created_at: string;
-}
+export type FetchProfileResponse = UserProfile;
-export const fetchProfileApi = async (accessToken: string) => {
- const response = await fetch("/api/v1/oauth/code", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${accessToken}`,
- },
- });
+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 = await response.json();
+ const data: FetchProfileResponse = response.data;
return data;
};
diff --git a/web/src/api/refresh.ts b/web/src/api/refresh.ts
new file mode 100644
index 0000000..bcf3ecf
--- /dev/null
+++ b/web/src/api/refresh.ts
@@ -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;
+};
diff --git a/web/src/components/Home/Tabs/Home.tsx b/web/src/components/Home/Tabs/Home.tsx
index b54d666..faf7c8f 100644
--- a/web/src/components/Home/Tabs/Home.tsx
+++ b/web/src/components/Home/Tabs/Home.tsx
@@ -1,23 +1,37 @@
+import { Button } from "@/components/ui/button";
+import { useAuth } from "@/store/auth";
+import { User } from "lucide-react";
import { type FC } from "react";
const Home: FC = () => {
+ const profile = useAuth((state) => state.profile);
+ console.log({ profile });
+ const signOut = useAuth((state) => state.signOut);
+
return (
- {/*
*/}
-

+ {profile?.profile_picture ? (
+

+ ) : (
+
+ )}
- Welcome, Amir Adal
+ Welcome, {profile?.full_name}
Manage your info, private and security to make Home Guard work better
for you.
+
+
);
};
diff --git a/web/src/context/db/index.ts b/web/src/context/db/index.ts
deleted file mode 100644
index 7b986f5..0000000
--- a/web/src/context/db/index.ts
+++ /dev/null
@@ -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({
- db: null,
- connected: false,
- setDb: () => {},
-});
-
-export const useDbContext = () => useContext(DbContext);
diff --git a/web/src/context/db/provider.tsx b/web/src/context/db/provider.tsx
deleted file mode 100644
index ff6ee26..0000000
--- a/web/src/context/db/provider.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useCallback, useState, type FC, type ReactNode } from "react";
-import { DbContext } from ".";
-import type { IDBPDatabase } from "idb";
-
-interface IDBProvider {
- children: ReactNode;
-}
-
-export const DbProvider: FC = ({ children }) => {
- const [db, _setDb] = useState(null);
-
- const setDb = useCallback((db: IDBPDatabase) => _setDb(db), []);
-
- return (
-
- {children}
-
- );
-};
diff --git a/web/src/feature/AccountList/index.tsx b/web/src/feature/AccountList/index.tsx
index 9fc8820..d942dd0 100644
--- a/web/src/feature/AccountList/index.tsx
+++ b/web/src/feature/AccountList/index.tsx
@@ -1,56 +1,24 @@
-import { useDbContext } from "@/context/db";
import { useOAuthContext } from "@/context/oauth";
-import { type LocalAccount, useAccountRepo } from "@/repository/account";
+import { type LocalAccount } from "@/repository/account";
+import { useAuth } from "@/store/auth";
import { CirclePlus, User } from "lucide-react";
-import { useCallback, useEffect, useState, type FC } from "react";
+import { useCallback, type FC } from "react";
import { Link } from "react-router-dom";
const AccountList: FC = () => {
- const [accounts, setAccounts] = useState([]);
-
- const repo = useAccountRepo();
- const { connected } = useDbContext();
+ const accounts = useAuth((state) => state.accounts);
+ const updateActiveAccount = useAuth((state) => state.updateActiveAccount);
const oauth = useOAuthContext();
const handleAccountSelect = useCallback(
(account: LocalAccount) => {
oauth.selectSession(account.access);
+ updateActiveAccount(account);
},
- [oauth]
+ [oauth, updateActiveAccount]
);
- useEffect(() => {
- if (connected) repo.loadAll().then(setAccounts);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [connected]);
-
- if (!connected) {
- return (
-
- );
- }
-
return (
<>
{accounts.map((account) => (
@@ -64,7 +32,7 @@ const AccountList: FC = () => {
{account.profilePicture ? (
) : (
diff --git a/web/src/layout/AuthLayout.tsx b/web/src/layout/AuthLayout.tsx
new file mode 100644
index 0000000..bb1dce4
--- /dev/null
+++ b/web/src/layout/AuthLayout.tsx
@@ -0,0 +1,119 @@
+import { useDbStore } from "@/store/db";
+import { useAuth } from "@/store/auth";
+import { useEffect, useMemo } from "react";
+import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
+import BackgroundLayout from "./BackgroundLayout";
+
+const AuthLayout = () => {
+ const { connecting, db, connect } = useDbStore();
+
+ const dbConnected = useMemo(() => !!db, [db]);
+
+ const loadingAccounts = useAuth((state) => state.loadingAccounts);
+ const loadAccounts = useAuth((state) => state.loadAccounts);
+ const activeAccount = useAuth((state) => state.activeAccount);
+
+ const hasAccounts = useAuth((state) => state.accounts.length > 0);
+
+ const fetchingProfile = useAuth((state) => state.authenticating);
+ const fetchProfile = useAuth((state) => state.authenticate);
+
+ const signInRequired = useAuth((state) => state.signInRequired);
+
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ const isAuthPage = useMemo(() => {
+ const allowedPaths = ["/login", "/register", "/authorize"];
+ if (!allowedPaths.some((p) => location.pathname.startsWith(p))) {
+ return false;
+ }
+
+ return true;
+ }, [location.pathname]);
+
+ console.log({
+ isAuthPage,
+ loadingAccounts,
+ fetchingProfile,
+ connecting,
+ dbConnected,
+ });
+
+ const loading = useMemo(() => {
+ if (isAuthPage) {
+ return connecting;
+ }
+ return loadingAccounts || fetchingProfile || connecting;
+ }, [connecting, fetchingProfile, isAuthPage, loadingAccounts]);
+
+ useEffect(() => {
+ connect();
+ }, [connect]);
+
+ useEffect(() => {
+ if (dbConnected) {
+ loadAccounts();
+ }
+ }, [dbConnected, loadAccounts]);
+
+ useEffect(() => {
+ if (dbConnected && !loadingAccounts && activeAccount) {
+ fetchProfile();
+ }
+ }, [activeAccount, dbConnected, fetchProfile, loadingAccounts]);
+
+ useEffect(() => {
+ if (!signInRequired && location.state?.from) {
+ navigate(location.state.from, { state: {} });
+ }
+ }, [location.state, location.state?.from, navigate, signInRequired]);
+
+ if (signInRequired && !isAuthPage) {
+ return (
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default AuthLayout;
diff --git a/web/src/layout/BackgroundLayout.tsx b/web/src/layout/BackgroundLayout.tsx
new file mode 100644
index 0000000..8fccfa7
--- /dev/null
+++ b/web/src/layout/BackgroundLayout.tsx
@@ -0,0 +1,15 @@
+import { type FC, type ReactNode } from "react";
+
+export interface IBackgroundLayoutProps {
+ children: ReactNode;
+}
+
+const BackgroundLayout: FC = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default BackgroundLayout;
diff --git a/web/src/main.tsx b/web/src/main.tsx
index b99810a..c12acfa 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -2,15 +2,12 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
-import { DbProvider } from "./context/db/provider";
import { OAuthProvider } from "./context/oauth/provider";
const root = document.getElementById("root")!;
createRoot(root).render(
-
-
-
-
-
+
+
+
);
diff --git a/web/src/pages/Agreement/index.tsx b/web/src/pages/Agreement/index.tsx
index 4c5b1f9..93e675d 100644
--- a/web/src/pages/Agreement/index.tsx
+++ b/web/src/pages/Agreement/index.tsx
@@ -3,8 +3,11 @@ import { type FC } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { ArrowLeftRight } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { useAuth } from "@/store/auth";
const AgreementPage: FC = () => {
+ const profile = useAuth((state) => state.profile);
+
return (
{
{/*
*/}
diff --git a/web/src/pages/Index/index.tsx b/web/src/pages/Index/index.tsx
index d9f8702..ba1d7f3 100644
--- a/web/src/pages/Index/index.tsx
+++ b/web/src/pages/Index/index.tsx
@@ -13,44 +13,37 @@ const IndexPage: FC = () => {
const [tab, setTab] = useState
("home");
return (
-
-
-
-
-
-

+
+
+
+
+

-
+
+
- {/*
*/}
-
-
-
setTab(tab)} />
-
-
-
setTab(tab)}
- />
- {tab === "home" && }
- {/* {tab === "personal-info" && } */}
-
-
- {tab === "personal-info" &&
}
-
+ {/*
*/}
+
+
+
setTab(tab)} />
+
+
+
setTab(tab)} />
+ {tab === "home" && }
+ {/* {tab === "personal-info" && } */}
+
+
+ {tab === "personal-info" &&
}
-
-
-
-
+
+
+
+
);
};
diff --git a/web/src/pages/Login/index.tsx b/web/src/pages/Login/index.tsx
index fbd8ea5..5c1c016 100644
--- a/web/src/pages/Login/index.tsx
+++ b/web/src/pages/Login/index.tsx
@@ -10,6 +10,7 @@ import { useCallback, useState } from "react";
import { loginApi } from "@/api/login";
import { useAccountRepo } from "@/repository/account";
import { useOAuthContext } from "@/context/oauth";
+import { useAuth } from "@/store/auth";
interface LoginForm {
email: string;
@@ -32,6 +33,8 @@ export default function LoginPage() {
const repo = useAccountRepo();
+ const updateActiveAccount = useAuth((state) => state.updateActiveAccount);
+
const onSubmit: SubmitHandler
= useCallback(
async (data) => {
console.log({ data });
@@ -48,7 +51,7 @@ export default function LoginPage() {
console.log(response);
- await repo.save({
+ const account = await repo.save({
accountId: response.id,
label: response.full_name,
email: response.email,
@@ -61,6 +64,7 @@ export default function LoginPage() {
reset();
oauth.selectSession(response.access);
+ updateActiveAccount(account);
} catch (err: any) {
console.log(err);
setError(
@@ -71,110 +75,105 @@ export default function LoginPage() {
setLoading(false);
}
},
- [oauth, repo, reset]
+ [oauth, repo, reset, updateActiveAccount]
);
return (
-
-
-
-
-

+
+
+
+

-
-
- Sign In
-
-
- Enter your credentials to access home services and tools.
-
-
-
-
-
-
+
+
+ Sign In
+
+
+ Enter your credentials to access home services and tools.
+
-
-
+
+
+
+
+
+
);
}
diff --git a/web/src/pages/OAuthAuthorize/index.tsx b/web/src/pages/OAuthAuthorize/index.tsx
index 676024f..c9ffd42 100644
--- a/web/src/pages/OAuthAuthorize/index.tsx
+++ b/web/src/pages/OAuthAuthorize/index.tsx
@@ -35,38 +35,33 @@ const OAuthAuthorizePage: FC = () => {
]);
return (
-
-
-
-
-
-

+
+
+
+
+

-
-
- Select Account
-
-
- Choose one of the accounts below in order to proceed to home
- lab services and tools.
-
-
+
+
+ Select Account
+
+
+ Choose one of the accounts below in order to proceed to home lab
+ services and tools.
+
-
- {/*
*/}
-
-
-
-
-
+
+ {/* */}
+
+
+
+
+
);
};
diff --git a/web/src/pages/Register/index.tsx b/web/src/pages/Register/index.tsx
index a108e2a..c09c0a0 100644
--- a/web/src/pages/Register/index.tsx
+++ b/web/src/pages/Register/index.tsx
@@ -73,185 +73,179 @@ export default function RegisterPage() {
);
return (
-
-
-
-
-

+
+
+
+

-
-
- Sign Up
-
-
- Fill up this form to start using homelab services and tools.
-
-
-
-
-
-
+
+
+ Sign Up
+
+
+ Fill up this form to start using homelab services and tools.
+
-
-
+
+
+
+
+
+
);
}
diff --git a/web/src/repository/account.ts b/web/src/repository/account.ts
index a8779aa..c936e7e 100644
--- a/web/src/repository/account.ts
+++ b/web/src/repository/account.ts
@@ -1,5 +1,6 @@
-import { useDbContext } from "@/context/db";
+import { useDbStore } from "@/store/db";
import { deriveDeviceKey, getDeviceId } from "@/util/deviceId";
+import type { IDBPDatabase } from "idb";
import { useCallback } from "react";
export interface RawLocalAccount {
@@ -31,110 +32,220 @@ export interface CreateAccountRequest {
refresh: string;
}
+export interface UpdateAccountTokensRequest {
+ accountId: string;
+ access: string;
+ refresh: string;
+}
+
+export interface UpdateAccountInfoRequest {
+ accountId: string;
+ label?: string;
+ profilePicture?: string;
+}
+
+export const getDeviceKey = async () => {
+ const deviceId = await getDeviceId();
+ const deviceKey = await deriveDeviceKey(deviceId);
+
+ return deviceKey;
+};
+
+export const encryptToken = async (token: string) => {
+ const encoder = new TextEncoder();
+
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+
+ const deviceKey = await getDeviceKey();
+
+ const cipherText = await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv },
+ deviceKey,
+ encoder.encode(token)
+ );
+
+ return {
+ data: Array.from(new Uint8Array(cipherText)),
+ iv: Array.from(iv),
+ };
+};
+
+export const decryptToken = async (encrypted: {
+ data: number[];
+ iv: number[];
+}) => {
+ const decoder = new TextDecoder();
+
+ const deviceKey = await getDeviceKey();
+
+ const decrypted = await crypto.subtle.decrypt(
+ {
+ name: "AES-GCM",
+ iv: new Uint8Array(encrypted.iv),
+ },
+ deviceKey,
+ new Uint8Array(encrypted.data)
+ );
+
+ return decoder.decode(decrypted);
+};
+
+export const saveAccount = async (
+ db: IDBPDatabase,
+ req: CreateAccountRequest
+): Promise => {
+ const access = await encryptToken(req.access);
+ const refresh = await encryptToken(req.refresh);
+
+ const tx = db.transaction("accounts", "readwrite");
+ const store = tx.objectStore("accounts");
+
+ await store.put({
+ accountId: req.accountId,
+ label: req.label,
+ email: req.email,
+ profilePicture: req.profilePicture,
+ access,
+ refresh,
+ updatedAt: new Date().toISOString(),
+ });
+
+ await tx.done;
+
+ const account = await getAccount(db, req.accountId);
+
+ return account as LocalAccount;
+};
+
+export const getAllAccounts = async (db: IDBPDatabase) => {
+ const tx = db.transaction("accounts", "readonly");
+ const store = tx.objectStore("accounts");
+
+ const accounts: RawLocalAccount[] = await store.getAll();
+
+ const results: LocalAccount[] = (
+ await Promise.all(
+ accounts.map(async (account) => {
+ try {
+ const accessToken = await decryptToken(account.access);
+ const refreshToken = await decryptToken(account.refresh);
+ return {
+ accountId: account.accountId,
+ label: account.label,
+ email: account.email,
+ profilePicture: account.profilePicture,
+ access: accessToken,
+ refresh: refreshToken,
+ updatedAt: account.updatedAt,
+ };
+ } catch (err) {
+ console.warn(`Failed to decrypt account ${account.label}:`, err);
+ }
+ })
+ )
+ ).filter((acc) => acc !== undefined);
+
+ return results;
+};
+
+export const getAccount = async (db: IDBPDatabase, accountId: string) => {
+ const tx = db.transaction("accounts", "readonly");
+ const store = tx.objectStore("accounts");
+
+ const account: RawLocalAccount = await store.get(accountId);
+
+ await tx.done;
+
+ if (!account) return null;
+
+ const accessToken = await decryptToken(account.access);
+ const refreshToken = await decryptToken(account.refresh);
+ return {
+ accountId: account.accountId,
+ label: account.label,
+ email: account.email,
+ profilePicture: account.profilePicture,
+ access: accessToken,
+ refresh: refreshToken,
+ updatedAt: account.updatedAt,
+ };
+};
+
+export const getAccountRaw = async (db: IDBPDatabase, accountId: string) => {
+ const tx = db.transaction("accounts", "readonly");
+ const store = tx.objectStore("accounts");
+
+ const account: RawLocalAccount = await store.get(accountId);
+
+ await tx.done;
+
+ if (!account) return null;
+
+ return account;
+};
+
+export const updateAccountTokens = async (
+ db: IDBPDatabase,
+ req: UpdateAccountTokensRequest
+) => {
+ const account = await getAccountRaw(db, req.accountId);
+
+ const access = await encryptToken(req.access);
+ const refresh = await encryptToken(req.refresh);
+
+ await db?.put?.("accounts", {
+ ...account,
+ accountId: req.accountId,
+ access,
+ refresh,
+ updatedAt: new Date().toISOString(),
+ });
+};
+
+export const updateAccountInfo = async (
+ db: IDBPDatabase,
+ req: UpdateAccountInfoRequest
+) => {
+ const account = await getAccountRaw(db, req.accountId);
+ await db?.put?.("accounts", {
+ ...account,
+ ...req,
+ });
+};
+
+export const deleteAccount = async (db: IDBPDatabase, accountId: string) => {
+ const tx = db.transaction("accounts", "readwrite");
+ const store = tx.objectStore("accounts");
+ await store.delete(accountId);
+ await tx.done;
+};
+
export const useAccountRepo = () => {
- const { db } = useDbContext();
-
- const getDeviceKey = useCallback(async () => {
- const deviceId = await getDeviceId();
- const deviceKey = await deriveDeviceKey(deviceId);
-
- return deviceKey;
- }, []);
-
- const encryptToken = useCallback(
- async (token: string) => {
- const encoder = new TextEncoder();
-
- const iv = crypto.getRandomValues(new Uint8Array(12));
-
- const deviceKey = await getDeviceKey();
-
- const cipherText = await crypto.subtle.encrypt(
- { name: "AES-GCM", iv },
- deviceKey,
- encoder.encode(token)
- );
-
- return {
- data: Array.from(new Uint8Array(cipherText)),
- iv: Array.from(iv),
- };
- },
- [getDeviceKey]
- );
-
- const decryptToken = useCallback(
- async (encrypted: { data: number[]; iv: number[] }) => {
- const decoder = new TextDecoder();
-
- const deviceKey = await getDeviceKey();
-
- const decrypted = await crypto.subtle.decrypt(
- {
- name: "AES-GCM",
- iv: new Uint8Array(encrypted.iv),
- },
- deviceKey,
- new Uint8Array(encrypted.data)
- );
-
- return decoder.decode(decrypted);
- },
- [getDeviceKey]
- );
+ const db = useDbStore((state) => state.db);
const save = useCallback(
async (req: CreateAccountRequest) => {
if (!db) throw new Error("No database connection");
- const access = await encryptToken(req.access);
- const refresh = await encryptToken(req.refresh);
-
- await db?.put?.("accounts", {
- accountId: req.accountId,
- label: req.label,
- email: req.email,
- profilePicture: req.profilePicture,
- access,
- refresh,
- updatedAt: new Date().toISOString(),
- });
+ return saveAccount(db, req);
},
- [db, encryptToken]
+ [db]
);
const loadAll = useCallback(async () => {
if (!db) throw new Error("No database connection");
- const tx = db.transaction("accounts", "readonly");
- const store = tx.objectStore("accounts");
+ return getAllAccounts(db);
+ }, [db]);
- const accounts: RawLocalAccount[] = await store.getAll();
+ const getOne = useCallback(
+ async (accountId: string) => {
+ if (!db) throw new Error("No database connection");
- const results: LocalAccount[] = (
- await Promise.all(
- accounts.map(async (account) => {
- try {
- const accessToken = await decryptToken(account.access);
- const refreshToken = await decryptToken(account.refresh);
- return {
- accountId: account.accountId,
- label: account.label,
- email: account.email,
- profilePicture: account.profilePicture,
- access: accessToken,
- refresh: refreshToken,
- updatedAt: account.updatedAt,
- };
- } catch (err) {
- console.warn(`Failed to decrypt account ${account.label}:`, err);
- }
- })
- )
- ).filter((acc) => acc !== undefined);
+ return getAccount(db, accountId);
+ },
+ [db]
+ );
- return results;
- }, [db, decryptToken]);
-
- return { save, loadAll };
+ return { save, loadAll, getOne };
};
diff --git a/web/src/store/auth.ts b/web/src/store/auth.ts
new file mode 100644
index 0000000..5f1cc7f
--- /dev/null
+++ b/web/src/store/auth.ts
@@ -0,0 +1,136 @@
+import {
+ deleteAccount,
+ getAllAccounts,
+ updateAccountInfo,
+ type LocalAccount,
+} from "@/repository/account";
+import type { UserProfile } from "@/types";
+import { create } from "zustand";
+import { useDbStore } from "./db";
+import { fetchProfileApi } from "@/api/profile";
+
+export interface IAuthState {
+ profile: UserProfile | null;
+ authenticating: boolean;
+ activeAccount: LocalAccount | null;
+ loadingAccounts: boolean;
+ accounts: LocalAccount[];
+ signInRequired: boolean;
+
+ loadAccounts: () => Promise;
+ updateActiveAccount: (account: LocalAccount) => Promise;
+ authenticate: () => Promise;
+ requireSignIn: () => void;
+ signOut: () => void;
+}
+
+const getActiveAccountId = (): string | null => {
+ const accountId = localStorage.getItem("guard-selected");
+ return accountId;
+};
+
+const saveActiveAccountId = (accountId: string): void => {
+ localStorage.setItem("guard-selected", accountId);
+};
+
+const resetActiveAccount = (): void => {
+ localStorage.removeItem("guard-selected");
+};
+
+// TODO: maybe add deleteActiveAccount
+
+export const useAuth = create((set, get) => ({
+ profile: null,
+ authenticating: false,
+ activeAccount: null,
+ accounts: [],
+ loadingAccounts: false,
+ signInRequired: false,
+
+ updateActiveAccount: async (account) => {
+ set({ activeAccount: account });
+
+ saveActiveAccountId(account.accountId);
+
+ set({ signInRequired: false });
+ },
+
+ loadAccounts: async () => {
+ set({ loadingAccounts: true });
+
+ const db = useDbStore.getState().db;
+ if (!db) return;
+
+ const accounts = await getAllAccounts(db);
+
+ console.log("loaded accounts:", accounts);
+
+ if (!accounts || accounts.length === 0) {
+ set({ signInRequired: true });
+ }
+
+ const active = getActiveAccountId();
+
+ if (!active) {
+ set({ signInRequired: true });
+ }
+
+ const account = accounts.find((acc) => acc.accountId === active);
+
+ if (!account) {
+ resetActiveAccount();
+ set({ signInRequired: true });
+ }
+
+ set({ activeAccount: account, accounts, loadingAccounts: false });
+ },
+
+ authenticate: async () => {
+ const { authenticating } = get();
+ if (authenticating) return;
+
+ set({ authenticating: true });
+
+ try {
+ const response = await fetchProfileApi();
+ console.log("authenticate response:", response);
+
+ try {
+ // update local account information
+ const db = useDbStore.getState().db;
+ if (db) {
+ await updateAccountInfo(db, {
+ accountId: response.id,
+ label: response.full_name,
+ ...(response.profile_picture
+ ? { profilePicture: response.profile_picture }
+ : {}),
+ });
+ }
+ } finally {
+ set({ profile: response });
+ }
+ } catch (err) {
+ // TODO: set error
+ console.log(err);
+ } finally {
+ set({ authenticating: false });
+ }
+ },
+
+ requireSignIn: () => set({ signInRequired: true }),
+
+ signOut: async () => {
+ resetActiveAccount();
+
+ const db = useDbStore.getState().db;
+ const activeAccount = get().activeAccount;
+ if (db && activeAccount) {
+ await deleteAccount(db, activeAccount.accountId);
+ }
+
+ await get().loadAccounts();
+
+ set({ activeAccount: null, signInRequired: true });
+ },
+}));
diff --git a/web/src/store/db.ts b/web/src/store/db.ts
new file mode 100644
index 0000000..0b4ce40
--- /dev/null
+++ b/web/src/store/db.ts
@@ -0,0 +1,29 @@
+import { openDB, type IDBPDatabase } from "idb";
+import { create } from "zustand";
+
+export interface IDbStore {
+ db: IDBPDatabase | null;
+ connecting: boolean;
+ connect: () => void;
+}
+
+export const useDbStore = create((set, get) => ({
+ db: null,
+ connecting: false,
+ connect: async () => {
+ if (get().connecting) return;
+
+ set({ connecting: true, db: null });
+ const dbPromise = openDB("guard-local", 3, {
+ upgrade: (db) => {
+ if (!db.objectStoreNames.contains("accounts")) {
+ db.createObjectStore("accounts", { keyPath: "accountId" });
+ }
+ },
+ });
+
+ const conn = await dbPromise;
+
+ set({ db: conn, connecting: false });
+ },
+}));
diff --git a/web/src/types/index.ts b/web/src/types/index.ts
new file mode 100644
index 0000000..7192632
--- /dev/null
+++ b/web/src/types/index.ts
@@ -0,0 +1,11 @@
+export interface UserProfile {
+ id: string;
+ full_name: string;
+ email: string;
+ phone_number: string;
+ isAdmin: boolean;
+ last_login: string;
+ profile_picture: string | null;
+ updated_at: string;
+ created_at: string;
+}