diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 67b9d38..1f6d59c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,7 @@ on: branches: - main - dev + workflow_dispatch: jobs: deploy-dev: @@ -27,6 +28,18 @@ jobs: echo "${{ secrets.VITE_ENV_FILE }}" > .env.production cat .env.production + - name: Create seaweedfs config + env: + BUCKET_ACCESS_KEY: ${{ secrets.BUCKET_ACCESS_KEY }} + BUCKET_SECRET_KEY: ${{ secrets.BUCKET_SECRET_KEY }} + run: | + echo "Generating seaweedfs config" + ls -R + mkdir -p seaweedfs_config + envsubst < seaweedfs_config/s3_config.template.json > seaweedfs_config/s3_config.json + echo "Seaweedfs config generated" + cat seaweedfs_config/s3_config.json + - name: Restart docker run: | echo "Restarting docker" @@ -59,6 +72,18 @@ jobs: echo "${{ secrets.VITE_ENV_FILE }}" > .env.production cat .env.production + - name: Create seaweedfs config + env: + BUCKET_ACCESS_KEY: ${{ secrets.BUCKET_ACCESS_KEY }} + BUCKET_SECRET_KEY: ${{ secrets.BUCKET_SECRET_KEY }} + run: | + echo "Generating seaweedfs config" + ls -R + mkdir -p seaweedfs_config + envsubst < seaweedfs_config/s3_config.template.json > seaweedfs_config/s3_config.json + echo "Seaweedfs config generated" + cat seaweedfs_config/s3_config.json + - name: Restart docker run: | echo "Restarting docker" diff --git a/.gitignore b/.gitignore index 42bb985..a683cc1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env* .idea -docker-compose.override.yml \ No newline at end of file +docker-compose.override.yml +seaweedfs_config/s3_config.json \ No newline at end of file diff --git a/Backend/controller/auth/handler.go b/Backend/controller/auth/handler.go index 17a81e1..2c8ae4a 100644 --- a/Backend/controller/auth/handler.go +++ b/Backend/controller/auth/handler.go @@ -4,6 +4,7 @@ import ( "net/http" "os" "personal-erp-backend/database" + "personal-erp-backend/internal/profile" "time" "github.com/gin-gonic/gin" @@ -15,9 +16,14 @@ var SecretKey = []byte(os.Getenv("SECRET_KEY")) func Register(c *gin.Context) { var input struct { - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + PhoneNumber string `json:"phone_number"` + Country string `json:"country"` + TeleUsername string `json:"tele_username"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -28,25 +34,40 @@ func Register(c *gin.Context) { c.JSON(500, gin.H{"error": err.Error()}) return } - user := User{Username: input.Username, Email: input.Email, Password: string(hashedPassword)} + user := profile.User{ + Username: input.Username, + Email: input.Email, + Password: string(hashedPassword), + FirstName: input.FirstName, + LastName: input.LastName, + PhoneNumber: input.PhoneNumber, + Country: input.Country, + TeleUsername: input.TeleUsername, + } if err := database.DB.Create(&user).Error; err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"user": user}) + + if err := Login; err != nil { + c.JSON(500, gin.H{"error": err}) + return + } } func Login(c *gin.Context) { var input struct { Username string `json:"username"` + Email string `json:"email"` Password string `json:"password"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - var user User - if err := database.DB.Where("username = ?", input.Username).First(&user).Error; err != nil { + var user profile.User + if err := database.DB.Where("username = ? OR email = ?", input.Username, input.Email).First(&user).Error; err != nil { c.JSON(404, gin.H{"error": err.Error()}) return } @@ -66,6 +87,12 @@ func Login(c *gin.Context) { c.SetSameSite(http.SameSiteLaxMode) c.SetCookie("authorization", tokenString, 3600*24, "", "", false, true) c.JSON(http.StatusOK, gin.H{"token": tokenString, "message": "success"}) + + if err := profile.GetUser; err != nil { + c.JSON(404, gin.H{"message": err}) + return + } + c.JSON(200, gin.H{"message": "OK", "user": user}) } func Logout(c *gin.Context) { diff --git a/Backend/controller/auth/middleware.go b/Backend/controller/auth/middleware.go index faa200a..c1c73f4 100644 --- a/Backend/controller/auth/middleware.go +++ b/Backend/controller/auth/middleware.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "personal-erp-backend/database" + "personal-erp-backend/internal/profile" "time" "github.com/gin-gonic/gin" @@ -42,7 +43,7 @@ func RequireAuth(c *gin.Context) { if float64(time.Now().Unix()) > claims["exp"].(float64) { c.JSON(401, gin.H{"message": "Token expired"}) } - var user User + var user profile.User if result := database.DB.First(&user, claims["sub"]); result.Error != nil { c.AbortWithStatusJSON(401, gin.H{"error": "User no"}) return diff --git a/Backend/controller/auth/modelDB.go b/Backend/controller/auth/modelDB.go deleted file mode 100644 index dd18fcb..0000000 --- a/Backend/controller/auth/modelDB.go +++ /dev/null @@ -1,8 +0,0 @@ -package auth - -type User struct { - ID uint `gorm:"primary_key;AUTO_INCREMENT" json:"id"` - Username string `gorm:"size:255;not null" json:"username"` - Email string `gorm:"size:255;not null" json:"email"` - Password string `json:"-"` -} diff --git a/Backend/database/bucket.go b/Backend/database/bucket.go new file mode 100644 index 0000000..3bf5f63 --- /dev/null +++ b/Backend/database/bucket.go @@ -0,0 +1,75 @@ +package database + +import ( + "context" + "fmt" + "log" + "mime/multipart" + "os" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +func InitBucket() *minio.Client { + endpoint := os.Getenv("BUCKET_ENDPOINT") + accessKeyID := os.Getenv("BUCKET_ACCESS_KEY") + secretAccessKey := os.Getenv("BUCKET_SECRET_KEY") + useSSL := false + + minioClient, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""), + Secure: useSSL, + }) + if err != nil { + log.Fatalln("Failed to initialize minio client:", err) + } + log.Println("Successfully connected to Minio") + return minioClient +} + +func UploadFile(client *minio.Client, file *multipart.FileHeader) (string, error) { + ctx := context.Background() + bucketName := "user-profile" + publicDomain := os.Getenv("BUCKET_PUBLIC_DOMAIN") + if publicDomain == "" { + publicDomain = "http://localhost:8334" + } + + src, err := file.Open() + if err != nil { + return "", err + } + defer func(src multipart.File) { + err := src.Close() + if err != nil { + + } + }(src) + + exists, err := client.BucketExists(ctx, bucketName) + if err != nil { + return "", err + } + if !exists { + err := client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{}) + if err != nil { + return "", fmt.Errorf("failed to create bucket %s: %v", bucketName, err) + } + log.Println("Successfully created bucket:", bucketName) + } + + objectName := fmt.Sprintf("file_%d_%s", time.Now().Unix(), file.Filename) + contentType := file.Header.Get("Content-Type") + + info, err := client.PutObject(ctx, bucketName, objectName, src, file.Size, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + return "", err + } + log.Println("Successfully uploaded file:", info) + fileURL := fmt.Sprintf("%s/%s/%s", publicDomain, bucketName, objectName) + return fileURL, nil +} diff --git a/Backend/go.mod b/Backend/go.mod index a91da1c..e0dba23 100644 --- a/Backend/go.mod +++ b/Backend/go.mod @@ -15,14 +15,17 @@ require ( github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect @@ -30,14 +33,22 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/crc64nvme v1.1.0 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.0.97 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.58.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.3.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.uber.org/mock v0.6.0 // indirect @@ -48,4 +59,5 @@ require ( golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/Backend/go.sum b/Backend/go.sum index 83424c0..cee7637 100644 --- a/Backend/go.sum +++ b/Backend/go.sum @@ -9,6 +9,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= @@ -17,6 +19,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +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/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -34,6 +38,8 @@ github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -50,12 +56,23 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= +github.com/minio/crc64nvme v1.1.0/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.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= +github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -63,12 +80,16 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug= github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -81,6 +102,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= diff --git a/Backend/internal/profile/handler.go b/Backend/internal/profile/handler.go new file mode 100644 index 0000000..2298dbe --- /dev/null +++ b/Backend/internal/profile/handler.go @@ -0,0 +1,68 @@ +package profile + +import ( + "personal-erp-backend/database" + + "github.com/gin-gonic/gin" + "github.com/minio/minio-go/v7" +) + +type UploadHandler struct { + minioClient *minio.Client +} + +func NewUploadHandler(client *minio.Client) *UploadHandler { + return &UploadHandler{minioClient: client} +} + +func GetUser(c *gin.Context) { + var user User + userID, _ := c.Get("userID") + if err := database.DB.Where("id = ?", userID).Find(&user).Error; err != nil { + c.JSON(404, gin.H{"message": err}) + return + } + c.JSON(200, gin.H{"Success": user}) +} + +func UpdateUser(c *gin.Context) { + var user User + userID, _ := c.Get("userID") + if err := database.DB.Where("id = ?", userID).First(&user).Error; err != nil { + c.JSON(404, gin.H{"message": err}) + return + } + if err := c.ShouldBind(&user); err != nil { + c.JSON(400, gin.H{"message": err}) + return + } + if err := database.DB.Model(&user).Updates(&user).Error; err != nil { + c.JSON(404, gin.H{"message": err}) + return + } + c.JSON(200, gin.H{"Success": user}) +} + +func (h *UploadHandler) UploadAvatar(c *gin.Context) { + file, err := c.FormFile("avatar") + if err != nil { + c.JSON(400, gin.H{"message": err}) + return + } + fileURL, err := database.UploadFile(h.minioClient, file) + if err != nil { + c.JSON(400, gin.H{"message": err}) + return + } + var user User + userID, _ := c.Get("userID") + if err := database.DB.Where("id = ?", userID).First(&user).Error; err != nil { + c.JSON(404, gin.H{"message": err}) + return + } + if err := database.DB.Model(&user).Update("profile_url", fileURL).Error; err != nil { + c.JSON(404, gin.H{"message": err}) + return + } + c.JSON(200, gin.H{"Success": fileURL}) +} diff --git a/Backend/internal/profile/modelDB.go b/Backend/internal/profile/modelDB.go new file mode 100644 index 0000000..8aef97f --- /dev/null +++ b/Backend/internal/profile/modelDB.go @@ -0,0 +1,14 @@ +package profile + +type User struct { + ID uint `gorm:"primary_key;AUTO_INCREMENT" json:"id"` + Username string `gorm:"size:255;not null" json:"username"` + Email string `gorm:"size:255;not null" json:"email"` + Password string `json:"-"` + FirstName string `gorm:"size:255" json:"first_name"` + LastName string `gorm:"size:255" json:"last_name"` + PhoneNumber string `gorm:"size:255" json:"phone_number"` + Country string `gorm:"size:255" json:"country"` + TeleUsername string `gorm:"size:255" json:"tele_username"` + ProfileURL string `json:"profile_url"` +} diff --git a/Backend/internal/profile/router.go b/Backend/internal/profile/router.go new file mode 100644 index 0000000..76478b4 --- /dev/null +++ b/Backend/internal/profile/router.go @@ -0,0 +1,19 @@ +package profile + +import ( + "personal-erp-backend/database" + + "github.com/gin-gonic/gin" +) + +func RegisterRouter(r *gin.RouterGroup) { + minioClient := database.InitBucket() + uploadHandler := NewUploadHandler(minioClient) + + profile := r.Group("/profile") + { + profile.GET("/user", GetUser) + profile.PUT("/user", UpdateUser) + profile.POST("/upload/avatar", uploadHandler.UploadAvatar) + } +} diff --git a/Backend/main.go b/Backend/main.go index f7ac912..76e4de8 100644 --- a/Backend/main.go +++ b/Backend/main.go @@ -6,6 +6,7 @@ import ( "personal-erp-backend/database" "personal-erp-backend/internal/academic" "personal-erp-backend/internal/finance" + "personal-erp-backend/internal/profile" "time" "github.com/gin-contrib/cors" @@ -14,9 +15,10 @@ import ( func main() { database.ConnectDB() + database.InitBucket() err := database.DB.AutoMigrate( - &auth2.User{}, + &profile.User{}, &academic.Subject{}, &academic.Task{}, &academic.Note{}, @@ -32,8 +34,8 @@ func main() { r := gin.Default() r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"http://localhost:5173", "http://100.111.195.90:3001", "https://core-life.arjunaa.my.id"}, - AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowOrigins: []string{"http://localhost:5173", "http://100.111.195.90:3001", "https://core-life-dev.arjunaa.my.id", "https://core-life.arjunaa.my.id"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, AllowCredentials: true, MaxAge: 12 * time.Hour, @@ -49,6 +51,7 @@ func main() { protected := r.Group("/api") protected.Use(auth2.RequireAuth) { + profile.RegisterRouter(protected) academic.RegisterRoutes(protected) finance.RegisterRouter(protected) } diff --git a/Frontend/project-CL/src/components/Button.tsx b/Frontend/project-CL/src/components/Button.tsx new file mode 100644 index 0000000..21e05be --- /dev/null +++ b/Frontend/project-CL/src/components/Button.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Loader2 } from 'lucide-react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + isLoading?: boolean; + icon?: React.ReactNode; +} + +export default function Button({ + children, + variant = 'primary', + isLoading = false, + icon, + className = '', + disabled, + ...props +}: ButtonProps) { + const baseStyles = "inline-flex items-center justify-center -gap-1 rounded-xl text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]"; + + const variants = { + primary: "bg-indigo-600 text-white hover:bg-indigo-700 hover:shadow-lg hover:shadow-indigo-200 focus:ring-indigo-500 border border-transparent", + secondary: "bg-white text-slate-700 border border-slate-200 hover:bg-slate-50 hover:border-slate-300 focus:ring-slate-200 shadow-sm", + danger: "bg-red-50 text-red-600 hover:bg-red-100 border border-transparent focus:ring-red-500", + ghost: "bg-transparent text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-transparent", + }; + + return ( + + ); +} diff --git a/Frontend/project-CL/src/components/Input.tsx b/Frontend/project-CL/src/components/Input.tsx new file mode 100644 index 0000000..251f1f4 --- /dev/null +++ b/Frontend/project-CL/src/components/Input.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; + icon?: React.ReactNode; +} + +export default function Input({ label, error, icon, className = '', ...props }: InputProps) { + return ( +
+ {label && ( + + )} +
+ {icon && ( +
+ {icon} +
+ )} + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/Frontend/project-CL/src/components/Sidebar.tsx b/Frontend/project-CL/src/components/Sidebar.tsx index 14a653c..d924b5f 100644 --- a/Frontend/project-CL/src/components/Sidebar.tsx +++ b/Frontend/project-CL/src/components/Sidebar.tsx @@ -1,8 +1,7 @@ import { NavLink } from "react-router"; -import { useAuth } from "../features/auth/context/AuthContext"; + export default function Sidebar() { - const { logout } = useAuth(); const links = [ { name: "Home", path: "/", icon: "🏠" }, { name: "Academic", path: "/academic", icon: "🎓" }, @@ -36,13 +35,18 @@ export default function Sidebar() { ))}
- + 👤 + Profile +
diff --git a/Frontend/project-CL/src/features/academic/hooks/useAcademicDashboard.ts b/Frontend/project-CL/src/features/academic/hooks/useAcademicDashboard.ts new file mode 100644 index 0000000..5f72b14 --- /dev/null +++ b/Frontend/project-CL/src/features/academic/hooks/useAcademicDashboard.ts @@ -0,0 +1,104 @@ +import { useState, useEffect } from 'react'; +import { academicService, type Subject, type Task } from '../services/academicService'; + +export interface AcademicMetrics { + gpa: number; + totalSks: number; + pendingTasks: number; + upcomingDeadlines: Task[]; +} + +export const useAcademicDashboard = () => { + const [subjects, setSubjects] = useState([]); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [metrics, setMetrics] = useState({ + gpa: 0, + totalSks: 0, + pendingTasks: 0, + upcomingDeadlines: [] + }); + + const refreshData = async () => { + setLoading(true); + try { + const [subjectData, taskData] = await Promise.all([ + academicService.getSubjects(), + academicService.getTasks() + ]); + + setSubjects(subjectData); + setTasks(taskData); + + calculateMetrics(subjectData, taskData); + } catch (err) { + console.error("Failed to fetch academic data", err); + setError("Failed to load academic data"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + refreshData(); + }, []); + + const calculateMetrics = (subs: Subject[], tasks: Task[]) => { + // Calculate GPA + let totalPoints = 0; + let totalSks = 0; + + subs.forEach(sub => { + const sks = parseInt(sub.sks) || 0; + const points = getGradePoints(sub.grade); + + // Only count if grade is valid (not empty/in-progress if applicable) + // Assuming empty grade means in progress, maybe don't count? + // For now, if we have a grade, we count it. + if (sub.grade && sks > 0) { + totalPoints += points * sks; + totalSks += sks; + } + }); + + const gpa = totalSks > 0 ? totalPoints / totalSks : 0; + + // Calculate Pending Tasks + const pending = tasks.filter(t => t.status !== 'Completed' && t.status !== 'Done'); + + // Get upcoming deadlines (next 7 days) + const now = new Date(); + const nextWeek = new Date(); + nextWeek.setDate(now.getDate() + 7); + + const upcoming = pending.filter(t => { + const deadline = new Date(t.deadline); + return deadline >= now && deadline <= nextWeek; + }).sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime()); + + setMetrics({ + gpa, + totalSks, + pendingTasks: pending.length, + upcomingDeadlines: upcoming + }); + }; + + const getGradePoints = (grade: string): number => { + switch (grade.toUpperCase()) { + case 'A': return 4.0; + case 'A-': return 3.7; + case 'B+': return 3.3; + case 'B': return 3.0; + case 'B-': return 2.7; + case 'C+': return 2.3; + case 'C': return 2.0; + case 'D': return 1.0; + default: return 0.0; + } + }; + + return { metrics, loading, error, subjects, tasks, refreshData }; +}; diff --git a/Frontend/project-CL/src/features/auth/components/LoginForm.tsx b/Frontend/project-CL/src/features/auth/components/LoginForm.tsx index 947ca6b..5023080 100644 --- a/Frontend/project-CL/src/features/auth/components/LoginForm.tsx +++ b/Frontend/project-CL/src/features/auth/components/LoginForm.tsx @@ -13,7 +13,7 @@ export function LoginForm({ onRegisterClick }: LoginFormProps) { return (
+
+ + +
+ + + +
+ + +
+ + + { setError(null); const formData = new FormData(e.currentTarget); - const data = Object.fromEntries(formData); + const rawData = Object.fromEntries(formData); + + // Backend expects username OR email. We send the same value for both + // so the backend query (username = ? || email = ?) works for either. + const data = { + ...rawData, + email: rawData.username // Duplicate username to email + }; try { const response = await authService.login(data); diff --git a/Frontend/project-CL/src/features/profile/services/profileService.ts b/Frontend/project-CL/src/features/profile/services/profileService.ts new file mode 100644 index 0000000..a2ffcba --- /dev/null +++ b/Frontend/project-CL/src/features/profile/services/profileService.ts @@ -0,0 +1,34 @@ +import api from '../../../services/api'; + +export interface UserProfile { + id: number; + username: string; + email: string; + first_name: string; + last_name: string; + phone_number: string; + country: string; + tele_username: string; + profile_url?: string; +} + +export const profileService = { + getUser: async () => { + const response = await api.get<{ Success: UserProfile }>('/profile/user'); + return response.data.Success; + }, + updateUser: async (data: Partial) => { + const response = await api.put<{ Success: UserProfile }>('/profile/user', data); + return response.data.Success; + }, + uploadAvatar: async (file: File) => { + const formData = new FormData(); + formData.append('avatar', file); + const response = await api.post<{ Success: string }>('/profile/upload/avatar', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data.Success; + } +}; diff --git a/Frontend/project-CL/src/main.tsx b/Frontend/project-CL/src/main.tsx index ea21ba4..4f15ccd 100644 --- a/Frontend/project-CL/src/main.tsx +++ b/Frontend/project-CL/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import './index.css' import { RouterProvider } from 'react-router' import Home from './pages/home' +import Profile from './pages/profile' import { createBrowserRouter } from 'react-router' import Academic from './features/academic/academic' import SidebarLayout from './layouts/SidebarLayout' @@ -33,6 +34,10 @@ const router = createBrowserRouter([ path: '/finance', element: , }, + { + path: '/profile', + element: , + }, ] } ] diff --git a/Frontend/project-CL/src/pages/home.tsx b/Frontend/project-CL/src/pages/home.tsx index b2055a5..11d7b3a 100644 --- a/Frontend/project-CL/src/pages/home.tsx +++ b/Frontend/project-CL/src/pages/home.tsx @@ -1,8 +1,22 @@ import Header from "../components/Header"; import ModuleCard from "../components/ModuleCard"; import Card from "../components/Card"; +import { useFinancialDashboard } from "../features/financial/hooks/useFinancialDashboard"; +import { useAcademicDashboard } from "../features/academic/hooks/useAcademicDashboard"; export default function Home() { + const { metrics: finMetrics, loading: finLoading } = useFinancialDashboard(); + const { metrics: acadMetrics, loading: acadLoading } = useAcademicDashboard(); + + const formatCurrency = (val: number) => { + if (Math.abs(val) >= 1_000_000_000) { + return `Rp ${(val / 1_000_000_000).toFixed(1)}B`; + } else if (Math.abs(val) >= 1_000_000) { + return `Rp ${(val / 1_000_000).toFixed(1)}M`; + } + return `Rp ${(val / 1000).toFixed(0)}k`; + }; + return (
@@ -12,18 +26,30 @@ export default function Home() {
Total Assets
-
RP 12.5M
-
+2.5% this month
+
+ {finLoading ? "Loading..." : formatCurrency(finMetrics.balance)} +
+
+ {finLoading ? "..." : (finMetrics.totalIncome > 0 ? "Active" : "No Income")} +
Pending Tasks
-
5
-
3 High Priority
+
+ {acadLoading ? "..." : acadMetrics.pendingTasks} +
+
+ {acadLoading ? "..." : `${acadMetrics.upcomingDeadlines.length} Upcoming`} +
Academic Status
-
3.8 GPA
-
Excellent
+
+ {acadLoading ? "..." : `${acadMetrics.gpa.toFixed(2)} GPA`} +
+
+ {acadLoading ? "..." : `${acadMetrics.totalSks} SKS Completed`} +
@@ -56,31 +82,19 @@ export default function Home() { } color="bg-emerald-50 text-emerald-600" /> - - - - } - color="bg-amber-50 text-amber-600" - /> - - + + {/* More Modules Placeholder */} +
+
+ + - } - color="bg-rose-50 text-rose-600" - /> +
+ More modules to come +
) -} \ No newline at end of file +} diff --git a/Frontend/project-CL/src/pages/profile.tsx b/Frontend/project-CL/src/pages/profile.tsx new file mode 100644 index 0000000..ab66671 --- /dev/null +++ b/Frontend/project-CL/src/pages/profile.tsx @@ -0,0 +1,305 @@ +import { useState, useEffect, useRef } from 'react'; +import { User, Mail, Shield, Camera, Save, Phone, MapPin, Send } from 'lucide-react'; +import Card from '../components/Card'; +import Input from '../components/Input'; +import Button from '../components/Button'; +import { useAuth } from '../features/auth/context/AuthContext'; +import { LogOut } from 'lucide-react'; +import { profileService } from '../features/profile/services/profileService'; + +export default function Profile() { + const { logout } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [formData, setFormData] = useState({ + username: '', + email: '', + first_name: '', + last_name: '', + phone_number: '', + country: '', + tele_username: '', + currentPassword: '', + newPassword: '', + confirmPassword: '', + profile_url: '', + }); + const fileInputRef = useRef(null); + + useEffect(() => { + const fetchUser = async () => { + try { + const user = await profileService.getUser(); + setFormData(prev => ({ + ...prev, + username: user.username, + email: user.email, + first_name: user.first_name || '', + last_name: user.last_name || '', + phone_number: user.phone_number || '', + country: user.country || '', + tele_username: user.tele_username || '', + profile_url: user.profile_url || '', + })); + } catch (error) { + console.error("Failed to fetch user:", error); + } + }; + fetchUser(); + }, []); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + try { + await profileService.updateUser({ + first_name: formData.first_name, + last_name: formData.last_name, + phone_number: formData.phone_number, + country: formData.country, + tele_username: formData.tele_username, + }); + alert("Profile updated successfully"); + } catch (error) { + console.error("Failed to update profile:", error); + alert("Failed to update profile"); + } finally { + setIsLoading(false); + } + }; + + const handleImageClick = () => { + fileInputRef.current?.click(); + }; + + const handleImageChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Optional: specific max size check or type check + if (file.size > 5 * 1024 * 1024) { // 5MB limit example + alert("File size too large (max 5MB)"); + return; + } + + try { + // Optimistic UI update or loading state could go here + const url = await profileService.uploadAvatar(file); + setFormData(prev => ({ ...prev, profile_url: url })); + } catch (error) { + console.error("Failed to upload avatar:", error); + alert("Failed to upload avatar"); + } + }; + + return ( +
+ {/* Header */} +
+

Profile Settings

+

Manage your account settings and preferences.

+
+ +
+ {/* Left Column - Profile Card */} +
+ +
+ +
+ {formData.profile_url ? ( + Profile + ) : ( + + {formData.username.charAt(0).toUpperCase()} + + )} +
+
+ +
+
+

{formData.username}

+

Administrator

+
+ +
+
+
+ +
+ Personal Information +
+
+
+ +
+ Security & Privacy +
+ + +
+
+ + {/* Right Column - Edit Form */} +
+ + +
+
+

+ + Basic Information +

+
+ +
+ + +
+ +
+ } + /> + } + /> +
+ +
+ +
+ } + readOnly + className="bg-slate-50" + /> + } + /> +
+
+
+ +
+
+

+ + Security +

+
+ +
+ +
+ + +
+
+
+ +
+ + +
+ +
+
+
+
+ ); +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b1afa5a..59a33f2 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -39,9 +39,24 @@ services: networks: - erp_network_dev + # --- BUCKETS (SEAWEDDFS) --- + seaweedfs: + image: chrislusf/seaweedfs + container_name: cl_bucket_dev + restart: always + ports: + - "127.0.0.1:8334:8333" + - "127.0.0.1:8889:8888" + command: "server -s3 -filer -s3.port=8333 -filer.port=8888 -master.volumeSizeLimitMB=1024 -volume.max=100 -s3.config=/etc/seaweedfs/s3_config.json" + volumes: + - seaweed_data_dev:/data + - ./seaweedfs_config/s3_config.json:/etc/seaweedfs/s3_config.json + networks: + - erp_network_dev + volumes: pg_data_dev: - + seaweed_data_dev: networks: erp_network_dev: driver: bridge \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index fa23fb5..dbf2934 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -41,8 +41,22 @@ services: networks: - erp_network_prod + seaweedfs: + image: chrislusf/seaweedfs + container_name: cl_bucket_prod + restart: always + ports: + - "127.0.0.1:8333:8333" + - "127.0.0.1:8888:8888" + command: "server -s3 -filer -s3.port=8333 -filer.port=8888 -master.volumeSizeLimitMB=1024 volume.max=100" + volumes: + - seaweed_data_prod:/data + networks: + - erp_network_prod + volumes: pg_data_prod: + seaweed_data_prod: networks: erp_network_prod: diff --git a/seaweedfs_config/s3_config.template.json b/seaweedfs_config/s3_config.template.json new file mode 100644 index 0000000..972a6fc --- /dev/null +++ b/seaweedfs_config/s3_config.template.json @@ -0,0 +1,18 @@ +{ + "identities": [ + { + "name": "Core-Life-Bucket", + "credentials": [ + { + "accessKey": "$BUCKET_ACCESS_KEY", + "secretKey": "$BUCKET_SECRET_KEY" + } + ], + "actions": [ "Read", "Write", "List", "Tagging", "Admin"] + }, + { + "name": "anonymous", + "actions": [ "Read"] + } + ] +}