add basic user management (#503)

* add basic user management

* allow admin to create another admin

* fix tests

* add multi user info to README
This commit is contained in:
David Radcliffe 2024-08-29 07:19:27 -04:00 committed by GitHub
parent fa0decc2b5
commit 4a82064521
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 678 additions and 94 deletions

View File

@ -17,13 +17,6 @@
> NOTE: Fasten is a Work-in-Progress and can only communicate with a limited number of Healthcare Instutions (approx 25,000 at last count). > 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 > 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)
<p align="center"> <p align="center">
<br/> <br/>
@ -61,7 +54,7 @@ I wanted a single (private) location to store our medical records, and I just co
- 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) - 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 - 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** **Fasten is an open-source, self-hosted, personal/family electronic medical record aggregator, designed to integrate with 1000's of insurances/hospitals/clinics**
@ -74,7 +67,7 @@ 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 - Supports the Medical industry's (semi-standard) FHIR protocol
- Uses OAuth2 (Smart-on-FHIR) authentication (no passwords necessary) - Uses OAuth2 (Smart-on-FHIR) authentication (no passwords necessary)
- Uses OAuth's `offline_access` scope (where possible) to automatically pull changes/updates - 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 - 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) ChatGPT-style interface to query your own medical history (offline)
@ -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 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 # FAQ's
See [FAQs](https://docs.fastenhealth.com/faqs.html) for common questions (& answers) regarding Fasten See [FAQs](https://docs.fastenhealth.com/faqs.html) for common questions (& answers) regarding Fasten
@ -161,9 +174,7 @@ Jason Kulatunga - Initial Development - @AnalogJ
# Fundraising & Sponsorships # 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)

View File

@ -3,14 +3,15 @@ package auth
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/fastenhealth/fasten-onprem/backend/pkg/models"
"github.com/golang-jwt/jwt/v4"
"strings" "strings"
"time" "time"
"github.com/fastenhealth/fasten-onprem/backend/pkg/models"
"github.com/golang-jwt/jwt/v4"
) )
// JwtGenerateFastenTokenFromUser Note: these functions are duplicated, in Fasten Cloud // 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) { func JwtGenerateFastenTokenFromUser(user models.User, issuerSigningKey string) (string, error) {
if len(strings.TrimSpace(issuerSigningKey)) == 0 { if len(strings.TrimSpace(issuerSigningKey)) == 0 {
return "", fmt.Errorf("issuer signing key cannot be empty") return "", fmt.Errorf("issuer signing key cannot be empty")
@ -26,8 +27,8 @@ func JwtGenerateFastenTokenFromUser(user models.User, issuerSigningKey string) (
}, },
UserMetadata: UserMetadata{ UserMetadata: UserMetadata{
FullName: user.FullName, FullName: user.FullName,
Picture: "", Email: user.Email,
Email: user.ID.String(), Role: user.Role,
}, },
} }

View File

@ -1,7 +1,12 @@
package auth package auth
import (
"github.com/fastenhealth/fasten-onprem/backend/pkg"
)
type UserMetadata struct { type UserMetadata struct {
FullName string `json:"full_name"` FullName string `json:"full_name"`
Picture string `json:"picture"` Picture string `json:"picture"`
Email string `json:"email"` Email string `json:"email"`
Role pkg.UserRole `json:"role"`
} }

View File

@ -9,6 +9,7 @@ type DatabaseRepositoryType string
type InstallationVerificationStatus string type InstallationVerificationStatus string
type InstallationQuotaStatus string type InstallationQuotaStatus string
type UserRole string
const ( const (
ResourceListPageSize int = 20 ResourceListPageSize int = 20
@ -50,4 +51,7 @@ const (
InstallationVerificationStatusVerified InstallationVerificationStatus = "VERIFIED" //email has been verified InstallationVerificationStatusVerified InstallationVerificationStatus = "VERIFIED" //email has been verified
InstallationQuotaStatusActive InstallationQuotaStatus = "ACTIVE" InstallationQuotaStatusActive InstallationQuotaStatus = "ACTIVE"
InstallationQuotaStatusConsumed InstallationQuotaStatus = "CONSUMED" InstallationQuotaStatusConsumed InstallationQuotaStatus = "CONSUMED"
UserRoleUser UserRole = "user"
UserRoleAdmin UserRole = "admin"
) )

View File

@ -5,10 +5,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
sourcePkg "github.com/fastenhealth/fasten-sources/pkg"
"strings" "strings"
"time" "time"
sourcePkg "github.com/fastenhealth/fasten-sources/pkg"
"github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg"
"github.com/fastenhealth/fasten-onprem/backend/pkg/config" "github.com/fastenhealth/fasten-onprem/backend/pkg/config"
"github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus" "github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus"
@ -179,6 +180,18 @@ func (gr *GormRepository) DeleteCurrentUser(ctx context.Context) error {
return nil 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
}
//</editor-fold> //</editor-fold>
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

View File

@ -3,11 +3,14 @@ package database
import ( import (
"context" "context"
"fmt" "fmt"
"log"
_20231017112246 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20231017112246" _20231017112246 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20231017112246"
_20231201122541 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20231201122541" _20231201122541 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20231201122541"
_0240114092806 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240114092806" _0240114092806 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240114092806"
_20240114103850 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240114103850" _20240114103850 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240114103850"
_20240208112210 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240208112210" _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" "github.com/fastenhealth/fasten-onprem/backend/pkg/models"
databaseModel "github.com/fastenhealth/fasten-onprem/backend/pkg/models/database" databaseModel "github.com/fastenhealth/fasten-onprem/backend/pkg/models/database"
sourceCatalog "github.com/fastenhealth/fasten-sources/catalog" sourceCatalog "github.com/fastenhealth/fasten-sources/catalog"
@ -15,7 +18,6 @@ import (
"github.com/go-gormigrate/gormigrate/v2" "github.com/go-gormigrate/gormigrate/v2"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"log"
) )
func (gr *GormRepository) Migrate() error { func (gr *GormRepository) Migrate() error {
@ -194,6 +196,35 @@ func (gr *GormRepository) Migrate() error {
return nil 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 // run when database is empty

View File

@ -2,6 +2,7 @@ package database
import ( import (
"context" "context"
"github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg"
"github.com/fastenhealth/fasten-onprem/backend/pkg/models" "github.com/fastenhealth/fasten-onprem/backend/pkg/models"
sourcePkg "github.com/fastenhealth/fasten-sources/clients/models" sourcePkg "github.com/fastenhealth/fasten-sources/clients/models"
@ -18,6 +19,7 @@ type DatabaseRepository interface {
GetUserByUsername(context.Context, string) (*models.User, error) GetUserByUsername(context.Context, string) (*models.User, error)
GetCurrentUser(ctx context.Context) (*models.User, error) GetCurrentUser(ctx context.Context) (*models.User, error)
DeleteCurrentUser(ctx context.Context) error DeleteCurrentUser(ctx context.Context) error
GetUsers(ctx context.Context) ([]models.User, error)
GetSummary(ctx context.Context) (*models.Summary, error) GetSummary(ctx context.Context) (*models.Summary, error)

View File

@ -13,5 +13,4 @@ type User struct {
//additional optional metadata that Fasten stores with users //additional optional metadata that Fasten stores with users
Picture string `json:"picture"` Picture string `json:"picture"`
Email string `json:"email"` Email string `json:"email"`
//Roles datatypes.JSON `json:"roles"`
} }

View File

@ -13,5 +13,4 @@ type User struct {
//additional optional metadata that Fasten stores with users //additional optional metadata that Fasten stores with users
Picture string `json:"picture"` Picture string `json:"picture"`
Email string `json:"email"` Email string `json:"email"`
//Roles datatypes.JSON `json:"roles"`
} }

View File

@ -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"`
}

View File

@ -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) 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. // ListBackgroundJobs mocks base method.
func (m *MockDatabaseRepository) ListBackgroundJobs(ctx context.Context, queryOptions models.BackgroundJobQueryOptions) ([]models.BackgroundJob, error) { func (m *MockDatabaseRepository) ListBackgroundJobs(ctx context.Context, queryOptions models.BackgroundJobQueryOptions) ([]models.BackgroundJob, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()

View File

@ -2,11 +2,12 @@ package database
import ( import (
"fmt" "fmt"
"net/url"
"strings"
"github.com/fastenhealth/fasten-onprem/backend/pkg/config" "github.com/fastenhealth/fasten-onprem/backend/pkg/config"
"github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus" "github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"net/url"
"strings"
//"github.com/glebarez/sqlite" //"github.com/glebarez/sqlite"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"

View File

@ -2,14 +2,12 @@ package models
import ( import (
"fmt" "fmt"
"golang.org/x/crypto/bcrypt"
"strings" "strings"
)
type UserWizard struct { "golang.org/x/crypto/bcrypt"
*User `json:",inline"`
JoinMailingList bool `json:"join_mailing_list"` "github.com/fastenhealth/fasten-onprem/backend/pkg"
} )
type User struct { type User struct {
ModelBase ModelBase
@ -18,9 +16,9 @@ type User struct {
Password string `json:"password"` Password string `json:"password"`
//additional optional metadata that Fasten stores with users //additional optional metadata that Fasten stores with users
Picture string `json:"picture"` Picture string `json:"picture"`
Email string `json:"email"` Email string `json:"email"`
//Roles datatypes.JSON `json:"roles"` Role pkg.UserRole `json:"role"`
} }
func (user *User) HashPassword(password string) error { func (user *User) HashPassword(password string) error {

View File

@ -2,6 +2,8 @@ package handler
import ( import (
"fmt" "fmt"
"net/http"
"github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg"
"github.com/fastenhealth/fasten-onprem/backend/pkg/auth" "github.com/fastenhealth/fasten-onprem/backend/pkg/auth"
"github.com/fastenhealth/fasten-onprem/backend/pkg/config" "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/models"
"github.com/fastenhealth/fasten-onprem/backend/pkg/utils" "github.com/fastenhealth/fasten-onprem/backend/pkg/utils"
"github.com/gin-gonic/gin" "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) { func AuthSignup(c *gin.Context) {
databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)
appConfig := c.MustGet(pkg.ContextKeyTypeConfig).(config.Interface) appConfig := c.MustGet(pkg.ContextKeyTypeConfig).(config.Interface)
var userWizard models.UserWizard var userWizard UserWizard
if err := c.ShouldBindJSON(&userWizard); err != nil { if err := c.ShouldBindJSON(&userWizard); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return 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. //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")) 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 //check if the user wants to join the mailing list
if userWizard.JoinMailingList { 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. //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}) c.JSON(http.StatusOK, gin.H{"success": true, "data": userFastenToken})
} }

View File

@ -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))
})
}

View File

@ -3,6 +3,10 @@ package handler
import ( import (
"context" "context"
"fmt" "fmt"
"io/ioutil"
"net/http"
"os"
"github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg"
"github.com/fastenhealth/fasten-onprem/backend/pkg/database" "github.com/fastenhealth/fasten-onprem/backend/pkg/database"
"github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus" "github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus"
@ -14,9 +18,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"io/ioutil"
"net/http"
"os"
) )
func CreateReconnectSource(c *gin.Context) { func CreateReconnectSource(c *gin.Context) {

View File

@ -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})
}

View File

@ -6,6 +6,11 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"runtime"
"strings"
"github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg"
"github.com/fastenhealth/fasten-onprem/backend/pkg/config" "github.com/fastenhealth/fasten-onprem/backend/pkg/config"
"github.com/fastenhealth/fasten-onprem/backend/pkg/database" "github.com/fastenhealth/fasten-onprem/backend/pkg/database"
@ -15,10 +20,6 @@ import (
"github.com/fastenhealth/fasten-onprem/backend/pkg/web/middleware" "github.com/fastenhealth/fasten-onprem/backend/pkg/web/middleware"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"io"
"net/http"
"runtime"
"strings"
) )
type AppEngine struct { type AppEngine struct {
@ -125,6 +126,9 @@ func (ae *AppEngine) Setup() (*gin.RouterGroup, *gin.Engine) {
secure.POST("/query", handler.QueryResourceFhir) secure.POST("/query", handler.QueryResourceFhir)
secure.GET("/users", handler.GetUsers)
secure.POST("/users", handler.CreateUser)
//server-side-events handler (only supported on mac/linux) //server-side-events handler (only supported on mac/linux)
// TODO: causes deadlock on Windows // TODO: causes deadlock on Windows
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {

View File

@ -1,24 +1,27 @@
import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser"; 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 { 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 { MedicalSourcesComponent } from './pages/medical-sources/medical-sources.component';
import {ResourceDetailComponent} from './pages/resource-detail/resource-detail.component'; import { PatientProfileComponent } from './pages/patient-profile/patient-profile.component';
import {AuthSigninComponent} from './pages/auth-signin/auth-signin.component'; import { ReportLabsComponent } from './pages/report-labs/report-labs.component';
import {AuthSignupComponent} from './pages/auth-signup/auth-signup.component'; import { ResourceCreatorComponent } from './pages/resource-creator/resource-creator.component';
import {IsAuthenticatedAuthGuard} from './auth-guards/is-authenticated-auth-guard'; import { ResourceDetailComponent } from './pages/resource-detail/resource-detail.component';
import {SourceDetailComponent} from './pages/source-detail/source-detail.component'; import { SourceDetailComponent } from './pages/source-detail/source-detail.component';
import {PatientProfileComponent} from './pages/patient-profile/patient-profile.component'; import { UserCreateComponent } from './pages/user-create/user-create.component';
import {MedicalHistoryComponent} from './pages/medical-history/medical-history.component'; import { UserListComponent } from './pages/user-list/user-list.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';
const routes: Routes = [ const routes: Routes = [
@ -50,6 +53,9 @@ const routes: Routes = [
{ path: 'labs', component: ReportLabsComponent, canActivate: [ IsAuthenticatedAuthGuard] }, { path: 'labs', component: ReportLabsComponent, canActivate: [ IsAuthenticatedAuthGuard] },
{ path: 'labs/report/:source_id/:resource_type/:resource_id', 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: '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: 'ui-elements', loadChildren: () => import('./ui-elements/ui-elements.module').then(m => m.UiElementsModule) },
// { path: 'form', loadChildren: () => import('./form/form.module').then(m => m.FormModule) }, // { path: 'form', loadChildren: () => import('./form/form.module').then(m => m.FormModule) },

View File

@ -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 { AuthSignupWizardComponent } from './pages/auth-signup-wizard/auth-signup-wizard.component';
import {ShowFirstRunWizardGuard} from './auth-guards/show-first-run-wizard-guard'; import {ShowFirstRunWizardGuard} from './auth-guards/show-first-run-wizard-guard';
import { IconsModule } from './icon-module'; import { IconsModule } from './icon-module';
import { UserListComponent } from './pages/user-list/user-list.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -60,6 +61,7 @@ import { IconsModule } from './icon-module';
DesktopCallbackComponent, DesktopCallbackComponent,
BackgroundJobsComponent, BackgroundJobsComponent,
AuthSignupWizardComponent, AuthSignupWizardComponent,
UserListComponent,
], ],
imports: [ imports: [
FormsModule, FormsModule,

View File

@ -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<boolean> {
if (await this.authService.IsAuthenticated() && await this.authService.IsAdmin()) {
return true;
}
this.router.navigate(['/dashboard']);
return false;
}
}

View File

@ -26,6 +26,9 @@
<li class="nav-item" ngbDropdown [ngClass]="{ 'active': sources?.isActive }"> <li class="nav-item" ngbDropdown [ngClass]="{ 'active': sources?.isActive }">
<a routerLink="/sources" routerLinkActive="active" #sources="routerLinkActive" class="nav-link"><fa-icon [icon]="['fas', 'hospital']"></fa-icon>&nbsp; Sources</a> <a routerLink="/sources" routerLinkActive="active" #sources="routerLinkActive" class="nav-link"><fa-icon [icon]="['fas', 'hospital']"></fa-icon>&nbsp; Sources</a>
</li> </li>
<li class="nav-item" *ngIf="isAdmin" ngbDropdown [ngClass]="{ 'active': users?.isActive }">
<a routerLink="/users" routerLinkActive="active" #users="routerLinkActive" class="nav-link"><fa-icon [icon]="['fas', 'users']"></fa-icon>&nbsp; Users</a>
</li>
</ul> </ul>
</div><!-- az-header-menu --> </div><!-- az-header-menu -->
<div class="az-header-right"> <div class="az-header-right">
@ -64,7 +67,7 @@
<img src="assets/logo/logo-text.png" alt=""> <img src="assets/logo/logo-text.png" alt="">
</div><!-- az-img-user --> </div><!-- az-img-user -->
<h6>{{current_user_claims.full_name || current_user_claims.sub || current_user_claims.email }}</h6> <h6>{{current_user_claims.full_name || current_user_claims.sub || current_user_claims.email }}</h6>
<span>Adminstrator</span> <span *ngIf="isAdmin">Administrator</span>
</div><!-- az-header-profile --> </div><!-- az-header-profile -->
<a (click)="openSupportForm(content)" class="dropdown-item cursor-pointer"><i style="font-size: medium;" class="fas fa-question-circle"></i> Get Support</a> <a (click)="openSupportForm(content)" class="dropdown-item cursor-pointer"><i style="font-size: medium;" class="fas fa-question-circle"></i> Get Support</a>

View File

@ -1,29 +1,44 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { HeaderComponent } from './header.component'; import { HttpClient } from '@angular/common/http';
import {HttpClientTestingModule} from '@angular/common/http/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing';
import {RouterModule} from '@angular/router'; import { RouterModule } from '@angular/router';
import {RouterTestingModule} from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import {HTTP_CLIENT_TOKEN} from '../../dependency-injection';
import {HttpClient} from '@angular/common/http';
import { IconsModule } from 'src/app/icon-module'; 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', () => { describe('HeaderComponent', () => {
let component: HeaderComponent; let component: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>; let fixture: ComponentFixture<HeaderComponent>;
let mockedAuthService;
beforeEach(async(() => { beforeEach(async(() => {
mockedAuthService = jasmine.createSpyObj(
'AuthService',
{
'getCurrentUser': of(new UserRegisteredClaims()),
'IsAdmin': of(false)
}
)
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ HttpClientTestingModule, RouterTestingModule, RouterModule, IconsModule ], imports: [HttpClientTestingModule, RouterTestingModule, RouterModule, IconsModule],
declarations: [ HeaderComponent ], declarations: [HeaderComponent],
providers: [ providers: [
{ {
provide: HTTP_CLIENT_TOKEN, provide: HTTP_CLIENT_TOKEN,
useClass: HttpClient, useClass: HttpClient,
}, },
{
provide: AuthService,
useValue: mockedAuthService
}
] ]
}) })
.compileComponents(); .compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {

View File

@ -29,6 +29,8 @@ export class HeaderComponent implements OnInit, OnDestroy {
is_environment_desktop: boolean = environment.environment_desktop is_environment_desktop: boolean = environment.environment_desktop
isAdmin: boolean = false;
constructor( constructor(
private authService: AuthService, private authService: AuthService,
private router: Router, private router: Router,
@ -42,6 +44,7 @@ export class HeaderComponent implements OnInit, OnDestroy {
this.current_user_claims = new UserRegisteredClaims() this.current_user_claims = new UserRegisteredClaims()
} }
this.isAdmin = this.authService.IsAdmin();
this.fastenApi.getBackgroundJobs().subscribe((data) => { this.fastenApi.getBackgroundJobs().subscribe((data) => {
this.backgroundJobs = data.filter((job) => { this.backgroundJobs = data.filter((job) => {

View File

@ -10,4 +10,5 @@ export class UserRegisteredClaims {
full_name: string //FullName full_name: string //FullName
picture: string //Picture picture: string //Picture
email: string //Email email: string //Email
role: string //Role
} }

View File

@ -4,4 +4,5 @@ export class User {
username?: string username?: string
email?: string email?: string
password?: string password?: string
role?: string
} }

View File

@ -0,0 +1,46 @@
<div class="az-content">
<div class="container">
<div class="az-content-body">
<h2 class="az-content-title">Create New User</h2>
<div *ngIf="errorMessage" class="alert alert-danger alert-dismissible fade show" role="alert">
{{ errorMessage }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close" (click)="errorMessage = null">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="full_name">Full Name</label>
<input type="text" id="full_name" formControlName="full_name" class="form-control" required>
</div>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" formControlName="username" class="form-control" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" formControlName="password" class="form-control" required>
</div>
<div class="form-group">
<label for="role">Role</label>
<select id="role" formControlName="role" class="form-control" required>
<option value="user" selected>User</option>
<option value="admin">Admin</option>
</select>
<div *ngIf="userForm.get('role')?.value === 'admin'" class="text-danger mt-2">
<strong>Warning:</strong> This will allow full system access including the ability to manage other users.
</div>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" formControlName="email" class="form-control">
</div>
<button type="submit" class="btn btn-az-primary" [disabled]="!userForm.valid || loading">
Create User
</button>
</form>
</div>
</div>
</div>

View File

@ -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;
}
);
}
}
}

View File

@ -0,0 +1,31 @@
<div class="az-content">
<div class="container">
<div class="az-content-body">
<h2 class="az-content-title">User List</h2>
<div *ngIf="loading" class="text-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<table *ngIf="!loading" class="table">
<thead>
<tr>
<th>Name</th>
<th>Username</th>
<th>Email</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let user of users">
<td>{{ user.full_name }}</td>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td><span class="badge badge-primary">{{ user.role }}</span></td>
</tr>
</tbody>
</table>
<button class="btn btn-az-primary mt-3" routerLink="/users/new">Create New User</button>
</div>
</div>
</div>

View File

@ -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<UserListComponent>;
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();
});
});

View File

@ -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;
});
}
}

View File

@ -1,16 +1,17 @@
import {Inject, Injectable} from '@angular/core'; import { HttpClient } from '@angular/common/http';
import {HttpClient, HttpHeaders} from '@angular/common/http'; import { Inject, Injectable } from '@angular/core';
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 * as Oauth from '@panva/oauth4webapi'; import * as Oauth from '@panva/oauth4webapi';
import {SourceState} from '../models/fasten/source-state';
import * as jose from 'jose'; import * as jose from 'jose';
import {UserRegisteredClaims} from '../models/fasten/user-registered-claims'; import { BehaviorSubject, Observable, throwError } from 'rxjs';
import {uuidV4} from '../../lib/utils/uuid'; import { catchError, map } from 'rxjs/operators';
import {HTTP_CLIENT_TOKEN} from "../dependency-injection"; import { environment } from '../../environments/environment';
import {BehaviorSubject, Observable, Subject} from 'rxjs'; 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({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -130,6 +131,21 @@ export class AuthService {
this.setAuthToken(resp.data) this.setAuthToken(resp.data)
} }
public createUser(newUser: User): Observable<any> {
let fastenApiEndpointBase = GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base);
return this._httpClient.post<ResponseWrapper>(`${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. //TODO: now that we've moved to remote-first database, we can refactor and simplify this function significantly.
public async IsAuthenticated(): Promise<boolean> { public async IsAuthenticated(): Promise<boolean> {
let authToken = this.GetAuthToken() let authToken = this.GetAuthToken()
@ -193,6 +209,12 @@ export class AuthService {
// } // }
// await this.Close() // await this.Close()
} }
public IsAdmin(): boolean {
const currentUser = this.GetCurrentUser();
return currentUser && currentUser.role === "admin";
}
///////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////
//Private Methods //Private Methods
///////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////

View File

@ -359,4 +359,13 @@ export class FastenApiService {
); );
} }
getAllUsers(): Observable<User[]> {
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/users`)
.pipe(
map((response: ResponseWrapper) => {
return response.data as User[]
})
);
}
} }