diff --git a/backend/pkg/constants.go b/backend/pkg/constants.go index 92328b37a..be5af0d32 100644 --- a/backend/pkg/constants.go +++ b/backend/pkg/constants.go @@ -10,6 +10,7 @@ type DatabaseRepositoryType string type InstallationVerificationStatus string type InstallationQuotaStatus string type UserRole string +type Permission string const ( ResourceListPageSize int = 20 @@ -54,4 +55,7 @@ const ( UserRoleUser UserRole = "user" UserRoleAdmin UserRole = "admin" + + PermissionManageSources Permission = "manage_sources" + PermissionRead Permission = "read" ) diff --git a/backend/pkg/database/gorm_common.go b/backend/pkg/database/gorm_common.go index 8fa02e8da..07b42c488 100644 --- a/backend/pkg/database/gorm_common.go +++ b/backend/pkg/database/gorm_common.go @@ -192,6 +192,69 @@ func (gr *GormRepository) GetUsers(ctx context.Context) ([]models.User, error) { return sanitizedUsers, result.Error } +func (gr *GormRepository) GetUser(ctx context.Context, userID uuid.UUID) (*models.User, error) { + var user models.User + result := gr.GormClient.WithContext(ctx). + Preload("UserPermissions"). + First(&user, userID) + if result.Error != nil { + return nil, result.Error + } + + // Convert UserPermissions to the Permissions map + user.Permissions = make(map[string]map[string]bool) + for _, perm := range user.UserPermissions { + if _, exists := user.Permissions[perm.TargetUserID.String()]; !exists { + user.Permissions[perm.TargetUserID.String()] = make(map[string]bool) + } + user.Permissions[perm.TargetUserID.String()][string(perm.Permission)] = true + } + + // Clear sensitive fields + user.Password = "" + + return &user, nil +} + +func (gr *GormRepository) UpdateUserAndPermissions(ctx context.Context, user models.User) error { + // Lookup user from the db + var dbUser models.User + result := gr.GormClient.WithContext(ctx).First(&dbUser, user.ID) + if result.Error != nil { + return result.Error + } + + // Update only changed fields using Select + if err := gr.GormClient.WithContext(ctx).Model(&dbUser). + Select("full_name", "username", "email", "role"). + Updates(user).Error; err != nil { + return err + } + + // Convert permissions map to UserPermissions slice + var newPermissions []models.UserPermission + for targetUserID, permissions := range user.Permissions { + for permission, value := range permissions { + if !value { + continue + } + newPermissions = append(newPermissions, models.UserPermission{ + UserID: user.ID, + TargetUserID: uuid.Must(uuid.Parse(targetUserID)), + Permission: pkg.Permission(permission), + }) + } + } + + // Replace all permissions in a single operation + // This will automatically handle adding new permissions and removing old ones + if err := gr.GormClient.WithContext(ctx).Model(&dbUser).Association("UserPermissions").Replace(newPermissions); err != nil { + return err + } + + return nil +} + // //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/backend/pkg/database/gorm_repository_migrations.go b/backend/pkg/database/gorm_repository_migrations.go index db60d0b8e..e12c21fcc 100644 --- a/backend/pkg/database/gorm_repository_migrations.go +++ b/backend/pkg/database/gorm_repository_migrations.go @@ -11,6 +11,7 @@ import ( _20240114103850 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240114103850" _20240208112210 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240208112210" _20240813222836 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240813222836" + _20240827214347 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240827214347" "github.com/fastenhealth/fasten-onprem/backend/pkg/models" databaseModel "github.com/fastenhealth/fasten-onprem/backend/pkg/models/database" sourceCatalog "github.com/fastenhealth/fasten-sources/catalog" @@ -225,6 +226,20 @@ func (gr *GormRepository) Migrate() error { return nil }, }, + { + ID: "20240827214347", // add UserPermission model + Migrate: func(tx *gorm.DB) error { + + err := tx.AutoMigrate( + &_20240827214347.UserPermission{}, + ) + if err != nil { + return err + } + + return nil + }, + }, }) // run when database is empty diff --git a/backend/pkg/database/gorm_repository_test.go b/backend/pkg/database/gorm_repository_test.go index 54237f52c..923af829a 100644 --- a/backend/pkg/database/gorm_repository_test.go +++ b/backend/pkg/database/gorm_repository_test.go @@ -1437,3 +1437,277 @@ func (suite *RepositoryTestSuite) TestUpdateBackgroundJob() { require.Equal(suite.T(), pkg.BackgroundJobStatusFailed, foundAllBackgroundJobs[0].JobStatus) require.NotNil(suite.T(), foundAllBackgroundJobs[0].DoneTime) } + +func (suite *RepositoryTestSuite) TestUpdateUserAndPermissions() { + //setup + fakeConfig := mock_config.NewMockInterface(suite.MockCtrl) + fakeConfig.EXPECT().GetString("database.location").Return(suite.TestDatabase.Name()).AnyTimes() + fakeConfig.EXPECT().GetString("database.type").Return("sqlite").AnyTimes() + fakeConfig.EXPECT().IsSet("database.encryption.key").Return(false).AnyTimes() + fakeConfig.EXPECT().GetString("log.level").Return("INFO").AnyTimes() + dbRepo, err := NewRepository(fakeConfig, logrus.WithField("test", suite.T().Name()), event_bus.NewNoopEventBusServer()) + require.NoError(suite.T(), err) + + // Create initial user + userModel := &models.User{ + Username: "test_username", + Password: "testpassword", + Email: "test@test.com", + FullName: "Test User", + Role: pkg.UserRoleUser, + } + err = dbRepo.CreateUser(context.Background(), userModel) + require.NoError(suite.T(), err) + + // Create target user for permissions + targetUser := &models.User{ + Username: "target_user", + Password: "targetpass", + Email: "target@test.com", + FullName: "Target User", + Role: pkg.UserRoleUser, + } + err = dbRepo.CreateUser(context.Background(), targetUser) + require.NoError(suite.T(), err) + + // Update user with new details and permissions + updatedUser := models.User{ + ModelBase: models.ModelBase{ID: userModel.ID}, + Username: "test_username", + Email: "newemail@test.com", + FullName: "Updated Name", + Role: pkg.UserRoleAdmin, + Permissions: map[string]map[string]bool{ + targetUser.ID.String(): { + "read": true, + "manage_sources": true, + }, + }, + } + + // Test + err = dbRepo.UpdateUserAndPermissions(context.WithValue(context.Background(), pkg.ContextKeyTypeAuthUsername, "test_username"), updatedUser) + require.NoError(suite.T(), err) + + // Verify + updatedUserResult, err := dbRepo.GetUser(context.Background(), userModel.ID) + require.NoError(suite.T(), err) + require.Equal(suite.T(), "newemail@test.com", updatedUserResult.Email) + require.Equal(suite.T(), "Updated Name", updatedUserResult.FullName) + require.Equal(suite.T(), pkg.UserRoleAdmin, updatedUserResult.Role) + require.Equal(suite.T(), true, updatedUserResult.Permissions[targetUser.ID.String()]["read"]) + require.Equal(suite.T(), true, updatedUserResult.Permissions[targetUser.ID.String()]["manage_sources"]) +} + +func (suite *RepositoryTestSuite) TestUpdateUserAndPermissions_NonExistentUser() { + //setup + fakeConfig := mock_config.NewMockInterface(suite.MockCtrl) + fakeConfig.EXPECT().GetString("database.location").Return(suite.TestDatabase.Name()).AnyTimes() + fakeConfig.EXPECT().GetString("database.type").Return("sqlite").AnyTimes() + fakeConfig.EXPECT().IsSet("database.encryption.key").Return(false).AnyTimes() + fakeConfig.EXPECT().GetString("log.level").Return("INFO").AnyTimes() + dbRepo, err := NewRepository(fakeConfig, logrus.WithField("test", suite.T().Name()), event_bus.NewNoopEventBusServer()) + require.NoError(suite.T(), err) + + // Try to update non-existent user + nonExistentUser := models.User{ + ModelBase: models.ModelBase{ID: uuid.New()}, + Username: "nonexistent", + Email: "nonexistent@test.com", + FullName: "Non Existent", + Role: pkg.UserRoleUser, + } + + // Test + err = dbRepo.UpdateUserAndPermissions(context.Background(), nonExistentUser) + require.Error(suite.T(), err) +} + +func (suite *RepositoryTestSuite) TestUpdateUserAndPermissions_RemovePermissions() { + //setup + fakeConfig := mock_config.NewMockInterface(suite.MockCtrl) + fakeConfig.EXPECT().GetString("database.location").Return(suite.TestDatabase.Name()).AnyTimes() + fakeConfig.EXPECT().GetString("database.type").Return("sqlite").AnyTimes() + fakeConfig.EXPECT().IsSet("database.encryption.key").Return(false).AnyTimes() + fakeConfig.EXPECT().GetString("log.level").Return("INFO").AnyTimes() + dbRepo, err := NewRepository(fakeConfig, logrus.WithField("test", suite.T().Name()), event_bus.NewNoopEventBusServer()) + require.NoError(suite.T(), err) + + // Create initial user + userModel := &models.User{ + Username: "test_username", + Password: "testpassword", + Email: "test@test.com", + FullName: "Test User", + Role: pkg.UserRoleUser, + } + err = dbRepo.CreateUser(context.Background(), userModel) + require.NoError(suite.T(), err) + + // Create target user for permissions + targetUser := &models.User{ + Username: "target_user", + Password: "targetpass", + Email: "target@test.com", + FullName: "Target User", + Role: pkg.UserRoleUser, + } + err = dbRepo.CreateUser(context.Background(), targetUser) + require.NoError(suite.T(), err) + + // First update: add permissions + userWithPermissions := models.User{ + ModelBase: models.ModelBase{ID: userModel.ID}, + Username: "test_username", + Email: "test@test.com", + FullName: "Test User", + Role: pkg.UserRoleUser, + Permissions: map[string]map[string]bool{ + targetUser.ID.String(): { + "read": true, + "manage_sources": true, + }, + }, + } + + err = dbRepo.UpdateUserAndPermissions(context.WithValue(context.Background(), pkg.ContextKeyTypeAuthUsername, "test_username"), userWithPermissions) + require.NoError(suite.T(), err) + + // Second update: remove all permissions + userWithoutPermissions := models.User{ + ModelBase: models.ModelBase{ID: userModel.ID}, + Username: "test_username", + Email: "test@test.com", + FullName: "Test User", + Role: pkg.UserRoleUser, + Permissions: map[string]map[string]bool{}, + } + + // Test + err = dbRepo.UpdateUserAndPermissions(context.WithValue(context.Background(), pkg.ContextKeyTypeAuthUsername, "test_username"), userWithoutPermissions) + require.NoError(suite.T(), err) + + // Verify + updatedUserResult, err := dbRepo.GetUser(context.Background(), userModel.ID) + require.NoError(suite.T(), err) + require.Empty(suite.T(), updatedUserResult.Permissions[targetUser.ID.String()]) +} + +func (suite *RepositoryTestSuite) TestGetUser() { + //setup + fakeConfig := mock_config.NewMockInterface(suite.MockCtrl) + fakeConfig.EXPECT().GetString("database.location").Return(suite.TestDatabase.Name()).AnyTimes() + fakeConfig.EXPECT().GetString("database.type").Return("sqlite").AnyTimes() + fakeConfig.EXPECT().IsSet("database.encryption.key").Return(false).AnyTimes() + fakeConfig.EXPECT().GetString("log.level").Return("INFO").AnyTimes() + dbRepo, err := NewRepository(fakeConfig, logrus.WithField("test", suite.T().Name()), event_bus.NewNoopEventBusServer()) + require.NoError(suite.T(), err) + + // Create initial user + userModel := &models.User{ + Username: "test_username", + Password: "testpassword", + Email: "test@test.com", + FullName: "Test User", + Role: pkg.UserRoleUser, + } + err = dbRepo.CreateUser(context.Background(), userModel) + require.NoError(suite.T(), err) + + // Create target user for permissions + targetUser := &models.User{ + Username: "target_user", + Password: "targetpass", + Email: "target@test.com", + FullName: "Target User", + Role: pkg.UserRoleUser, + } + err = dbRepo.CreateUser(context.Background(), targetUser) + require.NoError(suite.T(), err) + + // Add permissions + userWithPermissions := models.User{ + ModelBase: models.ModelBase{ID: userModel.ID}, + Username: "test_username", + Email: "test@test.com", + FullName: "Test User", + Role: pkg.UserRoleUser, + Permissions: map[string]map[string]bool{ + targetUser.ID.String(): { + "read": true, + "manage_sources": true, + }, + }, + } + + err = dbRepo.UpdateUserAndPermissions(context.WithValue(context.Background(), pkg.ContextKeyTypeAuthUsername, "test_username"), userWithPermissions) + require.NoError(suite.T(), err) + + // Test + foundUser, err := dbRepo.GetUser(context.Background(), userModel.ID) + + // Verify + require.NoError(suite.T(), err) + require.Equal(suite.T(), userModel.ID, foundUser.ID) + require.Equal(suite.T(), userModel.Username, foundUser.Username) + require.Equal(suite.T(), userModel.Email, foundUser.Email) + require.Equal(suite.T(), userModel.FullName, foundUser.FullName) + require.Equal(suite.T(), userModel.Role, foundUser.Role) + require.Empty(suite.T(), foundUser.Password) // Password should be sanitized + require.Equal(suite.T(), true, foundUser.Permissions[targetUser.ID.String()]["read"]) + require.Equal(suite.T(), true, foundUser.Permissions[targetUser.ID.String()]["manage_sources"]) +} + +func (suite *RepositoryTestSuite) TestGetUser_NonExistentUser() { + //setup + fakeConfig := mock_config.NewMockInterface(suite.MockCtrl) + fakeConfig.EXPECT().GetString("database.location").Return(suite.TestDatabase.Name()).AnyTimes() + fakeConfig.EXPECT().GetString("database.type").Return("sqlite").AnyTimes() + fakeConfig.EXPECT().IsSet("database.encryption.key").Return(false).AnyTimes() + fakeConfig.EXPECT().GetString("log.level").Return("INFO").AnyTimes() + dbRepo, err := NewRepository(fakeConfig, logrus.WithField("test", suite.T().Name()), event_bus.NewNoopEventBusServer()) + require.NoError(suite.T(), err) + + // Test + nonExistentID := uuid.New() + foundUser, err := dbRepo.GetUser(context.Background(), nonExistentID) + + // Verify + require.Error(suite.T(), err) + require.Nil(suite.T(), foundUser) +} + +func (suite *RepositoryTestSuite) TestGetUser_NoPermissions() { + //setup + fakeConfig := mock_config.NewMockInterface(suite.MockCtrl) + fakeConfig.EXPECT().GetString("database.location").Return(suite.TestDatabase.Name()).AnyTimes() + fakeConfig.EXPECT().GetString("database.type").Return("sqlite").AnyTimes() + fakeConfig.EXPECT().IsSet("database.encryption.key").Return(false).AnyTimes() + fakeConfig.EXPECT().GetString("log.level").Return("INFO").AnyTimes() + dbRepo, err := NewRepository(fakeConfig, logrus.WithField("test", suite.T().Name()), event_bus.NewNoopEventBusServer()) + require.NoError(suite.T(), err) + + // Create user without any permissions + userModel := &models.User{ + Username: "test_username", + Password: "testpassword", + Email: "test@test.com", + FullName: "Test User", + Role: pkg.UserRoleUser, + } + err = dbRepo.CreateUser(context.Background(), userModel) + require.NoError(suite.T(), err) + + // Test + foundUser, err := dbRepo.GetUser(context.Background(), userModel.ID) + + // Verify + require.NoError(suite.T(), err) + require.Equal(suite.T(), userModel.ID, foundUser.ID) + require.Equal(suite.T(), userModel.Username, foundUser.Username) + require.Equal(suite.T(), userModel.Email, foundUser.Email) + require.Equal(suite.T(), userModel.FullName, foundUser.FullName) + require.Equal(suite.T(), userModel.Role, foundUser.Role) + require.Empty(suite.T(), foundUser.Password) // Password should be sanitized + require.Empty(suite.T(), foundUser.Permissions) // No permissions should be present +} diff --git a/backend/pkg/database/interface.go b/backend/pkg/database/interface.go index b49363034..11961bfa3 100644 --- a/backend/pkg/database/interface.go +++ b/backend/pkg/database/interface.go @@ -20,6 +20,8 @@ type DatabaseRepository interface { GetCurrentUser(ctx context.Context) (*models.User, error) DeleteCurrentUser(ctx context.Context) error GetUsers(ctx context.Context) ([]models.User, error) + GetUser(ctx context.Context, userId uuid.UUID) (*models.User, error) + UpdateUserAndPermissions(ctx context.Context, user models.User) error GetSummary(ctx context.Context) (*models.Summary, error) diff --git a/backend/pkg/database/migrations/20240827214347/user_permission.go b/backend/pkg/database/migrations/20240827214347/user_permission.go new file mode 100644 index 000000000..e0337700a --- /dev/null +++ b/backend/pkg/database/migrations/20240827214347/user_permission.go @@ -0,0 +1,20 @@ +package _20240827214347 + +import ( + "github.com/fastenhealth/fasten-onprem/backend/pkg/models" + "github.com/google/uuid" +) + +type Permission string + +const ( + PermissionManageSources Permission = "manage_sources" + PermissionRead Permission = "read" +) + +type UserPermission struct { + models.ModelBase + UserID uuid.UUID `json:"user_id" gorm:"type:uuid"` + TargetUserID uuid.UUID `json:"target_user_id" gorm:"type:uuid"` + Permission Permission `json:"permission"` +} diff --git a/backend/pkg/database/mock/mock_database.go b/backend/pkg/database/mock/mock_database.go index 64dd3a57e..032940ca8 100644 --- a/backend/pkg/database/mock/mock_database.go +++ b/backend/pkg/database/mock/mock_database.go @@ -357,6 +357,21 @@ func (mr *MockDatabaseRepositoryMockRecorder) GetSummary(ctx interface{}) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSummary", reflect.TypeOf((*MockDatabaseRepository)(nil).GetSummary), ctx) } +// GetUser mocks base method. +func (m *MockDatabaseRepository) GetUser(ctx context.Context, userId uuid.UUID) (*models.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUser", ctx, userId) + ret0, _ := ret[0].(*models.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUser indicates an expected call of GetUser. +func (mr *MockDatabaseRepositoryMockRecorder) GetUser(ctx, userId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockDatabaseRepository)(nil).GetUser), ctx, userId) +} + // GetUserByUsername mocks base method. func (m *MockDatabaseRepository) GetUserByUsername(arg0 context.Context, arg1 string) (*models.User, error) { m.ctrl.T.Helper() @@ -575,6 +590,20 @@ func (mr *MockDatabaseRepositoryMockRecorder) UpdateSource(ctx, sourceCreds inte return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSource", reflect.TypeOf((*MockDatabaseRepository)(nil).UpdateSource), ctx, sourceCreds) } +// UpdateUserAndPermissions mocks base method. +func (m *MockDatabaseRepository) UpdateUserAndPermissions(ctx context.Context, user models.User) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserAndPermissions", ctx, user) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserAndPermissions indicates an expected call of UpdateUserAndPermissions. +func (mr *MockDatabaseRepositoryMockRecorder) UpdateUserAndPermissions(ctx, user interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserAndPermissions", reflect.TypeOf((*MockDatabaseRepository)(nil).UpdateUserAndPermissions), ctx, user) +} + // UpsertRawResource mocks base method. func (m *MockDatabaseRepository) UpsertRawResource(ctx context.Context, sourceCredentials models0.SourceCredential, rawResource models0.RawResourceFhir) (bool, error) { m.ctrl.T.Helper() diff --git a/backend/pkg/models/user.go b/backend/pkg/models/user.go index 3e81b819e..04711b4c2 100644 --- a/backend/pkg/models/user.go +++ b/backend/pkg/models/user.go @@ -4,21 +4,20 @@ import ( "fmt" "strings" - "golang.org/x/crypto/bcrypt" - "github.com/fastenhealth/fasten-onprem/backend/pkg" + "golang.org/x/crypto/bcrypt" ) type User struct { ModelBase - FullName string `json:"full_name"` - Username string `json:"username" gorm:"unique"` - Password string `json:"password"` - - //additional optional metadata that Fasten stores with users - Picture string `json:"picture"` - Email string `json:"email"` - Role pkg.UserRole `json:"role"` + FullName string `json:"full_name"` + Username string `json:"username" gorm:"unique"` + Password string `json:"password"` + Picture string `json:"picture"` + Email string `json:"email"` + Role pkg.UserRole `json:"role"` + Permissions map[string]map[string]bool `json:"permissions" gorm:"-:all"` + UserPermissions []UserPermission `json:"-" gorm:"foreignKey:UserID"` } func (user *User) HashPassword(password string) error { diff --git a/backend/pkg/models/user_permission.go b/backend/pkg/models/user_permission.go new file mode 100644 index 000000000..3d5cd76df --- /dev/null +++ b/backend/pkg/models/user_permission.go @@ -0,0 +1,13 @@ +package models + +import ( + "github.com/fastenhealth/fasten-onprem/backend/pkg" + "github.com/google/uuid" +) + +type UserPermission struct { + ModelBase + UserID uuid.UUID `json:"user_id" gorm:"type:uuid"` + TargetUserID uuid.UUID `json:"target_user_id" gorm:"type:uuid"` + Permission pkg.Permission `json:"permission"` +} diff --git a/backend/pkg/web/handler/users.go b/backend/pkg/web/handler/users.go index 465eba149..9f5eb5db7 100644 --- a/backend/pkg/web/handler/users.go +++ b/backend/pkg/web/handler/users.go @@ -8,6 +8,7 @@ import ( "github.com/fastenhealth/fasten-onprem/backend/pkg/database" "github.com/fastenhealth/fasten-onprem/backend/pkg/models" "github.com/gin-gonic/gin" + "github.com/google/uuid" "gorm.io/gorm" ) @@ -54,3 +55,43 @@ func CreateUser(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true, "data": newUser}) } + +func GetUser(c *gin.Context) { + if !IsAdmin(c) { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "Unauthorized"}) + return + } + + databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) + + user, err := databaseRepo.GetUser(c, uuid.MustParse(c.Param("userId"))) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } + + c.JSON(200, gin.H{"success": true, "data": user}) +} + +func UpdateUser(c *gin.Context) { + if !IsAdmin(c) { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "Unauthorized"}) + return + } + + databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) + + var user models.User + if err := c.ShouldBindJSON(&user); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + err := databaseRepo.UpdateUserAndPermissions(c, user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } + + c.JSON(200, gin.H{"success": true, "data": user}) +} diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index 54842a8e0..a612e7f2a 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -128,6 +128,8 @@ func (ae *AppEngine) Setup() (*gin.RouterGroup, *gin.Engine) { secure.GET("/users", handler.GetUsers) secure.POST("/users", handler.CreateUser) + secure.GET("/users/:userId", handler.GetUser) + secure.POST("/users/:userId", handler.UpdateUser) //server-side-events handler (only supported on mac/linux) // TODO: causes deadlock on Windows diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index d0e257f15..a60eef186 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -21,6 +21,7 @@ import { ResourceCreatorComponent } from './pages/resource-creator/resource-crea import { ResourceDetailComponent } from './pages/resource-detail/resource-detail.component'; import { SourceDetailComponent } from './pages/source-detail/source-detail.component'; import { UserCreateComponent } from './pages/user-create/user-create.component'; +import { UserEditComponent } from "./pages/user-edit/user-edit.component"; import { UserListComponent } from './pages/user-list/user-list.component'; const routes: Routes = [ @@ -55,6 +56,7 @@ const routes: Routes = [ { path: 'users', component: UserListComponent, canActivate: [ IsAuthenticatedAuthGuard, IsAdminAuthGuard ] }, { path: 'users/new', component: UserCreateComponent, canActivate: [ IsAuthenticatedAuthGuard, IsAdminAuthGuard ] }, + { path: 'users/:user_id', component: UserEditComponent, canActivate: [ IsAuthenticatedAuthGuard, IsAdminAuthGuard ] }, // { path: 'general-pages', loadChildren: () => import('./general-pages/general-pages.module').then(m => m.GeneralPagesModule) }, // { path: 'ui-elements', loadChildren: () => import('./ui-elements/ui-elements.module').then(m => m.UiElementsModule) }, diff --git a/frontend/src/app/models/fasten/user.ts b/frontend/src/app/models/fasten/user.ts index 527390486..58ba62567 100644 --- a/frontend/src/app/models/fasten/user.ts +++ b/frontend/src/app/models/fasten/user.ts @@ -1,8 +1,18 @@ +export const POSSIBLE_PERMISSIONS = [ + { name: 'Manage Sources', value: 'manage_sources' }, + { name: 'Read', value: 'read' }, +] + export class User { - user_id?: number + id?: string full_name?: string username?: string email?: string password?: string role?: string + permissions?: { + [targetUserId: string]: { + [key in typeof POSSIBLE_PERMISSIONS[number]['value']]: boolean; + } + } } diff --git a/frontend/src/app/pages/user-edit/user-edit.component.html b/frontend/src/app/pages/user-edit/user-edit.component.html new file mode 100644 index 000000000..47e9eaa97 --- /dev/null +++ b/frontend/src/app/pages/user-edit/user-edit.component.html @@ -0,0 +1,63 @@ +
+
+
+

Edit User

+ +
+
+ Loading... +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+ Warning: This will allow full system access including the ability to manage other users. +
+
+
+ + +
+ +
Access to other Users
+ +
+
+
{{ otherUser.full_name }}
+
+
+ + +
+
+
+
+ + +
+
+
+
diff --git a/frontend/src/app/pages/user-edit/user-edit.component.scss b/frontend/src/app/pages/user-edit/user-edit.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/pages/user-edit/user-edit.component.ts b/frontend/src/app/pages/user-edit/user-edit.component.ts new file mode 100644 index 000000000..4f84fef25 --- /dev/null +++ b/frontend/src/app/pages/user-edit/user-edit.component.ts @@ -0,0 +1,91 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { forkJoin } from 'rxjs'; +import { ToastNotification, ToastType } from '../../models/fasten/toast'; +import { POSSIBLE_PERMISSIONS, User } from '../../models/fasten/user'; +import { AuthService } from '../../services/auth.service'; +import { FastenApiService } from '../../services/fasten-api.service'; +import { ToastService } from '../../services/toast.service'; + +@Component({ + selector: 'app-user-edit', + templateUrl: './user-edit.component.html', + styleUrls: ['./user-edit.component.scss'], + standalone: true, + imports: [CommonModule, ReactiveFormsModule] +}) +export class UserEditComponent implements OnInit { + userForm: FormGroup; + loading = false; + userId: string; + userList: User[]; + errorMessage: string | null = null; + permissionsList = POSSIBLE_PERMISSIONS; + + constructor( + private fb: FormBuilder, + private authService: AuthService, + private fastenApi: FastenApiService, + private toastService: ToastService, + private router: Router, + private route: ActivatedRoute, + ) { } + + ngOnInit(): void { + this.loading = true; + this.userId = this.route.snapshot.paramMap.get('user_id'); + forkJoin([ + this.fastenApi.getAllUsers(), + this.fastenApi.getUser(this.userId) + ]).subscribe(([allUsers, user]) => { + this.userList = allUsers.filter(user => user.id !== this.userId).sort((a, b) => a.full_name.localeCompare(b.full_name)); + this.userForm = this.fb.group({ + full_name: [user.full_name, [Validators.required, Validators.minLength(2)]], + username: [user.username, [Validators.required, Validators.minLength(4)]], + email: [user.email, [Validators.email]], + role: [user.role, Validators.required], + permissions: this.fb.group({}) + }); + this.userList.forEach(otherUser => { + const pfg = (this.userForm.get('permissions') as FormGroup); + pfg.addControl(otherUser.id, this.fb.group({})); + this.permissionsList.forEach(permission => { + const isChecked = user.permissions?.[otherUser.id]?.[permission.value] ?? false; + (pfg.get(otherUser.id) as FormGroup).addControl(permission.value, new FormControl(isChecked)); + }); + }); + this.loading = false; + }, + (error) => { + this.errorMessage = error.message; + this.loading = false; + }); + + } + + onSubmit() { + if (this.userForm.valid) { + this.loading = true; + this.errorMessage = null; + + const user: User = this.userForm.value; + user.id = this.userId; + this.authService.updateUser(user).subscribe( + (response) => { + this.loading = false; + const toastNotification = new ToastNotification(); + toastNotification.type = ToastType.Success; + toastNotification.message = 'User updated successfully'; + this.toastService.show(toastNotification); + this.router.navigate(['/users']); + }, + (error) => { + this.loading = false; + this.errorMessage = 'Error updating user: ' + error.message; + } + ); + } + } +} diff --git a/frontend/src/app/pages/user-list/user-list.component.html b/frontend/src/app/pages/user-list/user-list.component.html index b7b89f460..912645c8a 100644 --- a/frontend/src/app/pages/user-list/user-list.component.html +++ b/frontend/src/app/pages/user-list/user-list.component.html @@ -2,11 +2,13 @@

User List

+
Loading...
+ @@ -14,6 +16,7 @@

User List

+ @@ -22,6 +25,9 @@

User List

+
Username Email RoleActions
{{ user.username }} {{ user.email }} {{ user.role }} + Edit +
diff --git a/frontend/src/app/pages/user-list/user-list.component.ts b/frontend/src/app/pages/user-list/user-list.component.ts index f3f1dc6db..2345d1720 100644 --- a/frontend/src/app/pages/user-list/user-list.component.ts +++ b/frontend/src/app/pages/user-list/user-list.component.ts @@ -24,13 +24,15 @@ export class UserListComponent implements OnInit { loadUsers(): void { this.loading = true; - this.fastenApi.getAllUsers().subscribe((users: User[]) => { - this.users = users; - this.loading = false; - }, - error => { + this.fastenApi.getAllUsers().subscribe( + (users: User[]) => { + this.users = users; + this.loading = false; + }, + (error: Error) => { console.error('Error loading users:', error); this.loading = false; - }); + } + ); } } diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts index 7082ac9d7..56ff3ef43 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -146,6 +146,21 @@ export class AuthService { ); } + public updateUser(user: User): Observable { + let fastenApiEndpointBase = GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base); + return this._httpClient.post(`${fastenApiEndpointBase}/secure/users/${user.id}`, user) + .pipe( + catchError((error) => { + if (error.status === 400) { + // Extract error information from the response body + const errorBody = error.error; + return throwError(new Error(errorBody.error || error.message)); + } + return throwError(error); + }) + ); + } + //TODO: now that we've moved to remote-first database, we can refactor and simplify this function significantly. public async IsAuthenticated(): Promise { let authToken = this.GetAuthToken() diff --git a/frontend/src/app/services/fasten-api.service.ts b/frontend/src/app/services/fasten-api.service.ts index 9ceb8f7f6..b1c5191ce 100644 --- a/frontend/src/app/services/fasten-api.service.ts +++ b/frontend/src/app/services/fasten-api.service.ts @@ -1,33 +1,27 @@ -import {Inject, Injectable} from '@angular/core'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; -import {Observable, of} from 'rxjs'; -import { Router } from '@angular/router'; -import {map} from 'rxjs/operators'; -import {ResponseWrapper} from '../models/response-wrapper'; -import {Source} from '../models/fasten/source'; -import {User} from '../models/fasten/user'; -import {ResourceFhir} from '../models/fasten/resource_fhir'; -import {SourceSummary} from '../models/fasten/source-summary'; -import {Summary} from '../models/fasten/summary'; -import {AuthService} from './auth.service'; -import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path'; -import {environment} from '../../environments/environment'; -import {ValueSet} from 'fhir/r4'; -import {AttachmentModel} from '../../lib/models/datatypes/attachment-model'; -import {BinaryModel} from '../../lib/models/resources/binary-model'; -import {HTTP_CLIENT_TOKEN} from "../dependency-injection"; -import * as fhirpath from 'fhirpath'; -import _ from 'lodash'; -import {DashboardConfig} from '../models/widget/dashboard-config'; -import {DashboardWidgetQuery} from '../models/widget/dashboard-widget-query'; -import {ResourceGraphResponse} from '../models/fasten/resource-graph-response'; -import { fetchEventSource } from '@microsoft/fetch-event-source'; -import {BackgroundJob, BackgroundJobSyncData} from '../models/fasten/background-job'; -import {SupportRequest} from '../models/fasten/support-request'; -import { - List -} from 'fhir/r4'; -import {FormRequestHealthSystem} from '../models/fasten/form-request-health-system'; +import { HttpClient } from '@angular/common/http'; +import { Inject, Injectable } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { List, ValueSet } from 'fhir/r4'; +import { Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { environment } from '../../environments/environment'; +import { AttachmentModel } from '../../lib/models/datatypes/attachment-model'; +import { BinaryModel } from '../../lib/models/resources/binary-model'; +import { GetEndpointAbsolutePath } from '../../lib/utils/endpoint_absolute_path'; +import { HTTP_CLIENT_TOKEN } from "../dependency-injection"; +import { BackgroundJob, BackgroundJobSyncData } from '../models/fasten/background-job'; +import { FormRequestHealthSystem } from '../models/fasten/form-request-health-system'; +import { ResourceGraphResponse } from '../models/fasten/resource-graph-response'; +import { ResourceFhir } from '../models/fasten/resource_fhir'; +import { Source } from '../models/fasten/source'; +import { SourceSummary } from '../models/fasten/source-summary'; +import { Summary } from '../models/fasten/summary'; +import { SupportRequest } from '../models/fasten/support-request'; +import { User } from '../models/fasten/user'; +import { ResponseWrapper } from '../models/response-wrapper'; +import { DashboardConfig } from '../models/widget/dashboard-config'; +import { DashboardWidgetQuery } from '../models/widget/dashboard-widget-query'; +import { AuthService } from './auth.service'; @Injectable({ providedIn: 'root' }) @@ -368,4 +362,17 @@ export class FastenApiService { ); } + getUser(userId: string): Observable { + return this._httpClient.get(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/users/${userId}`) + .pipe( + map((response: ResponseWrapper) => { + if (!response.success) { + throw new Error(response.error) + } + return response.data as User + }), + // catchError(() => of(null)) + ); + } + }