diff --git a/README.md b/README.md index 62a9b5c7..cdff52dd 100644 --- a/README.md +++ b/README.md @@ -13,17 +13,10 @@ **Fasten securely connects your healthcare providers together, creating a personal health record that never leaves your hands** -> [!NOTE] +> [!NOTE] > NOTE: Fasten is a Work-in-Progress and can only communicate with a limited number of Healthcare Instutions (approx 25,000 at last count). > Please fill out this [Google Form](https://forms.gle/SNsYX9BNMXB6TuTw6) if you'd like to be kept up-to-date on Fasten -> [!IMPORTANT] -> To ensure Fasten's long-term sustainability, we're exploring some funding options. While we're still deciding a long-term monetization strategy, I'm kicking off with a crowdfunding/fundraising experiment for the first 500 users (including a surprise desktop app): -> -> - [Fasten Self-Hosted Lifetime License - **$200**](https://buy.stripe.com/fZe00deiUexS58Y4gg) -> -> Got questions or want to learn more about our fundraising experiment? [Click here to dive into the details & FAQs](https://docs.fastenhealth.com/funding.html) -


@@ -49,19 +42,19 @@ # Introduction -Like many of you, I've worked for many companies over my career. In that time, I've had multiple health, vision and dental +Like many of you, I've worked for many companies over my career. In that time, I've had multiple health, vision and dental insurance providers, and visited many different clinics, hospitals and labs to get procedures & tests done. -Recently I had a semi-serious medical issue, and I realized that my medical history (and the medical history of my family members) -is a lot more complicated than I realized and distributed across the many healthcare providers I've used over the years. +Recently I had a semi-serious medical issue, and I realized that my medical history (and the medical history of my family members) +is a lot more complicated than I realized and distributed across the many healthcare providers I've used over the years. I wanted a single (private) location to store our medical records, and I just couldn't find any software that worked as I'd like: -- self-hosted/offline - this is my medical history, I'm not willing to give it to some random multi-national corporation to data-mine and sell -- It should aggregate my data from multiple healthcare providers (insurance companies, hospital networks, clinics, labs) across multiple industries (vision, dental, medical) -- all in one dashboard +- self-hosted/offline - this is my medical history, I'm not willing to give it to some random multi-national corporation to data-mine and sell +- It should aggregate my data from multiple healthcare providers (insurance companies, hospital networks, clinics, labs) across multiple industries (vision, dental, medical) -- all in one dashboard - automatic - it should pull my EMR (electronic medical record) directly from my insurance provider/clinic/hospital network - I dont want to scan/OCR physical documents (unless I have to) - open source - the code should be available for contributions & auditing -So, I built it +So, I built it. **Fasten is an open-source, self-hosted, personal/family electronic medical record aggregator, designed to integrate with 1000's of insurances/hospitals/clinics** @@ -74,9 +67,9 @@ It's pretty basic right now, but it's designed with a easily extensible core aro - Supports the Medical industry's (semi-standard) FHIR protocol - Uses OAuth2 (Smart-on-FHIR) authentication (no passwords necessary) - Uses OAuth's `offline_access` scope (where possible) to automatically pull changes/updates -- Multi-user support for household/family use +- (Future) Multi-user support for household/family use - Condition specific user Dashboards & tracking for diagnostic tests -- (Future) Vaccination & condition specific recommendations using NIH/WHO clinical care guidelines (HEDIS/CQL) +- (Future) Vaccination & condition specific recommendations using NIH/WHO clinical care guidelines (HEDIS/CQL) - (Future) ChatGPT-style interface to query your own medical history (offline) - (Future) Integration with smart-devices & wearables @@ -95,13 +88,13 @@ First, if you don't have Docker installed on your computer, get Docker by follow Next, run the following commands from the Windows command line or Mac/Linux terminal in order to download and start the Fasten docker container. ``` -docker pull ghcr.io/fastenhealth/fasten-onprem:main +docker pull ghcr.io/fastenhealth/fasten-onprem:main docker run --rm \ -p 9090:8080 \ -v ./db:/opt/fasten/db \ -v ./cache:/opt/fasten/cache \ -ghcr.io/fastenhealth/fasten-onprem:main +ghcr.io/fastenhealth/fasten-onprem:main ``` Next, open a browser to `http://localhost:9090` @@ -123,6 +116,26 @@ If you're using the `sandbox` version of Fasten, you'll only be able to connect https://docs.fastenhealth.com/getting-started/sandbox.html#connecting-a-new-source +## Using with multiple people + +> [!NOTE] +> NOTE: Multi-user features are a work in progress. This section describes the eventual goals. + +Fasten is designd to work well for an individual or a family. Since it is self-hosted, by nature the person running the service will have full root access to all user records. For most families, this is perfect! If you need stronger security, Fasten might not be for you. + +Fasten assumes that all records connected from a single user account (from one or more sources) belong to a single individual, and thus will show aggregations that will only make sense for a single person. Be careful to not connect sources for different people to the same Fasten user account. + +Tracking health data for multiple family members works by creating new user accounts for each person. Any user with the `admin` role can manage users and permissions. Any user can be granted access (by an admin) to view another user's records. Through this mechanism, it's easy to setup any family configuration needed. For example: a family of four can have two parents that can each see the records of the two children. + +It is also possible to create users with the `viewer` role that only have access to view records of other users. This can be used to share records with a caregiver. + +This allows for a more complex example: + +- a family consisting of 2 parents, and 2 children and a caregiver (nurse, babysitter, grandparent). +- both parents need to be able to access both children's records, and maybe each-others +- the caregiver should have view-only access to 1 or both children, but not the parents. + + # FAQ's See [FAQs](https://docs.fastenhealth.com/faqs.html) for common questions (& answers) regarding Fasten @@ -161,11 +174,9 @@ Jason Kulatunga - Initial Development - @AnalogJ # Fundraising & Sponsorships -To ensure Fasten's long-term sustainability, we're exploring some funding options. While we're still deciding a long-term monetization strategy, I'm kicking off with a crowdfunding/fundraising experiment for the first 500 users (including a surprise desktop app): +To ensure Fasten's long-term sustainability, we're exploring some funding options. We're still deciding a long-term monetization strategy. -- [Fasten Self-Hosted Lifetime License - **$200**](https://buy.stripe.com/fZe00deiUexS58Y4gg) - -Got questions or want to learn more about our fundraising experiment? [Click here to dive into the details & FAQs](https://docs.fastenhealth.com/FUNDRAISING.html) +Got questions or want to learn more about our fundraising experiment? [Click here to dive into the details & FAQs](https://docs.fastenhealth.com/FUNDRAISING.html) I'd also like to thank the following Corporate Sponsors: diff --git a/backend/pkg/auth/jwt_utils.go b/backend/pkg/auth/jwt_utils.go index a8a4c8d8..8e3b26ef 100644 --- a/backend/pkg/auth/jwt_utils.go +++ b/backend/pkg/auth/jwt_utils.go @@ -3,14 +3,15 @@ package auth import ( "errors" "fmt" - "github.com/fastenhealth/fasten-onprem/backend/pkg/models" - "github.com/golang-jwt/jwt/v4" "strings" "time" + + "github.com/fastenhealth/fasten-onprem/backend/pkg/models" + "github.com/golang-jwt/jwt/v4" ) // JwtGenerateFastenTokenFromUser Note: these functions are duplicated, in Fasten Cloud -//Any changes here must be replicated in that repo +// Any changes here must be replicated in that repo func JwtGenerateFastenTokenFromUser(user models.User, issuerSigningKey string) (string, error) { if len(strings.TrimSpace(issuerSigningKey)) == 0 { return "", fmt.Errorf("issuer signing key cannot be empty") @@ -26,8 +27,8 @@ func JwtGenerateFastenTokenFromUser(user models.User, issuerSigningKey string) ( }, UserMetadata: UserMetadata{ FullName: user.FullName, - Picture: "", - Email: user.ID.String(), + Email: user.Email, + Role: user.Role, }, } diff --git a/backend/pkg/auth/user_metadata.go b/backend/pkg/auth/user_metadata.go index 9b53f4b9..8168a516 100644 --- a/backend/pkg/auth/user_metadata.go +++ b/backend/pkg/auth/user_metadata.go @@ -1,7 +1,12 @@ package auth +import ( + "github.com/fastenhealth/fasten-onprem/backend/pkg" +) + type UserMetadata struct { - FullName string `json:"full_name"` - Picture string `json:"picture"` - Email string `json:"email"` + FullName string `json:"full_name"` + Picture string `json:"picture"` + Email string `json:"email"` + Role pkg.UserRole `json:"role"` } diff --git a/backend/pkg/constants.go b/backend/pkg/constants.go index 8062ffe0..92328b37 100644 --- a/backend/pkg/constants.go +++ b/backend/pkg/constants.go @@ -9,6 +9,7 @@ type DatabaseRepositoryType string type InstallationVerificationStatus string type InstallationQuotaStatus string +type UserRole string const ( ResourceListPageSize int = 20 @@ -50,4 +51,7 @@ const ( InstallationVerificationStatusVerified InstallationVerificationStatus = "VERIFIED" //email has been verified InstallationQuotaStatusActive InstallationQuotaStatus = "ACTIVE" InstallationQuotaStatusConsumed InstallationQuotaStatus = "CONSUMED" + + UserRoleUser UserRole = "user" + UserRoleAdmin UserRole = "admin" ) diff --git a/backend/pkg/database/gorm_common.go b/backend/pkg/database/gorm_common.go index 9f04aca3..8fa02e8d 100644 --- a/backend/pkg/database/gorm_common.go +++ b/backend/pkg/database/gorm_common.go @@ -5,10 +5,11 @@ import ( "encoding/json" "errors" "fmt" - sourcePkg "github.com/fastenhealth/fasten-sources/pkg" "strings" "time" + sourcePkg "github.com/fastenhealth/fasten-sources/pkg" + "github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg/config" "github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus" @@ -179,6 +180,18 @@ func (gr *GormRepository) DeleteCurrentUser(ctx context.Context) error { return nil } +func (gr *GormRepository) GetUsers(ctx context.Context) ([]models.User, error) { + var users []models.User + result := gr.GormClient.WithContext(ctx).Find(&users) + // Remove password field from each user + var sanitizedUsers []models.User + for _, user := range users { + user.Password = "" // Clear the password field + sanitizedUsers = append(sanitizedUsers, user) + } + return sanitizedUsers, result.Error +} + // //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/backend/pkg/database/gorm_repository_migrations.go b/backend/pkg/database/gorm_repository_migrations.go index 6a4a6f09..db60d0b8 100644 --- a/backend/pkg/database/gorm_repository_migrations.go +++ b/backend/pkg/database/gorm_repository_migrations.go @@ -3,11 +3,14 @@ package database import ( "context" "fmt" + "log" + _20231017112246 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20231017112246" _20231201122541 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20231201122541" _0240114092806 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240114092806" _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" "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" @@ -15,7 +18,6 @@ import ( "github.com/go-gormigrate/gormigrate/v2" "github.com/google/uuid" "gorm.io/gorm" - "log" ) func (gr *GormRepository) Migrate() error { @@ -194,6 +196,35 @@ func (gr *GormRepository) Migrate() error { return nil }, }, + { + ID: "20240813222836", // add role to user + Migrate: func(tx *gorm.DB) error { + + err := tx.AutoMigrate( + &_20240813222836.User{}, + ) + if err != nil { + return err + } + + // set first user to admin + // set all other users to user + users := []_20240813222836.User{} + results := tx.Order("created_at ASC").Find(&users) + if results.Error != nil { + return results.Error + } + for ndx, user := range users { + if ndx == 0 { + user.Role = _20240813222836.RoleAdmin + } else { + user.Role = _20240813222836.RoleUser + } + tx.Save(&user) + } + return nil + }, + }, }) // run when database is empty diff --git a/backend/pkg/database/interface.go b/backend/pkg/database/interface.go index 65adea78..b4936303 100644 --- a/backend/pkg/database/interface.go +++ b/backend/pkg/database/interface.go @@ -2,6 +2,7 @@ package database import ( "context" + "github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg/models" sourcePkg "github.com/fastenhealth/fasten-sources/clients/models" @@ -18,6 +19,7 @@ type DatabaseRepository interface { GetUserByUsername(context.Context, string) (*models.User, error) GetCurrentUser(ctx context.Context) (*models.User, error) DeleteCurrentUser(ctx context.Context) error + GetUsers(ctx context.Context) ([]models.User, error) GetSummary(ctx context.Context) (*models.Summary, error) diff --git a/backend/pkg/database/migrations/20231017112246/user.go b/backend/pkg/database/migrations/20231017112246/user.go index 746dc8f6..15ba80ee 100644 --- a/backend/pkg/database/migrations/20231017112246/user.go +++ b/backend/pkg/database/migrations/20231017112246/user.go @@ -13,5 +13,4 @@ type User struct { //additional optional metadata that Fasten stores with users Picture string `json:"picture"` Email string `json:"email"` - //Roles datatypes.JSON `json:"roles"` } diff --git a/backend/pkg/database/migrations/20231201122541/user.go b/backend/pkg/database/migrations/20231201122541/user.go index f31cee50..931ca5b3 100644 --- a/backend/pkg/database/migrations/20231201122541/user.go +++ b/backend/pkg/database/migrations/20231201122541/user.go @@ -13,5 +13,4 @@ type User struct { //additional optional metadata that Fasten stores with users Picture string `json:"picture"` Email string `json:"email"` - //Roles datatypes.JSON `json:"roles"` } diff --git a/backend/pkg/database/migrations/20240813222836/user.go b/backend/pkg/database/migrations/20240813222836/user.go new file mode 100644 index 00000000..8b78614d --- /dev/null +++ b/backend/pkg/database/migrations/20240813222836/user.go @@ -0,0 +1,24 @@ +package _20240813222836 + +import ( + "github.com/fastenhealth/fasten-onprem/backend/pkg/models" +) + +type Role string + +const ( + RoleUser Role = "user" + RoleAdmin Role = "admin" +) + +type User struct { + models.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 Role `json:"role"` +} diff --git a/backend/pkg/database/mock/mock_database.go b/backend/pkg/database/mock/mock_database.go index aa9592e8..64dd3a57 100644 --- a/backend/pkg/database/mock/mock_database.go +++ b/backend/pkg/database/mock/mock_database.go @@ -387,6 +387,21 @@ func (mr *MockDatabaseRepositoryMockRecorder) GetUserCount(arg0 interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCount", reflect.TypeOf((*MockDatabaseRepository)(nil).GetUserCount), arg0) } +// GetUsers mocks base method. +func (m *MockDatabaseRepository) GetUsers(ctx context.Context) ([]models.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsers", ctx) + ret0, _ := ret[0].([]models.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUsers indicates an expected call of GetUsers. +func (mr *MockDatabaseRepositoryMockRecorder) GetUsers(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsers", reflect.TypeOf((*MockDatabaseRepository)(nil).GetUsers), ctx) +} + // ListBackgroundJobs mocks base method. func (m *MockDatabaseRepository) ListBackgroundJobs(ctx context.Context, queryOptions models.BackgroundJobQueryOptions) ([]models.BackgroundJob, error) { m.ctrl.T.Helper() diff --git a/backend/pkg/database/sqlite_repository.go b/backend/pkg/database/sqlite_repository.go index 729fd9ee..ff463688 100644 --- a/backend/pkg/database/sqlite_repository.go +++ b/backend/pkg/database/sqlite_repository.go @@ -2,11 +2,12 @@ package database import ( "fmt" + "net/url" + "strings" + "github.com/fastenhealth/fasten-onprem/backend/pkg/config" "github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus" "github.com/sirupsen/logrus" - "net/url" - "strings" //"github.com/glebarez/sqlite" "gorm.io/driver/sqlite" diff --git a/backend/pkg/models/user.go b/backend/pkg/models/user.go index f967c704..3e81b819 100644 --- a/backend/pkg/models/user.go +++ b/backend/pkg/models/user.go @@ -2,14 +2,12 @@ package models import ( "fmt" - "golang.org/x/crypto/bcrypt" "strings" -) -type UserWizard struct { - *User `json:",inline"` - JoinMailingList bool `json:"join_mailing_list"` -} + "golang.org/x/crypto/bcrypt" + + "github.com/fastenhealth/fasten-onprem/backend/pkg" +) type User struct { ModelBase @@ -18,9 +16,9 @@ type User struct { Password string `json:"password"` //additional optional metadata that Fasten stores with users - Picture string `json:"picture"` - Email string `json:"email"` - //Roles datatypes.JSON `json:"roles"` + Picture string `json:"picture"` + Email string `json:"email"` + Role pkg.UserRole `json:"role"` } func (user *User) HashPassword(password string) error { diff --git a/backend/pkg/web/handler/auth.go b/backend/pkg/web/handler/auth.go index 982b4f11..38393975 100644 --- a/backend/pkg/web/handler/auth.go +++ b/backend/pkg/web/handler/auth.go @@ -2,6 +2,8 @@ package handler import ( "fmt" + "net/http" + "github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg/auth" "github.com/fastenhealth/fasten-onprem/backend/pkg/config" @@ -9,19 +11,49 @@ import ( "github.com/fastenhealth/fasten-onprem/backend/pkg/models" "github.com/fastenhealth/fasten-onprem/backend/pkg/utils" "github.com/gin-gonic/gin" - "net/http" + "github.com/sirupsen/logrus" ) +type UserWizard struct { + *models.User `json:",inline"` + JoinMailingList bool `json:"join_mailing_list"` +} + +func IsAdmin(c *gin.Context) bool { + logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry) + databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) + + currentUser, err := databaseRepo.GetCurrentUser(c) + if err != nil { + logger.Errorf("Error getting current user: %v", err) + return false + } + return currentUser.Role == pkg.UserRoleAdmin +} + func AuthSignup(c *gin.Context) { databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) appConfig := c.MustGet(pkg.ContextKeyTypeConfig).(config.Interface) - var userWizard models.UserWizard + var userWizard UserWizard if err := c.ShouldBindJSON(&userWizard); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } - err := databaseRepo.CreateUser(c, userWizard.User) + + // Check if this is the first user in the database + userCount, err := databaseRepo.GetUserCount(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "Failed to check user count"}) + return + } + + if userCount == 0 { + userWizard.User.Role = pkg.UserRoleAdmin + } else { + userWizard.User.Role = pkg.UserRoleUser + } + err = databaseRepo.CreateUser(c, userWizard.User) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return @@ -29,6 +61,10 @@ func AuthSignup(c *gin.Context) { //TODO: we can derive the encryption key and the hash'ed user from the responseData sub. For now the Sub will be the user id prepended with hello. userFastenToken, err := auth.JwtGenerateFastenTokenFromUser(*userWizard.User, appConfig.GetString("jwt.issuer.key")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } //check if the user wants to join the mailing list if userWizard.JoinMailingList { @@ -62,7 +98,11 @@ func AuthSignin(c *gin.Context) { } //TODO: we can derive the encryption key and the hash'ed user from the responseData sub. For now the Sub will be the user id prepended with hello. - userFastenToken, err := auth.JwtGenerateFastenTokenFromUser(user, appConfig.GetString("jwt.issuer.key")) + userFastenToken, err := auth.JwtGenerateFastenTokenFromUser(*foundUser, appConfig.GetString("jwt.issuer.key")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } c.JSON(http.StatusOK, gin.H{"success": true, "data": userFastenToken}) } diff --git a/backend/pkg/web/handler/auth_test.go b/backend/pkg/web/handler/auth_test.go new file mode 100644 index 00000000..18f8d4c9 --- /dev/null +++ b/backend/pkg/web/handler/auth_test.go @@ -0,0 +1,93 @@ +package handler_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/fastenhealth/fasten-onprem/backend/pkg" + mock_config "github.com/fastenhealth/fasten-onprem/backend/pkg/config/mock" + mock_database "github.com/fastenhealth/fasten-onprem/backend/pkg/database/mock" + "github.com/fastenhealth/fasten-onprem/backend/pkg/models" + "github.com/fastenhealth/fasten-onprem/backend/pkg/web/handler" + "github.com/gin-gonic/gin" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func TestAuthSignup(t *testing.T) { + // Setup + gin.SetMode(gin.TestMode) + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + t.Run("First user should be assigned admin role", func(t *testing.T) { + mockDB := mock_database.NewMockDatabaseRepository(mockCtrl) + mockConfig := mock_config.NewMockInterface(mockCtrl) + + mockDB.EXPECT().GetUserCount(gomock.Any()).Return(0, nil) + mockDB.EXPECT().CreateUser(gomock.Any(), gomock.Any()).Do(func(_ interface{}, user *models.User) { + assert.Equal(t, pkg.UserRoleAdmin, user.Role) + }).Return(nil) + mockConfig.EXPECT().GetString("jwt.issuer.key").Return("test_key") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(pkg.ContextKeyTypeDatabase, mockDB) + c.Set(pkg.ContextKeyTypeConfig, mockConfig) + + userWizard := handler.UserWizard{ + User: &models.User{ + Username: "testuser", + Password: "testpass", + }, + } + jsonData, _ := json.Marshal(userWizard) + c.Request, _ = http.NewRequest(http.MethodPost, "/signup", bytes.NewBuffer(jsonData)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.AuthSignup(c) + + assert.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.True(t, response["success"].(bool)) + }) + + t.Run("Subsequent user should be assigned user role", func(t *testing.T) { + mockDB := mock_database.NewMockDatabaseRepository(mockCtrl) + mockConfig := mock_config.NewMockInterface(mockCtrl) + + mockDB.EXPECT().GetUserCount(gomock.Any()).Return(1, nil) + mockDB.EXPECT().CreateUser(gomock.Any(), gomock.Any()).Do(func(_ interface{}, user *models.User) { + assert.Equal(t, pkg.UserRoleUser, user.Role) + }).Return(nil) + mockConfig.EXPECT().GetString("jwt.issuer.key").Return("test_key") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(pkg.ContextKeyTypeDatabase, mockDB) + c.Set(pkg.ContextKeyTypeConfig, mockConfig) + + userWizard := handler.UserWizard{ + User: &models.User{ + Username: "testuser2", + Password: "testpass2", + }, + } + jsonData, _ := json.Marshal(userWizard) + c.Request, _ = http.NewRequest(http.MethodPost, "/signup", bytes.NewBuffer(jsonData)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.AuthSignup(c) + + assert.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.True(t, response["success"].(bool)) + }) +} diff --git a/backend/pkg/web/handler/source.go b/backend/pkg/web/handler/source.go index d3d4deff..02f39f9f 100644 --- a/backend/pkg/web/handler/source.go +++ b/backend/pkg/web/handler/source.go @@ -3,6 +3,10 @@ package handler import ( "context" "fmt" + "io/ioutil" + "net/http" + "os" + "github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg/database" "github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus" @@ -14,9 +18,6 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/sirupsen/logrus" - "io/ioutil" - "net/http" - "os" ) func CreateReconnectSource(c *gin.Context) { diff --git a/backend/pkg/web/handler/users.go b/backend/pkg/web/handler/users.go new file mode 100644 index 00000000..465eba14 --- /dev/null +++ b/backend/pkg/web/handler/users.go @@ -0,0 +1,56 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/fastenhealth/fasten-onprem/backend/pkg" + "github.com/fastenhealth/fasten-onprem/backend/pkg/database" + "github.com/fastenhealth/fasten-onprem/backend/pkg/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func GetUsers(c *gin.Context) { + if !IsAdmin(c) { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "Unauthorized"}) + return + } + + databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) + + users, err := databaseRepo.GetUsers(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } + + c.JSON(200, gin.H{"success": true, "data": users}) +} + +func CreateUser(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 newUser models.User + if err := c.ShouldBindJSON(&newUser); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + err := databaseRepo.CreateUser(c, &newUser) + if err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "User already exists"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": newUser}) +} diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index 44424f86..54842a8e 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -6,6 +6,11 @@ import ( "embed" "encoding/json" "fmt" + "io" + "net/http" + "runtime" + "strings" + "github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg/config" "github.com/fastenhealth/fasten-onprem/backend/pkg/database" @@ -15,10 +20,6 @@ import ( "github.com/fastenhealth/fasten-onprem/backend/pkg/web/middleware" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "io" - "net/http" - "runtime" - "strings" ) type AppEngine struct { @@ -125,6 +126,9 @@ func (ae *AppEngine) Setup() (*gin.RouterGroup, *gin.Engine) { secure.POST("/query", handler.QueryResourceFhir) + secure.GET("/users", handler.GetUsers) + secure.POST("/users", handler.CreateUser) + //server-side-events handler (only supported on mac/linux) // TODO: causes deadlock on Windows if runtime.GOOS != "windows" { diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index d6a92114..d0e257f1 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -1,24 +1,27 @@ -import { NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; import { BrowserModule } from "@angular/platform-browser"; -import { Routes, RouterModule } from "@angular/router"; +import { RouterModule, Routes } from "@angular/router"; +import { environment } from '../environments/environment'; +import { IsAdminAuthGuard } from './auth-guards/is-admin-auth-guard'; +import { IsAuthenticatedAuthGuard } from './auth-guards/is-authenticated-auth-guard'; +import { ShowFirstRunWizardGuard } from './auth-guards/show-first-run-wizard-guard'; +import { AuthSigninComponent } from './pages/auth-signin/auth-signin.component'; +import { AuthSignupWizardComponent } from './pages/auth-signup-wizard/auth-signup-wizard.component'; +import { AuthSignupComponent } from './pages/auth-signup/auth-signup.component'; +import { BackgroundJobsComponent } from './pages/background-jobs/background-jobs.component'; import { DashboardComponent } from './pages/dashboard/dashboard.component'; +import { DesktopCallbackComponent } from './pages/desktop-callback/desktop-callback.component'; +import { ExploreComponent } from './pages/explore/explore.component'; +import { MedicalHistoryComponent } from './pages/medical-history/medical-history.component'; import { MedicalSourcesComponent } from './pages/medical-sources/medical-sources.component'; -import {ResourceDetailComponent} from './pages/resource-detail/resource-detail.component'; -import {AuthSigninComponent} from './pages/auth-signin/auth-signin.component'; -import {AuthSignupComponent} from './pages/auth-signup/auth-signup.component'; -import {IsAuthenticatedAuthGuard} from './auth-guards/is-authenticated-auth-guard'; -import {SourceDetailComponent} from './pages/source-detail/source-detail.component'; -import {PatientProfileComponent} from './pages/patient-profile/patient-profile.component'; -import {MedicalHistoryComponent} from './pages/medical-history/medical-history.component'; -import {ReportLabsComponent} from './pages/report-labs/report-labs.component'; -import {ResourceCreatorComponent} from './pages/resource-creator/resource-creator.component'; -import {ExploreComponent} from './pages/explore/explore.component'; -import {environment} from '../environments/environment'; -import {DesktopCallbackComponent} from './pages/desktop-callback/desktop-callback.component'; -import {BackgroundJobsComponent} from './pages/background-jobs/background-jobs.component'; -import {AuthSignupWizardComponent} from './pages/auth-signup-wizard/auth-signup-wizard.component'; -import {ShowFirstRunWizardGuard} from './auth-guards/show-first-run-wizard-guard'; +import { PatientProfileComponent } from './pages/patient-profile/patient-profile.component'; +import { ReportLabsComponent } from './pages/report-labs/report-labs.component'; +import { ResourceCreatorComponent } from './pages/resource-creator/resource-creator.component'; +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 { UserListComponent } from './pages/user-list/user-list.component'; const routes: Routes = [ @@ -50,6 +53,9 @@ const routes: Routes = [ { path: 'labs', component: ReportLabsComponent, canActivate: [ IsAuthenticatedAuthGuard] }, { path: 'labs/report/:source_id/:resource_type/:resource_id', component: ReportLabsComponent, canActivate: [ IsAuthenticatedAuthGuard] }, + { path: 'users', component: UserListComponent, canActivate: [ IsAuthenticatedAuthGuard, IsAdminAuthGuard ] }, + { path: 'users/new', component: UserCreateComponent, 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) }, // { path: 'form', loadChildren: () => import('./form/form.module').then(m => m.FormModule) }, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 45c07b73..9efff016 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -40,6 +40,7 @@ import {FhirDatatableModule} from './components/fhir-datatable/fhir-datatable.mo import { AuthSignupWizardComponent } from './pages/auth-signup-wizard/auth-signup-wizard.component'; import {ShowFirstRunWizardGuard} from './auth-guards/show-first-run-wizard-guard'; import { IconsModule } from './icon-module'; +import { UserListComponent } from './pages/user-list/user-list.component'; @NgModule({ declarations: [ @@ -60,6 +61,7 @@ import { IconsModule } from './icon-module'; DesktopCallbackComponent, BackgroundJobsComponent, AuthSignupWizardComponent, + UserListComponent, ], imports: [ FormsModule, diff --git a/frontend/src/app/auth-guards/is-admin-auth-guard.ts b/frontend/src/app/auth-guards/is-admin-auth-guard.ts new file mode 100644 index 00000000..b379b5b4 --- /dev/null +++ b/frontend/src/app/auth-guards/is-admin-auth-guard.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; + +@Injectable({ + providedIn: 'root' +}) +export class IsAdminAuthGuard implements CanActivate { + constructor(private authService: AuthService, private router: Router) {} + + async canActivate(): Promise { + if (await this.authService.IsAuthenticated() && await this.authService.IsAdmin()) { + return true; + } + this.router.navigate(['/dashboard']); + return false; + } +} diff --git a/frontend/src/app/components/header/header.component.html b/frontend/src/app/components/header/header.component.html index 88fdef63..45b374bf 100644 --- a/frontend/src/app/components/header/header.component.html +++ b/frontend/src/app/components/header/header.component.html @@ -26,6 +26,9 @@

+
@@ -64,7 +67,7 @@
{{current_user_claims.full_name || current_user_claims.sub || current_user_claims.email }}
- Adminstrator + Administrator Get Support diff --git a/frontend/src/app/components/header/header.component.spec.ts b/frontend/src/app/components/header/header.component.spec.ts index 32ac98a1..40653044 100644 --- a/frontend/src/app/components/header/header.component.spec.ts +++ b/frontend/src/app/components/header/header.component.spec.ts @@ -1,29 +1,44 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { HeaderComponent } from './header.component'; -import {HttpClientTestingModule} from '@angular/common/http/testing'; -import {RouterModule} from '@angular/router'; -import {RouterTestingModule} from '@angular/router/testing'; -import {HTTP_CLIENT_TOKEN} from '../../dependency-injection'; -import {HttpClient} from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterModule } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; import { IconsModule } from 'src/app/icon-module'; +import { HTTP_CLIENT_TOKEN } from '../../dependency-injection'; +import { AuthService } from '../../services/auth.service'; +import { HeaderComponent } from './header.component'; +import { of } from 'rxjs'; +import { UserRegisteredClaims } from '../../models/fasten/user-registered-claims'; describe('HeaderComponent', () => { let component: HeaderComponent; let fixture: ComponentFixture; + let mockedAuthService; beforeEach(async(() => { + mockedAuthService = jasmine.createSpyObj( + 'AuthService', + { + 'getCurrentUser': of(new UserRegisteredClaims()), + 'IsAdmin': of(false) + } + ) TestBed.configureTestingModule({ - imports: [ HttpClientTestingModule, RouterTestingModule, RouterModule, IconsModule ], - declarations: [ HeaderComponent ], + imports: [HttpClientTestingModule, RouterTestingModule, RouterModule, IconsModule], + declarations: [HeaderComponent], providers: [ { provide: HTTP_CLIENT_TOKEN, useClass: HttpClient, }, + { + provide: AuthService, + useValue: mockedAuthService + } ] }) - .compileComponents(); + .compileComponents(); })); beforeEach(() => { diff --git a/frontend/src/app/components/header/header.component.ts b/frontend/src/app/components/header/header.component.ts index ec309dc5..8ce09c61 100644 --- a/frontend/src/app/components/header/header.component.ts +++ b/frontend/src/app/components/header/header.component.ts @@ -29,6 +29,8 @@ export class HeaderComponent implements OnInit, OnDestroy { is_environment_desktop: boolean = environment.environment_desktop + isAdmin: boolean = false; + constructor( private authService: AuthService, private router: Router, @@ -42,6 +44,7 @@ export class HeaderComponent implements OnInit, OnDestroy { this.current_user_claims = new UserRegisteredClaims() } + this.isAdmin = this.authService.IsAdmin(); this.fastenApi.getBackgroundJobs().subscribe((data) => { this.backgroundJobs = data.filter((job) => { diff --git a/frontend/src/app/models/fasten/user-registered-claims.ts b/frontend/src/app/models/fasten/user-registered-claims.ts index 73c0c045..62250ff3 100644 --- a/frontend/src/app/models/fasten/user-registered-claims.ts +++ b/frontend/src/app/models/fasten/user-registered-claims.ts @@ -10,4 +10,5 @@ export class UserRegisteredClaims { full_name: string //FullName picture: string //Picture email: string //Email + role: string //Role } diff --git a/frontend/src/app/models/fasten/user.ts b/frontend/src/app/models/fasten/user.ts index ceef8fb8..52739048 100644 --- a/frontend/src/app/models/fasten/user.ts +++ b/frontend/src/app/models/fasten/user.ts @@ -4,4 +4,5 @@ export class User { username?: string email?: string password?: string + role?: string } diff --git a/frontend/src/app/pages/user-create/user-create.component.html b/frontend/src/app/pages/user-create/user-create.component.html new file mode 100644 index 00000000..c7ed33a4 --- /dev/null +++ b/frontend/src/app/pages/user-create/user-create.component.html @@ -0,0 +1,46 @@ +
+
+
+

Create New User

+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ Warning: This will allow full system access including the ability to manage other users. +
+
+
+ + +
+ +
+
+
+
diff --git a/frontend/src/app/pages/user-create/user-create.component.scss b/frontend/src/app/pages/user-create/user-create.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/pages/user-create/user-create.component.ts b/frontend/src/app/pages/user-create/user-create.component.ts new file mode 100644 index 00000000..2e4ed846 --- /dev/null +++ b/frontend/src/app/pages/user-create/user-create.component.ts @@ -0,0 +1,61 @@ +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { User } from '../../models/fasten/user'; +import { AuthService } from '../../services/auth.service'; +import { ToastService } from '../../services/toast.service'; +import { ToastNotification, ToastType } from '../../models/fasten/toast'; +import { Router } from '@angular/router'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-user-create', + templateUrl: './user-create.component.html', + styleUrls: ['./user-create.component.scss'], + standalone: true, + imports: [CommonModule, ReactiveFormsModule] +}) +export class UserCreateComponent implements OnInit { + userForm: FormGroup; + loading = false; + errorMessage: string | null = null; + + constructor( + private fb: FormBuilder, + private authService: AuthService, + private toastService: ToastService, + private router: Router + ) { } + + ngOnInit(): void { + this.userForm = this.fb.group({ + full_name: ['', [Validators.required, Validators.minLength(2)]], + username: ['', [Validators.required, Validators.minLength(4)]], + email: ['', [Validators.email]], + password: ['', [Validators.required, Validators.minLength(8)]], + role: ['user', Validators.required] + }); + } + + onSubmit() { + if (this.userForm.valid) { + this.loading = true; + this.errorMessage = null; + + const newUser: User = this.userForm.value; + this.authService.createUser(newUser).subscribe( + (response) => { + this.loading = false; + const toastNotification = new ToastNotification(); + toastNotification.type = ToastType.Success; + toastNotification.message = 'User created successfully'; + this.toastService.show(toastNotification); + this.router.navigate(['/users']); + }, + (error) => { + this.loading = false; + this.errorMessage = 'Error creating 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 new file mode 100644 index 00000000..b7b89f46 --- /dev/null +++ b/frontend/src/app/pages/user-list/user-list.component.html @@ -0,0 +1,31 @@ +
+
+
+

User List

+
+
+ Loading... +
+
+ + + + + + + + + + + + + + + + + +
NameUsernameEmailRole
{{ user.full_name }}{{ user.username }}{{ user.email }}{{ user.role }}
+ +
+
+
diff --git a/frontend/src/app/pages/user-list/user-list.component.scss b/frontend/src/app/pages/user-list/user-list.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/pages/user-list/user-list.component.spec.ts b/frontend/src/app/pages/user-list/user-list.component.spec.ts new file mode 100644 index 00000000..a57a7e45 --- /dev/null +++ b/frontend/src/app/pages/user-list/user-list.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; +import { PipesModule } from '../../pipes/pipes.module'; +import { FastenApiService } from '../../services/fasten-api.service'; +import { UserListComponent } from './user-list.component'; + +describe('UserListComponent', () => { + let component: UserListComponent; + let fixture: ComponentFixture; + let mockedFastenApiService; + + beforeEach(async () => { + mockedFastenApiService = jasmine.createSpyObj('FastenApiService', { 'getAllUsers': of([{}]) }) + await TestBed.configureTestingModule({ + declarations: [UserListComponent], + imports: [PipesModule, RouterTestingModule], + providers: [{ + provide: FastenApiService, + useValue: mockedFastenApiService + }] + }) + .compileComponents(); + fixture = TestBed.createComponent(UserListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/user-list/user-list.component.ts b/frontend/src/app/pages/user-list/user-list.component.ts new file mode 100644 index 00000000..f3f1dc6d --- /dev/null +++ b/frontend/src/app/pages/user-list/user-list.component.ts @@ -0,0 +1,36 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { User } from '../../models/fasten/user'; +import { FastenApiService } from '../../services/fasten-api.service'; + +@Component({ + selector: 'app-user-list', + templateUrl: './user-list.component.html', + styleUrls: ['./user-list.component.scss'] +}) +export class UserListComponent implements OnInit { + users: User[] = []; + loading: boolean = false; + + constructor( + private fastenApi: FastenApiService, + private router: Router, + private route: ActivatedRoute + ) { } + + ngOnInit(): void { + this.loadUsers(); + } + + loadUsers(): void { + this.loading = true; + this.fastenApi.getAllUsers().subscribe((users: User[]) => { + this.users = users; + this.loading = false; + }, + 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 041a7387..7082ac9d 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -1,16 +1,17 @@ -import {Inject, Injectable} from '@angular/core'; -import {HttpClient, HttpHeaders} from '@angular/common/http'; -import {User} from '../models/fasten/user'; -import {environment} from '../../environments/environment'; -import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path'; -import {ResponseWrapper} from '../models/response-wrapper'; +import { HttpClient } from '@angular/common/http'; +import { Inject, Injectable } from '@angular/core'; import * as Oauth from '@panva/oauth4webapi'; -import {SourceState} from '../models/fasten/source-state'; import * as jose from 'jose'; -import {UserRegisteredClaims} from '../models/fasten/user-registered-claims'; -import {uuidV4} from '../../lib/utils/uuid'; -import {HTTP_CLIENT_TOKEN} from "../dependency-injection"; -import {BehaviorSubject, Observable, Subject} from 'rxjs'; +import { BehaviorSubject, Observable, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { environment } from '../../environments/environment'; +import { GetEndpointAbsolutePath } from '../../lib/utils/endpoint_absolute_path'; +import { uuidV4 } from '../../lib/utils/uuid'; +import { HTTP_CLIENT_TOKEN } from "../dependency-injection"; +import { SourceState } from '../models/fasten/source-state'; +import { User } from '../models/fasten/user'; +import { UserRegisteredClaims } from '../models/fasten/user-registered-claims'; +import { ResponseWrapper } from '../models/response-wrapper'; @Injectable({ providedIn: 'root' @@ -130,6 +131,21 @@ export class AuthService { this.setAuthToken(resp.data) } + public createUser(newUser: User): Observable { + let fastenApiEndpointBase = GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base); + return this._httpClient.post(`${fastenApiEndpointBase}/secure/users`, newUser) + .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() @@ -193,6 +209,12 @@ export class AuthService { // } // await this.Close() } + + public IsAdmin(): boolean { + const currentUser = this.GetCurrentUser(); + return currentUser && currentUser.role === "admin"; + } + ///////////////////////////////////////////////////////////////////////////////////////////////// //Private Methods ///////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/frontend/src/app/services/fasten-api.service.ts b/frontend/src/app/services/fasten-api.service.ts index 3d2cf471..9ceb8f7f 100644 --- a/frontend/src/app/services/fasten-api.service.ts +++ b/frontend/src/app/services/fasten-api.service.ts @@ -359,4 +359,13 @@ export class FastenApiService { ); } + getAllUsers(): Observable { + return this._httpClient.get(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/users`) + .pipe( + map((response: ResponseWrapper) => { + return response.data as User[] + }) + ); + } + }