Compare commits

..

4 Commits

Author SHA1 Message Date
aa152a4127 feat: authentication integration 2025-05-28 20:51:34 +02:00
a1ed1113d9 feat: fetch profile API 2025-05-28 17:40:33 +02:00
2187c873ee feat: json resposne 2025-05-28 17:40:24 +02:00
595015f324 feat: install zustand 2025-05-28 17:40:16 +02:00
26 changed files with 1417 additions and 646 deletions

View File

@ -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)

View File

@ -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) {
@ -46,10 +148,13 @@ func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
return
}
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,
"phoneNumber": user.PhoneNumber,
"phone_number": user.PhoneNumber,
"isAdmin": user.IsAdmin,
"last_login": user.LastLogin,
"profile_picture": user.ProfilePicture.String,
@ -90,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
}
@ -140,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(),

292
web/package-lock.json generated
View File

@ -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",
@ -17,7 +18,8 @@
"react-hook-form": "^7.56.4",
"react-icons": "^5.5.0",
"react-router": "^7.6.0",
"tailwindcss": "^4.1.7"
"tailwindcss": "^4.1.7",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
@ -2073,7 +2075,7 @@
"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,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",
@ -2480,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",
@ -2572,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",
@ -2665,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",
@ -2678,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",
@ -2707,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",
@ -3078,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",
@ -3111,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",
@ -3137,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",
@ -3160,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",
@ -3690,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",
@ -3714,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",
@ -4011,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",
@ -4754,6 +5013,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
}
}
}
}
}

View File

@ -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",
@ -19,7 +20,8 @@
"react-hook-form": "^7.56.4",
"react-icons": "^5.5.0",
"react-router": "^7.6.0",
"tailwindcss": "^4.1.7"
"tailwindcss": "^4.1.7",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/js": "^9.25.0",

View File

@ -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: <IndexPage />,
},
{
path: "/agreement",
element: <AgreementPage />,
},
{
path: "/login",
element: <LoginPage />,
},
{
path: "/register",
element: <RegisterPage />,
},
{
path: "/authorize",
element: <OAuthAuthorizePage />,
element: <AuthLayout />,
children: [
{ index: true, element: <IndexPage /> },
{ path: "agreement", element: <AgreementPage /> },
{ path: "login", element: <LoginPage /> },
{ path: "register", element: <RegisterPage /> },
{ path: "authorize", element: <OAuthAuthorizePage /> },
],
},
]);
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} />;
};

View File

@ -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",
const response = await axios.post(
"/api/v1/oauth/code",
{ nonce },
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
nonce,
}),
});
}
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
const data: CodeResponse = await response.json();
const data: CodeResponse = response.data;
return data;
};

View File

@ -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));
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";
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.log(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 new Error("Unexpected error happened");
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));
};

View File

@ -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({
const response = await axios.post("/api/v1/auth/login", {
email: req.email,
password: req.password,
}),
headers: {
"Content-Type": "application/json",
},
});
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
View 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
View 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;
};

View File

@ -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 (
<div className="flex flex-col items-center gap-2 p-7">
<div className="w-24 h-24 sm:w-36 sm:h-36 overflow-hidden rounded-full flex items-center justify-center bg-gray-300">
{/* <User size={64} /> */}
{profile?.profile_picture ? (
<img
className="w-full h-full"
src="http://192.168.178.69:9000/guard-storage/profile_eff00028-2d9e-458d-8944-677855edc147_1748099702417601900.jpg"
className="w-full h-full object-cover"
src={profile.profile_picture}
alt="profile pic"
/>
) : (
<User size={64} />
)}
</div>
<h1 className="dark:text-gray-200 text-gray-800 text-2xl select-none">
Welcome, Amir Adal
Welcome, {profile?.full_name}
</h1>
<p className="text-gray-600 dark:text-gray-500 select-none text-center text-sm/normal sm:text-lg">
Manage your info, private and security to make Home Guard work better
for you.
</p>
<Button className="mt-10" onClick={signOut}>
Sign Out
</Button>
</div>
);
};

View File

@ -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);

View File

@ -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<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>
);
};

View File

@ -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<LocalAccount[]>([]);
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 (
<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>
);
}
return (
<>
{accounts.map((account) => (
@ -64,7 +32,7 @@ const AccountList: FC = () => {
{account.profilePicture ? (
<img
src={account.profilePicture}
className="w-full h-full flex-1"
className="w-full h-full flex-1 object-cover"
alt="profile"
/>
) : (

View File

@ -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 (
<Navigate
to={hasAccounts ? "/authorize" : "/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>
);
}
return (
<BackgroundLayout>
<Outlet />
</BackgroundLayout>
);
};
export default AuthLayout;

View File

@ -0,0 +1,15 @@
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-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]">
{children}
</div>
);
};
export default BackgroundLayout;

View File

@ -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(
<DbProvider>
<OAuthProvider>
<App />
</OAuthProvider>
</DbProvider>
);

View File

@ -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 (
<div
className={`relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]`}
@ -22,8 +25,8 @@ const AgreementPage: FC = () => {
<div className="w-12 h-12 overflow-hidden bg-gray-100 rounded-full ring ring-gray-400 dark:ring dark:ring-gray-500">
{/* <User size={32} /> */}
<img
src="http://192.168.178.69:9000/guard-storage/profile_eff00028-2d9e-458d-8944-677855edc147_1748099702417601900.jpg"
className="w-full h-full flex-1"
src={profile?.profile_picture?.toString()}
className="w-full h-full flex-1 object-cover"
alt="profile"
/>
</div>

View File

@ -13,9 +13,6 @@ const IndexPage: FC = () => {
const [tab, setTab] = useState<string>("home");
return (
<div
className={`relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]`}
>
<div className="relative z-10 flex items-center justify-center min-h-screen">
<Card className="overflow-y-auto min-h-screen w-full min-w-full shadow-lg bg-white/85 dark:bg-black/85 backdrop-blur-md sm:rounded-none">
<div className="flex flex-col items-center sm:pt-0 relative">
@ -35,10 +32,7 @@ const IndexPage: FC = () => {
<Sidebar activeTab={tab} onChangeTab={(tab) => setTab(tab)} />
<div className="sm:p-4 max-w-full flex-1">
<div className="flex flex-col w-full items-center gap-2">
<TopBar
activeTab={tab}
onChangeTab={(tab) => setTab(tab)}
/>
<TopBar activeTab={tab} onChangeTab={(tab) => setTab(tab)} />
{tab === "home" && <Home />}
{/* {tab === "personal-info" && <PersonalInfo />} */}
</div>
@ -51,7 +45,6 @@ const IndexPage: FC = () => {
</div>
</Card>
</div>
</div>
);
};

View File

@ -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<LoginForm> = 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,11 +75,10 @@ export default function LoginPage() {
setLoading(false);
}
},
[oauth, repo, reset]
[oauth, repo, reset, updateActiveAccount]
);
return (
<div className="relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]">
<div className="relative z-10 flex items-center justify-center min-h-screen">
<Card className="sm:w-96 sm:min-w-96 sm:max-w-96 sm:min-h-auto p-3 min-h-screen w-full min-w-full shadow-lg bg-white/65 dark:bg-black/70 backdrop-blur-md">
<div className="flex flex-col items-center pt-16 sm:pt-0">
@ -163,10 +166,7 @@ export default function LoginPage() {
</Button>
<div className="text-sm text-center text-gray-600">
Don't have an account?{" "}
<Link
to="/register"
className="text-blue-600 hover:underline"
>
<Link to="/register" className="text-blue-600 hover:underline">
Register
</Link>
</div>
@ -175,6 +175,5 @@ export default function LoginPage() {
</div>
</Card>
</div>
</div>
);
}

View File

@ -35,10 +35,6 @@ const OAuthAuthorizePage: FC = () => {
]);
return (
<div
className={`relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]`}
// style={{ backgroundImage: `url(${overlay})` }}
>
<div className="relative z-10 flex items-center justify-center min-h-screen">
<Card className="sm:w-[700px] sm:min-w-[700px] sm:max-w-96 sm:min-h-auto p-3 min-h-screen w-full min-w-full shadow-lg bg-white/65 dark:bg-black/65 backdrop-blur-md">
<div className="flex sm:flex-row flex-col sm:items-stretch items-center pt-16 sm:pt-0">
@ -54,8 +50,8 @@ const OAuthAuthorizePage: FC = () => {
Select Account
</h2>
<h4 className="text-base mb-3 text-gray-400 text-left dark:text-gray-300">
Choose one of the accounts below in order to proceed to home
lab services and tools.
Choose one of the accounts below in order to proceed to home lab
services and tools.
</h4>
</div>
</div>
@ -67,7 +63,6 @@ const OAuthAuthorizePage: FC = () => {
</div>
</Card>
</div>
</div>
);
};

View File

@ -73,9 +73,6 @@ export default function RegisterPage() {
);
return (
<div
className={`relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]`}
>
<div className="relative z-10 flex items-center justify-center min-h-screen">
<Card className="sm:w-96 sm:min-w-96 sm:max-w-96 sm:min-h-auto p-3 min-h-screen w-full min-w-full shadow-lg bg-white/65 dark:bg-black/65 backdrop-blur-md">
<div className="flex flex-col items-center pt-16 sm:pt-0">
@ -179,9 +176,7 @@ export default function RegisterPage() {
return "Password must contain characters";
}
if (
password
.split("")
.every((c) => c.toLowerCase() == c)
password.split("").every((c) => c.toLowerCase() == c)
) {
return "Password should contain at least 1 uppercase character";
}
@ -252,6 +247,5 @@ export default function RegisterPage() {
</div>
</Card>
</div>
</div>
);
}

View File

@ -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,18 +32,26 @@ export interface CreateAccountRequest {
refresh: string;
}
export const useAccountRepo = () => {
const { db } = useDbContext();
export interface UpdateAccountTokensRequest {
accountId: string;
access: string;
refresh: string;
}
const getDeviceKey = useCallback(async () => {
export interface UpdateAccountInfoRequest {
accountId: string;
label?: string;
profilePicture?: string;
}
export const getDeviceKey = async () => {
const deviceId = await getDeviceId();
const deviceKey = await deriveDeviceKey(deviceId);
return deviceKey;
}, []);
};
const encryptToken = useCallback(
async (token: string) => {
export const encryptToken = async (token: string) => {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
@ -59,12 +68,12 @@ export const useAccountRepo = () => {
data: Array.from(new Uint8Array(cipherText)),
iv: Array.from(iv),
};
},
[getDeviceKey]
);
};
const decryptToken = useCallback(
async (encrypted: { data: number[]; iv: number[] }) => {
export const decryptToken = async (encrypted: {
data: number[];
iv: number[];
}) => {
const decoder = new TextDecoder();
const deviceKey = await getDeviceKey();
@ -79,18 +88,19 @@ export const useAccountRepo = () => {
);
return decoder.decode(decrypted);
},
[getDeviceKey]
);
const save = useCallback(
async (req: CreateAccountRequest) => {
if (!db) throw new Error("No database connection");
};
export const saveAccount = async (
db: IDBPDatabase,
req: CreateAccountRequest
): Promise<LocalAccount> => {
const access = await encryptToken(req.access);
const refresh = await encryptToken(req.refresh);
await db?.put?.("accounts", {
const tx = db.transaction("accounts", "readwrite");
const store = tx.objectStore("accounts");
await store.put({
accountId: req.accountId,
label: req.label,
email: req.email,
@ -99,13 +109,15 @@ export const useAccountRepo = () => {
refresh,
updatedAt: new Date().toISOString(),
});
},
[db, encryptToken]
);
const loadAll = useCallback(async () => {
if (!db) throw new Error("No database connection");
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");
@ -134,7 +146,106 @@ export const useAccountRepo = () => {
).filter((acc) => acc !== undefined);
return results;
}, [db, decryptToken]);
return { save, loadAll };
};
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 = useDbStore((state) => state.db);
const save = useCallback(
async (req: CreateAccountRequest) => {
if (!db) throw new Error("No database connection");
return saveAccount(db, req);
},
[db]
);
const loadAll = useCallback(async () => {
if (!db) throw new Error("No database connection");
return getAllAccounts(db);
}, [db]);
const getOne = useCallback(
async (accountId: string) => {
if (!db) throw new Error("No database connection");
return getAccount(db, accountId);
},
[db]
);
return { save, loadAll, getOne };
};

136
web/src/store/auth.ts Normal file
View File

@ -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<void>;
updateActiveAccount: (account: LocalAccount) => Promise<void>;
authenticate: () => Promise<void>;
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<IAuthState>((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 });
},
}));

29
web/src/store/db.ts Normal file
View File

@ -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<IDbStore>((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 });
},
}));

11
web/src/types/index.ts Normal file
View File

@ -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;
}