diff --git a/backend/pkg/auth/jwt_utils.go b/backend/pkg/auth/jwt_utils.go index a8a4c8d80..8e3b26ef9 100644 --- a/backend/pkg/auth/jwt_utils.go +++ b/backend/pkg/auth/jwt_utils.go @@ -3,14 +3,15 @@ package auth import ( "errors" "fmt" - "github.com/fastenhealth/fasten-onprem/backend/pkg/models" - "github.com/golang-jwt/jwt/v4" "strings" "time" + + "github.com/fastenhealth/fasten-onprem/backend/pkg/models" + "github.com/golang-jwt/jwt/v4" ) // JwtGenerateFastenTokenFromUser Note: these functions are duplicated, in Fasten Cloud -//Any changes here must be replicated in that repo +// Any changes here must be replicated in that repo func JwtGenerateFastenTokenFromUser(user models.User, issuerSigningKey string) (string, error) { if len(strings.TrimSpace(issuerSigningKey)) == 0 { return "", fmt.Errorf("issuer signing key cannot be empty") @@ -26,8 +27,8 @@ func JwtGenerateFastenTokenFromUser(user models.User, issuerSigningKey string) ( }, UserMetadata: UserMetadata{ FullName: user.FullName, - Picture: "", - Email: user.ID.String(), + Email: user.Email, + Role: user.Role, }, } diff --git a/backend/pkg/auth/user_metadata.go b/backend/pkg/auth/user_metadata.go index 9b53f4b9c..90d7c6157 100644 --- a/backend/pkg/auth/user_metadata.go +++ b/backend/pkg/auth/user_metadata.go @@ -1,7 +1,12 @@ package auth +import ( + "github.com/fastenhealth/fasten-onprem/backend/pkg/models" +) + 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 models.Role `json:"role"` } diff --git a/backend/pkg/database/gorm_common.go b/backend/pkg/database/gorm_common.go index 9f04aca33..e38ce4f0d 100644 --- a/backend/pkg/database/gorm_common.go +++ b/backend/pkg/database/gorm_common.go @@ -5,10 +5,11 @@ import ( "encoding/json" "errors" "fmt" - sourcePkg "github.com/fastenhealth/fasten-sources/pkg" "strings" "time" + sourcePkg "github.com/fastenhealth/fasten-sources/pkg" + "github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg/config" "github.com/fastenhealth/fasten-onprem/backend/pkg/event_bus" @@ -179,6 +180,12 @@ 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) + return users, result.Error +} + // //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/backend/pkg/database/gorm_repository_migrations.go b/backend/pkg/database/gorm_repository_migrations.go index 6a4a6f099..c0d996565 100644 --- a/backend/pkg/database/gorm_repository_migrations.go +++ b/backend/pkg/database/gorm_repository_migrations.go @@ -3,11 +3,14 @@ package database import ( "context" "fmt" + "log" + _20231017112246 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20231017112246" _20231201122541 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20231201122541" _0240114092806 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240114092806" _20240114103850 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240114103850" _20240208112210 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240208112210" + _20240813222836 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240813222836" "github.com/fastenhealth/fasten-onprem/backend/pkg/models" databaseModel "github.com/fastenhealth/fasten-onprem/backend/pkg/models/database" sourceCatalog "github.com/fastenhealth/fasten-sources/catalog" @@ -15,7 +18,6 @@ import ( "github.com/go-gormigrate/gormigrate/v2" "github.com/google/uuid" "gorm.io/gorm" - "log" ) func (gr *GormRepository) Migrate() error { @@ -194,6 +196,35 @@ func (gr *GormRepository) Migrate() error { return nil }, }, + { + ID: "20240813222836", // add role to user + Migrate: func(tx *gorm.DB) error { + + err := tx.AutoMigrate( + &_20240813222836.User{}, + ) + if err != nil { + return err + } + + // set first user to admin + // set all other users to user + users := []_20240813222836.User{} + results := tx.Find(&users) + if results.Error != nil { + return results.Error + } + for ndx, user := range users { + if ndx == 0 { + user.Role = _20240813222836.RoleAdmin + } else { + user.Role = _20240813222836.RoleUser + } + tx.Save(&user) + } + return nil + }, + }, }) // run when database is empty diff --git a/backend/pkg/database/interface.go b/backend/pkg/database/interface.go index 65adea781..b49363034 100644 --- a/backend/pkg/database/interface.go +++ b/backend/pkg/database/interface.go @@ -2,6 +2,7 @@ package database import ( "context" + "github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg/models" sourcePkg "github.com/fastenhealth/fasten-sources/clients/models" @@ -18,6 +19,7 @@ type DatabaseRepository interface { GetUserByUsername(context.Context, string) (*models.User, error) GetCurrentUser(ctx context.Context) (*models.User, error) DeleteCurrentUser(ctx context.Context) error + GetUsers(ctx context.Context) ([]models.User, error) GetSummary(ctx context.Context) (*models.Summary, error) diff --git a/backend/pkg/database/migrations/20231017112246/user.go b/backend/pkg/database/migrations/20231017112246/user.go index 746dc8f66..15ba80eef 100644 --- a/backend/pkg/database/migrations/20231017112246/user.go +++ b/backend/pkg/database/migrations/20231017112246/user.go @@ -13,5 +13,4 @@ type User struct { //additional optional metadata that Fasten stores with users Picture string `json:"picture"` Email string `json:"email"` - //Roles datatypes.JSON `json:"roles"` } diff --git a/backend/pkg/database/migrations/20231201122541/user.go b/backend/pkg/database/migrations/20231201122541/user.go index f31cee502..931ca5b32 100644 --- a/backend/pkg/database/migrations/20231201122541/user.go +++ b/backend/pkg/database/migrations/20231201122541/user.go @@ -13,5 +13,4 @@ type User struct { //additional optional metadata that Fasten stores with users Picture string `json:"picture"` Email string `json:"email"` - //Roles datatypes.JSON `json:"roles"` } diff --git a/backend/pkg/database/migrations/20240813222836/user.go b/backend/pkg/database/migrations/20240813222836/user.go new file mode 100644 index 000000000..8b78614d7 --- /dev/null +++ b/backend/pkg/database/migrations/20240813222836/user.go @@ -0,0 +1,24 @@ +package _20240813222836 + +import ( + "github.com/fastenhealth/fasten-onprem/backend/pkg/models" +) + +type Role string + +const ( + RoleUser Role = "user" + RoleAdmin Role = "admin" +) + +type User struct { + models.ModelBase + FullName string `json:"full_name"` + Username string `json:"username" gorm:"unique"` + Password string `json:"password"` + + //additional optional metadata that Fasten stores with users + Picture string `json:"picture"` + Email string `json:"email"` + Role Role `json:"role"` +} diff --git a/backend/pkg/models/user.go b/backend/pkg/models/user.go index f967c7040..11f646658 100644 --- a/backend/pkg/models/user.go +++ b/backend/pkg/models/user.go @@ -2,14 +2,17 @@ package models import ( "fmt" - "golang.org/x/crypto/bcrypt" "strings" + + "golang.org/x/crypto/bcrypt" ) -type UserWizard struct { - *User `json:",inline"` - JoinMailingList bool `json:"join_mailing_list"` -} +type Role string + +const ( + RoleUser Role = "user" + RoleAdmin Role = "admin" +) type User struct { ModelBase @@ -20,7 +23,7 @@ type User struct { //additional optional metadata that Fasten stores with users Picture string `json:"picture"` Email string `json:"email"` - //Roles datatypes.JSON `json:"roles"` + Role Role `json:"role"` } func (user *User) HashPassword(password string) error { diff --git a/backend/pkg/web/handler/auth.go b/backend/pkg/web/handler/auth.go index 982b4f11f..c32456276 100644 --- a/backend/pkg/web/handler/auth.go +++ b/backend/pkg/web/handler/auth.go @@ -2,6 +2,8 @@ package handler import ( "fmt" + "net/http" + "github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg/auth" "github.com/fastenhealth/fasten-onprem/backend/pkg/config" @@ -9,19 +11,53 @@ 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 RequireAdmin(c *gin.Context) { + 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) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } + if currentUser.Role != models.RoleAdmin { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "Unauthorized"}) + return + } +} + 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 = "admin" + } else { + userWizard.User.Role = "user" + } + err = databaseRepo.CreateUser(c, userWizard.User) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return @@ -29,6 +65,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 +102,11 @@ func AuthSignin(c *gin.Context) { } //TODO: we can derive the encryption key and the hash'ed user from the responseData sub. For now the Sub will be the user id prepended with hello. - userFastenToken, err := auth.JwtGenerateFastenTokenFromUser(user, appConfig.GetString("jwt.issuer.key")) + userFastenToken, err := auth.JwtGenerateFastenTokenFromUser(*foundUser, appConfig.GetString("jwt.issuer.key")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } c.JSON(http.StatusOK, gin.H{"success": true, "data": userFastenToken}) } diff --git a/backend/pkg/web/handler/auth_test.go b/backend/pkg/web/handler/auth_test.go new file mode 100644 index 000000000..e43122bf1 --- /dev/null +++ b/backend/pkg/web/handler/auth_test.go @@ -0,0 +1,93 @@ +package handler_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/fastenhealth/fasten-onprem/backend/pkg" + mock_config "github.com/fastenhealth/fasten-onprem/backend/pkg/config/mock" + mock_database "github.com/fastenhealth/fasten-onprem/backend/pkg/database/mock" + "github.com/fastenhealth/fasten-onprem/backend/pkg/models" + "github.com/fastenhealth/fasten-onprem/backend/pkg/web/handler" + "github.com/gin-gonic/gin" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func TestAuthSignup(t *testing.T) { + // Setup + gin.SetMode(gin.TestMode) + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + t.Run("First user should be assigned admin role", func(t *testing.T) { + mockDB := mock_database.NewMockDatabaseRepository(mockCtrl) + mockConfig := mock_config.NewMockInterface(mockCtrl) + + mockDB.EXPECT().GetUserCount(gomock.Any()).Return(0, nil) + mockDB.EXPECT().CreateUser(gomock.Any(), gomock.Any()).Do(func(_ interface{}, user *models.User) { + assert.Equal(t, "admin", 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 := models.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, "user", 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 := models.UserWizard{ + User: &models.User{ + Username: "testuser2", + Password: "testpass2", + }, + } + jsonData, _ := json.Marshal(userWizard) + c.Request, _ = http.NewRequest(http.MethodPost, "/signup", bytes.NewBuffer(jsonData)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.AuthSignup(c) + + assert.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.True(t, response["success"].(bool)) + }) +} diff --git a/backend/pkg/web/handler/users.go b/backend/pkg/web/handler/users.go new file mode 100644 index 000000000..11b9d4910 --- /dev/null +++ b/backend/pkg/web/handler/users.go @@ -0,0 +1,54 @@ +package handler + +import ( + "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" +) + +func GetUsers(c *gin.Context) { + RequireAdmin(c) + + 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 + } + + // Remove password field from each user + var sanitizedUsers []models.User + for _, user := range users { + user.Password = "" // Clear the password field + sanitizedUsers = append(sanitizedUsers, user) + } + + c.JSON(200, gin.H{"success": true, "data": sanitizedUsers}) +} + +func CreateUser(c *gin.Context) { + RequireAdmin(c) + + 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 + } + + // Set the role to "user" by default + newUser.Role = models.RoleUser + + err := databaseRepo.CreateUser(c, &newUser) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": newUser}) +} diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index 44424f864..54842a8e0 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -6,6 +6,11 @@ import ( "embed" "encoding/json" "fmt" + "io" + "net/http" + "runtime" + "strings" + "github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg/config" "github.com/fastenhealth/fasten-onprem/backend/pkg/database" @@ -15,10 +20,6 @@ import ( "github.com/fastenhealth/fasten-onprem/backend/pkg/web/middleware" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "io" - "net/http" - "runtime" - "strings" ) type AppEngine struct { @@ -125,6 +126,9 @@ func (ae *AppEngine) Setup() (*gin.RouterGroup, *gin.Engine) { secure.POST("/query", handler.QueryResourceFhir) + secure.GET("/users", handler.GetUsers) + secure.POST("/users", handler.CreateUser) + //server-side-events handler (only supported on mac/linux) // TODO: causes deadlock on Windows if runtime.GOOS != "windows" { diff --git a/frontend/angular.json b/frontend/angular.json index 0adc37c58..a361f8c21 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -76,10 +76,24 @@ "src/styles.scss" ], "scripts": [ - "node_modules/@panva/oauth4webapi/build/index.js" + ] }, "configurations": { + "dev": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.dev.ts" + } + ], + "optimization": false, + "sourceMap": true, + "namedChunks": true, + "extractLicenses": true, + "vendorChunk": true, + "buildOptimizer": false + }, "prod": { "fileReplacements": [ { @@ -248,9 +262,13 @@ "serve": { "builder": "@angular-devkit/build-angular:dev-server", "options": { - "browserTarget": "fastenhealth:build" + "browserTarget": "fastenhealth:build", + "proxyConfig": "src/proxy.conf.json" }, "configurations": { + "dev": { + "browserTarget": "fastenhealth:build:dev" + }, "prod": { "browserTarget": "fastenhealth:build:prod" }, diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index d6a921141..d0e257f15 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -1,24 +1,27 @@ -import { NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; import { BrowserModule } from "@angular/platform-browser"; -import { Routes, RouterModule } from "@angular/router"; +import { RouterModule, Routes } from "@angular/router"; +import { environment } from '../environments/environment'; +import { IsAdminAuthGuard } from './auth-guards/is-admin-auth-guard'; +import { IsAuthenticatedAuthGuard } from './auth-guards/is-authenticated-auth-guard'; +import { ShowFirstRunWizardGuard } from './auth-guards/show-first-run-wizard-guard'; +import { AuthSigninComponent } from './pages/auth-signin/auth-signin.component'; +import { AuthSignupWizardComponent } from './pages/auth-signup-wizard/auth-signup-wizard.component'; +import { AuthSignupComponent } from './pages/auth-signup/auth-signup.component'; +import { BackgroundJobsComponent } from './pages/background-jobs/background-jobs.component'; import { DashboardComponent } from './pages/dashboard/dashboard.component'; +import { DesktopCallbackComponent } from './pages/desktop-callback/desktop-callback.component'; +import { ExploreComponent } from './pages/explore/explore.component'; +import { MedicalHistoryComponent } from './pages/medical-history/medical-history.component'; import { MedicalSourcesComponent } from './pages/medical-sources/medical-sources.component'; -import {ResourceDetailComponent} from './pages/resource-detail/resource-detail.component'; -import {AuthSigninComponent} from './pages/auth-signin/auth-signin.component'; -import {AuthSignupComponent} from './pages/auth-signup/auth-signup.component'; -import {IsAuthenticatedAuthGuard} from './auth-guards/is-authenticated-auth-guard'; -import {SourceDetailComponent} from './pages/source-detail/source-detail.component'; -import {PatientProfileComponent} from './pages/patient-profile/patient-profile.component'; -import {MedicalHistoryComponent} from './pages/medical-history/medical-history.component'; -import {ReportLabsComponent} from './pages/report-labs/report-labs.component'; -import {ResourceCreatorComponent} from './pages/resource-creator/resource-creator.component'; -import {ExploreComponent} from './pages/explore/explore.component'; -import {environment} from '../environments/environment'; -import {DesktopCallbackComponent} from './pages/desktop-callback/desktop-callback.component'; -import {BackgroundJobsComponent} from './pages/background-jobs/background-jobs.component'; -import {AuthSignupWizardComponent} from './pages/auth-signup-wizard/auth-signup-wizard.component'; -import {ShowFirstRunWizardGuard} from './auth-guards/show-first-run-wizard-guard'; +import { PatientProfileComponent } from './pages/patient-profile/patient-profile.component'; +import { ReportLabsComponent } from './pages/report-labs/report-labs.component'; +import { ResourceCreatorComponent } from './pages/resource-creator/resource-creator.component'; +import { ResourceDetailComponent } from './pages/resource-detail/resource-detail.component'; +import { SourceDetailComponent } from './pages/source-detail/source-detail.component'; +import { UserCreateComponent } from './pages/user-create/user-create.component'; +import { UserListComponent } from './pages/user-list/user-list.component'; const routes: Routes = [ @@ -50,6 +53,9 @@ const routes: Routes = [ { path: 'labs', component: ReportLabsComponent, canActivate: [ IsAuthenticatedAuthGuard] }, { path: 'labs/report/:source_id/:resource_type/:resource_id', component: ReportLabsComponent, canActivate: [ IsAuthenticatedAuthGuard] }, + { path: 'users', component: UserListComponent, canActivate: [ IsAuthenticatedAuthGuard, IsAdminAuthGuard ] }, + { path: 'users/new', component: UserCreateComponent, canActivate: [ IsAuthenticatedAuthGuard, IsAdminAuthGuard ] }, + // { path: 'general-pages', loadChildren: () => import('./general-pages/general-pages.module').then(m => m.GeneralPagesModule) }, // { path: 'ui-elements', loadChildren: () => import('./ui-elements/ui-elements.module').then(m => m.UiElementsModule) }, // { path: 'form', loadChildren: () => import('./form/form.module').then(m => m.FormModule) }, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 45c07b731..9efff0168 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -40,6 +40,7 @@ import {FhirDatatableModule} from './components/fhir-datatable/fhir-datatable.mo import { AuthSignupWizardComponent } from './pages/auth-signup-wizard/auth-signup-wizard.component'; import {ShowFirstRunWizardGuard} from './auth-guards/show-first-run-wizard-guard'; import { IconsModule } from './icon-module'; +import { UserListComponent } from './pages/user-list/user-list.component'; @NgModule({ declarations: [ @@ -60,6 +61,7 @@ import { IconsModule } from './icon-module'; DesktopCallbackComponent, BackgroundJobsComponent, AuthSignupWizardComponent, + UserListComponent, ], imports: [ FormsModule, diff --git a/frontend/src/app/auth-guards/is-admin-auth-guard.ts b/frontend/src/app/auth-guards/is-admin-auth-guard.ts new file mode 100644 index 000000000..b379b5b44 --- /dev/null +++ b/frontend/src/app/auth-guards/is-admin-auth-guard.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; + +@Injectable({ + providedIn: 'root' +}) +export class IsAdminAuthGuard implements CanActivate { + constructor(private authService: AuthService, private router: Router) {} + + async canActivate(): Promise { + if (await this.authService.IsAuthenticated() && await this.authService.IsAdmin()) { + return true; + } + this.router.navigate(['/dashboard']); + return false; + } +} diff --git a/frontend/src/app/components/header/header.component.html b/frontend/src/app/components/header/header.component.html index 88fdef638..d22dae97b 100644 --- a/frontend/src/app/components/header/header.component.html +++ b/frontend/src/app/components/header/header.component.html @@ -26,6 +26,9 @@ +
diff --git a/frontend/src/app/components/header/header.component.ts b/frontend/src/app/components/header/header.component.ts index ec309dc5f..8ce09c61c 100644 --- a/frontend/src/app/components/header/header.component.ts +++ b/frontend/src/app/components/header/header.component.ts @@ -29,6 +29,8 @@ export class HeaderComponent implements OnInit, OnDestroy { is_environment_desktop: boolean = environment.environment_desktop + isAdmin: boolean = false; + constructor( private authService: AuthService, private router: Router, @@ -42,6 +44,7 @@ export class HeaderComponent implements OnInit, OnDestroy { this.current_user_claims = new UserRegisteredClaims() } + this.isAdmin = this.authService.IsAdmin(); this.fastenApi.getBackgroundJobs().subscribe((data) => { this.backgroundJobs = data.filter((job) => { diff --git a/frontend/src/app/models/fasten/user-registered-claims.ts b/frontend/src/app/models/fasten/user-registered-claims.ts index 73c0c045e..62250ff35 100644 --- a/frontend/src/app/models/fasten/user-registered-claims.ts +++ b/frontend/src/app/models/fasten/user-registered-claims.ts @@ -10,4 +10,5 @@ export class UserRegisteredClaims { full_name: string //FullName picture: string //Picture email: string //Email + role: string //Role } diff --git a/frontend/src/app/models/fasten/user.ts b/frontend/src/app/models/fasten/user.ts index ceef8fb8f..9dc07a529 100644 --- a/frontend/src/app/models/fasten/user.ts +++ b/frontend/src/app/models/fasten/user.ts @@ -4,4 +4,5 @@ export class User { username?: string email?: string password?: string + is_admin?: boolean } diff --git a/frontend/src/app/pages/user-create/user-create.component.html b/frontend/src/app/pages/user-create/user-create.component.html new file mode 100644 index 000000000..17b7da228 --- /dev/null +++ b/frontend/src/app/pages/user-create/user-create.component.html @@ -0,0 +1,28 @@ +
+
+
+

Create New User

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
diff --git a/frontend/src/app/pages/user-create/user-create.component.scss b/frontend/src/app/pages/user-create/user-create.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/pages/user-create/user-create.component.ts b/frontend/src/app/pages/user-create/user-create.component.ts new file mode 100644 index 000000000..f72935116 --- /dev/null +++ b/frontend/src/app/pages/user-create/user-create.component.ts @@ -0,0 +1,60 @@ +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; + + 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.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(8)]] + }); + } + + onSubmit() { + if (this.userForm.valid) { + this.loading = true; + 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; + const toastNotification = new ToastNotification(); + toastNotification.type = ToastType.Error; + toastNotification.message = 'Error creating user: ' + error.message; + this.toastService.show(toastNotification); + } + ); + } + } +} diff --git a/frontend/src/app/pages/user-list/user-list.component.html b/frontend/src/app/pages/user-list/user-list.component.html new file mode 100644 index 000000000..b7b89f460 --- /dev/null +++ b/frontend/src/app/pages/user-list/user-list.component.html @@ -0,0 +1,31 @@ +
+
+
+

User List

+
+
+ Loading... +
+
+ + + + + + + + + + + + + + + + + +
NameUsernameEmailRole
{{ user.full_name }}{{ user.username }}{{ user.email }}{{ user.role }}
+ +
+
+
diff --git a/frontend/src/app/pages/user-list/user-list.component.scss b/frontend/src/app/pages/user-list/user-list.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/pages/user-list/user-list.component.ts b/frontend/src/app/pages/user-list/user-list.component.ts new file mode 100644 index 000000000..3bbe8d3bb --- /dev/null +++ b/frontend/src/app/pages/user-list/user-list.component.ts @@ -0,0 +1,32 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { User } from '../../models/fasten/user'; +import { FastenApiService } from '../../services/fasten-api.service'; + +@Component({ + selector: 'app-user-list', + templateUrl: './user-list.component.html', + styleUrls: ['./user-list.component.scss'] +}) +export class UserListComponent implements OnInit { + users: User[] = []; + loading: boolean = false; + + constructor(private fastenApi: FastenApiService, private router: Router, private route: ActivatedRoute) { } + + ngOnInit(): void { + this.loadUsers(); + } + + loadUsers(): void { + this.loading = true; + this.fastenApi.getAllUsers().subscribe((users: User[]) => { + this.users = users; + this.loading = false; + }, + error => { + console.error('Error loading users:', error); + this.loading = false; + }); + } +} diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts index 041a7387a..9998d960c 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -130,6 +130,11 @@ export class AuthService { this.setAuthToken(resp.data) } + public createUser(newUser: User): Observable { + let fastenApiEndpointBase = GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base); + return this._httpClient.post(`${fastenApiEndpointBase}/secure/users`, newUser); + } + //TODO: now that we've moved to remote-first database, we can refactor and simplify this function significantly. public async IsAuthenticated(): Promise { let authToken = this.GetAuthToken() @@ -193,6 +198,12 @@ export class AuthService { // } // await this.Close() } + + public IsAdmin(): boolean { + const currentUser = this.GetCurrentUser(); + return currentUser && currentUser.role === "admin"; + } + ///////////////////////////////////////////////////////////////////////////////////////////////// //Private Methods ///////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/frontend/src/app/services/fasten-api.service.ts b/frontend/src/app/services/fasten-api.service.ts index 3d2cf4717..9ceb8f7f6 100644 --- a/frontend/src/app/services/fasten-api.service.ts +++ b/frontend/src/app/services/fasten-api.service.ts @@ -359,4 +359,13 @@ export class FastenApiService { ); } + getAllUsers(): Observable { + return this._httpClient.get(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/users`) + .pipe( + map((response: ResponseWrapper) => { + return response.data as User[] + }) + ); + } + } diff --git a/frontend/src/assets/favicon/site.webmanifest b/frontend/src/assets/favicon/site.webmanifest index b20abb7cb..12c0d091b 100644 --- a/frontend/src/assets/favicon/site.webmanifest +++ b/frontend/src/assets/favicon/site.webmanifest @@ -3,12 +3,12 @@ "short_name": "", "icons": [ { - "src": "/android-chrome-192x192.png", + "src": "/assets/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/android-chrome-512x512.png", + "src": "/assets/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } diff --git a/frontend/src/assets/scss/custom/_nav.scss b/frontend/src/assets/scss/custom/_nav.scss index fdf5ef1cb..e69de29bb 100755 --- a/frontend/src/assets/scss/custom/_nav.scss +++ b/frontend/src/assets/scss/custom/_nav.scss @@ -1,269 +0,0 @@ -/* ###### 4.8 Nav ###### */ -@media (min-width: 768px) { - .az-nav { - align-items: center; - } -} -.az-nav .nav-link { - display: block; - color: #596882; - padding: 0; - position: relative; - line-height: normal; -} -.az-nav .nav-link:hover-focus() { - color: #1c273c; -} -.az-nav .nav-link + .nav-link { - padding-top: 12px; - margin-top: 12px; - border-top: 1px dotted #97a3b9; -} -@media (min-width: 768px) { - .az-nav .nav-link + .nav-link { - padding-top: 0; - margin-top: 0; - border-top: 0; - padding-left: 15px; - margin-left: 15px; - border-left: 1px dotted #97a3b9; - } -} -.az-nav .nav-link.active { - color: #5b47fb; -} - -.az-nav-column { - flex-direction: column; -} -.az-nav-column .nav-link { - padding: 0; - height: 38px; - color: #1c273c; - display: flex; - align-items: center; - justify-content: flex-start; -} -.az-nav-column .nav-link i { - font-size: 24px; - line-height: 0; - width: 24px; - margin-right: 12px; - text-align: center; - transition: all 0.2s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .az-nav-column .nav-link i { - transition: none; - } -} -.az-nav-column .nav-link i:not([class*=" tx-"]) { - color: #7987a1; -} -.az-nav-column .nav-link i.typcn { - line-height: 0.9; -} -.az-nav-column .nav-link span { - font-weight: 400; - font-size: 11px; - color: #97a3b9; - margin-left: auto; -} -.az-nav-column .nav-link:hover, .az-nav-column .nav-link:focus { - color: #1c273c; -} -.az-nav-column .nav-link:hover i:not([class*=" tx-"]), .az-nav-column .nav-link:focus i:not([class*=" tx-"]) { - color: #1c273c; -} -.az-nav-column .nav-link.active { - position: relative; -} -.az-nav-column .nav-link.active::before { - content: ""; - position: absolute; - top: 6px; - bottom: 6px; - left: -28px; - width: 3px; - background-color: #5b47fb; - border-radius: 3px; - display: none; -} -.az-nav-column .nav-link.active, .az-nav-column .nav-link.active:hover, .az-nav-column .nav-link.active:focus { - color: #5b47fb; -} -.az-nav-column .nav-link.active i, .az-nav-column .nav-link.active:hover i, .az-nav-column .nav-link.active:focus i { - color: #5b47fb; -} -.az-nav-column .nav-link + .nav-link { - border-top: 1px dotted #b4bdce; -} -.az-nav-column.sm .nav-link { - font-size: 0.875rem; - font-weight: 400; - padding: 10px 0; -} -.az-nav-column.sm .nav-link i { - font-size: 21px; -} - -.az-nav-dark .nav-link { - color: rgba(255, 255, 255, 0.7); -} -.az-nav-dark .nav-link:hover-focus() { - color: #fff; -} -.az-nav-dark .nav-link + .nav-link { - border-color: #596882; -} -.az-nav-dark .nav-link.active { - color: #5b47fb; -} - -.az-nav-colored-bg .nav-link + .nav-link { - border-color: rgba(255, 255, 255, 0.4); -} -.az-nav-colored-bg .nav-link.active { - color: #fff; -} - -.az-nav-line { - position: relative; -} -.az-nav-line .nav-link { - padding: 0; - color: #596882; - position: relative; -} -.az-nav-line .nav-link:hover-focus() { - color: #1c273c; -} -.az-nav-line .nav-link + .nav-link { - margin-top: 15px; -} -@media (min-width: 768px) { - .az-nav-line .nav-link + .nav-link { - margin-top: 0; - margin-left: 30px; - } -} -.az-nav-line .nav-link.active { - color: #1c273c; -} -.az-nav-line .nav-link.active::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: -20px; - width: 2px; - background-color: #1c273c; -} -@media (min-width: 768px) { - .az-nav-line .nav-link.active::before { - top: auto; - bottom: -20px; - left: 0; - right: 0; - height: 2px; - width: auto; - } -} -.az-nav-line.az-nav-dark .nav-link { - color: rgba(255, 255, 255, 0.7); -} -.az-nav-line.az-nav-dark .nav-link:hover-focus() { - color: #fff; -} -.az-nav-line.az-nav-dark .nav-link.active { - color: #fff; -} -.az-nav-line.az-nav-dark .nav-link.active::before { - background-color: #fff; -} - -.az-nav-tabs { - padding: 15px 15px 0; - background-color: #cdd4e0; -} -.az-nav-tabs .lSSlideOuter { - position: relative; - padding-left: 32px; - padding-right: 35px; -} -.az-nav-tabs .lSSlideWrapper { - overflow: visible; -} -.az-nav-tabs .lSAction > a { - display: block; - height: 40px; - top: 16px; - opacity: 1; - background-color: #b4bdce; - background-image: none; -} -.az-nav-tabs .lSAction > a:hover-focus() { - background-color: #a5afc4; -} -.az-nav-tabs .lSAction > a::before { - font-family: "Ionicons"; - font-size: 18px; - position: absolute; - top: -4px; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; -} -.az-nav-tabs .lSAction > a.lSPrev { - left: -32px; -} -.az-nav-tabs .lSAction > a.lSPrev::before { - content: "\f3cf"; -} -.az-nav-tabs .lSAction > a.lSNext { - right: -35px; -} -.az-nav-tabs .lSAction > a.lSNext::before { - content: "\f3d1"; -} -.az-nav-tabs .lSAction > a.disabled { - background-color: #e3e7ed; - color: #fff; -} -.az-nav-tabs .lightSlider { - display: flex; -} -.az-nav-tabs .tab-item { - flex-shrink: 0; - display: block; - float: none; - min-width: 150px; -} -.az-nav-tabs .tab-link { - display: flex; - align-items: center; - justify-content: center; - padding: 10px 20px; - line-height: 1.428; - color: #596882; - white-space: nowrap; - background-color: #e3e7ed; -} -.az-nav-tabs .tab-link:hover-focus() { - background-color: #f4f5f8; -} -.az-nav-tabs .tab-link.active { - background-color: #fff; - color: #1c273c; - font-weight: 500; -} - -.az-tab-pane { - display: none; -} -.az-tab-pane.active { - display: block; -} diff --git a/frontend/src/environments/environment.dev.ts b/frontend/src/environments/environment.dev.ts new file mode 100644 index 000000000..bd558517f --- /dev/null +++ b/frontend/src/environments/environment.dev.ts @@ -0,0 +1,15 @@ +export const environment = { + production: false, + environment_cloud: false, + environment_desktop: false, + environment_name: "dev", + popup_source_auth: false, + + lighthouse_api_endpoint_base: 'https://lighthouse.fastenhealth.com/sandbox', + + //used to specify the couchdb server that we're going to use (can be relative or absolute). Must not have trailing slash + couchdb_endpoint_base: '/database', + + //used to specify the api server that we're going to use (can be relative or absolute). Must not have trailing slash + fasten_api_endpoint_base: '/api', +}; diff --git a/frontend/src/index.html b/frontend/src/index.html index 6371b0977..d53c4d13b 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -14,7 +14,7 @@ - fastenhealth + Fasten Health @@ -40,9 +40,6 @@ - - -