Compare commits
59 Commits
3cfc89eb39
...
3279f1fb90
Author | SHA1 | Date | |
---|---|---|---|
3279f1fb90 | |||
1819629008 | |||
4e9fa2337b | |||
68899e98bd | |||
47f5188961 | |||
1840194bae | |||
d3bcc785a1 | |||
64faa4ca5f | |||
24c72800ad | |||
f559f54683 | |||
9b0de4512b | |||
b6d365cc48 | |||
8f755b6d1e | |||
587a463623 | |||
1a596eef87 | |||
e6b87a6561 | |||
3bcc5f8900 | |||
c5ee912408 | |||
9766da7cfd | |||
a004a82272 | |||
64ca9b922e | |||
38a2ce1ce9 | |||
accde2662f | |||
92fda8cb24 | |||
eee3839dea | |||
b941561ccf | |||
0bda5495c4 | |||
b5f5346536 | |||
68e2ece877 | |||
04bd27607f | |||
eb42b61b2c | |||
06e0e90677 | |||
eaf3596580 | |||
b8f3fa0a32 | |||
d4adc1b538 | |||
edfa3e63b9 | |||
7c58473ff1 | |||
9473c83679 | |||
0b8c03e8c5 | |||
55eb4c9862 | |||
de28470432 | |||
8ccf9f281c | |||
a9df6fa559 | |||
eb9c2b1da1 | |||
af8b347173 | |||
ba89880f8a | |||
07e1cbc66f | |||
aee3306c2b | |||
d2cb426170 | |||
e9e1414c90 | |||
4a71f6c5ee | |||
a8e75d75f0 | |||
afc9208269 | |||
ac07b5d723 | |||
9267cf2618 | |||
55ccd8ea8e | |||
fdf99d82e5 | |||
d9c3223228 | |||
3f369de3fa |
@ -1,5 +1,7 @@
|
|||||||
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
|
HOST="127.0.0.1"
|
||||||
|
|
||||||
DATABASE_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable"
|
DATABASE_URL="postgres://<user>:<user>@<host>:<port>/<db>?sslmode=disable"
|
||||||
|
|
||||||
ADMIN_NAME="admin"
|
ADMIN_NAME="admin"
|
||||||
@ -9,6 +11,10 @@ ADMIN_PASSWORD="secret"
|
|||||||
JWT_PRIVATE_KEY="ecdsa"
|
JWT_PRIVATE_KEY="ecdsa"
|
||||||
JWT_PUBLIC_KEY="ecdsa"
|
JWT_PUBLIC_KEY="ecdsa"
|
||||||
|
|
||||||
|
MINIO_ENDPOINT="localhost:9000"
|
||||||
|
MINIO_ACCESS_KEY=""
|
||||||
|
MINIO_SECRET_KEY=""
|
||||||
|
|
||||||
GOOSE_DRIVER="postgres"
|
GOOSE_DRIVER="postgres"
|
||||||
GOOSE_DBSTRING=$DATABASE_URL
|
GOOSE_DBSTRING=$DATABASE_URL
|
||||||
GOOSE_MIGRATION_DIR="./migrations"
|
GOOSE_MIGRATION_DIR="./migrations"
|
||||||
|
2
.gitignore
vendored
@ -28,3 +28,5 @@ go.work.sum
|
|||||||
|
|
||||||
# key files
|
# key files
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
dist/
|
||||||
|
@ -5,25 +5,27 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gitea.local/admin/hspguard/internal/auth"
|
"gitea.local/admin/hspguard/internal/auth"
|
||||||
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
||||||
"gitea.local/admin/hspguard/internal/repository"
|
"gitea.local/admin/hspguard/internal/repository"
|
||||||
|
"gitea.local/admin/hspguard/internal/storage"
|
||||||
"gitea.local/admin/hspguard/internal/user"
|
"gitea.local/admin/hspguard/internal/user"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
type APIServer struct {
|
type APIServer struct {
|
||||||
addr string
|
addr string
|
||||||
repo *repository.Queries
|
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{
|
return &APIServer{
|
||||||
addr: addr,
|
addr: addr,
|
||||||
repo: db,
|
repo: db,
|
||||||
|
storage: minio,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,18 +33,27 @@ func (s *APIServer) Run() error {
|
|||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Use(middleware.Logger)
|
router.Use(middleware.Logger)
|
||||||
|
|
||||||
workDir, _ := os.Getwd()
|
// workDir, _ := os.Getwd()
|
||||||
staticDir := http.Dir(filepath.Join(workDir, "static"))
|
// staticDir := http.Dir(filepath.Join(workDir, "static"))
|
||||||
FileServer(router, "/static", staticDir)
|
// FileServer(router, "/static", staticDir)
|
||||||
|
|
||||||
router.Route("/api/v1", func(r chi.Router) {
|
router.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Use(imiddleware.WithSkipper(imiddleware.AuthMiddleware, "/api/v1/login", "/api/v1/register"))
|
r.Use(imiddleware.WithSkipper(imiddleware.AuthMiddleware, "/api/v1/login", "/api/v1/register"))
|
||||||
|
|
||||||
userHandler := user.NewUserHandler(s.repo)
|
userHandler := user.NewUserHandler(s.repo, s.storage)
|
||||||
userHandler.RegisterRoutes(router, r)
|
userHandler.RegisterRoutes(r)
|
||||||
|
|
||||||
authHandler := auth.NewAuthHandler(s.repo)
|
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
|
// Handle unknown routes
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"gitea.local/admin/hspguard/cmd/hspguard/api"
|
"gitea.local/admin/hspguard/cmd/hspguard/api"
|
||||||
"gitea.local/admin/hspguard/internal/repository"
|
"gitea.local/admin/hspguard/internal/repository"
|
||||||
|
"gitea.local/admin/hspguard/internal/storage"
|
||||||
"gitea.local/admin/hspguard/internal/user"
|
"gitea.local/admin/hspguard/internal/user"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@ -30,14 +31,21 @@ func main() {
|
|||||||
|
|
||||||
repo := repository.New(conn)
|
repo := repository.New(conn)
|
||||||
|
|
||||||
|
fStorage := storage.New()
|
||||||
|
|
||||||
user.EnsureAdminUser(ctx, repo)
|
user.EnsureAdminUser(ctx, repo)
|
||||||
|
|
||||||
|
host := os.Getenv("HOST")
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "3000"
|
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 {
|
if err := server.Run(); err != nil {
|
||||||
log.Fatalln("ERR: Failed to start server:", err)
|
log.Fatalln("ERR: Failed to start server:", err)
|
||||||
}
|
}
|
||||||
|
13
go.mod
@ -11,8 +11,21 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/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
|
golang.org/x/text v0.25.0 // indirect
|
||||||
)
|
)
|
||||||
|
28
go.sum
@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
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 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
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.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 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
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 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
@ -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.Get("/profile", h.getProfile)
|
||||||
api.Post("/login", h.login)
|
api.Post("/login", h.login)
|
||||||
}
|
}
|
||||||
@ -44,21 +44,22 @@ func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||||
"full_name": user.FullName,
|
"full_name": user.FullName,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"phoneNumber": user.PhoneNumber,
|
"phoneNumber": user.PhoneNumber,
|
||||||
"isAdmin": user.IsAdmin,
|
"isAdmin": user.IsAdmin,
|
||||||
"last_login": user.LastLogin,
|
"last_login": user.LastLogin,
|
||||||
"updated_at": user.UpdatedAt,
|
"profile_picture": user.ProfilePicture.String,
|
||||||
"created_at": user.CreatedAt,
|
"updated_at": user.UpdatedAt,
|
||||||
|
"created_at": user.CreatedAt,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
|
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginParams struct {
|
type LoginParams struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -81,32 +82,65 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := types.UserClaims{
|
if !util.VerifyPassword(params.Password, user.PasswordHash) {
|
||||||
UserID: user.ID.String(),
|
web.Error(w, "username or/and password are incorrect", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accessClaims := types.UserClaims{
|
||||||
|
UserEmail: user.Email,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Issuer: "hspguard",
|
Issuer: "hspguard",
|
||||||
Subject: user.Email,
|
Subject: user.ID.String(),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
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 {
|
if err != nil {
|
||||||
web.Error(w, fmt.Sprintf("failed to generate access token: %v", err), http.StatusBadRequest)
|
web.Error(w, fmt.Sprintf("failed to generate access token: %v", err), http.StatusBadRequest)
|
||||||
return
|
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)
|
encoder := json.NewEncoder(w)
|
||||||
|
|
||||||
type Response struct {
|
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{
|
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 {
|
}); err != nil {
|
||||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,13 +26,13 @@ func AuthMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokenStr := parts[1]
|
tokenStr := parts[1]
|
||||||
token, userClaims, err := auth.VerifyToken(tokenStr)
|
token, userClaims, err := auth.VerifyToken(tokenStr)
|
||||||
if err != nil || !token.Valid {
|
if err != nil || !token.Valid {
|
||||||
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||||
return
|
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))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -50,4 +50,3 @@ func WithSkipper(mw func(http.Handler) http.Handler, excludedPaths ...string) fu
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,13 +10,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
PasswordHash string `json:"password_hash"`
|
PasswordHash string `json:"password_hash"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
LastLogin pgtype.Timestamptz `json:"last_login"`
|
LastLogin pgtype.Timestamptz `json:"last_login"`
|
||||||
PhoneNumber pgtype.Text `json:"phone_number"`
|
PhoneNumber pgtype.Text `json:"phone_number"`
|
||||||
|
ProfilePicture pgtype.Text `json:"profile_picture"`
|
||||||
}
|
}
|
||||||
|
@ -9,10 +9,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
const findAllUsers = `-- name: FindAllUsers :many
|
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) {
|
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.UpdatedAt,
|
||||||
&i.LastLogin,
|
&i.LastLogin,
|
||||||
&i.PhoneNumber,
|
&i.PhoneNumber,
|
||||||
|
&i.ProfilePicture,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -46,7 +48,7 @@ func (q *Queries) FindAllUsers(ctx context.Context) ([]User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const findUserEmail = `-- name: FindUserEmail :one
|
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) {
|
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.UpdatedAt,
|
||||||
&i.LastLogin,
|
&i.LastLogin,
|
||||||
&i.PhoneNumber,
|
&i.PhoneNumber,
|
||||||
|
&i.ProfilePicture,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const findUserId = `-- name: FindUserId :one
|
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) {
|
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.UpdatedAt,
|
||||||
&i.LastLogin,
|
&i.LastLogin,
|
||||||
&i.PhoneNumber,
|
&i.PhoneNumber,
|
||||||
|
&i.ProfilePicture,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@ -114,3 +118,19 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (uuid.UU
|
|||||||
err := row.Scan(&id)
|
err := row.Scan(&id)
|
||||||
return id, err
|
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
@ -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()
|
||||||
|
}
|
@ -3,8 +3,17 @@ package types
|
|||||||
import "github.com/golang-jwt/jwt/v5"
|
import "github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
type UserClaims struct {
|
type UserClaims struct {
|
||||||
UserID string `json:"user_id"`
|
UserEmail string `json:"user_email"`
|
||||||
// Role
|
|
||||||
jwt.RegisteredClaims
|
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
|
||||||
|
}
|
||||||
|
@ -2,10 +2,12 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gitea.local/admin/hspguard/internal/repository"
|
"gitea.local/admin/hspguard/internal/repository"
|
||||||
|
"gitea.local/admin/hspguard/internal/util"
|
||||||
"github.com/google/uuid"
|
"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) {
|
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{
|
return repo.InsertUser(ctx, repository.InsertUserParams{
|
||||||
FullName: name,
|
FullName: name,
|
||||||
Email: email,
|
Email: email,
|
||||||
PasswordHash: password,
|
PasswordHash: hash,
|
||||||
IsAdmin: true,
|
IsAdmin: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -3,43 +3,37 @@ package user
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.local/admin/hspguard/internal/repository"
|
"gitea.local/admin/hspguard/internal/repository"
|
||||||
|
"gitea.local/admin/hspguard/internal/storage"
|
||||||
|
"gitea.local/admin/hspguard/internal/util"
|
||||||
"gitea.local/admin/hspguard/internal/web"
|
"gitea.local/admin/hspguard/internal/web"
|
||||||
"github.com/go-chi/chi/v5"
|
"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 {
|
type UserHandler struct {
|
||||||
repo *repository.Queries
|
repo *repository.Queries
|
||||||
|
minio *storage.FileStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserHandler(repo *repository.Queries) *UserHandler {
|
func NewUserHandler(repo *repository.Queries, minio *storage.FileStorage) *UserHandler {
|
||||||
return &UserHandler{
|
return &UserHandler{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
|
minio: minio,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) RegisterRoutes(router chi.Router, api chi.Router) {
|
func (h *UserHandler) RegisterRoutes(api chi.Router) {
|
||||||
router.Get("/login", h.loginPage)
|
|
||||||
router.Get("/register", h.registerPage)
|
|
||||||
api.Post("/register", h.register)
|
api.Post("/register", h.register)
|
||||||
}
|
api.Put("/avatar", h.uploadAvatar)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegisterParams struct {
|
type RegisterParams struct {
|
||||||
@ -69,10 +63,16 @@ func (h *UserHandler) register(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
id, err := h.repo.InsertUser(context.Background(), repository.InsertUserParams{
|
||||||
FullName: params.FullName,
|
FullName: params.FullName,
|
||||||
Email: params.Email,
|
Email: params.Email,
|
||||||
PasswordHash: params.Password,
|
PasswordHash: hash,
|
||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
@ -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
|
||||||
|
}
|
11
migrations/00003_add_profile_image.sql
Normal 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
|
@ -14,3 +14,8 @@ SELECT * FROM users WHERE email = $1 LIMIT 1;
|
|||||||
|
|
||||||
-- name: FindUserId :one
|
-- name: FindUserId :one
|
||||||
SELECT * FROM users WHERE id = $1 LIMIT 1;
|
SELECT * FROM users WHERE id = $1 LIMIT 1;
|
||||||
|
|
||||||
|
-- name: UpdateProfilePicture :exec
|
||||||
|
UPDATE users
|
||||||
|
SET profile_picture = $1
|
||||||
|
WHERE id = $2;
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
Before Width: | Height: | Size: 499 B |
Before Width: | Height: | Size: 584 B |
Before Width: | Height: | Size: 563 B |
@ -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")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
@ -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
@ -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
@ -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
@ -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
@ -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
40
web/package.json
Normal 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
After Width: | Height: | Size: 6.9 MiB |
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
54
web/src/App.tsx
Normal 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
@ -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
@ -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;
|
||||||
|
};
|
BIN
web/src/assets/dark-overlay.jpg
Normal file
After Width: | Height: | Size: 6.9 MiB |
BIN
web/src/assets/icon.png
Normal file
After Width: | Height: | Size: 9.5 KiB |
BIN
web/src/assets/overlay.jpg
Normal file
After Width: | Height: | Size: 279 KiB |
75
web/src/components/ui/button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
18
web/src/components/ui/card.tsx
Normal 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>;
|
||||||
|
}
|
14
web/src/components/ui/input.tsx
Normal 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
@ -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);
|
25
web/src/context/db/provider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
89
web/src/feature/AccountList/index.tsx
Normal 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
@ -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
@ -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>
|
||||||
|
);
|
117
web/src/pages/Agreement/index.tsx
Normal 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;
|
48
web/src/pages/Index/index.tsx
Normal 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;
|
177
web/src/pages/Login/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
257
web/src/pages/Register/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
140
web/src/repository/account.ts
Normal 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
@ -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
@ -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
@ -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
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
29
web/tsconfig.app.json
Normal 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
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
25
web/tsconfig.node.json
Normal 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
@ -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: "/",
|
||||||
|
}));
|