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:
parent
fa0decc2b5
commit
4a82064521
35
README.md
35
README.md
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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"`
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"`
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"`
|
||||||
|
}
|
|
@ -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()
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
})
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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})
|
||||||
|
}
|
|
@ -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" {
|
||||||
|
|
|
@ -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) },
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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> Sources</a>
|
<a routerLink="/sources" routerLinkActive="active" #sources="routerLinkActive" class="nav-link"><fa-icon [icon]="['fas', 'hospital']"></fa-icon> 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> 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>
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,4 +4,5 @@ export class User {
|
||||||
username?: string
|
username?: string
|
||||||
email?: string
|
email?: string
|
||||||
password?: string
|
password?: string
|
||||||
|
role?: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">×</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>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
|
@ -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[]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue