feat: authentication integration

This commit is contained in:
2025-05-28 20:51:34 +02:00
parent a1ed1113d9
commit aa152a4127
26 changed files with 1371 additions and 662 deletions

View File

@ -46,7 +46,7 @@ func (s *APIServer) Run() error {
router.Route("/api/v1", func(r chi.Router) {
am := imiddleware.New(s.cfg)
r.Use(imiddleware.WithSkipper(am.Runner, "/api/v1/login", "/api/v1/register", "/api/v1/oauth/token"))
r.Use(imiddleware.WithSkipper(am.Runner, "/api/v1/auth/login", "/api/v1/auth/register", "/api/v1/auth/refresh", "/api/v1/oauth/token"))
userHandler := user.NewUserHandler(s.repo, s.storage)
userHandler.RegisterRoutes(r)

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"gitea.local/admin/hspguard/internal/config"
@ -21,6 +22,42 @@ type AuthHandler struct {
cfg *config.AppConfig
}
func (h *AuthHandler) signTokens(user *repository.User) (string, string, error) {
accessClaims := types.UserClaims{
UserEmail: user.Email,
IsAdmin: user.IsAdmin,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: h.cfg.Jwt.Issuer,
Subject: user.ID.String(),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
},
}
accessToken, err := SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey)
if err != nil {
return "", "", err
}
refreshClaims := types.UserClaims{
UserEmail: user.Email,
IsAdmin: user.IsAdmin,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: h.cfg.Jwt.Issuer,
Subject: user.ID.String(),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
},
}
refreshToken, err := SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey)
if err != nil {
return "", "", err
}
return accessToken, refreshToken, nil
}
func NewAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *AuthHandler {
return &AuthHandler{
repo,
@ -29,8 +66,73 @@ func NewAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *AuthHandle
}
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
api.Get("/profile", h.getProfile)
api.Post("/login", h.login)
api.Get("/auth/profile", h.getProfile)
api.Post("/auth/login", h.login)
api.Post("/auth/refresh", h.refreshToken)
}
func (h *AuthHandler) refreshToken(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
web.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
parts := strings.Split(authHeader, "Bearer ")
if len(parts) != 2 {
web.Error(w, "invalid auth header format", http.StatusUnauthorized)
return
}
tokenStr := parts[1]
token, userClaims, err := VerifyToken(tokenStr, h.cfg.Jwt.PublicKey)
if err != nil || !token.Valid {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
return
}
expire, err := userClaims.GetExpirationTime()
if err != nil {
web.Error(w, "failed to retrieve enough info from the token", http.StatusInternalServerError)
return
}
if time.Now().After(expire.Time) {
web.Error(w, "token is expired", http.StatusUnauthorized)
return
}
userId, err := uuid.Parse(userClaims.Subject)
if err != nil {
web.Error(w, "failed to parsej user id from token", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), userId)
if err != nil {
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
return
}
access, refresh, err := h.signTokens(&user)
if err != nil {
web.Error(w, "failed to generate tokens", http.StatusInternalServerError)
return
}
type Response struct {
AccessToken string `json:"access"`
RefreshToken string `json:"refresh"`
}
encoder := json.NewEncoder(w)
if err := encoder.Encode(Response{
AccessToken: access,
RefreshToken: refresh,
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
@ -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
View File

@ -10,6 +10,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.9.0",
"lucide-react": "^0.511.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
@ -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",

View File

@ -12,6 +12,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.9.0",
"lucide-react": "^0.511.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",

View File

@ -1,58 +1,28 @@
import { useEffect, type FC } from "react";
import { type FC } from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import IndexPage from "./pages/Index";
import LoginPage from "./pages/Login";
import RegisterPage from "./pages/Register";
import { useDbContext } from "./context/db";
import { openDB } from "idb";
import AgreementPage from "./pages/Agreement";
import OAuthAuthorizePage from "./pages/OAuthAuthorize";
import AuthLayout from "./layout/AuthLayout";
const router = createBrowserRouter([
{
path: "/",
element: <IndexPage />,
},
{
path: "/agreement",
element: <AgreementPage />,
},
{
path: "/login",
element: <LoginPage />,
},
{
path: "/register",
element: <RegisterPage />,
},
{
path: "/authorize",
element: <OAuthAuthorizePage />,
element: <AuthLayout />,
children: [
{ index: true, element: <IndexPage /> },
{ path: "agreement", element: <AgreementPage /> },
{ path: "login", element: <LoginPage /> },
{ path: "register", element: <RegisterPage /> },
{ path: "authorize", element: <OAuthAuthorizePage /> },
],
},
]);
const App: FC = () => {
const { db, setDb } = useDbContext();
useEffect(() => {
const openConnection = async () => {
const dbPromise = openDB("guard-local", 3, {
upgrade: (db) => {
if (!db.objectStoreNames.contains("accounts")) {
db.createObjectStore("accounts", { keyPath: "accountId" });
}
},
});
const conn = await dbPromise;
setDb(conn);
};
openConnection();
}, [db, setDb]);
return <RouterProvider router={router} />;
};

View File

@ -1,25 +1,25 @@
import { handleApiError } from ".";
import { handleApiError, axios } from ".";
export interface CodeResponse {
code: string;
}
export const codeApi = async (accessToken: string, nonce: string) => {
const response = await fetch("/api/v1/oauth/code", {
method: "POST",
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;
};

View File

@ -1,20 +1,74 @@
export const handleApiError = async (response: Response) => {
try {
const json = await response.json();
console.log({ json });
const text = json.error ?? "unexpected error happpened";
return new Error(text[0].toUpperCase() + text.slice(1));
} catch (err) {
try {
console.log(err);
const text = await response.text();
if (text.length > 0) {
return new Error(text[0].toUpperCase() + text.slice(1));
}
} 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));
};

View File

@ -1,3 +1,4 @@
import axios from "axios";
import { handleApiError } from ".";
export interface LoginRequest {
@ -15,21 +16,15 @@ export interface LoginResponse {
}
export const loginApi = async (req: LoginRequest) => {
const response = await fetch("/api/v1/login", {
method: "POST",
body: JSON.stringify({
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;
};

View File

@ -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
View File

@ -0,0 +1,27 @@
import axios from "axios";
import { handleApiError } from ".";
export interface RefreshTokenResponse {
access: string;
refresh: string;
}
export const refreshTokenApi = async (refreshToken: string) => {
const response = await axios.post(
"/api/v1/auth/refresh",
{},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${refreshToken}`,
},
}
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
const data: RefreshTokenResponse = response.data;
return data;
};

View File

@ -1,23 +1,37 @@
import { Button } from "@/components/ui/button";
import { useAuth } from "@/store/auth";
import { User } from "lucide-react";
import { type FC } from "react";
const Home: FC = () => {
const profile = useAuth((state) => state.profile);
console.log({ profile });
const signOut = useAuth((state) => state.signOut);
return (
<div className="flex flex-col items-center gap-2 p-7">
<div className="w-24 h-24 sm:w-36 sm:h-36 overflow-hidden rounded-full flex items-center justify-center bg-gray-300">
{/* <User size={64} /> */}
<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>
);
};

View File

@ -1,16 +0,0 @@
import type { IDBPDatabase } from "idb";
import { createContext, useContext } from "react";
interface DbContextValues {
db: IDBPDatabase | null;
connected: boolean;
setDb: (db: IDBPDatabase) => void;
}
export const DbContext = createContext<DbContextValues>({
db: null,
connected: false,
setDb: () => {},
});
export const useDbContext = () => useContext(DbContext);

View File

@ -1,25 +0,0 @@
import { useCallback, useState, type FC, type ReactNode } from "react";
import { DbContext } from ".";
import type { IDBPDatabase } from "idb";
interface IDBProvider {
children: ReactNode;
}
export const DbProvider: FC<IDBProvider> = ({ children }) => {
const [db, _setDb] = useState<IDBPDatabase | null>(null);
const setDb = useCallback((db: IDBPDatabase) => _setDb(db), []);
return (
<DbContext.Provider
value={{
db,
connected: Boolean(db),
setDb,
}}
>
{children}
</DbContext.Provider>
);
};

View File

@ -1,56 +1,24 @@
import { useDbContext } from "@/context/db";
import { useOAuthContext } from "@/context/oauth";
import { type LocalAccount, useAccountRepo } from "@/repository/account";
import { type LocalAccount } from "@/repository/account";
import { useAuth } from "@/store/auth";
import { CirclePlus, User } from "lucide-react";
import { useCallback, useEffect, useState, type FC } from "react";
import { useCallback, type FC } from "react";
import { Link } from "react-router-dom";
const AccountList: FC = () => {
const [accounts, setAccounts] = useState<LocalAccount[]>([]);
const repo = useAccountRepo();
const { connected } = useDbContext();
const accounts = useAuth((state) => state.accounts);
const updateActiveAccount = useAuth((state) => state.updateActiveAccount);
const oauth = useOAuthContext();
const handleAccountSelect = useCallback(
(account: LocalAccount) => {
oauth.selectSession(account.access);
updateActiveAccount(account);
},
[oauth]
[oauth, updateActiveAccount]
);
useEffect(() => {
if (connected) repo.loadAll().then(setAccounts);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connected]);
if (!connected) {
return (
<div className="p-5 flex-1 h-full flex-full flex items-center justify-center">
<div role="status">
<svg
aria-hidden="true"
className="w-12 h-12 text-gray-200 dark:text-gray-600 animate-spin fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
</div>
);
}
return (
<>
{accounts.map((account) => (
@ -64,7 +32,7 @@ const AccountList: FC = () => {
{account.profilePicture ? (
<img
src={account.profilePicture}
className="w-full h-full flex-1"
className="w-full h-full flex-1 object-cover"
alt="profile"
/>
) : (

View File

@ -0,0 +1,119 @@
import { useDbStore } from "@/store/db";
import { useAuth } from "@/store/auth";
import { useEffect, useMemo } from "react";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import BackgroundLayout from "./BackgroundLayout";
const AuthLayout = () => {
const { connecting, db, connect } = useDbStore();
const dbConnected = useMemo(() => !!db, [db]);
const loadingAccounts = useAuth((state) => state.loadingAccounts);
const loadAccounts = useAuth((state) => state.loadAccounts);
const activeAccount = useAuth((state) => state.activeAccount);
const hasAccounts = useAuth((state) => state.accounts.length > 0);
const fetchingProfile = useAuth((state) => state.authenticating);
const fetchProfile = useAuth((state) => state.authenticate);
const signInRequired = useAuth((state) => state.signInRequired);
const location = useLocation();
const navigate = useNavigate();
const isAuthPage = useMemo(() => {
const allowedPaths = ["/login", "/register", "/authorize"];
if (!allowedPaths.some((p) => location.pathname.startsWith(p))) {
return false;
}
return true;
}, [location.pathname]);
console.log({
isAuthPage,
loadingAccounts,
fetchingProfile,
connecting,
dbConnected,
});
const loading = useMemo(() => {
if (isAuthPage) {
return connecting;
}
return loadingAccounts || fetchingProfile || connecting;
}, [connecting, fetchingProfile, isAuthPage, loadingAccounts]);
useEffect(() => {
connect();
}, [connect]);
useEffect(() => {
if (dbConnected) {
loadAccounts();
}
}, [dbConnected, loadAccounts]);
useEffect(() => {
if (dbConnected && !loadingAccounts && activeAccount) {
fetchProfile();
}
}, [activeAccount, dbConnected, fetchProfile, loadingAccounts]);
useEffect(() => {
if (!signInRequired && location.state?.from) {
navigate(location.state.from, { state: {} });
}
}, [location.state, location.state?.from, navigate, signInRequired]);
if (signInRequired && !isAuthPage) {
return (
<Navigate
to={hasAccounts ? "/authorize" : "/login"}
state={{ from: location.pathname }}
/>
);
}
if (loading) {
return (
<BackgroundLayout>
<div className="w-screen h-screen flex flex-1 items-center justify-center flex-col">
<div role="status">
<svg
aria-hidden="true"
className="w-10 h-10 text-gray-400 animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
<p className="text-gray-200 dark:text-gray-400 mt-4 text-lg">
Loading...
</p>
</div>
</BackgroundLayout>
);
}
return (
<BackgroundLayout>
<Outlet />
</BackgroundLayout>
);
};
export default AuthLayout;

View File

@ -0,0 +1,15 @@
import { type FC, type ReactNode } from "react";
export interface IBackgroundLayoutProps {
children: ReactNode;
}
const BackgroundLayout: FC<IBackgroundLayoutProps> = ({ children }) => {
return (
<div className="relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]">
{children}
</div>
);
};
export default BackgroundLayout;

View File

@ -2,15 +2,12 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
import { DbProvider } from "./context/db/provider";
import { OAuthProvider } from "./context/oauth/provider";
const root = document.getElementById("root")!;
createRoot(root).render(
<DbProvider>
<OAuthProvider>
<App />
</OAuthProvider>
</DbProvider>
<OAuthProvider>
<App />
</OAuthProvider>
);

View File

@ -3,8 +3,11 @@ import { type FC } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { ArrowLeftRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/store/auth";
const AgreementPage: FC = () => {
const profile = useAuth((state) => state.profile);
return (
<div
className={`relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]`}
@ -22,8 +25,8 @@ const AgreementPage: FC = () => {
<div className="w-12 h-12 overflow-hidden bg-gray-100 rounded-full ring ring-gray-400 dark:ring dark:ring-gray-500">
{/* <User size={32} /> */}
<img
src="http://192.168.178.69:9000/guard-storage/profile_eff00028-2d9e-458d-8944-677855edc147_1748099702417601900.jpg"
className="w-full h-full flex-1"
src={profile?.profile_picture?.toString()}
className="w-full h-full flex-1 object-cover"
alt="profile"
/>
</div>

View File

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

View File

@ -10,6 +10,7 @@ import { useCallback, useState } from "react";
import { loginApi } from "@/api/login";
import { useAccountRepo } from "@/repository/account";
import { useOAuthContext } from "@/context/oauth";
import { useAuth } from "@/store/auth";
interface LoginForm {
email: string;
@ -32,6 +33,8 @@ export default function LoginPage() {
const repo = useAccountRepo();
const updateActiveAccount = useAuth((state) => state.updateActiveAccount);
const onSubmit: SubmitHandler<LoginForm> = useCallback(
async (data) => {
console.log({ data });
@ -48,7 +51,7 @@ export default function LoginPage() {
console.log(response);
await repo.save({
const account = await repo.save({
accountId: response.id,
label: response.full_name,
email: response.email,
@ -61,6 +64,7 @@ export default function LoginPage() {
reset();
oauth.selectSession(response.access);
updateActiveAccount(account);
} catch (err: any) {
console.log(err);
setError(
@ -71,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>
);
}

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { useDbContext } from "@/context/db";
import { useDbStore } from "@/store/db";
import { deriveDeviceKey, getDeviceId } from "@/util/deviceId";
import type { IDBPDatabase } from "idb";
import { useCallback } from "react";
export interface RawLocalAccount {
@ -31,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
View File

@ -0,0 +1,136 @@
import {
deleteAccount,
getAllAccounts,
updateAccountInfo,
type LocalAccount,
} from "@/repository/account";
import type { UserProfile } from "@/types";
import { create } from "zustand";
import { useDbStore } from "./db";
import { fetchProfileApi } from "@/api/profile";
export interface IAuthState {
profile: UserProfile | null;
authenticating: boolean;
activeAccount: LocalAccount | null;
loadingAccounts: boolean;
accounts: LocalAccount[];
signInRequired: boolean;
loadAccounts: () => Promise<void>;
updateActiveAccount: (account: LocalAccount) => Promise<void>;
authenticate: () => Promise<void>;
requireSignIn: () => void;
signOut: () => void;
}
const getActiveAccountId = (): string | null => {
const accountId = localStorage.getItem("guard-selected");
return accountId;
};
const saveActiveAccountId = (accountId: string): void => {
localStorage.setItem("guard-selected", accountId);
};
const resetActiveAccount = (): void => {
localStorage.removeItem("guard-selected");
};
// TODO: maybe add deleteActiveAccount
export const useAuth = create<IAuthState>((set, get) => ({
profile: null,
authenticating: false,
activeAccount: null,
accounts: [],
loadingAccounts: false,
signInRequired: false,
updateActiveAccount: async (account) => {
set({ activeAccount: account });
saveActiveAccountId(account.accountId);
set({ signInRequired: false });
},
loadAccounts: async () => {
set({ loadingAccounts: true });
const db = useDbStore.getState().db;
if (!db) return;
const accounts = await getAllAccounts(db);
console.log("loaded accounts:", accounts);
if (!accounts || accounts.length === 0) {
set({ signInRequired: true });
}
const active = getActiveAccountId();
if (!active) {
set({ signInRequired: true });
}
const account = accounts.find((acc) => acc.accountId === active);
if (!account) {
resetActiveAccount();
set({ signInRequired: true });
}
set({ activeAccount: account, accounts, loadingAccounts: false });
},
authenticate: async () => {
const { authenticating } = get();
if (authenticating) return;
set({ authenticating: true });
try {
const response = await fetchProfileApi();
console.log("authenticate response:", response);
try {
// update local account information
const db = useDbStore.getState().db;
if (db) {
await updateAccountInfo(db, {
accountId: response.id,
label: response.full_name,
...(response.profile_picture
? { profilePicture: response.profile_picture }
: {}),
});
}
} finally {
set({ profile: response });
}
} catch (err) {
// TODO: set error
console.log(err);
} finally {
set({ authenticating: false });
}
},
requireSignIn: () => set({ signInRequired: true }),
signOut: async () => {
resetActiveAccount();
const db = useDbStore.getState().db;
const activeAccount = get().activeAccount;
if (db && activeAccount) {
await deleteAccount(db, activeAccount.accountId);
}
await get().loadAccounts();
set({ activeAccount: null, signInRequired: true });
},
}));

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

@ -0,0 +1,29 @@
import { openDB, type IDBPDatabase } from "idb";
import { create } from "zustand";
export interface IDbStore {
db: IDBPDatabase | null;
connecting: boolean;
connect: () => void;
}
export const useDbStore = create<IDbStore>((set, get) => ({
db: null,
connecting: false,
connect: async () => {
if (get().connecting) return;
set({ connecting: true, db: null });
const dbPromise = openDB("guard-local", 3, {
upgrade: (db) => {
if (!db.objectStoreNames.contains("accounts")) {
db.createObjectStore("accounts", { keyPath: "accountId" });
}
},
});
const conn = await dbPromise;
set({ db: conn, connecting: false });
},
}));

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

@ -0,0 +1,11 @@
export interface UserProfile {
id: string;
full_name: string;
email: string;
phone_number: string;
isAdmin: boolean;
last_login: string;
profile_picture: string | null;
updated_at: string;
created_at: string;
}