diff --git a/Makefile b/Makefile index 3792027ea16d8b7e081eb69c2c554752f2d8467b..d4fe896313d45d23a65a2f071540ef8576f399fd 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,7 @@ +.PHONY: templ-generate +templ-generate: + templ generate + .PHONY: run run: - go run ./cmd/main.go \ No newline at end of file + go run ./cmd/main.go diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..f2bfa4f857acd20822f714913d0c7ca766745dff --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.9' + +services: + db: + container_name: db + hostname: db + image: postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: aidb + POSTGRES_NAME: + PGDATA: /data/postgres + volumes: + - postgres:/data/postgres + ports: + - "5432:5432" + networks: + - airadio + restart: unless-stopped + + +networks: + airadio: + driver: bridge + +volumes: + postgres: \ No newline at end of file diff --git a/go.mod b/go.mod index 9e63b2e80421bb05aea511c6f86becf7c6dadedc..4b295fc53279f9db7d4caa1c091c284a8733ace1 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,21 @@ go 1.22 require ( github.com/a-h/templ v0.2.663 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/jmoiron/sqlx v1.3.5 + github.com/lib/pq v1.2.0 + github.com/pressly/goose/v3 v3.20.0 github.com/rs/zerolog v1.32.0 + golang.org/x/crypto v0.21.0 ) require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/sethvargo/go-retry v0.2.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index 6da209fd1eb503338c9f46a4ce2a205ac591c564..808333dcc953c5cb2c53e2a3037cbab19a37e724 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,24 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/a-h/templ v0.2.663 h1:aa0WMm27InkYHGjimcM7us6hJ6BLhg98ZbfaiDPyjHE= github.com/a-h/templ v0.2.663/go.mod h1:SA7mtYwVEajbIXFRh3vKdYm/4FYyLQAtPH1+KxzGPA8= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= @@ -16,14 +29,51 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.20.0 h1:uPJdOxF/Ipj7ABVNOAMJXSxwFXZGwMGHNqjC8e61VA0= +github.com/pressly/goose/v3 v3.20.0/go.mod h1:BRfF2GcG4FTG12QfdBVy3q1yveaf4ckL9vWwEcIO3lA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec= +github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4= +modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/app/app.go b/internal/app/app.go index 578e0c35c37cece9475b4df9ad3c2ab6a561dc14..d59b88c4a39d0c2ad813ca2aba7d5a40e2d34738 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,9 +2,12 @@ package app import ( "context" + "embed" "errors" "fmt" "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + "github.com/pressly/goose/v3" http2 "github.com/radiologist-ai/web-app/internal/app/http" "github.com/radiologist-ai/web-app/internal/app/http/handlers" "github.com/radiologist-ai/web-app/internal/app/users/usersrepo" @@ -18,16 +21,19 @@ import ( "time" ) +//go:embed migrations/*.sql +var embedMigrations embed.FS + func Run(backgroundCtx context.Context, wg *sync.WaitGroup) error { defer wg.Done() cfg := config.GetConfig() logger := ptr.Pointer(zerolog.New(os.Stderr).With().Timestamp().Caller().Logger()) - // TODO Unmock - db := &sqlx.DB{ - DB: nil, - Mapper: nil, + + db, err := PreparePostgres(cfg.Database) + if err != nil { + return err } // repository @@ -43,7 +49,7 @@ func Run(backgroundCtx context.Context, wg *sync.WaitGroup) error { } // handlers - handle, err := handlers.NewHandlers(logger, usersService) + handle, err := handlers.NewHandlers(logger, usersService, cfg.Server.Secret) if err != nil { return err } @@ -86,4 +92,22 @@ func Run(backgroundCtx context.Context, wg *sync.WaitGroup) error { return nil } -//func PreparePostgres(cfg) +func PreparePostgres(cfg config.Database) (*sqlx.DB, error) { + db, err := sqlx.Connect("postgres", + fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", + cfg.Username, cfg.Password, cfg.Host, cfg.Port, cfg.Database)) + if err != nil { + return nil, err + } + + goose.SetBaseFS(embedMigrations) + + if err := goose.SetDialect("postgres"); err != nil { + return nil, err + } + + if err := goose.Up(db.DB, "migrations"); err != nil { + return nil, err + } + return db, nil +} diff --git a/internal/app/http/handlers/context_manipulations.go b/internal/app/http/handlers/context_manipulations.go new file mode 100644 index 0000000000000000000000000000000000000000..06653e42502e82af520e3b806cf017adefc8e3a3 --- /dev/null +++ b/internal/app/http/handlers/context_manipulations.go @@ -0,0 +1,18 @@ +package handlers + +import ( + "context" + "github.com/radiologist-ai/web-app/internal/domain" +) + +func SetCurrentUser(ctx context.Context, user domain.UserRepoModel) error { + return nil +} + +func GetCurrentUser(ctx context.Context) (*domain.UserRepoModel, bool) { + currentUser, ok := ctx.Value(domain.CurrentUserCtxKey).(domain.UserRepoModel) + if !ok { + return nil, false + } + return ¤tUser, true +} diff --git a/internal/app/http/handlers/handleauth.go b/internal/app/http/handlers/handleauth.go index 5ac8282f4bcd598a41eaa57a5d16bf36d0005630..fb99f2701bfca4e434cf11d93f6b68c9e67ddf4b 100644 --- a/internal/app/http/handlers/handleauth.go +++ b/internal/app/http/handlers/handleauth.go @@ -1 +1,140 @@ package handlers + +import ( + "context" + "errors" + "github.com/radiologist-ai/web-app/internal/domain" + "github.com/radiologist-ai/web-app/internal/domain/customerrors" + "github.com/radiologist-ai/web-app/internal/views" + "golang.org/x/crypto/bcrypt" + "net/http" + "net/mail" + "time" +) + +func (h *Handlers) PostLogout(w http.ResponseWriter, r *http.Request) { + cookie := http.Cookie{Name: domain.AuthTokenCookieKey, Value: "", Expires: time.Time{}} + http.SetCookie(w, &cookie) + ctx := context.WithValue(r.Context(), domain.CurrentUserCtxKey, nil) + r = r.WithContext(ctx) + if err := views.Nav(nil).Render(r.Context(), w); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + return +} + +func (h *Handlers) PostLogin(w http.ResponseWriter, r *http.Request) { + email := r.FormValue("email") + password := r.FormValue("password") + user, ok, err := h.users.GetByEmail(r.Context(), email) + if err != nil { + http.Redirect(w, r, "/internal_server_error", http.StatusFound) + return + } + if !ok { + if err := views.Layout(views.LoginFormUserDoesntExist(), "Radiologist AI").Render(r.Context(), w); err != nil { + http.Redirect(w, r, "/internal_server_error", http.StatusFound) + } + return + } + if bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(password)) != nil { + if err := views.Layout(views.LoginFormWrongPassword(), "Radiologist AI").Render(r.Context(), w); err != nil { + http.Redirect(w, r, "/internal_server_error", http.StatusFound) + } + } + + token, err := h.users.GenerateToken(h.secret, user.Email) + if err != nil { + h.logger.Error().Err(err).Any("user", user).Msg("Error generating token") + http.Redirect(w, r, "/internal_server_error", http.StatusFound) + return + } + + expiration := time.Now().Add(365 * 24 * time.Hour) + cookie := http.Cookie{Name: domain.AuthTokenCookieKey, Value: token, Expires: expiration} + http.SetCookie(w, &cookie) + http.Redirect(w, r, "/home", http.StatusFound) + return +} + +func (h *Handlers) PostRegister(w http.ResponseWriter, r *http.Request) { + var form domain.UserForm + form.Email = r.FormValue("email") + form.Password = r.FormValue("password") + form.FirstName = r.FormValue("firstName") + form.LastName = r.FormValue("lastName") + form.IsDoctor = r.FormValue("isDoctor") == "on" + + user, err := h.users.CreateOne(r.Context(), form) + if err != nil { + if errors.Is(err, customerrors.ValidationError) { + errTxt := ValidationErrorToResponseText(err) + if err := views.Layout(views.RegistrationFormBad(errTxt), "Radiologist AI").Render(r.Context(), w); err != nil { + http.Redirect(w, r, "/internal_server_error", http.StatusFound) + } + return + } + http.Redirect(w, r, "/internal_server_error", http.StatusFound) + return + } + token, err := h.users.GenerateToken(h.secret, user.Email) + if err != nil { + h.logger.Error().Err(err).Any("user", user).Msg("Error generating token") + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + expiration := time.Now().Add(365 * 24 * time.Hour) + cookie := http.Cookie{Name: domain.AuthTokenCookieKey, Value: token, Expires: expiration} + http.SetCookie(w, &cookie) + http.Redirect(w, r, "/home", http.StatusFound) + return +} + +func (h *Handlers) ValidateEmail(w http.ResponseWriter, r *http.Request) { + var email = r.FormValue("email") + if email == "" { + if err := views.EmailInput("", email, "Invalid Email").Render(r.Context(), w); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + return + } + if _, err := mail.ParseAddress(email); err != nil { + if err = views.EmailInput("is-invalid", email, "Invalid Email").Render(r.Context(), w); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + return + } + if _, ok, _ := h.users.GetByEmail(r.Context(), email); ok { + if err := views.EmailInput("is-invalid", email, "User with same email already exists").Render(r.Context(), w); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + return + } + if err := views.EmailInput("is-valid", email, "Invalid Email").Render(r.Context(), w); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } +} + +func (h *Handlers) ValidatePassword(w http.ResponseWriter, r *http.Request) { + var password = r.FormValue("password") + if password == "" { + if err := views.PasswordInput("", password, "").Render(r.Context(), w); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + return + } + if err := h.users.ValidatePassword(password); err != nil { + feedback := "Invalid password" // TODO may be return status 500? + if errors.Is(err, customerrors.ValidationErrorPassword) { + feedback = ValidationErrorToResponseText(err) + } + if err := views.PasswordInput("is-invalid", password, feedback).Render(r.Context(), w); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + return + } + if err := views.PasswordInput("is-valid", password, "").Render(r.Context(), w); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } +} diff --git a/internal/app/http/handlers/handleindex.go b/internal/app/http/handlers/handleindex.go index f8640bdeb548142f478285e70ea20b333b73ea76..a073c51de697d2c06ab2d9f914c8105c916c06cd 100644 --- a/internal/app/http/handlers/handleindex.go +++ b/internal/app/http/handlers/handleindex.go @@ -6,7 +6,7 @@ import ( ) func (h *Handlers) HandleIndex(w http.ResponseWriter, r *http.Request) { - if err := views.Index().Render(r.Context(), w); err != nil { + if err := views.Layout(views.Index(), "Radiologist AI").Render(r.Context(), w); err != nil { w.WriteHeader(http.StatusInternalServerError) } } diff --git a/internal/app/http/handlers/handlers.go b/internal/app/http/handlers/handlers.go index 39de3f5f03c450f85e4b1cd39be43c3eea0b4294..6ae9dcd3558ea49db357852c6b56f9942c047d6a 100644 --- a/internal/app/http/handlers/handlers.go +++ b/internal/app/http/handlers/handlers.go @@ -9,14 +9,18 @@ import ( type Handlers struct { logger *zerolog.Logger users domain.UsersService + secret []byte } -func NewHandlers(logger *zerolog.Logger, users domain.UsersService) (*Handlers, error) { +func NewHandlers(logger *zerolog.Logger, users domain.UsersService, secret string) (*Handlers, error) { if logger == nil { return nil, errors.New("logger is required") } if users == nil { return nil, errors.New("users is required") } - return &Handlers{logger: logger, users: users}, nil + if secret == "" { + return nil, errors.New("secret is required") + } + return &Handlers{logger: logger, users: users, secret: []byte(secret)}, nil } diff --git a/internal/app/http/handlers/middlewares.go b/internal/app/http/handlers/middlewares.go index 74c20d810772db37b486bc8f6ef6ec24522c8ae7..988ddcc34b72b71e54e87b93ef1c78c7ed8f2064 100644 --- a/internal/app/http/handlers/middlewares.go +++ b/internal/app/http/handlers/middlewares.go @@ -1,6 +1,10 @@ package handlers -import "net/http" +import ( + "context" + "github.com/radiologist-ai/web-app/internal/domain" + "net/http" +) func (h *Handlers) WithHTMLResponse(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { @@ -8,3 +12,55 @@ func (h *Handlers) WithHTMLResponse(handler func(http.ResponseWriter, *http.Requ handler(w, r) } } + +func (h *Handlers) WithCurrentUser(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var ( + token string + email string + user domain.UserRepoModel + ok bool + ) + cookie, err := r.Cookie(domain.AuthTokenCookieKey) + if err != nil { + goto handle + } + + token = cookie.Value + if token == "" { + goto handle + } + email, err = h.users.ValidateToken(h.secret, token) + if err != nil { + goto handle + } + if user, ok, err = h.users.GetByEmail(r.Context(), email); err != nil || !ok { + h.logger.Error().Err(err).Bool("userExists", ok).Str("email", email).Str("token", token).Msg("error or user not found") + goto handle + } + r = r.WithContext(context.WithValue(r.Context(), domain.CurrentUserCtxKey, user)) + + handle: + handler(w, r) + } +} + +func (h *Handlers) AnonymousRequired(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := GetCurrentUser(r.Context()); ok { + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + handler(w, r) + } +} + +func (h *Handlers) AuthRequired(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := GetCurrentUser(r.Context()); !ok { + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + handler(w, r) + } +} diff --git a/internal/app/http/handlers/validation.go b/internal/app/http/handlers/validation.go new file mode 100644 index 0000000000000000000000000000000000000000..7b0ae31492ef49e82d429cd6903e72c06b95714e --- /dev/null +++ b/internal/app/http/handlers/validation.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "errors" + "github.com/radiologist-ai/web-app/internal/domain/customerrors" +) + +func ValidationErrorToResponseText(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, customerrors.ValidationErrorPasswordTooShort): + return "Password too short, use at least 8 characters." + case errors.Is(err, customerrors.ValidationErrorPasswordTooLong): + return "Password too long, use at most 64 characters." + case errors.Is(err, customerrors.ValidationErrorPasswordNoLetters): + return "Password must contain at least one letter." + case errors.Is(err, customerrors.ValidationErrorPasswordNoDigits): + return "Password must contain at least one digits." + case errors.Is(err, customerrors.ValidationErrorPasswordUnacceptableCharacters): + return "Password can only contain letters and digits." + default: + return err.Error() + } +} diff --git a/internal/app/http/routs.go b/internal/app/http/routs.go index 0c015a49e74e62a3aab92d293380a6b47434d5c6..f335de4494548740ce26b9fb56f86cbdc362b71a 100644 --- a/internal/app/http/routs.go +++ b/internal/app/http/routs.go @@ -2,7 +2,9 @@ package http import ( "errors" + "github.com/a-h/templ" "github.com/radiologist-ai/web-app/internal/app/http/handlers" + "github.com/radiologist-ai/web-app/internal/views" "net/http" ) @@ -13,7 +15,65 @@ func NewRouter(handlers *handlers.Handlers) (*http.ServeMux, error) { mux := http.NewServeMux() // auth - mux.HandleFunc("GET /", handlers.WithHTMLResponse(handlers.HandleIndex)) + mux.HandleFunc("GET /{$}", + handlers.WithHTMLResponse( + handlers.WithCurrentUser( + handlers.HandleIndex))) + mux.HandleFunc("GET /register", + handlers.WithHTMLResponse( + handlers.WithCurrentUser( + handlers.AnonymousRequired( + templ.Handler( + views.Layout( + views.RegistrationForm(), + "Radiologist AI. Register.")). + ServeHTTP)))) + mux.HandleFunc("POST /register", + handlers.WithCurrentUser( + handlers.AnonymousRequired( + handlers.PostRegister))) + mux.HandleFunc("POST /validate/email", + handlers.WithHTMLResponse( + handlers.ValidateEmail)) + mux.HandleFunc("POST /validate/password", + handlers.WithHTMLResponse( + handlers.ValidatePassword)) + mux.HandleFunc("GET /login", + handlers.WithHTMLResponse( + handlers.WithCurrentUser( + handlers.AnonymousRequired( + templ.Handler( + views.Layout( + views.LoginForm(), + "Radiologist AI. Login.")). + ServeHTTP)))) + mux.HandleFunc("POST /login", + handlers.WithHTMLResponse( + handlers.WithCurrentUser( + handlers.AnonymousRequired( + handlers.PostLogin)))) + + mux.HandleFunc("POST /logout", + handlers.WithCurrentUser( + handlers.AuthRequired( + handlers.PostLogout))) + + // technical + mux.HandleFunc("GET /internal_server_error", + handlers.WithHTMLResponse( + templ.Handler( + views.Layout( + views.InternalError(), + "Internal Error")). + ServeHTTP)) + + mux.HandleFunc("GET /", handlers.WithHTMLResponse( + handlers.WithCurrentUser( + templ.Handler( + views.Layout( + views.NotFound(), + "404")). + ServeHTTP))) return mux, nil } diff --git a/internal/app/migrations/20240501092910_add_user_models.sql b/internal/app/migrations/20240501092910_add_user_models.sql new file mode 100644 index 0000000000000000000000000000000000000000..50bc3b40abedde1c9fcf8f07ed09d78f8642579e --- /dev/null +++ b/internal/app/migrations/20240501092910_add_user_models.sql @@ -0,0 +1,58 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS reports( + id bigserial PRIMARY KEY, + patient_id UUID NOT NULL, + image_path TEXT NOT NULL, + report_text TEXT NOT NULL, + approved BOOLEAN NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS patients( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id BIGINT NULL, + creator_id BIGINT NOT NULL, + "name" TEXT NULL, + patient_identifier TEXT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS users( + id bigserial PRIMARY KEY, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash BYTEA NOT NULL, + is_doctor BOOLEAN NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); +ALTER TABLE + patients ADD CONSTRAINT patients_creator_id_foreign FOREIGN KEY(creator_id) REFERENCES users(id); +ALTER TABLE + reports ADD CONSTRAINT reports_patient_id_foreign FOREIGN KEY(patient_id) REFERENCES patients(id); +ALTER TABLE + patients ADD CONSTRAINT patients_user_id_foreign FOREIGN KEY(user_id) REFERENCES users(id); + +CREATE OR REPLACE FUNCTION update_modified_column() +RETURNS TRIGGER AS $$ +BEGIN +NEW.updated_at = CURRENT_TIMESTAMP; +RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_modified_time_users BEFORE UPDATE ON users FOR EACH ROW EXECUTE PROCEDURE update_modified_column(); +CREATE TRIGGER update_modified_time_patients BEFORE UPDATE ON patients FOR EACH ROW EXECUTE PROCEDURE update_modified_column(); +CREATE TRIGGER update_modified_time_reports BEFORE UPDATE ON reports FOR EACH ROW EXECUTE PROCEDURE update_modified_column(); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE CASCADE IF EXISTS reports; +DROP TABLE CASCADE IF EXISTS patients; +DROP TABLE CASCADE IF EXISTS users; +-- +goose StatementEnd diff --git a/internal/app/users/usersrepo/usersrepo.go b/internal/app/users/usersrepo/usersrepo.go index 200a6dc3623a7c3b90f447d7aeec626cc8184b4c..88a4c04eb63a9652491ad3038d6dc65e05bf2cf2 100644 --- a/internal/app/users/usersrepo/usersrepo.go +++ b/internal/app/users/usersrepo/usersrepo.go @@ -2,9 +2,13 @@ package usersrepo import ( "context" + "database/sql" "errors" + "fmt" "github.com/jmoiron/sqlx" + "github.com/lib/pq" "github.com/radiologist-ai/web-app/internal/domain" + "github.com/radiologist-ai/web-app/internal/domain/customerrors" "github.com/rs/zerolog" ) @@ -26,12 +30,67 @@ func New(logger *zerolog.Logger, db *sqlx.DB) (*Repo, error) { }, nil } -func (r *Repo) SelectByEmail(ctx context.Context, email string) (user domain.UserRepoModel, err error) { - //TODO implement me - panic("implement me") +func (r *Repo) SelectByEmail(ctx context.Context, email string) (user domain.UserRepoModel, ok bool, err error) { + q := `SELECT id, first_name, last_name, email, password_hash, is_doctor, created_at, updated_at FROM users WHERE email = $1 LIMIT 1` + if err := r.db.QueryRowxContext(ctx, q, email).StructScan(&user); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.UserRepoModel{}, false, nil + } + return domain.UserRepoModel{}, false, err + } + return user, true, nil } func (r *Repo) InsertOne(ctx context.Context, model domain.UserRepoModel) (user domain.UserRepoModel, err error) { - //TODO implement me - panic("implement me") + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + r.logger.Error().Err(err).Msg("start transaction") + return user, fmt.Errorf("%w%w", customerrors.InternalErrorSQL, err) + } + defer func() { + if err != nil { + errTx := tx.Rollback() + if errTx != nil { + r.logger.Error().Err(errTx).Msg("rollback transaction") + } + } + }() + if user, err = r.insertUser(ctx, tx, model); err != nil { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Code == "23505" { + return domain.UserRepoModel{}, customerrors.ValidationErrorEmailAlreadyInUse + } + return + } + if !model.IsDoctor { + if err = r.insertSelfPatient(ctx, tx, user.ID, fmt.Sprintf("%s %s", user.FirstName, user.LastName)); err != nil { + return + } + } + if err = tx.Commit(); err != nil { + return domain.UserRepoModel{}, fmt.Errorf("%w%w", customerrors.InternalErrorSQL, err) + } + + return user, nil +} + +func (r *Repo) insertUser(ctx context.Context, tx *sqlx.Tx, model domain.UserRepoModel) (user domain.UserRepoModel, err error) { + q := `Insert into users + (email, first_name, last_name, password_hash, is_doctor) values + ($1, $2, $3, $4, $5) + RETURNING id, email, first_name, last_name, password_hash, is_doctor, created_at, updated_at` + if err = tx.QueryRowxContext(ctx, q, model.Email, model.FirstName, model.LastName, model.PasswordHash, model.IsDoctor).StructScan(&user); err != nil { + r.logger.Error().Err(err).Msg("insert user") + return domain.UserRepoModel{}, fmt.Errorf("%w%w", customerrors.InternalErrorSQL, err) + } + return user, nil +} + +func (r *Repo) insertSelfPatient(ctx context.Context, tx *sqlx.Tx, userID int, name string) error { + q := `INSERT INTO patients (user_id, creator_id, name) VALUES ($1, $2, $3)` + _, err := tx.ExecContext(ctx, q, userID, userID, name) + if err != nil { + return fmt.Errorf("%w%w", customerrors.InternalErrorSQL, err) + } + return nil } diff --git a/internal/app/users/usersservice/dto.go b/internal/app/users/usersservice/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..ca9e6cc5f653e388e1b23b3ec496aeeab927e5db --- /dev/null +++ b/internal/app/users/usersservice/dto.go @@ -0,0 +1,20 @@ +package usersservice + +import ( + "github.com/radiologist-ai/web-app/internal/domain" + "golang.org/x/crypto/bcrypt" +) + +func (s *Service) userFormToUserRepoModel(in domain.UserForm) (domain.UserRepoModel, error) { + var out domain.UserRepoModel + out.FirstName = in.FirstName + out.LastName = in.LastName + out.Email = in.Email + out.IsDoctor = in.IsDoctor + var err error + out.PasswordHash, err = bcrypt.GenerateFromPassword([]byte(in.Password), 10) + if err != nil { + return domain.UserRepoModel{}, err + } + return out, nil +} diff --git a/internal/app/users/usersservice/usersservice.go b/internal/app/users/usersservice/usersservice.go index 1721ed7bbaf70d0950386bad60fd96c92981ec2a..05eb48df7669f788c4e52bff1b9568ef2de8b85f 100644 --- a/internal/app/users/usersservice/usersservice.go +++ b/internal/app/users/usersservice/usersservice.go @@ -3,8 +3,11 @@ package usersservice import ( "context" "errors" + "github.com/golang-jwt/jwt/v5" "github.com/radiologist-ai/web-app/internal/domain" + "github.com/radiologist-ai/web-app/internal/domain/customerrors" "github.com/rs/zerolog" + "time" ) type Service struct { @@ -22,22 +25,55 @@ func New(logger *zerolog.Logger, repo domain.UsersRepository) (*Service, error) return &Service{logger: logger, repo: repo}, nil } -func (s *Service) GenerateToken(ctx context.Context, email string) (token string, err error) { - //TODO implement me - panic("implement me") +func (s *Service) GenerateToken(secret []byte, email string) (token string, err error) { + payload := jwt.MapClaims{ + "sub": email, + "exp": time.Now().Add(time.Hour * 24 * 365).Unix(), + } + + jwToken := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) + + token, err = jwToken.SignedString(secret) + if err != nil { + return "", err + } + return token, nil } -func (s *Service) ValidateToken(ctx context.Context, token string) (email string, err error) { - //TODO implement me - panic("implement me") +func (s *Service) ValidateToken(secret []byte, token string) (email string, err error) { + jwToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { return secret, nil }) + if err != nil { + return "", err + } + claims, ok := jwToken.Claims.(jwt.MapClaims) + if !ok || !jwToken.Valid { + return "", customerrors.ValidationErrorJWT + } + email, err = claims.GetSubject() + if err != nil { + return "", err + } + return email, nil } -func (s *Service) GetByEmail(ctx context.Context, email string) (user domain.UserRepoModel, err error) { - //TODO implement me - panic("implement me") +func (s *Service) GetByEmail(ctx context.Context, email string) (user domain.UserRepoModel, ok bool, err error) { + if user, ok, err = s.repo.SelectByEmail(ctx, email); err != nil { + return domain.UserRepoModel{}, false, err + } + return } -func (s *Service) CreateOne(ctx context.Context, user domain.UserRepoModel) (domain.UserRepoModel, error) { - //TODO implement me - panic("implement me") +func (s *Service) CreateOne(ctx context.Context, user domain.UserForm) (domain.UserRepoModel, error) { + if err := s.validateRegisterForm(user); err != nil { + return domain.UserRepoModel{}, err + } + repoModel, err := s.userFormToUserRepoModel(user) + if err != nil { + return domain.UserRepoModel{}, err + } + newUser, err := s.repo.InsertOne(ctx, repoModel) + if err != nil { + return domain.UserRepoModel{}, err + } + return newUser, nil } diff --git a/internal/app/users/usersservice/validation.go b/internal/app/users/usersservice/validation.go new file mode 100644 index 0000000000000000000000000000000000000000..066f69db8ef1328c78d3fe5652f8453f4658aa0b --- /dev/null +++ b/internal/app/users/usersservice/validation.go @@ -0,0 +1,56 @@ +package usersservice + +import ( + "github.com/radiologist-ai/web-app/internal/domain" + "github.com/radiologist-ai/web-app/internal/domain/customerrors" + "net/mail" + "unicode" +) + +func (s *Service) ValidatePassword(pwd string) error { + runes := []rune(pwd) + if len(runes) < 8 { + return customerrors.ValidationErrorPasswordTooShort + } + if len(runes) > 64 { + return customerrors.ValidationErrorPasswordTooLong + } + var ( + containsLetter bool + containsDigit bool + ) + for _, r := range runes { + if unicode.IsDigit(r) { + containsDigit = true + } else if unicode.IsLetter(r) { + containsLetter = true + } else { + return customerrors.ValidationErrorPasswordUnacceptableCharacters + } + } + if !containsLetter { + return customerrors.ValidationErrorPasswordNoLetters + } + if !containsDigit { + return customerrors.ValidationErrorPasswordNoDigits + } + return nil +} + +// TODO check if email already in use +func (s *Service) validateRegisterForm(form domain.UserForm) error { + if form.LastName == "" { + return customerrors.ValidationErrorLastNameEmpty + } + if form.FirstName == "" { + return customerrors.ValidationErrorFirstNameEmpty + } + if _, err := mail.ParseAddress(form.Email); err != nil { + return customerrors.ValidationErrorEmailInvalid + } + if err := s.ValidatePassword(form.Password); err != nil { + return err + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index f48b74914b8a59470f636d661af54cee3789f21e..bb8680b00d3734115870e109366660cd4553b1bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,21 +1,38 @@ package config type Config struct { - Server Server + Server Server + Database Database } -type Server struct { - ListenAddr string `yaml:"listen_addr"` - Port int `yaml:"port"` - Secret string `yaml:"secret"` -} +type ( + Server struct { + ListenAddr string `yaml:"listen_addr"` + Port int `yaml:"port"` + Secret string `yaml:"secret"` + } + Database struct { + Host string `yaml:"db_host"` + Port int `yaml:"db_port"` + Username string `yaml:"db_username"` + Password string `yaml:"db_password"` + Database string `yaml:"db_database"` + } +) func GetConfig() Config { return Config{ Server: Server{ ListenAddr: "0.0.0.0", - Port: 80, + Port: 5000, Secret: "secret", }, + Database: Database{ + Host: "localhost", + Port: 5432, + Username: "postgres", + Password: "password", + Database: "aidb", + }, } } diff --git a/internal/domain/const.go b/internal/domain/const.go new file mode 100644 index 0000000000000000000000000000000000000000..f0b88b831d4c34b177375bdf29dbbbfe19b87565 --- /dev/null +++ b/internal/domain/const.go @@ -0,0 +1,6 @@ +package domain + +const ( + CurrentUserCtxKey = "current_user" + AuthTokenCookieKey = "auth_token" +) diff --git a/internal/domain/customerrors/internal.go b/internal/domain/customerrors/internal.go new file mode 100644 index 0000000000000000000000000000000000000000..623613667b094b8552b7292d114b32d9bed9ce6d --- /dev/null +++ b/internal/domain/customerrors/internal.go @@ -0,0 +1,11 @@ +package customerrors + +import ( + "errors" + "fmt" +) + +var ( + InternalError = errors.New("internal error. ") + InternalErrorSQL = fmt.Errorf("%wfailed to execute sql query. ", InternalError) +) diff --git a/internal/domain/customerrors/validation.go b/internal/domain/customerrors/validation.go new file mode 100644 index 0000000000000000000000000000000000000000..29f679492d6060dd95ff1a98981473f28a56efed --- /dev/null +++ b/internal/domain/customerrors/validation.go @@ -0,0 +1,23 @@ +package customerrors + +import ( + "errors" + "fmt" +) + +var ( + ValidationError = errors.New("validation error. ") + ValidationErrorPassword = fmt.Errorf("%winvalid password. ", ValidationError) + ValidationErrorEmailInvalid = fmt.Errorf("%winvalid email. ", ValidationError) + ValidationErrorEmailAlreadyInUse = fmt.Errorf("%wemail already in use. ", ValidationError) + ValidationErrorPasswordTooShort = fmt.Errorf("%wpassword must contain at least 8 characters. ", ValidationErrorPassword) + ValidationErrorPasswordTooLong = fmt.Errorf("%wpassword can't contain more than 64 characters. ", ValidationErrorPassword) + ValidationErrorPasswordUnacceptableCharacters = fmt.Errorf("%wpassword can contain only latin letters and digits. ", ValidationErrorPassword) + ValidationErrorPasswordNoLetters = fmt.Errorf("%wpassword must containat least 1 letter. ", ValidationErrorPassword) + ValidationErrorPasswordNoDigits = fmt.Errorf("%wpassword must contain at least 1 digit. ", ValidationErrorPassword) + ValidationErrorFirstName = fmt.Errorf("%winvalid first name. ", ValidationError) + ValidationErrorLastName = fmt.Errorf("%winvalid last name. ", ValidationError) + ValidationErrorFirstNameEmpty = fmt.Errorf("%wempty first name. ", ValidationErrorFirstName) + ValidationErrorLastNameEmpty = fmt.Errorf("%wempty last name. ", ValidationErrorLastName) + ValidationErrorJWT = fmt.Errorf("%winvalid JWT. ", ValidationError) +) diff --git a/internal/domain/users.go b/internal/domain/users.go index 28a1a099a84555e00566b9d903f0df38f3399b2f..2b4a130496a6e4b6af78074e7071427d82f29492 100644 --- a/internal/domain/users.go +++ b/internal/domain/users.go @@ -1,29 +1,40 @@ package domain -import "context" +import ( + "context" + "time" +) type UsersService interface { AuthService - GetByEmail(ctx context.Context, email string) (user UserRepoModel, err error) - CreateOne(ctx context.Context, user UserRepoModel) (UserRepoModel, error) + UsersValidator + GetByEmail(ctx context.Context, email string) (user UserRepoModel, ok bool, err error) + CreateOne(ctx context.Context, user UserForm) (UserRepoModel, error) } type UsersRepository interface { - SelectByEmail(ctx context.Context, email string) (user UserRepoModel, err error) + SelectByEmail(ctx context.Context, email string) (user UserRepoModel, ok bool, err error) InsertOne(ctx context.Context, model UserRepoModel) (user UserRepoModel, err error) } type AuthService interface { - GenerateToken(ctx context.Context, email string) (token string, err error) - ValidateToken(ctx context.Context, token string) (email string, err error) + GenerateToken(secret []byte, email string) (token string, err error) + ValidateToken(secret []byte, token string) (email string, err error) +} + +type UsersValidator interface { + ValidatePassword(password string) error } type UserRepoModel struct { - ID int - FirstName string - LastName string - Email string - PasswordHash []byte + ID int `db:"id"` + FirstName string `db:"first_name"` + LastName string `db:"last_name"` + Email string `db:"email"` + PasswordHash []byte `db:"password_hash"` + IsDoctor bool `db:"is_doctor"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type UserForm struct { @@ -31,4 +42,5 @@ type UserForm struct { LastName string Email string Password string + IsDoctor bool } diff --git a/internal/views/auth.templ b/internal/views/auth.templ new file mode 100644 index 0000000000000000000000000000000000000000..6d1ec0dd54c8c354daa74ac1721976196b617f0d --- /dev/null +++ b/internal/views/auth.templ @@ -0,0 +1,151 @@ +package views + + +templ EmailInput(isValidClass, val, feedback string) { + <div class="col-md-6" id="emailInput"> + <label for="inputEmail" class="form-label">Email</label> + <input type="email" class={ "form-control", isValidClass } name="email" id="inputEmail" value={ val } + hx-post="/validate/email" + hx-trigger="change" + hx-target="#emailInput" + hx-swap="outerHTML" + required/> + <div class="invalid-feedback" id="feedbackEmail"> + { feedback } + </div> + </div> +} + +templ PasswordInput(isValidClass, val, feedback string) { + <div class="col-md-6" id="passwordInput"> + <label for="inputPassword" class="form-label">Password</label> + <input type="password" class={ "form-control", isValidClass } name="password" id="inputPassword" value={ val } + hx-post="/validate/password" + hx-trigger="change" + hx-target="#passwordInput" + hx-swap="outerHTML" + required/> + <div class="invalid-feedback" id="feedbackPassword"> + { feedback } + </div> + </div> +} + + +templ RegistrationForm() { + { children... } + <form id="registerForm" class="row g-3 needs-validation" + action="/register" + method="POST" + novalidate> + @EmailInput("", "", "Invalid Email") + @PasswordInput("", "", "Invalid Password") + <div class="col-md-6"> + <label for="validationCustom01" class="form-label">First name</label> + <input type="text" class="form-control" name="firstName" id="validationCustom01" value="" required/> + <div class="invalid-feedback"> + Field cannot be empty + </div> + + </div> + <div class="col-md-6"> + <label for="validationCustom02" class="form-label">Last name</label> + <input type="text" class="form-control" name="lastName" id="validationCustom02" value="" required/> + <div class="invalid-feedback"> + Field cannot be empty + </div> + </div> + <div class="col-md-6"> + <div class="form-check form-switch"> + <input class="form-check-input" name="isDoctor" type="checkbox" id="flexSwitchCheckDefault"/> + <label class="form-check-label" for="flexSwitchCheckDefault">I am a Doctor</label> + </div> + </div> + <div class="col-12"> + <button type="submit" class="btn btn-primary">Sign up</button> + </div> + </form> + <script> + (() => { + 'use strict' + + // Fetch all the forms we want to apply custom Bootstrap validation styles to + const forms = document.querySelectorAll('.needs-validation') + + // Loop over them and prevent submission + Array.from(forms).forEach(form => { + form.addEventListener('submit', event => { + const form = event.target; + + let flag = false; + Array.from(form.elements).forEach(el => { + if (el.classList.contains('is-invalid')) { + el.classList.add('border-danger') + flag = true + } + }) + + if (!form.checkValidity() || flag) { + event.preventDefault(); + event.stopPropagation(); + } + + form.classList.add('was-validated'); + }, false) + }) + })() + </script> +} + +templ RegistrationFormBad(errs ...string) { + @RegistrationForm() { + <div> + <ul> + for _, err := range errs { + <li> { err } </li> + } + </ul> + </div> + } +} + +templ LoginForm() { + { children... } + <form id="loginForm" class="row g-3 needs-validation" + action="/login" + method="POST"> + <div class="col-md-12" id="emailInputLogin"> + <label for="inputEmailLogin" class="form-label">Email</label> + <input type="email" class="form-control" name="email" id="inputEmailLogin" required/> + <div class="invalid-feedback" id="feedbackEmail"> + Invalid Email + </div> + </div> + <div class="col-md-12" id="passwordInputLogin"> + <label for="inputPasswordLogin" class="form-label">Password</label> + <input type="password" class="form-control" name="password" id="inputPasswordLogin" required/> + <div class="invalid-feedback" id="feedbackPassword"> + Invalid Password + </div> + </div> + <div class="col-12"> + <button type="submit" class="btn btn-primary">Log in</button> + </div> + </form> +} + +templ LoginFormUserDoesntExist() { + @LoginForm() { + <div class="alert alert-warning" role="alert"> + User for provided credentials doesn't exist. <a href="/register" class="alert-link">Sign up?</a> + </div> + } +} + +templ LoginFormWrongPassword() { + @LoginForm() { + <div class="alert alert-danger" role="alert"> + Wrong Password! Try again. + </div> + } +} diff --git a/internal/views/auth_templ.go b/internal/views/auth_templ.go new file mode 100644 index 0000000000000000000000000000000000000000..4b26060b35414be978736758958a715c0153f936 --- /dev/null +++ b/internal/views/auth_templ.go @@ -0,0 +1,363 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.663 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +func EmailInput(isValidClass, val, feedback string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"col-md-6\" id=\"emailInput\"><label for=\"inputEmail\" class=\"form-label\">Email</label> ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 = []any{"form-control", isValidClass} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<input type=\"email\" class=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal\views\auth.templ`, Line: 1, Col: 0} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" name=\"email\" id=\"inputEmail\" value=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(val) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal\views\auth.templ`, Line: 7, Col: 111} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-post=\"/validate/email\" hx-trigger=\"change\" hx-target=\"#emailInput\" hx-swap=\"outerHTML\" required><div class=\"invalid-feedback\" id=\"feedbackEmail\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(feedback) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal\views\auth.templ`, Line: 14, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func PasswordInput(isValidClass, val, feedback string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"col-md-6\" id=\"passwordInput\"><label for=\"inputPassword\" class=\"form-label\">Password</label> ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 = []any{"form-control", isValidClass} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<input type=\"password\" class=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal\views\auth.templ`, Line: 1, Col: 0} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" name=\"password\" id=\"inputPassword\" value=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(val) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal\views\auth.templ`, Line: 22, Col: 116} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-post=\"/validate/password\" hx-trigger=\"change\" hx-target=\"#passwordInput\" hx-swap=\"outerHTML\" required><div class=\"invalid-feedback\" id=\"feedbackPassword\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(feedback) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal\views\auth.templ`, Line: 29, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func RegistrationForm() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templ_7745c5c3_Var11.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<form id=\"registerForm\" class=\"row g-3 needs-validation\" action=\"/register\" method=\"POST\" novalidate>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = EmailInput("", "", "Invalid Email").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = PasswordInput("", "", "Invalid Password").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"col-md-6\"><label for=\"validationCustom01\" class=\"form-label\">First name</label> <input type=\"text\" class=\"form-control\" name=\"firstName\" id=\"validationCustom01\" value=\"\" required><div class=\"invalid-feedback\">Field cannot be empty\r</div></div><div class=\"col-md-6\"><label for=\"validationCustom02\" class=\"form-label\">Last name</label> <input type=\"text\" class=\"form-control\" name=\"lastName\" id=\"validationCustom02\" value=\"\" required><div class=\"invalid-feedback\">Field cannot be empty\r</div></div><div class=\"col-md-6\"><div class=\"form-check form-switch\"><input class=\"form-check-input\" name=\"isDoctor\" type=\"checkbox\" id=\"flexSwitchCheckDefault\"> <label class=\"form-check-label\" for=\"flexSwitchCheckDefault\">I am a Doctor</label></div></div><div class=\"col-12\"><button type=\"submit\" class=\"btn btn-primary\">Sign up</button></div></form><script>\r\n (() => {\r\n 'use strict'\r\n\r\n // Fetch all the forms we want to apply custom Bootstrap validation styles to\r\n const forms = document.querySelectorAll('.needs-validation')\r\n\r\n // Loop over them and prevent submission\r\n Array.from(forms).forEach(form => {\r\n form.addEventListener('submit', event => {\r\n const form = event.target;\r\n\r\n let flag = false;\r\n Array.from(form.elements).forEach(el => {\r\n if (el.classList.contains('is-invalid')) {\r\n el.classList.add('border-danger')\r\n flag = true\r\n }\r\n })\r\n\r\n if (!form.checkValidity() || flag) {\r\n event.preventDefault();\r\n event.stopPropagation();\r\n }\r\n\r\n form.classList.add('was-validated');\r\n }, false)\r\n })\r\n })()\r\n </script>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func RegistrationFormBad(errs ...string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var13 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div><ul>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, err := range errs { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(err) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal\views\auth.templ`, Line: 105, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</li>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul></div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = RegistrationForm().Render(templ.WithChildren(ctx, templ_7745c5c3_Var13), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func LoginForm() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var15 := templ.GetChildren(ctx) + if templ_7745c5c3_Var15 == nil { + templ_7745c5c3_Var15 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templ_7745c5c3_Var15.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<form id=\"loginForm\" class=\"row g-3 needs-validation\" action=\"/login\" method=\"POST\"><div class=\"col-md-12\" id=\"emailInputLogin\"><label for=\"inputEmailLogin\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" name=\"email\" id=\"inputEmailLogin\" required><div class=\"invalid-feedback\" id=\"feedbackEmail\">Invalid Email\r</div></div><div class=\"col-md-12\" id=\"passwordInputLogin\"><label for=\"inputPasswordLogin\" class=\"form-label\">Password</label> <input type=\"password\" class=\"form-control\" name=\"password\" id=\"inputPasswordLogin\" required><div class=\"invalid-feedback\" id=\"feedbackPassword\">Invalid Password\r</div></div><div class=\"col-12\"><button type=\"submit\" class=\"btn btn-primary\">Log in</button></div></form>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func LoginFormUserDoesntExist() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var17 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"alert alert-warning\" role=\"alert\">User for provided credentials doesn't exist. <a href=\"/register\" class=\"alert-link\">Sign up?</a></div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = LoginForm().Render(templ.WithChildren(ctx, templ_7745c5c3_Var17), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func LoginFormWrongPassword() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var19 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"alert alert-danger\" role=\"alert\">Wrong Password! Try again.\r</div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = LoginForm().Render(templ.WithChildren(ctx, templ_7745c5c3_Var19), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/internal/views/base_layout.templ b/internal/views/base_layout.templ new file mode 100644 index 0000000000000000000000000000000000000000..33ed9cb411ed1f798eaf307ea1abfcce890d7799 --- /dev/null +++ b/internal/views/base_layout.templ @@ -0,0 +1,84 @@ +package views + +import "github.com/radiologist-ai/web-app/internal/domain" + +func GetCurrentUser(ctx context.Context) *domain.UserRepoModel { + currentUser, ok := ctx.Value(domain.CurrentUserCtxKey).(domain.UserRepoModel) + if !ok { + return nil + } + return ¤tUser +} + + +templ header(title string) { + <head> + <title>{ title }</title> + <meta charset="UTF-8"/> + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <script src="https://unpkg.com/htmx.org@1.9.12"></script> + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script> + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous"/> + <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"/> <!-- load fontawesome --> + </head> +} + +templ footer() { + <footer class="bg-blue-600 p-4"></footer> +} + +templ Nav(user *domain.UserRepoModel){ + <nav id="mainNav" class="navbar navbar-expand-lg navbar-light bg-light"> + <div class="container-fluid"> + <a class="navbar-brand" href="/">Radiologist AI</a> + <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation"> + <span class="navbar-toggler-icon"></span> + </button> + <div class="collapse navbar-collapse justify-content-end" id="navbarNavDropdown"> + <ul class="navbar-nav"> + <li class="nav-item"> + <a class="nav-link active" aria-current="page" href="/home">Home</a> + </li> + if user == nil { + <li class="nav-item"> + <a class="nav-link" href="/register">Register</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="/login">Log In</a> + </li> + } else { + <li class="nav-item dropdown"> + <a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false"> + { user.FirstName } + </a> + <ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink"> + <li><a class="dropdown-item" href="/users/{user.ID}/reports">Library</a></li> + <li><a class="dropdown-item" href="/settings">Settings</a></li> + <li><a class="dropdown-item btn-outline-danger" href="#" + hx-post="/logout" + hx-trigger="click" + hx-swap="outerHTML" + hx-target="#mainNav" + >Log out</a></li> + </ul> + </li> + } + </ul> + </div> + </div> + </nav> + +} + +templ Layout(contents templ.Component, title string) { + @header(title) + <body class="flex flex-col h-full"> + @Nav(GetCurrentUser(ctx)) + <main class="flex-1"> + <div class="container-fluid"> + @contents + </div> + </main> + @footer() + </body> +} \ No newline at end of file diff --git a/internal/views/base_layout_templ.go b/internal/views/base_layout_templ.go new file mode 100644 index 0000000000000000000000000000000000000000..87c27e3c5e0248a531cab29f3cf5778e61f1165b --- /dev/null +++ b/internal/views/base_layout_templ.go @@ -0,0 +1,186 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.663 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +import "github.com/radiologist-ai/web-app/internal/domain" + +func GetCurrentUser(ctx context.Context) *domain.UserRepoModel { + currentUser, ok := ctx.Value(domain.CurrentUserCtxKey).(domain.UserRepoModel) + if !ok { + return nil + } + return ¤tUser +} + +func header(title string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<head><title>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal\views\base_layout.templ`, Line: 16, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</title><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><script src=\"https://unpkg.com/htmx.org@1.9.12\"></script><script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js\" integrity=\"sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM\" crossorigin=\"anonymous\"></script><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css\" rel=\"stylesheet\" integrity=\"sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC\" crossorigin=\"anonymous\"><link rel=\"stylesheet\" href=\"https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css\"><!-- load fontawesome --></head>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func footer() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<footer class=\"bg-blue-600 p-4\"></footer>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func Nav(user *domain.UserRepoModel) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<nav id=\"mainNav\" class=\"navbar navbar-expand-lg navbar-light bg-light\"><div class=\"container-fluid\"><a class=\"navbar-brand\" href=\"/\">Radiologist AI</a> <button class=\"navbar-toggler\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#navbarNavDropdown\" aria-controls=\"navbarNavDropdown\" aria-expanded=\"false\" aria-label=\"Toggle navigation\"><span class=\"navbar-toggler-icon\"></span></button><div class=\"collapse navbar-collapse justify-content-end\" id=\"navbarNavDropdown\"><ul class=\"navbar-nav\"><li class=\"nav-item\"><a class=\"nav-link active\" aria-current=\"page\" href=\"/home\">Home</a></li>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if user == nil { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"nav-item\"><a class=\"nav-link\" href=\"/register\">Register</a></li><li class=\"nav-item\"><a class=\"nav-link\" href=\"/login\">Log In</a></li>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"nav-item dropdown\"><a class=\"nav-link dropdown-toggle\" href=\"#\" id=\"navbarDropdownMenuLink\" role=\"button\" data-bs-toggle=\"dropdown\" aria-expanded=\"false\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(user.FirstName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal\views\base_layout.templ`, Line: 52, Col: 40} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a><ul class=\"dropdown-menu\" aria-labelledby=\"navbarDropdownMenuLink\"><li><a class=\"dropdown-item\" href=\"/users/{user.ID}/reports\">Library</a></li><li><a class=\"dropdown-item\" href=\"/settings\">Settings</a></li><li><a class=\"dropdown-item btn-outline-danger\" href=\"#\" hx-post=\"/logout\" hx-trigger=\"click\" hx-swap=\"outerHTML\" hx-target=\"#mainNav\">Log out</a></li></ul></li>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul></div></div></nav>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func Layout(contents templ.Component, title string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = header(title).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<body class=\"flex flex-col h-full\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Nav(GetCurrentUser(ctx)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<main class=\"flex-1\"><div class=\"container-fluid\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = contents.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></main>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = footer().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</body>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/internal/views/index.templ b/internal/views/index.templ index affefd43145ef7313b627a20eac2912ccb14f1b3..dd4eeeee136f332ec9b4a2b23193840e2420374c 100644 --- a/internal/views/index.templ +++ b/internal/views/index.templ @@ -1,5 +1,15 @@ package views +import "github.com/radiologist-ai/web-app/internal/domain" + +templ UnauthenticatedIndex() { +<div>asd</div> +} + +templ AuthenticatedIndex(user *domain.UserRepoModel) { +<div>asd</div> +} + templ Index() { <h1>AI Radiologist</h1> } \ No newline at end of file diff --git a/internal/views/index_templ.go b/internal/views/index_templ.go index 44fefa2d06fb49ea223bd6ab8b0ac7941c506fa5..d9116e9e80539ab56d3bff8397ea853873077e2b 100644 --- a/internal/views/index_templ.go +++ b/internal/views/index_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.2.648 +// templ: version: v0.2.663 package views //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -10,7 +10,9 @@ import "context" import "io" import "bytes" -func Index() templ.Component { +import "github.com/radiologist-ai/web-app/internal/domain" + +func UnauthenticatedIndex() templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { @@ -23,6 +25,54 @@ func Index() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div>asd</div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func AuthenticatedIndex(user *domain.UserRepoModel) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div>asd</div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func Index() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<h1>AI Radiologist</h1>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err diff --git a/internal/views/internal_error.templ b/internal/views/internal_error.templ new file mode 100644 index 0000000000000000000000000000000000000000..2213340cc64d06d53e1fe68a3e349a4de18412cd --- /dev/null +++ b/internal/views/internal_error.templ @@ -0,0 +1,7 @@ +package views + +templ InternalError() { + <p class="h1 text-center">An Error has been occurred on server.</p> + <p class="h2 text-center">Try Again Later.</p> + <p class="h3 text-center">We are sorry...</p> +} diff --git a/internal/views/not_found.templ b/internal/views/not_found.templ new file mode 100644 index 0000000000000000000000000000000000000000..d4e090dfebbad74934cfc0f046a11415df9b7172 --- /dev/null +++ b/internal/views/not_found.templ @@ -0,0 +1,5 @@ +package views + +templ NotFound() { + <p class="h1 text-center">Requested Page doesn't exist.</p> +} \ No newline at end of file