package auth import ( "context" "net/http" "time" "tyto/internal/database" "github.com/gin-gonic/gin" ) // UsersAPI provides HTTP handlers for user management (admin). type UsersAPI struct { db database.Database auth *Service authz *Authorizer } // NewUsersAPI creates a new users API handler. func NewUsersAPI(db database.Database, auth *Service, authz *Authorizer) *UsersAPI { return &UsersAPI{ db: db, auth: auth, authz: authz, } } // RegisterRoutes registers user management routes. func (a *UsersAPI) RegisterRoutes(group *gin.RouterGroup, authMiddleware *Middleware) { users := group.Group("/users") users.Use(authMiddleware.Required()) { // View users (requires users:view) users.GET("", a.authz.RequirePermission(PermUsersView), a.handleListUsers) users.GET("/:id", a.authz.RequirePermission(PermUsersView), a.handleGetUser) users.GET("/:id/roles", a.authz.RequirePermission(PermUsersView), a.handleGetUserRoles) // Manage users (requires users:manage) users.POST("", a.authz.RequirePermission(PermUsersManage), a.handleCreateUser) users.PUT("/:id", a.authz.RequirePermission(PermUsersManage), a.handleUpdateUser) users.DELETE("/:id", a.authz.RequirePermission(PermUsersManage), a.handleDisableUser) users.POST("/:id/enable", a.authz.RequirePermission(PermUsersManage), a.handleEnableUser) users.POST("/:id/reset-password", a.authz.RequirePermission(PermUsersManage), a.handleResetPassword) users.PUT("/:id/roles", a.authz.RequirePermission(PermUsersManage), a.handleSetUserRoles) } } // AdminUserResponse represents a user in admin API responses. type AdminUserResponse struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email,omitempty"` AuthProvider string `json:"authProvider"` Roles []string `json:"roles,omitempty"` Disabled bool `json:"disabled"` CreatedAt string `json:"createdAt"` LastLogin string `json:"lastLogin,omitempty"` } func (a *UsersAPI) userToResponse(ctx context.Context, u *database.User) AdminUserResponse { resp := AdminUserResponse{ ID: u.ID, Username: u.Username, Email: u.Email, AuthProvider: string(u.AuthProvider), Disabled: u.Disabled, CreatedAt: u.CreatedAt.Format(time.RFC3339), } if !u.LastLogin.IsZero() { resp.LastLogin = u.LastLogin.Format(time.RFC3339) } // Get roles roles, _ := a.db.GetUserRoles(ctx, u.ID) resp.Roles = make([]string, len(roles)) for i, r := range roles { resp.Roles[i] = r.ID } return resp } func (a *UsersAPI) handleListUsers(c *gin.Context) { users, err := a.db.ListUsers(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"}) return } response := make([]AdminUserResponse, len(users)) for i, u := range users { response[i] = a.userToResponse(c.Request.Context(), u) } c.JSON(http.StatusOK, response) } func (a *UsersAPI) handleGetUser(c *gin.Context) { id := c.Param("id") user, err := a.db.GetUser(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"}) return } if user == nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } c.JSON(http.StatusOK, a.userToResponse(c.Request.Context(), user)) } func (a *UsersAPI) handleGetUserRoles(c *gin.Context) { id := c.Param("id") roles, err := a.db.GetUserRoles(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user roles"}) return } response := make([]RoleResponse, len(roles)) for i, r := range roles { response[i] = roleToResponse(r) } c.JSON(http.StatusOK, response) } // CreateUserRequest represents a user creation request. type CreateUserRequest struct { Username string `json:"username" binding:"required,min=3,max=32"` Password string `json:"password" binding:"omitempty,min=8"` Email string `json:"email" binding:"omitempty,email"` Roles []string `json:"roles"` Disabled bool `json:"disabled"` } func (a *UsersAPI) handleCreateUser(c *gin.Context) { var req CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } user, err := a.auth.CreateUser(c.Request.Context(), req.Username, req.Password, req.Email, req.Disabled) if err != nil { if err == ErrUsernameExists { c.JSON(http.StatusConflict, gin.H{"error": "username already exists"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) return } // Assign roles for _, roleID := range req.Roles { a.db.AssignRole(c.Request.Context(), user.ID, roleID) } c.JSON(http.StatusCreated, a.userToResponse(c.Request.Context(), user)) } // AdminUpdateUserRequest represents a user update request from admin. type AdminUpdateUserRequest struct { Email string `json:"email" binding:"omitempty,email"` Disabled bool `json:"disabled"` } func (a *UsersAPI) handleUpdateUser(c *gin.Context) { id := c.Param("id") user, err := a.db.GetUser(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"}) return } if user == nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } var req AdminUpdateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } user.Email = req.Email // Handle disable state change if req.Disabled && !user.Disabled { // Disabling user - invalidate sessions if err := a.auth.DisableUser(c.Request.Context(), user.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to disable user"}) return } } else if !req.Disabled && user.Disabled { // Enabling user if err := a.auth.EnableUser(c.Request.Context(), user.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable user"}) return } } else { // Just update profile if err := a.auth.UpdateUser(c.Request.Context(), user); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"}) return } } // Refresh user data user, _ = a.db.GetUser(c.Request.Context(), id) c.JSON(http.StatusOK, a.userToResponse(c.Request.Context(), user)) } func (a *UsersAPI) handleDisableUser(c *gin.Context) { id := c.Param("id") // Prevent self-disable currentUser := GetUser(c) if currentUser != nil && currentUser.ID == id { c.JSON(http.StatusBadRequest, gin.H{"error": "cannot disable your own account"}) return } if err := a.auth.DisableUser(c.Request.Context(), id); err != nil { if err == ErrUserNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to disable user"}) return } c.JSON(http.StatusOK, gin.H{"message": "user disabled"}) } func (a *UsersAPI) handleEnableUser(c *gin.Context) { id := c.Param("id") if err := a.auth.EnableUser(c.Request.Context(), id); err != nil { if err == ErrUserNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable user"}) return } c.JSON(http.StatusOK, gin.H{"message": "user enabled"}) } // ResetPasswordRequest represents a password reset request. type ResetPasswordRequest struct { NewPassword string `json:"newPassword" binding:"required,min=8"` } func (a *UsersAPI) handleResetPassword(c *gin.Context) { id := c.Param("id") var req ResetPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } if err := a.auth.ResetPassword(c.Request.Context(), id, req.NewPassword); err != nil { if err == ErrUserNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"}) return } c.JSON(http.StatusOK, gin.H{"message": "password reset"}) } // SetUserRolesRequest represents a role assignment request. type SetUserRolesRequest struct { Roles []string `json:"roles" binding:"required"` } func (a *UsersAPI) handleSetUserRoles(c *gin.Context) { id := c.Param("id") user, err := a.db.GetUser(c.Request.Context(), id) if err != nil || user == nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } var req SetUserRolesRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } // Validate roles exist for _, roleID := range req.Roles { role, err := a.db.GetRole(c.Request.Context(), roleID) if err != nil || role == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role: " + roleID}) return } } // Get current roles currentRoles, _ := a.db.GetUserRoles(c.Request.Context(), id) currentRoleIDs := make(map[string]bool) for _, r := range currentRoles { currentRoleIDs[r.ID] = true } // Build target role set targetRoleIDs := make(map[string]bool) for _, roleID := range req.Roles { targetRoleIDs[roleID] = true } // Remove roles not in target for roleID := range currentRoleIDs { if !targetRoleIDs[roleID] { a.db.RemoveRole(c.Request.Context(), id, roleID) } } // Add roles not in current for roleID := range targetRoleIDs { if !currentRoleIDs[roleID] { a.db.AssignRole(c.Request.Context(), id, roleID) } } c.JSON(http.StatusOK, gin.H{"message": "roles updated"}) }