diff --git a/db/data/001_initial_seed_data.sql b/db/data/001_initial_seed_data.sql index f5f2cb0..299a3a4 100644 --- a/db/data/001_initial_seed_data.sql +++ b/db/data/001_initial_seed_data.sql @@ -72,10 +72,10 @@ VALUES 'Admin', 'Female', '1995-01-01', - 'admin@yimaru.com', + 'yaredyemane1@gmail.com', '0911001100', 'ADMIN', - crypt('password@123', gen_salt('bf'))::bytea, + crypt('password123', gen_salt('bf'))::bytea, '35_44', 'Master', 'Ethiopia', @@ -410,3 +410,172 @@ VALUES (3, 12, 1), (4, 10, 1), (4, 12, 2) ON CONFLICT (question_set_id, user_id) DO NOTHING; + +-- ====================================================== +-- Team Members (Internal LMS Staff) +-- ====================================================== + +INSERT INTO team_members ( + id, + first_name, + last_name, + email, + phone_number, + password, + team_role, + department, + job_title, + employment_type, + hire_date, + bio, + status, + email_verified, + permissions, + created_at +) +VALUES +( + 1, + 'Yared', + 'Yemane', + 'yared@yimaru.com', + '0911001100', + crypt('password@123', gen_salt('bf'))::bytea, + 'SUPER_ADMIN', + 'Engineering', + 'CTO', + 'full_time', + '2024-01-01', + 'Platform super administrator with full system access.', + 'active', + TRUE, + '["*"]'::jsonb, + CURRENT_TIMESTAMP +), +( + 2, + 'Admin', + 'User', + 'admin@yimaru.com', + '0922001100', + crypt('password@123', gen_salt('bf'))::bytea, + 'ADMIN', + 'Operations', + 'Operations Manager', + 'full_time', + '2024-02-01', + 'Administrative staff managing day-to-day operations.', + 'active', + TRUE, + '["users.manage", "courses.manage", "settings.manage"]'::jsonb, + CURRENT_TIMESTAMP +), +( + 3, + 'Content', + 'MANAGER', + 'content@yimaru.com', + '0933001100', + crypt('password@123', gen_salt('bf'))::bytea, + 'CONTENT_MANAGER', + 'Content', + 'Content Lead', + 'full_time', + '2024-03-01', + 'Manages all course content and curriculum.', + 'active', + TRUE, + '["courses.manage", "courses.publish", "content.manage"]'::jsonb, + CURRENT_TIMESTAMP +), +( + 4, + 'Support', + 'AGENT', + 'support-team@yimaru.com', + '0944001100', + crypt('password@123', gen_salt('bf'))::bytea, + 'SUPPORT_AGENT', + 'Support', + 'Customer Support Specialist', + 'full_time', + '2024-03-15', + 'Handles customer inquiries and support tickets.', + 'active', + TRUE, + '["users.view", "tickets.manage", "support.manage"]'::jsonb, + CURRENT_TIMESTAMP +), +( + 5, + 'INSTRUCTOR', + 'Demo', + 'instructor@yimaru.com', + '0955001100', + crypt('password@123', gen_salt('bf'))::bytea, + 'INSTRUCTOR', + 'Education', + 'Senior Instructor', + 'full_time', + '2024-04-01', + 'Creates and manages course materials.', + 'active', + TRUE, + '["courses.create", "courses.edit", "students.view"]'::jsonb, + CURRENT_TIMESTAMP +), +( + 6, + 'FINANCE', + 'Officer', + 'finance@yimaru.com', + '0966001100', + crypt('password@123', gen_salt('bf'))::bytea, + 'FINANCE', + 'Finance', + 'Finance Officer', + 'full_time', + '2024-04-15', + 'Manages payments, subscriptions, and financial reports.', + 'active', + TRUE, + '["payments.manage", "subscriptions.manage", "reports.finance"]'::jsonb, + CURRENT_TIMESTAMP +), +( + 7, + 'HR', + 'MANAGER', + 'hr@yimaru.com', + '0977001100', + crypt('password@123', gen_salt('bf'))::bytea, + 'HR', + 'Human Resources', + 'HR Manager', + 'full_time', + '2024-05-01', + 'Manages team members and HR operations.', + 'active', + TRUE, + '["team.manage", "team.create", "team.delete"]'::jsonb, + CURRENT_TIMESTAMP +), +( + 8, + 'Data', + 'Analyst', + 'analyst@yimaru.com', + '0988001100', + crypt('password@123', gen_salt('bf'))::bytea, + 'ANALYST', + 'Analytics', + 'Data Analyst', + 'contract', + '2024-06-01', + 'Generates reports and analyzes platform metrics.', + 'active', + TRUE, + '["reports.view", "analytics.view", "users.view"]'::jsonb, + CURRENT_TIMESTAMP +) +ON CONFLICT (id) DO NOTHING; diff --git a/db/migrations/000011_team_management.up.sql b/db/migrations/000011_team_management.up.sql index 09c587f..172b293 100644 --- a/db/migrations/000011_team_management.up.sql +++ b/db/migrations/000011_team_management.up.sql @@ -14,14 +14,14 @@ CREATE TABLE IF NOT EXISTS team_members ( -- Role within the team (different from learner roles) team_role VARCHAR(50) NOT NULL CHECK ( team_role IN ( - 'super_admin', -- Full system access - 'admin', -- Administrative tasks - 'content_manager', -- Manages courses, content - 'support_agent', -- Customer support - 'instructor', -- Creates/manages courses - 'finance', -- Payment/subscription management - 'hr', -- Team member management - 'analyst' -- Reports and analytics + 'SUPER_ADMIN', -- Full system access + 'ADMIN', -- Administrative tasks + 'CONTENT_MANAGER', -- Manages courses, content + 'SUPPORT_AGENT', -- Customer support + 'INSTRUCTOR', -- Creates/manages courses + 'FINANCE', -- Payment/subscription management + 'HR', -- Team member management + 'ANALYST' -- Reports and analytics ) ), diff --git a/internal/domain/role.go b/internal/domain/role.go index a974dc2..27ed20c 100644 --- a/internal/domain/role.go +++ b/internal/domain/role.go @@ -3,11 +3,11 @@ package domain type Role string const ( - RoleSuperAdmin Role = "super_admin" - RoleAdmin Role = "admin" - RoleStudent Role = "student" - RoleInstructor Role = "instructor" - RoleSupport Role = "support" + RoleSuperAdmin Role = "SUPER_ADMIN" + RoleAdmin Role = "ADMIN" + RoleStudent Role = "STUDENT" + RoleInstructor Role = "INSTRUCTOR" + RoleSupport Role = "SUPPORT" ) func (r Role) IsValid() bool { diff --git a/internal/domain/user.go b/internal/domain/user.go index 73c930f..6f45aa2 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -169,9 +169,10 @@ type CreateUserReq struct { } type ResetPasswordReq struct { - UserID int64 - Password string - OtpCode string + Email string + PhoneNumber string + Password string + OtpCode string } type UpdateUserStatusReq struct { diff --git a/internal/ports/user.go b/internal/ports/user.go index 243f924..c362572 100644 --- a/internal/ports/user.go +++ b/internal/ports/user.go @@ -71,6 +71,7 @@ type UserStore interface { phone string, ) (domain.User, error) UpdatePassword(ctx context.Context, password string, userID int64) error + UpdatePasswordHash(ctx context.Context, hashedPassword []byte, userID int64) error RegisterDevice(ctx context.Context, userID int64, deviceToken, platform string) error GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error) DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error diff --git a/internal/repository/user.go b/internal/repository/user.go index acb4779..bfa0d81 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -751,7 +751,7 @@ func (s *Store) GetUserByEmailPhone( }, nil } -// UpdatePassword updates a user's password +// UpdatePassword updates a user's password (deprecated - use UpdatePasswordHash) func (s *Store) UpdatePassword(ctx context.Context, password string, userID int64) error { return s.queries.UpdatePassword(ctx, dbgen.UpdatePasswordParams{ Password: []byte(password), @@ -759,6 +759,14 @@ func (s *Store) UpdatePassword(ctx context.Context, password string, userID int6 }) } +// UpdatePasswordHash updates a user's password with a pre-hashed value +func (s *Store) UpdatePasswordHash(ctx context.Context, hashedPassword []byte, userID int64) error { + return s.queries.UpdatePassword(ctx, dbgen.UpdatePasswordParams{ + Password: hashedPassword, + ID: userID, + }) +} + // mapUser converts dbgen.User to domain.User func mapCreateUserResult( userRes dbgen.CreateUserRow, diff --git a/internal/services/user/reset.go b/internal/services/user/reset.go index a3ccfb4..969b05d 100644 --- a/internal/services/user/reset.go +++ b/internal/services/user/reset.go @@ -3,54 +3,91 @@ package user import ( "Yimaru-Backend/internal/domain" "context" - + "crypto/subtle" + "errors" "time" ) -func (s *Service) SendResetCode(ctx context.Context, userID int64, medium domain.OtpMedium, sentTo string, provider domain.SMSProvider) error { - +func (s *Service) SendResetCode(ctx context.Context, medium domain.OtpMedium, sentTo string, provider domain.SMSProvider) error { + var user domain.User var err error - // check if user exists + + // Look up user by email or phone to get the actual userID switch medium { case domain.OtpMediumEmail: - _, err = s.userStore.GetUserByEmailPhone(ctx, sentTo, "") + user, err = s.userStore.GetUserByEmailPhone(ctx, sentTo, "") case domain.OtpMediumSms: - _, err = s.userStore.GetUserByEmailPhone(ctx, "", sentTo) + user, err = s.userStore.GetUserByEmailPhone(ctx, "", sentTo) + default: + return errors.New("invalid OTP medium") } if err != nil { return err } - return s.SendOtp(ctx, userID, sentTo, domain.OtpReset, medium, provider) - + // Use the actual user ID when storing OTP + return s.SendOtp(ctx, user.ID, sentTo, domain.OtpReset, medium, provider) } func (s *Service) ResetPassword(ctx context.Context, resetReq domain.ResetPasswordReq) error { + // Look up user by email or phone (don't trust client-provided user_id) + var user domain.User + var err error + + if resetReq.Email != "" { + user, err = s.userStore.GetUserByEmailPhone(ctx, resetReq.Email, "") + } else if resetReq.PhoneNumber != "" { + user, err = s.userStore.GetUserByEmailPhone(ctx, "", resetReq.PhoneNumber) + } else { + return errors.New("email or phone number is required") + } - otp, err := s.otpStore.GetOtp(ctx, resetReq.UserID) if err != nil { return err } - // user, err := s.userStore.GetUserByUserName(ctx, resetReq.UserName) - // if err != nil { - // return err - // } + // Get OTP for the actual user + otp, err := s.otpStore.GetOtp(ctx, user.ID) + if err != nil { + return err + } + + // Validate OTP purpose (should be for reset, not registration) + if otp.For != domain.OtpReset { + return domain.ErrInvalidOtp + } if otp.Used { return domain.ErrOtpAlreadyUsed } + if time.Now().After(otp.ExpiresAt) { return domain.ErrOtpExpired } - if otp.Otp != resetReq.OtpCode { + + // Use constant-time comparison for OTP + if subtle.ConstantTimeCompare([]byte(otp.Otp), []byte(resetReq.OtpCode)) != 1 { return domain.ErrInvalidOtp } - err = s.userStore.UpdatePassword(ctx, resetReq.Password, resetReq.UserID) + // Hash the new password before storing + hashedPassword, err := hashPassword(resetReq.Password) if err != nil { return err } + + // Update password with hashed value + err = s.userStore.UpdatePasswordHash(ctx, hashedPassword, user.ID) + if err != nil { + return err + } + + // Mark OTP as used to prevent replay attacks + err = s.otpStore.MarkOtpAsUsed(ctx, otp) + if err != nil { + return err + } + return nil } diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index 0b11c18..6f2cde7 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -73,11 +73,11 @@ func (h *Handler) GoogleAndroidLogin(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Login successful", - Data: fiber.Map{ - "accessToken": accessToken, - "refreshToken": loginRes.RfToken, - "userId": loginRes.UserId, - "role": loginRes.Role, + Data: loginUserRes{ + AccessToken: accessToken, + RefreshToken: loginRes.RfToken, + Role: string(loginRes.Role), + UserID: loginRes.UserId, }, }) } @@ -145,11 +145,11 @@ func (h *Handler) GoogleCallback(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Login successful", - Data: fiber.Map{ - "accessToken": accessToken, - "refreshToken": loginRes.RfToken, - "userId": loginRes.UserId, - "role": loginRes.Role, + Data: loginUserRes{ + AccessToken: accessToken, + RefreshToken: loginRes.RfToken, + Role: string(loginRes.Role), + UserID: loginRes.UserId, }, }) } diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 51c2be9..7eaf73a 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -1001,7 +1001,7 @@ func (h *Handler) SendResetCode(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Email or PhoneNumber must be provided") } - if err := h.userSvc.SendResetCode(c.Context(), 0, medium, sentTo, domain.AfroMessage); err != nil { + if err := h.userSvc.SendResetCode(c.Context(), medium, sentTo, domain.AfroMessage); err != nil { h.mongoLoggerSvc.Error("Failed to send reset code", zap.String("medium", string(medium)), zap.String("sentTo", string(sentTo)), @@ -1068,7 +1068,7 @@ func (h *Handler) SendTenantResetCode(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Email or PhoneNumber must be provided") } - if err := h.userSvc.SendResetCode(c.Context(), 0, medium, sentTo, domain.AfroMessage); err != nil { + if err := h.userSvc.SendResetCode(c.Context(), medium, sentTo, domain.AfroMessage); err != nil { h.mongoLoggerSvc.Error("Failed to send reset code", zap.String("medium", string(medium)), zap.String("sentTo", string(sentTo)), @@ -1083,9 +1083,10 @@ func (h *Handler) SendTenantResetCode(c *fiber.Ctx) error { } type ResetPasswordReq struct { - UserID int64 `json:"user_name" validate:"required" example:"johndoe"` - Password string `json:"password" validate:"required,min=8" example:"newpassword123"` - Otp string `json:"otp" validate:"required" example:"123456"` + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + Password string `json:"password" validate:"required,min=8" example:"newpassword123"` + Otp string `json:"otp" validate:"required" example:"123456"` } // ResetPassword godoc @@ -1100,7 +1101,6 @@ type ResetPasswordReq struct { // @Failure 500 {object} response.APIResponse // @Router /api/v1/user/resetPassword [post] func (h *Handler) ResetPassword(c *fiber.Ctx) error { - var req ResetPasswordReq if err := c.BodyParser(&req); err != nil { h.mongoLoggerSvc.Info("Failed to parse ResetPassword request", @@ -1119,37 +1119,21 @@ func (h *Handler) ResetPassword(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, errMsg) } - // user, err := h.userSvc.GetUserByUserName(c.Context(), req.UserName) - // if err != nil { - // h.mongoLoggerSvc.Info("Failed to get user by user name", - // zap.String("user_name", req.UserName), - // zap.Int("status_code", fiber.StatusBadRequest), - // zap.Error(err), - // zap.Time("timestamp", time.Now()), - // ) - // } - - // medium, err := getMedium(user.Email, user.PhoneNumber) - // if err != nil { - // h.mongoLoggerSvc.Info("Failed to determine medium for ResetPassword", - // zap.String("Email", user.Email), - // zap.String("Phone Number", user.PhoneNumber), - // zap.Int("status_code", fiber.StatusBadRequest), - // zap.Error(err), - // zap.Time("timestamp", time.Now()), - // ) - // return fiber.NewError(fiber.StatusBadRequest, err.Error()) - // } + if req.Email == "" && req.PhoneNumber == "" { + return fiber.NewError(fiber.StatusBadRequest, "Email or phone number is required") + } resetReq := domain.ResetPasswordReq{ - UserID: req.UserID, - Password: req.Password, - OtpCode: req.Otp, + Email: req.Email, + PhoneNumber: req.PhoneNumber, + Password: req.Password, + OtpCode: req.Otp, } if err := h.userSvc.ResetPassword(c.Context(), resetReq); err != nil { h.mongoLoggerSvc.Error("Failed to reset password", - zap.Any("userID", resetReq), + zap.String("email", req.Email), + zap.String("phone", req.PhoneNumber), zap.Int("status_code", fiber.StatusInternalServerError), zap.Error(err), zap.Time("timestamp", time.Now()), @@ -1172,59 +1156,8 @@ func (h *Handler) ResetPassword(c *fiber.Ctx) error { // @Failure 500 {object} response.APIResponse // @Router /api/v1/{tenant_slug}/user/resetPassword [post] func (h *Handler) ResetTenantPassword(c *fiber.Ctx) error { - // companyID := c.Locals("company_id").(domain.ValidInt64) - // if !companyID.Valid { - // h.BadRequestLogger().Error("invalid company id") - // return fiber.NewError(fiber.StatusBadRequest, "invalid company id") - // } - - var req ResetPasswordReq - if err := c.BodyParser(&req); err != nil { - h.mongoLoggerSvc.Info("Failed to parse ResetPassword request", - zap.Int("status_code", fiber.StatusBadRequest), - zap.Error(err), - zap.Time("timestamp", time.Now()), - ) - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body:"+err.Error()) - } - - if valErrs, ok := h.validator.Validate(c, req); !ok { - var errMsg string - for field, msg := range valErrs { - errMsg += fmt.Sprintf("%s: %s; ", field, msg) - } - return fiber.NewError(fiber.StatusBadRequest, errMsg) - } - - // medium, err := getMedium(req.Email, req.PhoneNumber) - // if err != nil { - // h.mongoLoggerSvc.Info("Failed to determine medium for ResetPassword", - // zap.String("Email", req.Email), - // zap.String("Phone Number", req.PhoneNumber), - // zap.Int("status_code", fiber.StatusBadRequest), - // zap.Error(err), - // zap.Time("timestamp", time.Now()), - // ) - // return fiber.NewError(fiber.StatusBadRequest, err.Error()) - // } - - resetReq := domain.ResetPasswordReq{ - UserID: req.UserID, - Password: req.Password, - OtpCode: req.Otp, - } - - if err := h.userSvc.ResetPassword(c.Context(), resetReq); err != nil { - h.mongoLoggerSvc.Error("Failed to reset password", - zap.Any("userID", resetReq), - zap.Int("status_code", fiber.StatusInternalServerError), - zap.Error(err), - zap.Time("timestamp", time.Now()), - ) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset password:"+err.Error()) - } - - return response.WriteJSON(c, fiber.StatusOK, "Password reset successful", nil, nil) + // Reuse the main ResetPassword handler + return h.ResetPassword(c) } // CustomerProfile godoc diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index e751064..2dd62ca 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -288,9 +288,6 @@ func (a *App) initAppRoutes() { groupV1.Post("/user/sendResetCode", h.SendResetCode) groupV1.Post("/user/verify-otp", h.VerifyOtp) groupV1.Post("/user/resend-otp", h.ResendOtp) - - groupV1.Post("/user/resetPassword", h.ResetTenantPassword) - groupV1.Post("/user/sendResetCode", h.SendTenantResetCode) groupV1.Post("/user/register", h.RegisterUser) groupV1.Post("/user/sendRegisterCode", h.SendRegisterCode) groupV1.Post("/user/checkPhoneEmailExist", h.CheckPhoneEmailExist) @@ -314,7 +311,7 @@ func (a *App) initAppRoutes() { // groupV1.Put("/t-approver/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTransactionApprover) //mongoDB logs - groupV1.Get("/logs", a.authMiddleware, a.SuperAdminOnly, handlers.GetLogsHandler(context.Background())) + groupV1.Get("/logs", a.authMiddleware, a.OnlyAdminAndAbove, handlers.GetLogsHandler(context.Background())) // groupV1.Get("/shop/transaction", a.authMiddleware, a.CompanyOnly, h.GetAllTransactions) // groupV1.Get("/shop/transaction/:id", a.authMiddleware, a.CompanyOnly, h.GetTransactionByID) @@ -361,13 +358,13 @@ func (a *App) initAppRoutes() { // Team Management Routes (Internal HR/Team) teamGroup := groupV1.Group("/team") - teamGroup.Post("/login", h.TeamMemberLogin) // Team member authentication - teamGroup.Get("/me", a.authMiddleware, h.GetMyTeamProfile) // Get own profile - teamGroup.Get("/stats", a.authMiddleware, a.OnlyAdminAndAbove, h.GetTeamMemberStats) // Team statistics - teamGroup.Get("/members", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAllTeamMembers) // List all team members - teamGroup.Post("/members", a.authMiddleware, a.OnlyAdminAndAbove, h.CreateTeamMember) // Create team member - teamGroup.Get("/members/:id", a.authMiddleware, h.GetTeamMember) // Get team member by ID - teamGroup.Put("/members/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTeamMember) // Update team member + teamGroup.Post("/login", h.TeamMemberLogin) // Team member authentication + teamGroup.Get("/me", a.authMiddleware, h.GetMyTeamProfile) // Get own profile + teamGroup.Get("/stats", a.authMiddleware, a.OnlyAdminAndAbove, h.GetTeamMemberStats) // Team statistics + teamGroup.Get("/members", a.authMiddleware, a.OnlyAdminAndAbove, h.GetAllTeamMembers) // List all team members + teamGroup.Post("/members", a.authMiddleware, a.OnlyAdminAndAbove, h.CreateTeamMember) // Create team member + teamGroup.Get("/members/:id", a.authMiddleware, h.GetTeamMember) // Get team member by ID + teamGroup.Put("/members/:id", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTeamMember) // Update team member teamGroup.Patch("/members/:id/status", a.authMiddleware, a.OnlyAdminAndAbove, h.UpdateTeamMemberStatus) // Update status teamGroup.Delete("/members/:id", a.authMiddleware, a.SuperAdminOnly, h.DeleteTeamMember) // Delete team member teamGroup.Post("/members/:id/change-password", a.authMiddleware, h.ChangeTeamMemberPassword) // Change password