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

@ -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)
<p align="center">
<br/>
@ -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:

View File

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

View File

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

View File

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

View File

@ -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
}
//</editor-fold>
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

View File

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

View File

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

View File

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

View File

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

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)
}
// 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()

View File

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

View File

@ -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 {

View File

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

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 (
"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) {

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"
"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" {

View File

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

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 {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,

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 }">
<a routerLink="/sources" routerLinkActive="active" #sources="routerLinkActive" class="nav-link"><fa-icon [icon]="['fas', 'hospital']"></fa-icon>&nbsp; Sources</a>
</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>
</div><!-- az-header-menu -->
<div class="az-header-right">
@ -64,7 +67,7 @@
<img src="assets/logo/logo-text.png" alt="">
</div><!-- az-img-user -->
<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 -->
<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 { 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<HeaderComponent>;
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(() => {

View File

@ -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) => {

View File

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

View File

@ -4,4 +4,5 @@ export class User {
username?: string
email?: 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, 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<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.
public async IsAuthenticated(): Promise<boolean> {
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
/////////////////////////////////////////////////////////////////////////////////////////////////

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[]
})
);
}
}