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
55
README.md
55
README.md
|
@ -13,17 +13,10 @@
|
|||
|
||||
**Fasten securely connects your healthcare providers together, creating a personal health record that never leaves your hands**
|
||||
|
||||
> [!NOTE]
|
||||
> [!NOTE]
|
||||
> NOTE: Fasten is a Work-in-Progress and can only communicate with a limited number of Healthcare Instutions (approx 25,000 at last count).
|
||||
> Please fill out this [Google Form](https://forms.gle/SNsYX9BNMXB6TuTw6) if you'd like to be kept up-to-date on Fasten
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To ensure Fasten's long-term sustainability, we're exploring some funding options. While we're still deciding a long-term monetization strategy, I'm kicking off with a crowdfunding/fundraising experiment for the first 500 users (including a surprise desktop app):
|
||||
>
|
||||
> - [Fasten Self-Hosted Lifetime License - **$200**](https://buy.stripe.com/fZe00deiUexS58Y4gg)
|
||||
>
|
||||
> Got questions or want to learn more about our fundraising experiment? [Click here to dive into the details & FAQs](https://docs.fastenhealth.com/funding.html)
|
||||
|
||||
|
||||
<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:
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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})
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
"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) {
|
||||
|
|
|
@ -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"
|
||||
"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" {
|
||||
|
|
|
@ -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) },
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }">
|
||||
<a routerLink="/sources" routerLinkActive="active" #sources="routerLinkActive" class="nav-link"><fa-icon [icon]="['fas', 'hospital']"></fa-icon> 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> 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>
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -10,4 +10,5 @@ export class UserRegisteredClaims {
|
|||
full_name: string //FullName
|
||||
picture: string //Picture
|
||||
email: string //Email
|
||||
role: string //Role
|
||||
}
|
||||
|
|
|
@ -4,4 +4,5 @@ export class User {
|
|||
username?: string
|
||||
email?: 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, 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
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -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