diff --git a/Internal/adapters/repository/authRepo.go b/Internal/adapters/repository/authRepo.go index d4c80fb..cbe6488 100644 --- a/Internal/adapters/repository/authRepo.go +++ b/Internal/adapters/repository/authRepo.go @@ -24,3 +24,29 @@ func (ar *AuthRepo) LoginRepo(email string) (*domain.User, error) { } return &user, nil } + +func (ar *AuthRepo) CreateInvite(invite *domain.Invitation) error { + if err := ar.DB.Gorm.Create(invite).Error; err != nil { + return err + } + return nil +} + +func (ar *AuthRepo) FindInviteByToken(token string) (*domain.Invitation, error) { + var invite domain.Invitation + if err := ar.DB.Gorm.Where("token = ?", token).First(&invite).Error; err != nil { + return nil, err + } + return &invite, nil +} + +func (ar *AuthRepo) MarkInviteUsed(token string) error { + var invite domain.Invitation + if err := ar.DB.Gorm.Where("token = ?", token).First(&invite).Error; err != nil { + return err + } + if err := ar.DB.Gorm.Model(&invite).Update("used", true).Error; err != nil { + return err + } + return nil +} diff --git a/Internal/adapters/repository/postgres.go b/Internal/adapters/repository/postgres.go index bc02520..e1974f5 100644 --- a/Internal/adapters/repository/postgres.go +++ b/Internal/adapters/repository/postgres.go @@ -40,6 +40,7 @@ func ConnectDB() (*DBContainer, error) { func (db *DBContainer) Migrate() error { err := db.Gorm.AutoMigrate( &domain.User{}, + &domain.Invitation{}, ) if err != nil { log.Fatalf("Failed to migrate users: %v", err) diff --git a/Internal/adapters/repository/userRepo.go b/Internal/adapters/repository/userRepo.go index db3fe87..e965fbd 100644 --- a/Internal/adapters/repository/userRepo.go +++ b/Internal/adapters/repository/userRepo.go @@ -28,3 +28,23 @@ func (ur *UserRepository) FindById(id uint) (*domain.User, error) { } return &user, nil } + +func (ur *UserRepository) AllUser() ([]domain.User, error) { + var users []domain.User + err := ur.db.Gorm.Find(&users).Error + if err != nil { + return nil, err + } + return users, nil +} + +func (ur *UserRepository) RoleUpdate(id uint, role string) (*domain.User, error) { + var user domain.User + if err := ur.db.Gorm.First(&user, id).Error; err != nil { + return nil, err + } + if err := ur.db.Gorm.Model(&user).Update("role", role).Error; err != nil { + return nil, err + } + return &user, nil +} diff --git a/Internal/api/Routes/routev1.go b/Internal/api/Routes/routev1.go index db257b4..2a50cb8 100644 --- a/Internal/api/Routes/routev1.go +++ b/Internal/api/Routes/routev1.go @@ -40,6 +40,12 @@ func SetupRouterV1(r *gin.Engine, deps Deps) { { user.GET("/me", deps.User.GetUserHandler) } + admin := protected.Group("/admin") + { + admin.POST("/invite", deps.Auth.CreateInviteHandler) + admin.GET("/alluser", deps.User.AllUserHandler) + admin.PUT("/updaterole", deps.User.UpdateRoleHandler) + } } } } diff --git a/Internal/api/handlers/authHandler.go b/Internal/api/handlers/authHandler.go index c6a3932..75506bc 100644 --- a/Internal/api/handlers/authHandler.go +++ b/Internal/api/handlers/authHandler.go @@ -11,6 +11,11 @@ type AuthHandler struct { Service *services.AuthService } +type InviteInput struct { + Email string `json:"email"` + Role string `json:"role"` +} + func NewAuthHandler(service *services.AuthService) *AuthHandler { return &AuthHandler{Service: service} } @@ -69,3 +74,29 @@ func (ah *AuthHandler) LogoutHandler(c *gin.Context) { "data": nil, }) } + +func (ah *AuthHandler) CreateInviteHandler(c *gin.Context) { + var req InviteInput + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{ + "message": "Invalid Input", + "data": err.Error(), + }) + return + } + if req.Role == "" { + req.Role = "user" + } + link, err := ah.Service.GenerateInviteService(req.Email, req.Role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "message": "Invite service failed", + "data": err.Error(), + }) + return + } + c.JSON(200, gin.H{ + "message": "Invite service success", + "data": link, + }) +} diff --git a/Internal/api/handlers/userHandler.go b/Internal/api/handlers/userHandler.go index 897e371..26fb067 100644 --- a/Internal/api/handlers/userHandler.go +++ b/Internal/api/handlers/userHandler.go @@ -35,3 +35,56 @@ func (uh *UserHandler) GetUserHandler(c *gin.Context) { "data": user, }) } + +func (uh *UserHandler) AllUserHandler(c *gin.Context) { + userRole, _ := c.Get("userRole") + if userRole != "admin" { + c.JSON(400, gin.H{ + "message": "Not allowed to get all users", + "data": false, + }) + return + } + users, err := uh.Service.GetAllUser() + if err != nil { + c.JSON(400, gin.H{ + "message": "Unable to get all users", + "data": false, + }) + } + c.JSON(200, gin.H{ + "message": "Successfully get all users", + "data": users, + }) +} + +func (uh *UserHandler) UpdateRoleHandler(c *gin.Context) { + var input services.RoleInput + //userRole, _ := c.Get("userRole") + //if userRole != "admin" { + // c.JSON(400, gin.H{ + // "message": "Not allowed to update role", + // "data": false, + // }) + // return + //} + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(400, gin.H{ + "message": "input not valid", + "data": err.Error(), + }) + return + } + user, err := uh.Service.UpdateRole(&input) + if err != nil { + c.JSON(400, gin.H{ + "message": "Unable to update role", + "data": err, + }) + return + } + c.JSON(200, gin.H{ + "message": "Successfully update role", + "data": user, + }) +} diff --git a/Internal/api/middleware/auth.go b/Internal/api/middleware/auth.go index d274e69..d9a6038 100644 --- a/Internal/api/middleware/auth.go +++ b/Internal/api/middleware/auth.go @@ -73,10 +73,6 @@ func AuthMiddleware(userRepo *repository.UserRepository, cfg *config.Config) gin c.Set("userRole", user.Role) c.Next() - c.JSON(200, gin.H{ - "message": "Success", - "data": " ", - }) } else { c.JSON(401, gin.H{ "message": "Invalid token", diff --git a/Internal/core/domain/invitationdb.go b/Internal/core/domain/invitationdb.go new file mode 100644 index 0000000..de3dcec --- /dev/null +++ b/Internal/core/domain/invitationdb.go @@ -0,0 +1,13 @@ +package domain + +import "time" + +type Invitation struct { + ID uint `gorm:"primary_key;AUTO_INCREMENT" json:"id"` + Email string `gorm:"size:255; not null" json:"email"` + Token string `gorm:"size:255; not null; unique; index" json:"token"` + Role string `gorm:"default:'user'; size:255; not null" json:"role"` + Used bool `gorm:"default:false" json:"used"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} diff --git a/Internal/core/services/authService.go b/Internal/core/services/authService.go index 848445e..8c7145f 100644 --- a/Internal/core/services/authService.go +++ b/Internal/core/services/authService.go @@ -1,17 +1,20 @@ package services import ( + "errors" "time" "github.com/Arjuna-Ragil/Localbase/Internal/adapters/repository" "github.com/Arjuna-Ragil/Localbase/Internal/config" "github.com/Arjuna-Ragil/Localbase/Internal/core/domain" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) type AuthService struct { - Repo *repository.AuthRepo + AuthRepo *repository.AuthRepo + SysRepo *repository.SystemRepo } type RegisterInput struct { @@ -19,6 +22,7 @@ type RegisterInput struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` Role string `json:"role" binding:"required"` + Token string `json:"token"` } type LoginInput struct { @@ -26,28 +30,64 @@ type LoginInput struct { Password string `json:"password" binding:"required,min=6"` } -func NewAuthService(repo *repository.AuthRepo) *AuthService { - return &AuthService{Repo: repo} +func NewAuthService(auth *repository.AuthRepo, sys *repository.SystemRepo) *AuthService { + return &AuthService{AuthRepo: auth, SysRepo: sys} } func (as *AuthService) RegisterService(input *RegisterInput) error { - user := domain.User{ - Username: input.Username, - Email: input.Email, - Password: input.Password, - Role: input.Role, - } - err := as.Repo.RegisterRepo(&user) + isInitialized, err := as.SysRepo.IsAppInitialized() if err != nil { return err } + + if !isInitialized { + user := domain.User{ + Username: input.Username, + Email: input.Email, + Password: input.Password, + Role: "admin", + } + + if err := as.AuthRepo.RegisterRepo(&user); err != nil { + return err + } + + } else { + if input.Token == "" { + return errors.New("token is required") + } + + invite, err := as.VerifyInviteService(input.Token) + if err != nil { + return err + } + + if invite.Email == "" && invite.Email != input.Email { + return errors.New("email does not match") + } + + user := domain.User{ + Username: input.Username, + Email: invite.Email, + Password: input.Password, + Role: invite.Role, + } + + if err := as.AuthRepo.MarkInviteUsed(input.Token); err != nil { + return err + } + + if err := as.AuthRepo.RegisterRepo(&user); err != nil { + return err + } + } return nil } func (as *AuthService) LoginService(input *LoginInput) (string, error) { cfg := config.LoadConfig() - user, err := as.Repo.LoginRepo(input.Email) + user, err := as.AuthRepo.LoginRepo(input.Email) if err != nil { return "", err } @@ -64,3 +104,34 @@ func (as *AuthService) LoginService(input *LoginInput) (string, error) { } return tokenString, nil } + +func (as *AuthService) GenerateInviteService(email string, role string) (string, error) { + token := uuid.NewString() + expiresAt := time.Now().Add(time.Hour * 24) + invite := domain.Invitation{ + Email: email, + Token: token, + Role: role, + Used: false, + ExpiresAt: expiresAt, + } + if err := as.AuthRepo.CreateInvite(&invite); err != nil { + return "", err + } + linkInvite := "http://localhost:5173/registerinvite?token=" + token //Change link if different + return linkInvite, nil +} + +func (as *AuthService) VerifyInviteService(token string) (*domain.Invitation, error) { + invite, err := as.AuthRepo.FindInviteByToken(token) + if err != nil { + return nil, err + } + if invite.Used == true { + return nil, errors.New("invite already used") + } + if time.Now().After(invite.ExpiresAt) { + return nil, errors.New("invite expired") + } + return invite, nil +} diff --git a/Internal/core/services/userService.go b/Internal/core/services/userService.go index 60d7146..c7fef83 100644 --- a/Internal/core/services/userService.go +++ b/Internal/core/services/userService.go @@ -15,6 +15,11 @@ func NewUserService(repo *repository.UserRepository) *UserService { return &UserService{Repo: repo} } +type RoleInput struct { + UserID uint `json:"id"` + Role string `json:"role"` +} + func (us *UserService) GetUser(id uint) (*domain.User, error) { user, err := us.Repo.FindById(id) if err != nil { @@ -22,3 +27,19 @@ func (us *UserService) GetUser(id uint) (*domain.User, error) { } return user, nil } + +func (us *UserService) GetAllUser() ([]domain.User, error) { + users, err := us.Repo.AllUser() + if err != nil { + return nil, errors.New("user not found") + } + return users, nil +} + +func (us *UserService) UpdateRole(input *RoleInput) (*domain.User, error) { + user, err := us.Repo.RoleUpdate(input.UserID, input.Role) + if err != nil { + return nil, errors.New("user not found") + } + return user, nil +} diff --git a/Lb-web/package-lock.json b/Lb-web/package-lock.json index 311c25b..1ab2efa 100644 --- a/Lb-web/package-lock.json +++ b/Lb-web/package-lock.json @@ -8,6 +8,7 @@ "name": "lb-web", "version": "0.0.0", "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/vite": "^4.1.18", @@ -947,6 +948,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -962,6 +969,265 @@ } } }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-label": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", @@ -985,6 +1251,95 @@ } } }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-primitive": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", @@ -1026,6 +1381,91 @@ } } }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", @@ -2233,6 +2673,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2518,6 +2970,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3027,6 +3485,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -3875,6 +4342,53 @@ "react": "^19.2.3" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", @@ -3897,6 +4411,28 @@ } } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4089,6 +4625,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -4198,6 +4740,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/Lb-web/package.json b/Lb-web/package.json index 896865c..ea99d56 100644 --- a/Lb-web/package.json +++ b/Lb-web/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/vite": "^4.1.18", diff --git a/Lb-web/src/components/ui/dialog.tsx b/Lb-web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..120c448 --- /dev/null +++ b/Lb-web/src/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/Lb-web/src/features/Login/components/AuthWrappers.tsx b/Lb-web/src/features/Login/components/AuthWrappers.tsx index cca6571..a2a2504 100644 --- a/Lb-web/src/features/Login/components/AuthWrappers.tsx +++ b/Lb-web/src/features/Login/components/AuthWrappers.tsx @@ -5,7 +5,7 @@ export const ProtectedRoute = () => { const { isAuthenticated, loading } = useAuth(); if (loading) { - return
Loading...
; + return
Loading...
; } if (!isAuthenticated) { @@ -19,7 +19,7 @@ export const PublicRoute = () => { const { isAuthenticated, loading } = useAuth(); if (loading) { - return
Loading...
; + return
Loading...
; } if (isAuthenticated) { @@ -28,3 +28,17 @@ export const PublicRoute = () => { return ; }; + +export const AdminRoute = () => { + const { role, loading } = useAuth(); + + if (loading) { + return
Loading...
; + } + + if (role !== 'admin') { + return ; + } + + return ; +}; diff --git a/Lb-web/src/features/Login/context/AuthContext.tsx b/Lb-web/src/features/Login/context/AuthContext.tsx index 7c2e2dc..f083f77 100644 --- a/Lb-web/src/features/Login/context/AuthContext.tsx +++ b/Lb-web/src/features/Login/context/AuthContext.tsx @@ -3,6 +3,7 @@ import api from "@/services/api"; interface AuthContextType { isAuthenticated: boolean | null; + role: string | null; loading: boolean; login: () => void; // call this after successful login to update state logout: () => Promise; @@ -12,14 +13,17 @@ const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { const [isAuthenticated, setIsAuthenticated] = useState(null); + const [role, setRole] = useState(null); const [loading, setLoading] = useState(true); const checkAuth = async () => { try { - await api.get('/protected/user/me'); + const response = await api.get('/protected/user/me'); setIsAuthenticated(true); + setRole(response.data.data.role); } catch { setIsAuthenticated(false); + setRole(null); } finally { setLoading(false); } @@ -31,6 +35,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const login = () => { setIsAuthenticated(true); + checkAuth(); // Re-fetch user data to get role }; const logout = async () => { @@ -40,11 +45,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { console.error("Logout failed", error); } finally { setIsAuthenticated(false); + setRole(null); } }; return ( - + {children} ); diff --git a/Lb-web/src/features/Login/hooks/useLogin.ts b/Lb-web/src/features/Login/hooks/useLogin.ts index 70192eb..95bb01e 100644 --- a/Lb-web/src/features/Login/hooks/useLogin.ts +++ b/Lb-web/src/features/Login/hooks/useLogin.ts @@ -1,6 +1,7 @@ import { useState } from 'react'; -import api from '@/services/api'; + import { useAuth } from '../context/AuthContext'; +import { authService } from '../services/authService'; export const useLogin = () => { const [email, setEmail] = useState(''); @@ -15,7 +16,7 @@ export const useLogin = () => { setError(null); try { - await api.post('/auth/Login', { email, password }); + await authService.login({ email, password }); // Redirect or update global auth state here // For now, assuming successful cookie set by backend login(); // Update auth state, wrapper will handle redirect diff --git a/Lb-web/src/features/Login/hooks/useRegister.ts b/Lb-web/src/features/Login/hooks/useRegister.ts index cf3d13e..8ebceec 100644 --- a/Lb-web/src/features/Login/hooks/useRegister.ts +++ b/Lb-web/src/features/Login/hooks/useRegister.ts @@ -10,6 +10,8 @@ export const useRegister = () => { // Assuming for first run, the user being created is likely an Admin/Owner. // The backend might enforce this or we can just send it. + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const navigate = useNavigate(); @@ -24,7 +26,8 @@ export const useRegister = () => { username, email, password, - role + role, + token }); console.log("Registration successful"); // After successful registration, usually redirect to login or auto-login. @@ -51,6 +54,8 @@ export const useRegister = () => { setPassword, role, setRole, + token, + setToken, loading, error, handleRegister, diff --git a/Lb-web/src/features/Login/register.tsx b/Lb-web/src/features/Login/register.tsx index b692fce..eb0b6e4 100644 --- a/Lb-web/src/features/Login/register.tsx +++ b/Lb-web/src/features/Login/register.tsx @@ -9,6 +9,9 @@ import { } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { useNavigate, useSearchParams } from "react-router"; +import { useEffect, useState } from "react"; +import { systemService } from "./services/systemService"; import { useRegister } from "./hooks/useRegister"; export default function Register() { @@ -17,9 +20,48 @@ export default function Register() { email, setEmail, password, setPassword, role, setRole, - loading, error, handleRegister + loading, error, handleRegister, setToken } = useRegister(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [checking, setChecking] = useState(true); + + useEffect(() => { + const checkAccess = async () => { + const token = searchParams.get("token"); + if (token) { + // If token exists, we assume it's a valid invite (or backend will reject on submit) + // Just allow access to the page + setToken(token); + setChecking(false); + return; + } + + try { + // No token, check if system is already initialized + const isInit = await systemService.checkInitStatus(); + if (isInit) { + // System initialized and no token -> unauthorized + navigate("/login"); + } else { + // System not initialized -> first user setup -> allow + setChecking(false); + } + } catch (err) { + console.error("Failed to check system status", err); + // Fail safe? Maybe redirect to login + navigate("/login"); + } + }; + + checkAccess(); + }, [navigate, searchParams]); + + if (checking) { + return
Checking access...
; + } + return (
{/* Dynamic Background Elements */} diff --git a/Lb-web/src/features/Login/services/authService.ts b/Lb-web/src/features/Login/services/authService.ts new file mode 100644 index 0000000..75ba87e --- /dev/null +++ b/Lb-web/src/features/Login/services/authService.ts @@ -0,0 +1,13 @@ +import api from '@/services/api'; + +export interface LoginRequest { + email: string; + password?: string; + // Add other fields if necessary +} + +export const authService = { + login: async (data: LoginRequest) => { + return api.post('/auth/Login', data); + }, +}; diff --git a/Lb-web/src/features/Login/services/systemService.ts b/Lb-web/src/features/Login/services/systemService.ts new file mode 100644 index 0000000..8a247ec --- /dev/null +++ b/Lb-web/src/features/Login/services/systemService.ts @@ -0,0 +1,8 @@ +import api from '@/services/api'; + +export const systemService = { + checkInitStatus: async (): Promise => { + const response = await api.get<{ data: boolean }>('/system/status'); + return response.data.data; + } +}; diff --git a/Lb-web/src/features/admin-setting/components/SettingsSidebar.tsx b/Lb-web/src/features/admin-setting/components/SettingsSidebar.tsx new file mode 100644 index 0000000..83c7d8a --- /dev/null +++ b/Lb-web/src/features/admin-setting/components/SettingsSidebar.tsx @@ -0,0 +1,76 @@ +import { Users, Palette } from "lucide-react"; +import { Link, useLocation } from "react-router"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/features/Login/context/AuthContext"; + +const generalItems = [ + { + title: "Appearance", + icon: Palette, + href: "/settings/appearance", + variant: "ghost" + } +]; + +const adminItems = [ + { + title: "User Management", + icon: Users, + href: "/settings/users", + variant: "ghost" + } +]; + +export function SettingsSidebar() { + const location = useLocation(); + const { role } = useAuth(); + + const NavItem = ({ item }: { item: any }) => ( + + ); + + return ( + + ); +} diff --git a/Lb-web/src/features/admin-setting/layout/SettingsLayout.tsx b/Lb-web/src/features/admin-setting/layout/SettingsLayout.tsx new file mode 100644 index 0000000..e9e2875 --- /dev/null +++ b/Lb-web/src/features/admin-setting/layout/SettingsLayout.tsx @@ -0,0 +1,71 @@ +import { Outlet, Link } from "react-router"; +import { SettingsSidebar } from "../components/SettingsSidebar"; +import { LogOut, User } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/features/Login/context/AuthContext"; +import { useState } from "react"; + +export default function SettingsLayout() { + const { logout } = useAuth(); + const [isProfileOpen, setIsProfileOpen] = useState(false); + + const handleLogout = async () => { + await logout(); + }; + + return ( +
+ {/* Subtle Grid & Gradient Background (Reused from Home) */} +
+ + {/* Navbar (Reused from Home but simplified or modularized ideally) */} +
+
+ + Localbase Logo +

Localbase

+ +
+
+
+ + + {isProfileOpen && ( + <> +
setIsProfileOpen(false)}>
+
+
+ +
+
+ + )} +
+
+
+ + {/* Main Content Area with Sidebar */} +
+ +
+ +
+
+
+ ); +} diff --git a/Lb-web/src/features/admin-setting/pages/Appearance.tsx b/Lb-web/src/features/admin-setting/pages/Appearance.tsx new file mode 100644 index 0000000..8811b57 --- /dev/null +++ b/Lb-web/src/features/admin-setting/pages/Appearance.tsx @@ -0,0 +1,16 @@ +export default function Appearance() { + return ( +
+
+

Appearance

+

Customize the look and feel of your application.

+
+ +
+
+

Theme settings coming soon...

+
+
+
+ ); +} diff --git a/Lb-web/src/features/admin-setting/pages/UserManagement.tsx b/Lb-web/src/features/admin-setting/pages/UserManagement.tsx new file mode 100644 index 0000000..ea7e214 --- /dev/null +++ b/Lb-web/src/features/admin-setting/pages/UserManagement.tsx @@ -0,0 +1,333 @@ +import { Button } from "@/components/ui/button"; +import { Plus, Loader2, Copy, Check, Pencil } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { useState, useEffect } from "react"; +import { userManagementService, type User } from "../services/userManagementService"; +import { Label } from "@/components/ui/label"; + +export default function UserManagement() { + const [email, setEmail] = useState(""); + const [role, setRole] = useState("user"); + const [open, setOpen] = useState(false); + + // Add User / Invite loading state + const [loading, setLoading] = useState(false); + const [inviteLink, setInviteLink] = useState(null); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + + // User list state + const [users, setUsers] = useState([]); + const [fetchingUsers, setFetchingUsers] = useState(true); + + // Edit User state + const [editOpen, setEditOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [editRole, setEditRole] = useState("user"); + const [updatingRole, setUpdatingRole] = useState(false); + + useEffect(() => { + fetchUsers(); + }, []); + + const fetchUsers = async () => { + setFetchingUsers(true); + try { + const data = await userManagementService.getAllUsers(); + setUsers(data); + } catch (err) { + console.error("Failed to fetch users:", err); + } finally { + setFetchingUsers(false); + } + }; + + const handleInvite = async () => { + if (!email) return; + + setLoading(true); + setError(null); + try { + const response = await userManagementService.inviteUser({ email, role }); + setInviteLink(response.data); + } catch (err) { + console.error("Failed to invite user:", err); + setError("Failed to generate invite link. Please try again."); + } finally { + setLoading(false); + } + }; + + const handleCopyLink = () => { + if (inviteLink) { + navigator.clipboard.writeText(inviteLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const resetForm = () => { + setEmail(""); + setRole("user"); + setInviteLink(null); + setError(null); + setLoading(false); + }; + + const openEditModal = (user: User) => { + setEditingUser(user); + setEditRole(user.role); + setEditOpen(true); + }; + + const handleUpdateRole = async () => { + if (!editingUser) return; + setUpdatingRole(true); + try { + await userManagementService.updateUserRole(editingUser.id, editRole); + setEditOpen(false); + setEditingUser(null); + fetchUsers(); // Refresh the list + } catch (err) { + console.error("Failed to update role:", err); + // Optionally show error toast or message + } finally { + setUpdatingRole(false); + } + }; + + return ( +
+
+
+

User Management

+

Manage system users and their roles.

+
+ {/* Add User Dialog */} + { + setOpen(val); + if (!val) resetForm(); + }}> + + + + + + + {inviteLink ? "Invite Link Generated" : "Add New User"} + + + {inviteLink + ? "Share this link with the user to let them join the system." + : "Invite a new user to the system by entering their email address and selecting a role." + } + + + + {!inviteLink ? ( +
+
+ + setEmail(e.target.value)} + /> +
+
+ + +
+ {error &&

{error}

} +
+ ) : ( +
+
+
+ + +
+ +
+
+ )} + + + {!inviteLink ? ( + + ) : ( + + )} + +
+
+ + {/* Edit Role Dialog */} + + + + Edit User Role + + Change the role for {editingUser?.username} ({editingUser?.email}). + + +
+
+ + +
+
+ + + + +
+
+
+ + {fetchingUsers ? ( +
+ +
+ ) : users.length === 0 ? ( +
+
+ +
+

No users found

+

+ You haven't added any users yet. Click the button above to create a new user account. +

+
+ ) : ( +
+
+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + +
IDUsernameEmailRoleCreated AtActions
{user.id}{user.username}{user.email} + + {user.role} + + + {new Date(user.created_at).toLocaleDateString()} + + +
+
+
+ )} +
+ ); +} diff --git a/Lb-web/src/features/admin-setting/services/userManagementService.ts b/Lb-web/src/features/admin-setting/services/userManagementService.ts new file mode 100644 index 0000000..7e3ffa5 --- /dev/null +++ b/Lb-web/src/features/admin-setting/services/userManagementService.ts @@ -0,0 +1,41 @@ +import api from '@/services/api'; + +export interface InviteUserResponse { + data: string; + message: string; +} + +export interface InviteUserRequest { + email: string; + role: string; +} + +export interface User { + id: number; + username: string; + email: string; + role: string; + created_at: string; +} + +export interface GetAllUsersResponse { + message: string; + data: User[]; +} + +export const userManagementService = { + inviteUser: async (data: InviteUserRequest): Promise => { + const response = await api.post('/protected/admin/invite', data); + return response.data; + }, + + getAllUsers: async (): Promise => { + const response = await api.get('/protected/admin/alluser'); + return response.data.data; + }, + + updateUserRole: async (id: number, role: string): Promise => { + const response = await api.put<{ message: string, data: User }>('/protected/admin/updaterole', { id, role }); + return response.data.data; + } +}; diff --git a/Lb-web/src/main.tsx b/Lb-web/src/main.tsx index bd46efe..e120bda 100644 --- a/Lb-web/src/main.tsx +++ b/Lb-web/src/main.tsx @@ -4,9 +4,13 @@ import './index.css' import Login from './features/Login/login.tsx' import Register from './features/Login/register.tsx' import Home from './pages/Home.tsx' -import { createBrowserRouter, RouterProvider } from 'react-router' +import { createBrowserRouter, RouterProvider, Navigate } from 'react-router' import { AuthProvider } from './features/Login/context/AuthContext.tsx' -import { ProtectedRoute, PublicRoute } from './features/Login/components/AuthWrappers.tsx' +import { ProtectedRoute, PublicRoute, AdminRoute } from './features/Login/components/AuthWrappers.tsx' + +import SettingsLayout from './features/admin-setting/layout/SettingsLayout.tsx' +import UserManagement from './features/admin-setting/pages/UserManagement.tsx' +import Appearance from './features/admin-setting/pages/Appearance.tsx' const router = createBrowserRouter([ { @@ -19,6 +23,10 @@ const router = createBrowserRouter([ { path: 'register', element: + }, + { + path: 'registerinvite', + element: } ] }, @@ -29,6 +37,29 @@ const router = createBrowserRouter([ { index: true, element: + }, + { + path: 'settings', + element: , + children: [ + { + element: , + children: [ + { + path: 'users', + element: + } + ] + }, + { + path: 'appearance', + element: + }, + { + index: true, + element: + } + ] } ] } diff --git a/Lb-web/src/pages/Home.tsx b/Lb-web/src/pages/Home.tsx index 4b4844a..a6d446c 100644 --- a/Lb-web/src/pages/Home.tsx +++ b/Lb-web/src/pages/Home.tsx @@ -1,5 +1,6 @@ import { Button } from "@/components/ui/button"; import { User, Settings, Plus, LogOut } from "lucide-react"; +import { Link } from "react-router"; import { useState } from "react"; import { useAuth } from "@/features/Login/context/AuthContext"; @@ -58,10 +59,12 @@ export default function Home() { )}
- + + +
diff --git a/cmd/server/main.go b/cmd/server/main.go index a5dc1df..e5025f8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -52,14 +52,14 @@ func SetupApp(db *repository.DBContainer, cfg *config.Config) Routes.Deps { userService := services.NewUserService(userRepo) userHandler := handlers.NewUserHandler(userService) - authRepo := repository.NewAuthRepo(db) - authService := services.NewAuthService(authRepo) - authHandler := handlers.NewAuthHandler(authService) - systemRepo := repository.NewSystemRepo(db) systemService := services.NewSystemService(systemRepo) systemHandler := handlers.NewSystemHandler(systemService) + authRepo := repository.NewAuthRepo(db) + authService := services.NewAuthService(authRepo, systemRepo) + authHandler := handlers.NewAuthHandler(authService) + return Routes.Deps{ User: userHandler, Auth: authHandler, diff --git a/go.mod b/go.mod index 23d07ae..a52399d 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // 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.8.0 // indirect diff --git a/go.sum b/go.sum index 32c3108..10ec40e 100644 --- a/go.sum +++ b/go.sum @@ -38,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=