From aa152a412732e2b887a34a007ab8d45681ce9ca2 Mon Sep 17 00:00:00 2001 From: LandaMm Date: Wed, 28 May 2025 20:51:34 +0200 Subject: [PATCH] feat: authentication integration --- cmd/hspguard/api/api.go | 2 +- internal/auth/routes.go | 143 +++++++--- web/package-lock.json | 258 ++++++++++++++++++ web/package.json | 1 + web/src/App.tsx | 50 +--- web/src/api/code.ts | 24 +- web/src/api/index.ts | 90 +++++-- web/src/api/login.ts | 15 +- web/src/api/profile.ts | 26 +- web/src/api/refresh.ts | 27 ++ web/src/components/Home/Tabs/Home.tsx | 28 +- web/src/context/db/index.ts | 16 -- web/src/context/db/provider.tsx | 25 -- web/src/feature/AccountList/index.tsx | 48 +--- web/src/layout/AuthLayout.tsx | 119 +++++++++ web/src/layout/BackgroundLayout.tsx | 15 ++ web/src/main.tsx | 9 +- web/src/pages/Agreement/index.tsx | 7 +- web/src/pages/Index/index.tsx | 59 ++--- web/src/pages/Login/index.tsx | 199 +++++++------- web/src/pages/OAuthAuthorize/index.tsx | 53 ++-- web/src/pages/Register/index.tsx | 348 ++++++++++++------------- web/src/repository/account.ts | 295 ++++++++++++++------- web/src/store/auth.ts | 136 ++++++++++ web/src/store/db.ts | 29 +++ web/src/types/index.ts | 11 + 26 files changed, 1371 insertions(+), 662 deletions(-) create mode 100644 web/src/api/refresh.ts delete mode 100644 web/src/context/db/index.ts delete mode 100644 web/src/context/db/provider.tsx create mode 100644 web/src/layout/AuthLayout.tsx create mode 100644 web/src/layout/BackgroundLayout.tsx create mode 100644 web/src/store/auth.ts create mode 100644 web/src/store/db.ts create mode 100644 web/src/types/index.ts 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 pic + {profile?.profile_picture ? ( + profile pic + ) : ( + + )}

- 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 ( -
-
- - Loading... -
-
- ); - } - return ( <> {accounts.map((account) => ( @@ -64,7 +32,7 @@ const AccountList: FC = () => { {account.profilePicture ? ( profile ) : ( 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 ( + +
+
+ + Loading... +
+

+ Loading... +

+
+
+ ); + } + + 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 (
{
{/* */} profile
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 ( -
-
- -
-
- icon +
+ +
+
+ icon -
-

- Home Guard -

-
+
+

+ Home Guard +

+
- {/* */} - -
- 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 ( -
-
- -
- icon +
+ +
+ icon -
-

- Sign In -

-

- Enter your credentials to access home services and tools. -

-
- - -
-
-
- - -
- {!!errors.email && ( -

- {errors.email.message ?? "Email is required"} -

- )} -
- -
-
- - -
- {!!errors.password && ( -

- {errors.password.message ?? "Password is required"} -

- )} -
- - {success.length > 0 && ( -
- {success} -
- )} - - {error.length > 0 && ( -
- {error} -
- )} - - -
- Don't have an account?{" "} - - Register - -
-
-
+
+

+ Sign In +

+

+ Enter your credentials to access home services and tools. +

- -
+ + +
+
+
+ + +
+ {!!errors.email && ( +

+ {errors.email.message ?? "Email is required"} +

+ )} +
+ +
+
+ + +
+ {!!errors.password && ( +

+ {errors.password.message ?? "Password is required"} +

+ )} +
+ + {success.length > 0 && ( +
+ {success} +
+ )} + + {error.length > 0 && ( +
+ {error} +
+ )} + + +
+ Don't have an account?{" "} + + Register + +
+
+
+
+
); } 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 ( -
-
- -
-
- icon +
+ +
+
+ icon -
-

- 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 ( -
-
- -
- icon +
+ +
+ icon -
-

- Sign Up -

-

- Fill up this form to start using homelab services and tools. -

-
- - -
-
-
- - -
- {!!errors.fullName && ( -

- {errors.fullName.message ?? "Full Name is required"} -

- )} -
- -
-
- - -
- {!!errors.email && ( -

- {errors.email.message ?? "Email is required"} -

- )} -
- -
-
- - -
- {!!errors.phoneNumber && ( -

- {errors.phoneNumber.message ?? "Phone Number is required"} -

- )} -
- -
-
- - { - if (password.length < 8) { - return "Password must be at least 8 characters long"; - } - if (!password.match(/[a-zA-Z]+/gi)) { - return "Password must contain characters"; - } - if ( - password - .split("") - .every((c) => c.toLowerCase() == c) - ) { - return "Password should contain at least 1 uppercase character"; - } - if (!password.match(/\d+/gi)) { - return "Password should contain at least 1 digit"; - } - }, - })} - aria-invalid={errors.password ? "true" : "false"} - /> -
- {!!errors.password && ( -

- {errors.password.message ?? "Password is required"} -

- )} -
- -
-
- - { - if (repeatPassword != password) { - return "Password does not match"; - } - }, - })} - aria-invalid={errors.repeatPassword ? "true" : "false"} - /> -
- {!!errors.repeatPassword && ( -

- {errors.repeatPassword.message ?? "Password is required"} -

- )} -
- - {success.length > 0 && ( -
- {success} -
- )} - - {error.length > 0 && ( -
- {error} -
- )} - - -
- Already have an account?{" "} - - Login - -
-
-
+
+

+ Sign Up +

+

+ Fill up this form to start using homelab services and tools. +

- -
+ + +
+
+
+ + +
+ {!!errors.fullName && ( +

+ {errors.fullName.message ?? "Full Name is required"} +

+ )} +
+ +
+
+ + +
+ {!!errors.email && ( +

+ {errors.email.message ?? "Email is required"} +

+ )} +
+ +
+
+ + +
+ {!!errors.phoneNumber && ( +

+ {errors.phoneNumber.message ?? "Phone Number is required"} +

+ )} +
+ +
+
+ + { + if (password.length < 8) { + return "Password must be at least 8 characters long"; + } + if (!password.match(/[a-zA-Z]+/gi)) { + return "Password must contain characters"; + } + if ( + password.split("").every((c) => c.toLowerCase() == c) + ) { + return "Password should contain at least 1 uppercase character"; + } + if (!password.match(/\d+/gi)) { + return "Password should contain at least 1 digit"; + } + }, + })} + aria-invalid={errors.password ? "true" : "false"} + /> +
+ {!!errors.password && ( +

+ {errors.password.message ?? "Password is required"} +

+ )} +
+ +
+
+ + { + if (repeatPassword != password) { + return "Password does not match"; + } + }, + })} + aria-invalid={errors.repeatPassword ? "true" : "false"} + /> +
+ {!!errors.repeatPassword && ( +

+ {errors.repeatPassword.message ?? "Password is required"} +

+ )} +
+ + {success.length > 0 && ( +
+ {success} +
+ )} + + {error.length > 0 && ( +
+ {error} +
+ )} + + +
+ Already have an account?{" "} + + Login + +
+
+
+
+
); } 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; +}