feat: authentication integration
This commit is contained in:
@ -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)
|
||||
|
@ -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(),
|
||||
|
258
web/package-lock.json
generated
258
web/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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} />;
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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));
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
27
web/src/api/refresh.ts
Normal file
27
web/src/api/refresh.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import axios from "axios";
|
||||
import { handleApiError } from ".";
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
}
|
||||
|
||||
export const refreshTokenApi = async (refreshToken: string) => {
|
||||
const response = await axios.post(
|
||||
"/api/v1/auth/refresh",
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${refreshToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status !== 200 && response.status !== 201)
|
||||
throw await handleApiError(response);
|
||||
|
||||
const data: RefreshTokenResponse = response.data;
|
||||
|
||||
return data;
|
||||
};
|
@ -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} /> */}
|
||||
<img
|
||||
className="w-full h-full"
|
||||
src="http://192.168.178.69:9000/guard-storage/profile_eff00028-2d9e-458d-8944-677855edc147_1748099702417601900.jpg"
|
||||
alt="profile pic"
|
||||
/>
|
||||
{profile?.profile_picture ? (
|
||||
<img
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -1,16 +0,0 @@
|
||||
import type { IDBPDatabase } from "idb";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface DbContextValues {
|
||||
db: IDBPDatabase | null;
|
||||
connected: boolean;
|
||||
setDb: (db: IDBPDatabase) => void;
|
||||
}
|
||||
|
||||
export const DbContext = createContext<DbContextValues>({
|
||||
db: null,
|
||||
connected: false,
|
||||
setDb: () => {},
|
||||
});
|
||||
|
||||
export const useDbContext = () => useContext(DbContext);
|
@ -1,25 +0,0 @@
|
||||
import { useCallback, useState, type FC, type ReactNode } from "react";
|
||||
import { DbContext } from ".";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
|
||||
interface IDBProvider {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const DbProvider: FC<IDBProvider> = ({ children }) => {
|
||||
const [db, _setDb] = useState<IDBPDatabase | null>(null);
|
||||
|
||||
const setDb = useCallback((db: IDBPDatabase) => _setDb(db), []);
|
||||
|
||||
return (
|
||||
<DbContext.Provider
|
||||
value={{
|
||||
db,
|
||||
connected: Boolean(db),
|
||||
setDb,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DbContext.Provider>
|
||||
);
|
||||
};
|
@ -1,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"
|
||||
/>
|
||||
) : (
|
||||
|
119
web/src/layout/AuthLayout.tsx
Normal file
119
web/src/layout/AuthLayout.tsx
Normal 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;
|
15
web/src/layout/BackgroundLayout.tsx
Normal file
15
web/src/layout/BackgroundLayout.tsx
Normal 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;
|
@ -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>
|
||||
<OAuthProvider>
|
||||
<App />
|
||||
</OAuthProvider>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -13,44 +13,37 @@ 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">
|
||||
<div className="flex flex-row items-center absolute left-4 top-4">
|
||||
<img src="/icon.png" alt="icon" className="w-6 h-6" />
|
||||
<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">
|
||||
<div className="flex flex-row items-center absolute left-4 top-4">
|
||||
<img src="/icon.png" alt="icon" className="w-6 h-6" />
|
||||
|
||||
<div className="ml-2">
|
||||
<p className="text-sm text-gray-600 text-left dark:text-gray-500">
|
||||
Home Guard
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<p className="text-sm text-gray-600 text-left dark:text-gray-500">
|
||||
Home Guard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <LogIn className="w-8 h-8 text-gray-700 mb-4" /> */}
|
||||
<CardContent className="w-full space-y-4 flex-1" spacing={false}>
|
||||
<div className="flex flex-row">
|
||||
<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)}
|
||||
/>
|
||||
{tab === "home" && <Home />}
|
||||
{/* {tab === "personal-info" && <PersonalInfo />} */}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{tab === "personal-info" && <PersonalInfo />}
|
||||
</div>
|
||||
{/* <LogIn className="w-8 h-8 text-gray-700 mb-4" /> */}
|
||||
<CardContent className="w-full space-y-4 flex-1" spacing={false}>
|
||||
<div className="flex flex-row">
|
||||
<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)} />
|
||||
{tab === "home" && <Home />}
|
||||
{/* {tab === "personal-info" && <PersonalInfo />} */}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{tab === "personal-info" && <PersonalInfo />}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -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,110 +75,105 @@ 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">
|
||||
<img
|
||||
src="/icon.png"
|
||||
alt="icon"
|
||||
className="w-16 h-16 mb-4 mt-2 sm:mt-6"
|
||||
/>
|
||||
<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">
|
||||
<img
|
||||
src="/icon.png"
|
||||
alt="icon"
|
||||
className="w-16 h-16 mb-4 mt-2 sm:mt-6"
|
||||
/>
|
||||
|
||||
<div className="px-4 sm:mt-4 mt-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-200 text-left w-full">
|
||||
Sign In
|
||||
</h2>
|
||||
<h4 className="text-base mb-3 text-gray-400 dark:text-gray-500 text-left">
|
||||
Enter your credentials to access home services and tools.
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<CardContent className="w-full space-y-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-600 w-4 h-4" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Email Address"
|
||||
className="pl-10"
|
||||
{...register("email", {
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /\S+@\S+\.\S+/,
|
||||
message: "Invalid email",
|
||||
},
|
||||
})}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.email && (
|
||||
<p className="text-red-500">
|
||||
{errors.email.message ?? "Email is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-600 w-4 h-4" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="pl-10"
|
||||
{...register("password", {
|
||||
required: true,
|
||||
})}
|
||||
aria-invalid={errors.password ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.password && (
|
||||
<p className="text-red-500">
|
||||
{errors.password.message ?? "Password is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success.length > 0 && (
|
||||
<div className="border border-green-400 p-2 rounded bg-green-200 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.length > 0 && (
|
||||
<div className="border border-red-400 p-2 rounded bg-red-200 dark:border-red-600 dark:bg-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full mt-2 mb-4"
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
>
|
||||
Log In
|
||||
</Button>
|
||||
<div className="text-sm text-center text-gray-600">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<div className="px-4 sm:mt-4 mt-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-200 text-left w-full">
|
||||
Sign In
|
||||
</h2>
|
||||
<h4 className="text-base mb-3 text-gray-400 dark:text-gray-500 text-left">
|
||||
Enter your credentials to access home services and tools.
|
||||
</h4>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<CardContent className="w-full space-y-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-600 w-4 h-4" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Email Address"
|
||||
className="pl-10"
|
||||
{...register("email", {
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /\S+@\S+\.\S+/,
|
||||
message: "Invalid email",
|
||||
},
|
||||
})}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.email && (
|
||||
<p className="text-red-500">
|
||||
{errors.email.message ?? "Email is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-600 w-4 h-4" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="pl-10"
|
||||
{...register("password", {
|
||||
required: true,
|
||||
})}
|
||||
aria-invalid={errors.password ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.password && (
|
||||
<p className="text-red-500">
|
||||
{errors.password.message ?? "Password is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success.length > 0 && (
|
||||
<div className="border border-green-400 p-2 rounded bg-green-200 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.length > 0 && (
|
||||
<div className="border border-red-400 p-2 rounded bg-red-200 dark:border-red-600 dark:bg-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full mt-2 mb-4"
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
>
|
||||
Log In
|
||||
</Button>
|
||||
<div className="text-sm text-center text-gray-600">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/register" className="text-blue-600 hover:underline">
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -35,38 +35,33 @@ 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">
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<img
|
||||
src="/icon.png"
|
||||
alt="icon"
|
||||
className="w-16 h-16 mb-4 mt-2 sm:mt-6"
|
||||
/>
|
||||
<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">
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<img
|
||||
src="/icon.png"
|
||||
alt="icon"
|
||||
className="w-16 h-16 mb-4 mt-2 sm:mt-6"
|
||||
/>
|
||||
|
||||
<div className="px-4 sm:mt-4 mt-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800 text-left w-full dark:text-gray-100">
|
||||
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.
|
||||
</h4>
|
||||
</div>
|
||||
<div className="px-4 sm:mt-4 mt-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800 text-left w-full dark:text-gray-100">
|
||||
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.
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* <LogIn className="w-8 h-8 text-gray-700 mb-4" /> */}
|
||||
<CardContent className="w-full space-y-4 flex-1">
|
||||
<AccountList />
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* <LogIn className="w-8 h-8 text-gray-700 mb-4" /> */}
|
||||
<CardContent className="w-full space-y-4 flex-1">
|
||||
<AccountList />
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -73,185 +73,179 @@ 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">
|
||||
<img
|
||||
src="/icon.png"
|
||||
alt="icon"
|
||||
className="w-16 h-16 mb-4 mt-2 sm:mt-6"
|
||||
/>
|
||||
<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">
|
||||
<img
|
||||
src="/icon.png"
|
||||
alt="icon"
|
||||
className="w-16 h-16 mb-4 mt-2 sm:mt-6"
|
||||
/>
|
||||
|
||||
<div className="px-4 sm:mt-4 mt-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-200 text-left w-full">
|
||||
Sign Up
|
||||
</h2>
|
||||
<h4 className="text-base mb-3 text-gray-400 dark:text-gray-600 text-left">
|
||||
Fill up this form to start using homelab services and tools.
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<CardContent className="w-full space-y-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
id="full_name"
|
||||
type="text"
|
||||
placeholder="Full Name"
|
||||
className="pl-10"
|
||||
{...register("fullName", { required: true })}
|
||||
aria-invalid={errors.fullName ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.fullName && (
|
||||
<p className="text-red-600 opacity-70 text-sm">
|
||||
{errors.fullName.message ?? "Full Name is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Email Address"
|
||||
className="pl-10"
|
||||
{...register("email", {
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /\S+@\S+\.\S+/,
|
||||
message: "Invalid email",
|
||||
},
|
||||
})}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.email && (
|
||||
<p className="text-red-600 opacity-70 text-sm">
|
||||
{errors.email.message ?? "Email is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
id="phone_number"
|
||||
type="tel"
|
||||
placeholder="Phone Number"
|
||||
className="pl-10"
|
||||
{...register("phoneNumber", {
|
||||
required: false,
|
||||
})}
|
||||
aria-invalid={errors.phoneNumber ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.phoneNumber && (
|
||||
<p className="text-red-600 opacity-70 text-sm">
|
||||
{errors.phoneNumber.message ?? "Phone Number is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="pl-10"
|
||||
{...register("password", {
|
||||
required: true,
|
||||
validate: (password) => {
|
||||
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"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.password && (
|
||||
<p className="text-red-600 opacity-70 text-sm">
|
||||
{errors.password.message ?? "Password is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
id="repeat_password"
|
||||
type="password"
|
||||
placeholder="Repeat Password"
|
||||
className="pl-10"
|
||||
{...register("repeatPassword", {
|
||||
required: true,
|
||||
validate: (repeatPassword, { password }) => {
|
||||
if (repeatPassword != password) {
|
||||
return "Password does not match";
|
||||
}
|
||||
},
|
||||
})}
|
||||
aria-invalid={errors.repeatPassword ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.repeatPassword && (
|
||||
<p className="text-red-600 opacity-70 text-sm">
|
||||
{errors.repeatPassword.message ?? "Password is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success.length > 0 && (
|
||||
<div className="border border-green-400 p-2 rounded bg-green-200 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.length > 0 && (
|
||||
<div className="border border-red-400 p-2 rounded bg-red-200 dark:border-red-600 dark:bg-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="w-full mt-2 mb-4" loading={isLoading}>
|
||||
Register
|
||||
</Button>
|
||||
<div className="text-sm text-center text-gray-600">
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" className="text-blue-600 hover:underline">
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<div className="px-4 sm:mt-4 mt-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-200 text-left w-full">
|
||||
Sign Up
|
||||
</h2>
|
||||
<h4 className="text-base mb-3 text-gray-400 dark:text-gray-600 text-left">
|
||||
Fill up this form to start using homelab services and tools.
|
||||
</h4>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<CardContent className="w-full space-y-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
id="full_name"
|
||||
type="text"
|
||||
placeholder="Full Name"
|
||||
className="pl-10"
|
||||
{...register("fullName", { required: true })}
|
||||
aria-invalid={errors.fullName ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.fullName && (
|
||||
<p className="text-red-600 opacity-70 text-sm">
|
||||
{errors.fullName.message ?? "Full Name is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Email Address"
|
||||
className="pl-10"
|
||||
{...register("email", {
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /\S+@\S+\.\S+/,
|
||||
message: "Invalid email",
|
||||
},
|
||||
})}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.email && (
|
||||
<p className="text-red-600 opacity-70 text-sm">
|
||||
{errors.email.message ?? "Email is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
id="phone_number"
|
||||
type="tel"
|
||||
placeholder="Phone Number"
|
||||
className="pl-10"
|
||||
{...register("phoneNumber", {
|
||||
required: false,
|
||||
})}
|
||||
aria-invalid={errors.phoneNumber ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.phoneNumber && (
|
||||
<p className="text-red-600 opacity-70 text-sm">
|
||||
{errors.phoneNumber.message ?? "Phone Number is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="pl-10"
|
||||
{...register("password", {
|
||||
required: true,
|
||||
validate: (password) => {
|
||||
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"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.password && (
|
||||
<p className="text-red-600 opacity-70 text-sm">
|
||||
{errors.password.message ?? "Password is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
id="repeat_password"
|
||||
type="password"
|
||||
placeholder="Repeat Password"
|
||||
className="pl-10"
|
||||
{...register("repeatPassword", {
|
||||
required: true,
|
||||
validate: (repeatPassword, { password }) => {
|
||||
if (repeatPassword != password) {
|
||||
return "Password does not match";
|
||||
}
|
||||
},
|
||||
})}
|
||||
aria-invalid={errors.repeatPassword ? "true" : "false"}
|
||||
/>
|
||||
</div>
|
||||
{!!errors.repeatPassword && (
|
||||
<p className="text-red-600 opacity-70 text-sm">
|
||||
{errors.repeatPassword.message ?? "Password is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success.length > 0 && (
|
||||
<div className="border border-green-400 p-2 rounded bg-green-200 text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.length > 0 && (
|
||||
<div className="border border-red-400 p-2 rounded bg-red-200 dark:border-red-600 dark:bg-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="w-full mt-2 mb-4" loading={isLoading}>
|
||||
Register
|
||||
</Button>
|
||||
<div className="text-sm text-center text-gray-600">
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" className="text-blue-600 hover:underline">
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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<LocalAccount> => {
|
||||
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 };
|
||||
};
|
||||
|
136
web/src/store/auth.ts
Normal file
136
web/src/store/auth.ts
Normal 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
29
web/src/store/db.ts
Normal 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
11
web/src/types/index.ts
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user