Compare commits

..

59 Commits

Author SHA1 Message Date
3279f1fb90 feat: remove unused import 2025-05-24 18:02:25 +02:00
1819629008 test: profile pictures on agree preview 2025-05-24 17:51:16 +02:00
4e9fa2337b feat: change color 2025-05-24 17:46:16 +02:00
68899e98bd feat: profile picture support 2025-05-24 17:46:06 +02:00
47f5188961 feat: profile picture 2025-05-24 17:45:47 +02:00
1840194bae feat: use host env var 2025-05-24 17:45:37 +02:00
d3bcc785a1 feat: pass file storage instance 2025-05-24 17:17:54 +02:00
64faa4ca5f feat: update user model + update profile picture 2025-05-24 17:17:45 +02:00
24c72800ad feat: file storage 2025-05-24 17:17:31 +02:00
f559f54683 feat: upload avatar route 2025-05-24 17:17:25 +02:00
9b0de4512b feat: add profile_picture field to user table 2025-05-24 17:17:16 +02:00
b6d365cc48 feat: install minio v7 2025-05-24 17:16:55 +02:00
8f755b6d1e feat: minio env vars example 2025-05-24 17:16:45 +02:00
587a463623 feat: dark theme support 2025-05-24 16:15:22 +02:00
1a596eef87 feat: no rounded corners 2025-05-24 16:15:12 +02:00
e6b87a6561 feat: button variants 2025-05-24 16:15:00 +02:00
3bcc5f8900 fix: database wasn't opening 2025-05-24 16:14:53 +02:00
c5ee912408 feat: dark overlay in assets 2025-05-24 16:14:44 +02:00
9766da7cfd feat: icon in assets 2025-05-24 16:14:37 +02:00
a004a82272 feat: dark and light overlays in public 2025-05-24 16:14:25 +02:00
64ca9b922e feat: allow all hosts on dev 2025-05-24 16:14:15 +02:00
38a2ce1ce9 feat: start agreement page 2025-05-24 14:31:26 +02:00
accde2662f feat: account list 2025-05-24 14:31:16 +02:00
92fda8cb24 feat: separate interface for decoded accounts 2025-05-24 14:24:50 +02:00
eee3839dea feat: remove unnecessary comment 2025-05-24 14:24:39 +02:00
b941561ccf feat: render accounts saved in db 2025-05-24 14:24:29 +02:00
0bda5495c4 feat: connected field set 2025-05-24 14:24:22 +02:00
b5f5346536 feat: connected field in context 2025-05-24 14:23:56 +02:00
68e2ece877 feat: load all accounts req 2025-05-24 12:16:11 +02:00
04bd27607f feat: load all saved accounts 2025-05-24 12:15:51 +02:00
eb42b61b2c feat: store logged account 2025-05-24 12:05:57 +02:00
06e0e90677 feat: return id 2025-05-24 12:05:49 +02:00
eaf3596580 feat: database context 2025-05-24 11:19:16 +02:00
b8f3fa0a32 feat: unique device id 2025-05-21 22:10:46 +02:00
d4adc1b538 feat: refactoring 2025-05-21 21:59:50 +02:00
edfa3e63b9 feat: generate refresh token 2025-05-21 21:59:31 +02:00
7c58473ff1 feat: define ApiClaims 2025-05-21 21:59:15 +02:00
9473c83679 feat: hash utility 2025-05-21 21:17:55 +02:00
0b8c03e8c5 feat: hash user's password on register 2025-05-21 21:17:50 +02:00
55eb4c9862 feat: hash admin's password before creation 2025-05-21 21:17:41 +02:00
de28470432 feat: check passwords on login 2025-05-21 21:17:30 +02:00
8ccf9f281c feat: remove static folder 2025-05-21 19:28:10 +02:00
a9df6fa559 feat: remove unused rotues 2025-05-21 19:28:04 +02:00
eb9c2b1da1 feat: use register page 2025-05-21 19:27:17 +02:00
af8b347173 feat: finished login page 2025-05-21 19:27:08 +02:00
ba89880f8a feat: finished register page 2025-05-21 19:27:03 +02:00
07e1cbc66f feat: spread input props 2025-05-21 19:26:54 +02:00
aee3306c2b feat: proper button with loading 2025-05-21 19:26:41 +02:00
d2cb426170 feat: font 2025-05-21 19:26:31 +02:00
e9e1414c90 feat: dev proxy 2025-05-21 19:26:26 +02:00
4a71f6c5ee feat: react-hook-form pkg 2025-05-21 19:26:21 +02:00
a8e75d75f0 feat: accept host variable 2025-05-21 19:26:14 +02:00
afc9208269 feat: basic setup for web with tailwind and routing 2025-05-20 19:39:55 +02:00
ac07b5d723 feat: vite base 2025-05-20 18:46:01 +02:00
9267cf2618 feat: remove frontend bindings 2025-05-20 18:45:54 +02:00
55ccd8ea8e feat: serve frontend 2025-05-20 18:45:45 +02:00
fdf99d82e5 feat: out dir correction 2025-05-20 18:38:49 +02:00
d9c3223228 feat: react router + no swc 2025-05-20 18:38:01 +02:00
3f369de3fa feat: created react project sub directory 2025-05-20 18:30:15 +02:00
63 changed files with 6589 additions and 551 deletions

View File

@ -1,5 +1,7 @@
PORT=3001
HOST="127.0.0.1"
DATABASE_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable"
ADMIN_NAME="admin"
@ -9,6 +11,10 @@ ADMIN_PASSWORD="secret"
JWT_PRIVATE_KEY="ecdsa"
JWT_PUBLIC_KEY="ecdsa"
MINIO_ENDPOINT="localhost:9000"
MINIO_ACCESS_KEY=""
MINIO_SECRET_KEY=""
GOOSE_DRIVER="postgres"
GOOSE_DBSTRING=$DATABASE_URL
GOOSE_MIGRATION_DIR="./migrations"

2
.gitignore vendored
View File

@ -28,3 +28,5 @@ go.work.sum
# key files
*.pem
dist/

View File

@ -5,11 +5,11 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"gitea.local/admin/hspguard/internal/auth"
imiddleware "gitea.local/admin/hspguard/internal/middleware"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage"
"gitea.local/admin/hspguard/internal/user"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@ -18,12 +18,14 @@ import (
type APIServer struct {
addr string
repo *repository.Queries
storage *storage.FileStorage
}
func NewAPIServer(addr string, db *repository.Queries) *APIServer {
func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage) *APIServer {
return &APIServer{
addr: addr,
repo: db,
storage: minio,
}
}
@ -31,18 +33,27 @@ func (s *APIServer) Run() error {
router := chi.NewRouter()
router.Use(middleware.Logger)
workDir, _ := os.Getwd()
staticDir := http.Dir(filepath.Join(workDir, "static"))
FileServer(router, "/static", staticDir)
// workDir, _ := os.Getwd()
// staticDir := http.Dir(filepath.Join(workDir, "static"))
// FileServer(router, "/static", staticDir)
router.Route("/api/v1", func(r chi.Router) {
r.Use(imiddleware.WithSkipper(imiddleware.AuthMiddleware, "/api/v1/login", "/api/v1/register"))
userHandler := user.NewUserHandler(s.repo)
userHandler.RegisterRoutes(router, r)
userHandler := user.NewUserHandler(s.repo, s.storage)
userHandler.RegisterRoutes(r)
authHandler := auth.NewAuthHandler(s.repo)
authHandler.RegisterRoutes(router, r)
authHandler.RegisterRoutes(r)
})
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
path := "./dist" + r.URL.Path
if _, err := os.Stat(path); os.IsNotExist(err) {
http.ServeFile(w, r, "./dist/index.html")
return
}
http.FileServer(http.Dir("./dist")).ServeHTTP(w, r)
})
// Handle unknown routes

View File

@ -8,6 +8,7 @@ import (
"gitea.local/admin/hspguard/cmd/hspguard/api"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage"
"gitea.local/admin/hspguard/internal/user"
"github.com/jackc/pgx/v5"
"github.com/joho/godotenv"
@ -30,14 +31,21 @@ func main() {
repo := repository.New(conn)
fStorage := storage.New()
user.EnsureAdminUser(ctx, repo)
host := os.Getenv("HOST")
if host == "" {
host = "127.0.0.1"
}
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
server := api.NewAPIServer(fmt.Sprintf(":%s", port), repo)
server := api.NewAPIServer(fmt.Sprintf("%s:%s", host, port), repo, fStorage)
if err := server.Run(); err != nil {
log.Fatalln("ERR: Failed to start server:", err)
}

13
go.mod
View File

@ -11,8 +11,21 @@ require (
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/minio/crc64nvme v1.0.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.92 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
)

28
go.sum
View File

@ -1,8 +1,14 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -17,17 +23,39 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY=
github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -25,7 +25,7 @@ func NewAuthHandler(repo *repository.Queries) *AuthHandler {
}
}
func (h *AuthHandler) RegisterRoutes(router chi.Router, api chi.Router) {
func (h *AuthHandler) RegisterRoutes(api chi.Router) {
api.Get("/profile", h.getProfile)
api.Post("/login", h.login)
}
@ -49,6 +49,7 @@ func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
"phoneNumber": user.PhoneNumber,
"isAdmin": user.IsAdmin,
"last_login": user.LastLogin,
"profile_picture": user.ProfilePicture.String,
"updated_at": user.UpdatedAt,
"created_at": user.CreatedAt,
}); err != nil {
@ -81,32 +82,65 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
return
}
claims := types.UserClaims{
UserID: user.ID.String(),
if !util.VerifyPassword(params.Password, user.PasswordHash) {
web.Error(w, "username or/and password are incorrect", http.StatusBadRequest)
return
}
accessClaims := types.UserClaims{
UserEmail: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "hspguard",
Subject: user.Email,
Subject: user.ID.String(),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
},
}
token, err := SignJwtToken(claims)
accessToken, err := SignJwtToken(accessClaims)
if err != nil {
web.Error(w, fmt.Sprintf("failed to generate access token: %v", err), http.StatusBadRequest)
return
}
refreshClaims := types.UserClaims{
UserEmail: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "hspguard",
Subject: user.ID.String(),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
},
}
refreshToken, err := SignJwtToken(refreshClaims)
if err != nil {
web.Error(w, fmt.Sprintf("failed to generate refresh token: %v", err), http.StatusBadRequest)
return
}
encoder := json.NewEncoder(w)
type Response struct {
Token string `json:"token"`
AccessToken string `json:"access"`
RefreshToken string `json:"refresh"`
// fields required for UI in account selector, e.g. email, full name and avatar
FullName string `json:"full_name"`
Email string `json:"email"`
Id string `json:"id"`
ProfilePicture string `json:"profile_picture"`
// Avatar
}
if err := encoder.Encode(Response{
Token: token,
AccessToken: accessToken,
RefreshToken: refreshToken,
FullName: user.FullName,
Email: user.Email,
Id: user.ID.String(),
ProfilePicture: user.ProfilePicture.String,
// Avatar
}); err != nil {
web.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}

View File

@ -32,7 +32,7 @@ func AuthMiddleware(next http.Handler) http.Handler {
return
}
ctx := context.WithValue(r.Context(), types.UserIdKey, userClaims.UserID)
ctx := context.WithValue(r.Context(), types.UserIdKey, userClaims.Subject)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@ -50,4 +50,3 @@ func WithSkipper(mw func(http.Handler) http.Handler, excludedPaths ...string) fu
})
}
}

View File

@ -19,4 +19,5 @@ type User struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastLogin pgtype.Timestamptz `json:"last_login"`
PhoneNumber pgtype.Text `json:"phone_number"`
ProfilePicture pgtype.Text `json:"profile_picture"`
}

View File

@ -9,10 +9,11 @@ import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const findAllUsers = `-- name: FindAllUsers :many
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number FROM users
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users
`
func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
@ -34,6 +35,7 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
&i.UpdatedAt,
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
); err != nil {
return nil, err
}
@ -46,7 +48,7 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
}
const findUserEmail = `-- name: FindUserEmail :one
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number FROM users WHERE email = $1 LIMIT 1
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users WHERE email = $1 LIMIT 1
`
func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error) {
@ -62,12 +64,13 @@ func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error)
&i.UpdatedAt,
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
)
return i, err
}
const findUserId = `-- name: FindUserId :one
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number FROM users WHERE id = $1 LIMIT 1
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number, profile_picture FROM users WHERE id = $1 LIMIT 1
`
func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
@ -83,6 +86,7 @@ func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
&i.UpdatedAt,
&i.LastLogin,
&i.PhoneNumber,
&i.ProfilePicture,
)
return i, err
}
@ -114,3 +118,19 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (uuid.UU
err := row.Scan(&id)
return id, err
}
const updateProfilePicture = `-- name: UpdateProfilePicture :exec
UPDATE users
SET profile_picture = $1
WHERE id = $2
`
type UpdateProfilePictureParams struct {
ProfilePicture pgtype.Text `json:"profile_picture"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) UpdateProfilePicture(ctx context.Context, arg UpdateProfilePictureParams) error {
_, err := q.db.Exec(ctx, updateProfilePicture, arg.ProfilePicture, arg.ID)
return err
}

57
internal/storage/mod.go Normal file
View File

@ -0,0 +1,57 @@
package storage
import (
"context"
"io"
"log"
"net/url"
"os"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type FileStorage struct {
client *minio.Client
}
func New() *FileStorage {
endpoint := os.Getenv("MINIO_ENDPOINT")
if endpoint == "" {
log.Fatalln("MINIO_ENDPOINT env var is required")
return nil
}
accessKey := os.Getenv("MINIO_ACCESS_KEY")
if accessKey == "" {
log.Fatalln("MINIO_ACCESS_KEY env var is required")
return nil
}
secretKey := os.Getenv("MINIO_SECRET_KEY")
if secretKey == "" {
log.Fatalln("MINIO_SECRET_KEY env var is required")
return nil
}
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: false,
})
if err != nil {
log.Fatalln("Failed to create minio client:", err)
return nil
}
return &FileStorage{
client,
}
}
func (fs *FileStorage) PutObject(ctx context.Context, bucketName string, objectName string, reader io.Reader, size int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) {
return fs.client.PutObject(ctx, bucketName, objectName, reader, size, opts)
}
func (fs *FileStorage) EndpointURL() *url.URL {
return fs.client.EndpointURL()
}

View File

@ -3,8 +3,17 @@ package types
import "github.com/golang-jwt/jwt/v5"
type UserClaims struct {
UserID string `json:"user_id"`
// Role
UserEmail string `json:"user_email"`
jwt.RegisteredClaims
}
type ApiClaims struct {
UserID string `json:"user_id"`
// Permissions are guard's defined permissions
// Examples:
// 1. User MetaData (specifically some fields like email, profile picture and name)
// 2. Actions on User, e.g. home permissions fetching, notifications emitting
Permissions []string `json:"permissions"`
// Subject is an API ID defined in guard's DB after registration
jwt.RegisteredClaims
}

View File

@ -2,10 +2,12 @@ package user
import (
"context"
"fmt"
"log"
"os"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/util"
"github.com/google/uuid"
)
@ -35,10 +37,16 @@ func EnsureAdminUser(ctx context.Context, repo *repository.Queries) {
}
func createAdmin(ctx context.Context, name, email, password string, repo *repository.Queries) (uuid.UUID, error) {
hash, err := util.HashPassword(password)
if err != nil {
var id uuid.UUID
return id, fmt.Errorf("failed to hash the admin password")
}
return repo.InsertUser(ctx, repository.InsertUserParams{
FullName: name,
Email: email,
PasswordHash: password,
PasswordHash: hash,
IsAdmin: true,
})
}

View File

@ -3,43 +3,37 @@ package user
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
"gitea.local/admin/hspguard/internal/repository"
"gitea.local/admin/hspguard/internal/storage"
"gitea.local/admin/hspguard/internal/util"
"gitea.local/admin/hspguard/internal/web"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/minio/minio-go/v7"
)
type UserHandler struct {
repo *repository.Queries
minio *storage.FileStorage
}
func NewUserHandler(repo *repository.Queries) *UserHandler {
func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage) *UserHandler {
return &UserHandler{
repo: repo,
minio: minio,
}
}
func (h *UserHandler) RegisterRoutes(router chi.Router, api chi.Router) {
router.Get("/login", h.loginPage)
router.Get("/register", h.registerPage)
func (h *UserHandler) RegisterRoutes(api chi.Router) {
api.Post("/register", h.register)
}
func (h *UserHandler) loginPage(w http.ResponseWriter, r *http.Request) {
data := map[string]any{
"Title": "Login",
}
web.RenderTemplate(w, "login", data)
}
func (h *UserHandler) registerPage(w http.ResponseWriter, r *http.Request) {
data := map[string]any{
"Title": "Register",
}
web.RenderTemplate(w, "register", data)
api.Put("/avatar", h.uploadAvatar)
}
type RegisterParams struct {
@ -69,10 +63,16 @@ func (h *UserHandler) register(w http.ResponseWriter, r *http.Request) {
return
}
hash, err := util.HashPassword(params.Password)
if err != nil {
web.Error(w, "failed to create user account", http.StatusInternalServerError)
return
}
id, err := h.repo.InsertUser(context.Background(), repository.InsertUserParams{
FullName: params.FullName,
Email: params.Email,
PasswordHash: params.Password,
PasswordHash: hash,
IsAdmin: false,
})
if err != nil {
@ -93,3 +93,69 @@ func (h *UserHandler) register(w http.ResponseWriter, r *http.Request) {
}
}
func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
userId, ok := util.GetRequestUserId(r.Context())
if !ok {
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
return
}
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
if err != nil {
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
return
}
err = r.ParseMultipartForm(10 << 20)
if err != nil {
web.Error(w, "invalid form data", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("image")
if err != nil {
web.Error(w, "missing image file", http.StatusBadRequest)
return
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" {
web.Error(w, "unsupported image format", http.StatusBadRequest)
return
}
objectName := fmt.Sprintf("profile_%s_%d%s", userId, time.Now().UnixNano(), ext)
uploadInfo, err := h.minio.PutObject(r.Context(), "guard-storage", objectName, file, header.Size, minio.PutObjectOptions{
ContentType: header.Header.Get("Content-Type"),
})
if err != nil {
web.Error(w, "failed to upload image", http.StatusInternalServerError)
return
}
imageURL := fmt.Sprintf("http://%s/%s/%s", h.minio.EndpointURL().Host, "guard-storage", uploadInfo.Key)
if err := h.repo.UpdateProfilePicture(r.Context(), repository.UpdateProfilePictureParams{
ProfilePicture: pgtype.Text{
String: imageURL,
Valid: true,
},
ID: user.ID,
}); err != nil {
web.Error(w, "failed to update profile picture", http.StatusInternalServerError)
return
}
type Response struct {
URL string `json:"url"`
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
if err := encoder.Encode(Response{URL: imageURL}); err != nil {
web.Error(w, "failed to write response", http.StatusInternalServerError)
}
}

15
internal/util/hash.go Normal file
View File

@ -0,0 +1,15 @@
package util
import "golang.org/x/crypto/bcrypt"
// HashPassword generates a bcrypt hash for the given password.
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}
// VerifyPassword verifies if the given password matches the stored hash.
func VerifyPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View File

@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE users
ADD profile_picture TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE users
DROP COLUMN profile_picture;
-- +goose StatementEnd

View File

@ -14,3 +14,8 @@ SELECT * FROM users WHERE email = $1 LIMIT 1;
-- name: FindUserId :one
SELECT * FROM users WHERE id = $1 LIMIT 1;
-- name: UpdateProfilePicture :exec
UPDATE users
SET profile_picture = $1
WHERE id = $2;

View File

@ -1,14 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: Inter, Arial, sans-serif;
background-color: #f2f2f2;
}

View File

@ -1,20 +0,0 @@
.footer {
position: absolute;
bottom: 1rem;
text-align: center;
color: #0005;
font-size: 0.9rem;
user-select: none;
pointer-events: none;
}
@media only screen and (max-width: 450px) {
.footer {
position: absolute;
bottom: 1rem;
left: 50%;
translate: -50% 0;
z-index: 999;
}
}

View File

@ -1,106 +0,0 @@
@import "./ui.css";
.container {
display: flex;
flex-direction: column;
min-height: 100vh;
justify-content: center;
align-items: center;
padding: 2rem;
position: relative;
background: #ffffff;
background: linear-gradient(0deg,rgba(255, 255, 255, 1) 90%, rgba(30, 144, 255, 1) 100%);
background-image: url(/static/overlay.jpg);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
.modal-box {
background-color: white;
padding: 2rem;
border-radius: 8px;
border: 1px solid #ddd;
max-width: 400px;
width: 100%;
z-index: 1;
}
.modal-title {
font-size: 28px;
font-weight: 600;
margin-bottom: 5px;
}
.modal-description {
font-size: 16px;
font-weight: 400;
margin-bottom: 15px;
}
.form {
margin-top: 25px;
}
.validation_box {
display: none;
width: 100%;
padding: 5px;
background: #D0000011;
border: 1px solid #D00000aa;
border-radius: 5px;
margin: 10px 0;
}
.validation_box__msg {
color: #111;
font-size: 0.85rem;
}
.success_box {
display: none;
width: 100%;
padding: 5px;
background: #6AB54711;
border: 1px solid #6AB547aa;
border-radius: 5px;
margin: 10px 0;
}
.success_box__msg {
color: #111;
font-size: 0.85rem;
}
.login_link {
font-size: 0.8rem;
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
margin: 20px 0 15px;
}
.login_link > a {
padding-left: 4px;
}
@media only screen and (max-width: 450px) {
.container {
padding: 0;
margin: 0;
width: 100vw;
height: 100vh;
min-width: 100vw;
}
.modal-box {
flex: 1;
width: 100vw;
min-width: 100vw;
height: 100vh;
padding: 20px;
padding-top: 50px;
}
}

View File

@ -1,106 +0,0 @@
@import "./ui.css";
.container {
display: flex;
flex-direction: column;
min-height: 100vh;
justify-content: center;
align-items: center;
padding: 2rem;
position: relative;
background: #ffffff;
background: linear-gradient(0deg,rgba(255, 255, 255, 1) 90%, rgba(30, 144, 255, 1) 100%);
background-image: url(/static/overlay.jpg);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
.modal-box {
background-color: white;
padding: 2rem;
border-radius: 8px;
border: 1px solid #ddd;
max-width: 400px;
width: 100%;
z-index: 1;
}
.modal-title {
font-size: 28px;
font-weight: 600;
margin-bottom: 5px;
}
.modal-description {
font-size: 16px;
font-weight: 400;
margin-bottom: 15px;
}
.form {
margin-top: 25px;
}
.validation_box {
display: none;
width: 100%;
padding: 5px;
background: #D0000011;
border: 1px solid #D00000aa;
border-radius: 5px;
margin: 10px 0;
}
.validation_box__msg {
color: #111;
font-size: 0.85rem;
}
.success_box {
display: none;
width: 100%;
padding: 5px;
background: #6AB54711;
border: 1px solid #6AB547aa;
border-radius: 5px;
margin: 10px 0;
}
.success_box__msg {
color: #111;
font-size: 0.85rem;
}
.login_link {
font-size: 0.8rem;
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
margin: 20px 0 15px;
}
.login_link > a {
padding-left: 4px;
}
@media only screen and (max-width: 450px) {
.container {
padding: 0;
margin: 0;
width: 100vw;
height: 100vh;
min-width: 100vw;
}
.modal-box {
flex: 1;
width: 100vw;
min-width: 100vw;
height: 100vh;
padding: 20px;
padding-top: 50px;
}
}

View File

@ -1,73 +0,0 @@
.input-group {
display: flex;
flex-direction: row;
align-items: stretch;
border: 1px solid #ccc;
border-radius: 5px;
margin-bottom: 10px;
}
.input-icon {
padding: 10px;
}
.input-field {
flex: 1;
border: none;
background: transparent;
}
.input-field:focus {
border: none;
outline: none;
}
.input-icon {
font-size: 1rem;
color: #777;
}
.input-icon > img {
width: 1rem;
height: 1rem;
}
.checkbox-group {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
font-size: 0.85rem;
color: #0008;
margin: 15px 0;
}
.button {
display: block;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
border: none;
outline: none;
width: 100%;
font-size: 0.9rem;
font-weight: 500;
}
.button.primary {
background-color: rgb(13, 112, 212);
color: #fefefe;
}
.icon-wrapper {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 20px;
}
.icon {
width: 64px;
height: 64px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 B

View File

@ -1,71 +0,0 @@
const $form = document.querySelector(".form")
const $validationBox = document.querySelector("#validationBox")
const $validationMsg = document.querySelector("#validationMsg")
const $successBox = document.querySelector("#successBox")
const $successMsg = document.querySelector("#successMsg")
const showError = (message) => {
$validationBox.style.display = "block"
$validationMsg.innerText = message
}
const clearError = () => {
$validationMsg.innerText = ""
$validationBox.style.display = "none"
}
const showSuccess = (message) => {
$successBox.style.display = "block"
$successMsg.innerText = message
}
const clearSuccess = () => {
$successMsg.innerText = ""
$successBox.style.display = "none"
}
document.addEventListener("DOMContentLoaded", (e) => {
$form.addEventListener("submit", async (e) => {
e.preventDefault()
clearError()
clearSuccess()
const formData = new FormData($form)
const data = Object.fromEntries(formData)
if ([{key: "email", name: "Email"}, {key: "password", name: "Password"}].some(({key, name}) => {
if (!data[key]) {
showError(`${name} is required`)
return true
}
return false;
})) {
return
}
try {
const response = await fetch("/api/v1/login", {
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
})
if (response.status != 200) {
const json = await response.json()
const text = json.error || "Unexpected error happened"
showError(`Failed to log into account. ${text[0].toUpperCase() + text.slice(1)}`)
} else {
showSuccess("Successfully logged into your account")
$form.reset()
}
} catch(err) {
showError("Failed to log into account. Unexpected error happened")
}
})
})

View File

@ -1,84 +0,0 @@
const $form = document.querySelector(".form")
const $validationBox = document.querySelector("#validationBox")
const $validationMsg = document.querySelector("#validationMsg")
const $successBox = document.querySelector("#successBox")
const $successMsg = document.querySelector("#successMsg")
const showError = (message) => {
$validationBox.style.display = "block"
$validationMsg.innerText = message
}
const clearError = () => {
$validationMsg.innerText = ""
$validationBox.style.display = "none"
}
const showSuccess = (message) => {
$successBox.style.display = "block"
$successMsg.innerText = message
}
const clearSuccess = () => {
$successMsg.innerText = ""
$successBox.style.display = "none"
}
document.addEventListener("DOMContentLoaded", (e) => {
$form.addEventListener("submit", async (e) => {
e.preventDefault()
clearError()
clearSuccess()
const formData = new FormData($form)
const data = Object.fromEntries(formData)
if ([{key: "full_name", name: "Full Name"}, {key: "email", name: "Email"}, {key: "password", name: "Password"}, {key: "repeat_password", name: "Password"}].some(({key, name}) => {
if (!data[key]) {
showError(`${name} is required`)
return true
}
return false;
})) {
return
}
if (data.repeat_password != data.password) {
console.log("passwords do not match")
showError("Password does not match")
return
}
if (data.terms_and_conditions !== "on") {
console.log("terms and conditions are not accepted")
showError("Terms and Conditions are not accepted. Cannot proceed without agreement")
return
}
try {
const response = await fetch("/api/v1/register", {
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
})
if (response.status != 200) {
const json = await response.json()
const text = json.error || "Unexpected error happened"
showError(`Failed to create an account. ${text[0].toUpperCase() + text.slice(1)}`)
} else {
showSuccess("Account has been created. You can now log into your new account")
$form.reset()
}
} catch(err) {
showError("Failed to create account. Unexpected error happened")
}
})
})

24
web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
web/README.md Normal file
View File

@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

28
web/eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Home Guard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4759
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
web/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build --watch",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.7",
"lucide-react": "^0.511.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
"react-icons": "^5.5.0",
"react-router": "^7.6.0",
"tailwindcss": "^4.1.7"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/node": "^22.15.19",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"path": "^0.12.7",
"sass": "^1.89.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

BIN
web/public/dark-overlay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

View File

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 279 KiB

54
web/src/App.tsx Normal file
View File

@ -0,0 +1,54 @@
import { useEffect, 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/db";
import { openDB } from "idb";
import AgreementPage from "./pages/Agreement";
const router = createBrowserRouter([
{
path: "/",
element: <IndexPage />,
},
{
path: "/agreement",
element: <AgreementPage />,
},
{
path: "/login",
element: <LoginPage />,
},
{
path: "/register",
element: <RegisterPage />,
},
]);
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} />;
};
export default App;

20
web/src/api/index.ts Normal file
View File

@ -0,0 +1,20 @@
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);
}
}
return new Error("Unexpected error happened");
};

35
web/src/api/login.ts Normal file
View File

@ -0,0 +1,35 @@
import { handleApiError } from ".";
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
id: string;
email: string;
full_name: string;
profile_picture: string;
access: string;
refresh: string;
}
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",
},
});
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
const data: LoginResponse = await response.json();
return data;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

BIN
web/src/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
web/src/assets/overlay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@ -0,0 +1,75 @@
import {
useMemo,
type ButtonHTMLAttributes,
type FC,
type ReactNode,
} from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
className?: string;
loading?: boolean;
variant?: "contained" | "outlined" | "text";
}
export const Button: FC<ButtonProps> = ({
children,
className,
loading,
variant = "contained",
...props
}) => {
const appearance = useMemo(() => {
switch (variant) {
case "contained":
return "bg-blue-600 text-white hover:bg-blue-700";
case "outlined":
return "border border-blue-600 text-blue-600 hover:text-blue-700 font-medium";
case "text":
return "text-blue-600 hover:text-blue-700 font-medium";
}
return "";
}, [variant]);
return (
<button
className={`cursor-pointer py-2 px-4 rounded-md transition-colors ${appearance} ${
className || ""
}${
loading
? " flex items-center justify-center gap-3 cursor-not-allowed"
: ""
}`}
{...props}
disabled={props.disabled || loading}
>
{loading ? (
<>
<div role="status">
<svg
aria-hidden="true"
className="w-5 h-5 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>
<span className="text-gray-300">{children}</span>
</>
) : (
children
)}
</button>
);
};

View File

@ -0,0 +1,18 @@
import type { FC, ReactNode } from "react";
interface ComponentProps {
children: ReactNode;
className?: string;
}
export const Card: FC<ComponentProps> = ({ children, className }) => {
return (
<div className={`bg-white sm:rounded-lg shadow-md ${className || ""}`}>
{children}
</div>
);
};
export function CardContent({ children, className }: ComponentProps) {
return <div className={`p-4 ${className || ""}`}>{children}</div>;
}

View File

@ -0,0 +1,14 @@
import type { FC, InputHTMLAttributes } from "react";
type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input: FC<InputProps> = ({ className, ...props }) => {
return (
<input
{...props}
className={`w-full border border-gray-300 dark:border-gray-600 dark:placeholder-gray-600 dark:text-gray-100 rounded-md px-3 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
className || ""
}`}
/>
);
};

16
web/src/context/db/db.ts Normal file
View File

@ -0,0 +1,16 @@
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

@ -0,0 +1,25 @@
import { useCallback, useState, type FC, type ReactNode } from "react";
import { DbContext } from "./db";
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

@ -0,0 +1,89 @@
import { useDbContext } from "@/context/db/db";
import { type LocalAccount, useAccountRepo } from "@/repository/account";
import { CirclePlus, User } from "lucide-react";
import { useEffect, useState, type FC } from "react";
const AccountList: FC = () => {
const [accounts, setAccounts] = useState<LocalAccount[]>([]);
const repo = useAccountRepo();
const { connected } = useDbContext();
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) => (
<div
key={account.accountId}
className="flex flex-row items-center p-4 border-gray-200 dark:border-gray-700/65 border-b border-r-0 border-l-0 select-none cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/10 transition-colors mb-0"
>
<div>
<div className="rounded-full w-10 h-10 overflow-hidden bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-200 mr-3 ring ring-gray-400 dark:ring dark:ring-gray-500">
{account.profilePicture ? (
<img
src={account.profilePicture}
className="w-full h-full flex-1"
alt="profile"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<User />
</div>
)}
</div>
</div>
<div className="flex flex-col">
<p className="text-base text-gray-900 dark:text-gray-200">
{account.label}
</p>
<p className="text-gray-500 dark:text-gray-600 text-sm">
{account.email}
</p>
</div>
</div>
))}
<div className="flex flex-row items-center p-4 border-gray-200 dark:border-gray-700/65 border-b border-r-0 border-l-0 select-none cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/10 transition-colors mb-0">
<div>
<div className="rounded-full p-2 text-gray-900 dark:text-gray-200 mr-3">
<CirclePlus />
</div>
</div>
<p className="text-base text-gray-900 dark:text-gray-200">
Add new account
</p>
</div>
</>
);
};
export default AccountList;

7
web/src/index.css Normal file
View File

@ -0,0 +1,7 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
@import "tailwindcss";
html,
body {
font-family: "Inter", Arial, Helvetica, sans-serif;
}

13
web/src/main.tsx Normal file
View File

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

View File

@ -0,0 +1,117 @@
import { type FC } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { ArrowLeftRight } from "lucide-react";
import { Button } from "@/components/ui/button";
const AgreementPage: 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)]`}
>
<div className="relative z-10 flex items-center justify-center min-h-screen">
<Card className="sm:w-[425px] sm:min-w-[425px] 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-10 sm:pt-0">
<div className="flex flex-col items-center flex-5/6">
{/* <img
src="/icon.png"
alt="icon"
className="w-16 h-16 mb-4 mt-2 sm:mt-6"
/> */}
<div className="flex flex-row items-center gap-4 mt-2 mb-4 sm:mt-6">
<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"
alt="profile"
/>
</div>
<div className="text-gray-400 dark:text-gray-600">
{/* <Activity /> */}
<ArrowLeftRight />
</div>
<div className="p-2 rounded-full bg-gray-900 ring ring-gray-400 dark:ring dark:ring-gray-500">
{/* <img
src="https://lucide.dev/logo.dark.svg"
className="w-8 h-8"
/> */}
<img
src="https://developer.mozilla.org/favicon.svg"
className="w-8 h-8"
/>
</div>
</div>
<div className="px-4 sm:mt-4 mt-8">
<h2 className="text-2xl font-medium text-gray-800 dark:text-gray-300 text-center w-full mb-2">
<a href="#" className="text-blue-500">
MDN Lab Services
</a>{" "}
wants to access your Home Account
</h2>
<div className="flex flex-row items-center justify-center mb-6 gap-2">
<div className="w-10 h-10 overflow-hidden bg-gray-100 rounded-full ring ring-gray-400 dark:ring dark:ring-gray-500">
<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"
alt="profile"
/>
</div>
<p className="text-sm text-gray-500 dark:text-gray-500">
qwer.009771@gmail.com
</p>
</div>
<h4 className="text-base mb-3 text-gray-400 dark:text-gray-500 text-left">
This will allow{" "}
<a href="#" className="text-blue-500">
MDN Lab Services
</a>{" "}
to:
</h4>
</div>
</div>
{/* <LogIn className="w-8 h-8 text-gray-700 mb-4" /> */}
<CardContent className="w-full space-y-4 text-sm">
<div className="flex flex-col gap-3 mb-8">
<div className="flex flex-row items-center justify-between text-gray-600 dark:text-gray-400">
<div className="flex flex-row items-center gap-4">
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<p>View your full name, email and profile image</p>
</div>
</div>
<div className="flex flex-row items-center justify-between text-gray-600 dark:text-gray-400">
<div className="flex flex-row items-center gap-4">
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<p>View your permission from "MDN" group</p>
</div>
</div>
</div>
<div className="mb-10">
<p className="font-medium mb-4 dark:text-gray-200">
Are you sure you want to trust MDN Lab Services?
</p>
<p className="text-sm text-gray-400 dark:text-gray-500">
Please do not share any sensitive, personal, or unnecessary
information unless you trust this service. Protect your
privacy and only provide information that is required for the
intended purpose.
</p>
</div>
<div className="flex flex-row justify-between items-center">
<Button variant="text">Cancel</Button>
<Button>Allow</Button>
</div>
</CardContent>
</div>
</Card>
</div>
</div>
);
};
export default AgreementPage;

View File

@ -0,0 +1,48 @@
import { type FC } from "react";
// import overlay from "@/assets/overlay.jpg";
// import darkOverlay from "@/assets/dark-overlay.jpg";
import { Card, CardContent } from "@/components/ui/card";
import AccountList from "@/feature/AccountList";
const IndexPage: FC = () => {
// console.log(overlay);
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="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>
{/* <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>
</div>
);
};
export default IndexPage;

View File

@ -0,0 +1,177 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Card, CardContent } from "@/components/ui/card";
import { Mail, Lock } from "lucide-react";
import { Link } from "react-router-dom";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useForm, type SubmitHandler } from "react-hook-form";
import { useCallback, useState } from "react";
import { loginApi } from "@/api/login";
import { useAccountRepo } from "@/repository/account";
interface LoginForm {
email: string;
password: string;
}
export default function LoginPage() {
const {
register,
reset,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>();
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const repo = useAccountRepo();
const onSubmit: SubmitHandler<LoginForm> = useCallback(
async (data) => {
console.log({ data });
setLoading(true);
setError("");
setSuccess("");
try {
const response = await loginApi({
email: data.email,
password: data.password,
});
console.log(response);
await repo.save({
accountId: response.id,
label: response.full_name,
email: response.email,
profilePicture: response.profile_picture,
access: response.access,
refresh: response.refresh,
});
setSuccess("You have successfully logged in");
reset();
} catch (err: any) {
console.log(err);
setError(
"Failed to create account. " +
(err.message ?? "Unexpected error happened")
);
} finally {
setLoading(false);
}
},
[repo, reset]
);
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="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>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,257 @@
import { Card, CardContent } from "@/components/ui/card";
import { Mail, Lock, User, Phone } from "lucide-react";
import { Link } from "react-router-dom";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useCallback, useState } from "react";
import { useForm, type SubmitHandler } from "react-hook-form";
interface RegisterForm {
fullName: string;
email: string;
phoneNumber: string;
password: string;
repeatPassword: string;
}
export default function RegisterPage() {
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<RegisterForm>();
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const onSubmit: SubmitHandler<RegisterForm> = useCallback(
async (data) => {
console.log({ data });
setLoading(true);
setError("");
setSuccess("");
try {
const response = await fetch("/api/v1/register", {
method: "POST",
body: JSON.stringify({
full_name: data.fullName,
email: data.email,
...(data.phoneNumber ? { phone_number: data.phoneNumber } : {}),
password: data.password,
}),
headers: {
"Content-Type": "application/json",
},
});
if (response.status != 200) {
const json = await response.json();
const text = json.error || "Unexpected error happened";
setError(
`Failed to create an account. ${
text[0].toUpperCase() + text.slice(1)
}`
);
} else {
setSuccess(
"Account has been created. You can now log into your new account"
);
reset();
}
} catch (err) {
console.log(err);
setError("Failed to create account. Unexpected error happened");
} finally {
setLoading(false);
}
},
[reset]
);
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="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>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,140 @@
import { useDbContext } from "@/context/db/db";
import { deriveDeviceKey, getDeviceId } from "@/util/deviceId";
import { useCallback } from "react";
export interface RawLocalAccount {
accountId: string;
label: string;
email: string;
profilePicture: string;
access: { data: number[]; iv: number[] };
refresh: { data: number[]; iv: number[] };
updatedAt: string;
}
export interface LocalAccount {
accountId: string;
label: string;
email: string;
profilePicture: string;
access: string;
refresh: string;
updatedAt: string;
}
export interface CreateAccountRequest {
accountId: string;
label: string;
email: string;
profilePicture: string;
access: string;
refresh: string;
}
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 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(),
});
},
[db, encryptToken]
);
const loadAll = useCallback(async () => {
if (!db) throw new Error("No database connection");
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;
}, [db, decryptToken]);
return { save, loadAll };
};

30
web/src/util/account.ts Normal file
View File

@ -0,0 +1,30 @@
import { deriveDeviceKey, getDeviceId } from "./deviceId";
import { encryptToken } from "./token";
const storeTokensForAccount = async (
accountId: string,
accessToken: string,
refreshToken: string
) => {
const deviceKeyId = await getDeviceId();
const key = await deriveDeviceKey(deviceKeyId);
const access = await encryptToken(accessToken, key);
const refresh = await encryptToken(refreshToken, key);
const entry = {
accountId,
label: `Account for ${accountId}`,
access: {
data: Array.from(new Uint8Array(access.cipherText)),
iv: Array.from(access.iv),
},
refresh: {
data: Array.from(new Uint8Array(refresh.cipherText)),
iv: Array.from(refresh.iv),
},
updatedAt: new Date().toISOString(),
};
// Save this `entry` in IndexedDB (or use a localforage wrapper)
};

54
web/src/util/deviceId.ts Normal file
View File

@ -0,0 +1,54 @@
export const getDeviceId = async () => {
const existing = localStorage.getItem("guard-device-id");
if (existing) return existing;
const fingerprintParts = [
navigator.userAgent, // Browser and OS
screen.width + "x" + screen.height, // Screen resolution
screen.colorDepth, // Color depth
Intl.DateTimeFormat().resolvedOptions().timeZone, // Time zone
navigator.platform, // OS platform
navigator.hardwareConcurrency, // Number of CPU cores
navigator.language, // Primary language
navigator.maxTouchPoints, // Touch capability
];
console.log(fingerprintParts);
const rawFingerprint = fingerprintParts.join("|");
const encoder = new TextEncoder();
const data = encoder.encode(rawFingerprint);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const deviceId = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
localStorage.setItem("guard-device-id", deviceId);
return deviceId; // A 64-character hex string
};
export const deriveDeviceKey = async (deviceKeyId: string) => {
const encoder = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
"raw",
encoder.encode(deviceKeyId),
{ name: "PBKDF2" },
false,
["deriveKey"]
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: encoder.encode("guard_salt"),
iterations: 100000,
hash: "SHA-256",
},
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
};

18
web/src/util/token.ts Normal file
View File

@ -0,0 +1,18 @@
export type EncryptedToken = {
cipherText: ArrayBuffer;
iv: Uint8Array<ArrayBuffer>;
};
export const encryptToken = async (
token: string,
key: CryptoKey
): Promise<EncryptedToken> => {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const cipherText = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
encoder.encode(token)
);
return { cipherText, iv };
};

1
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

29
web/tsconfig.app.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
},
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
web/tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

32
web/vite.config.ts Normal file
View File

@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { resolve } from "path";
// https://vite.dev/config/
export default defineConfig(({ mode }) => ({
plugins: [react(), tailwindcss()],
server:
mode === "development"
? {
proxy: {
"/api": {
target: "http://127.0.0.1:3001",
changeOrigin: true,
secure: false,
},
},
allowedHosts: true,
}
: undefined,
build: {
outDir: "../dist",
emptyOutDir: true,
},
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
base: "/",
}));