Commit 227af02f authored by Lee Brown's avatar Lee Brown

issue#16 web-app account signup

Account signup works with validation and translations.
parent a225f9f2
......@@ -15,7 +15,7 @@ import (
)
// API returns a handler for a set of routes.
func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, globalMids ...web.Middleware) http.Handler {
func API(shutdown chan os.Signal, log *log.Logger, env web.Env, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, globalMids ...web.Middleware) http.Handler {
// Define base middlewares applied to all requests.
middlewares := []web.Middleware{
......@@ -28,7 +28,7 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *red
}
// Construct the web.App which holds all routes as well as common Middleware.
app := web.NewApp(shutdown, log, middlewares...)
app := web.NewApp(shutdown, log, env, middlewares...)
// Register health check endpoint. This route is not authenticated.
check := Check{
......
......@@ -6,6 +6,7 @@ import (
"encoding/json"
"expvar"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"log"
"net"
"net/http"
......@@ -359,8 +360,9 @@ func main() {
// =========================================================================
// Load middlewares that need to be configured specific for the service.
var serviceMiddlewares []web.Middleware
var serviceMiddlewares = []web.Middleware{
mid.Translator(webcontext.UniversalTranslator()),
}
// Init redirect middleware to ensure all requests go to the primary domain contained in the base URL.
if primaryServiceHost != "127.0.0.1" && primaryServiceHost != "localhost" {
......
......@@ -22,6 +22,48 @@ an image and displays resvised versions of it on the index page. See section bel
If you would like to help, please email [email protected]
## Local Installation
### Build
```bash
go build .
```
### Docker
To build using the docker file, need to be in the project root directory. `Dockerfile` references go.mod in root directory.
```bash
docker build -f cmd/web-app/Dockerfile -t saas-web-app .
```
## Getting Started
### Errors
- **validation error** - Test by appending `test-validation-error=1` to the request URL.
http://127.0.0.1:3000/signup?test-validation-error=1
- **web error** - Test by appending `test-web-error=1` to the request URL.
http://127.0.0.1:3000/signup?test-web-error=1
### Localization
Test a specific language by appending the locale to the request URL.
127.0.0.1:3000/signup?local=fr
[github.com/go-playground/validator](https://github.com/go-playground/validator) supports the following languages.
- en - English
- fr - French
- id - Indonesian
- ja - Japanese
- nl - Dutch
- zh - Chinese
### Future Functionality
This example Web App is going to allow users to manage checklists. Users with role of admin will be allowed to
......@@ -66,20 +108,3 @@ This web-app service eventually will include the following:
## Local Installation
### Build
```bash
go build .
```
### Docker
To build using the docker file, need to be in the project root directory. `Dockerfile` references go.mod in root directory.
```bash
docker build -f cmd/web-app/Dockerfile -t saas-web-app .
```
......@@ -38,7 +38,7 @@ func (c *Check) Health(ctx context.Context, w http.ResponseWriter, r *http.Reque
"Status": "ok",
}
return c.Renderer.Render(ctx, w, r, baseLayoutTmpl, "health.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
return web.RespondJson(ctx, w, data, http.StatusOK)
}
// Ping validates the service is ready to accept requests.
......
package handlers
import (
"context"
"net/http"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"github.com/jmoiron/sqlx"
)
// User represents the User API method handler set.
type Projects struct {
MasterDB *sqlx.DB
Renderer web.Renderer
// ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE.
}
// List returns all the existing users in the system.
func (p *Projects) Index(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
return p.Renderer.Render(ctx, w, r, tmplLayoutBase, "projects-index.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
}
......@@ -2,6 +2,7 @@ package handlers
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"net/http"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
......@@ -17,9 +18,16 @@ type Root struct {
// List returns all the existing users in the system.
func (u *Root) Index(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
// Force users to login to access the index page.
if claims, err := auth.ClaimsFromContext(ctx); err != nil || !claims.HasAuth() {
http.Redirect(w, r, "/user/login", http.StatusFound)
return nil
}
data := map[string]interface{}{
"imgSizes": []int{100, 200, 300, 400, 500},
}
return u.Renderer.Render(ctx, w, r, baseLayoutTmpl, "root-index.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "root-index.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
}
package handlers
import (
"context"
"fmt"
"log"
"net/http"
"os"
"geeks-accelerator/oss/saas-starter-kit/internal/mid"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"github.com/jmoiron/sqlx"
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
)
const baseLayoutTmpl = "base.tmpl"
const (
tmplLayoutBase = "base.tmpl"
tmplContentErrorGeneric = "error-generic.gohtml"
)
// API returns a handler for a set of routes.
func APP(shutdown chan os.Signal, log *log.Logger, staticDir, templateDir string, masterDB *sqlx.DB, redis *redis.Client, renderer web.Renderer, globalMids ...web.Middleware) http.Handler {
func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir, templateDir string, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, renderer web.Renderer, globalMids ...web.Middleware) http.Handler {
// Define base middlewares applied to all requests.
middlewares := []web.Middleware{
......@@ -27,25 +35,37 @@ func APP(shutdown chan os.Signal, log *log.Logger, staticDir, templateDir string
}
// Construct the web.App which holds all routes as well as common Middleware.
app := web.NewApp(shutdown, log, middlewares...)
app := web.NewApp(shutdown, log, env, middlewares...)
// Register health check endpoint. This route is not authenticated.
check := Check{
// Register project management pages.
p := Projects{
MasterDB: masterDB,
Redis: redis,
Renderer: renderer,
}
app.Handle("GET", "/v1/health", check.Health)
app.Handle("GET", "/projects", p.Index, mid.HasAuth())
// Register user management and authentication endpoints.
u := User{
MasterDB: masterDB,
Renderer: renderer,
MasterDB: masterDB,
Renderer: renderer,
Authenticator: authenticator,
}
// This route is not authenticated
app.Handle("POST", "/user/login", u.Login)
app.Handle("GET", "/user/login", u.Login)
app.Handle("GET", "/user/logout", u.Logout)
app.Handle("POST", "/user/forgot-password", u.ForgotPassword)
app.Handle("GET", "/user/forgot-password", u.ForgotPassword)
// Register user management and authentication endpoints.
s := Signup{
MasterDB: masterDB,
Renderer: renderer,
Authenticator: authenticator,
}
// This route is not authenticated
app.Handle("POST", "/users/login", u.Login)
app.Handle("GET", "/users/login", u.Login)
app.Handle("POST", "/signup", s.Step1)
app.Handle("GET", "/signup", s.Step1)
// Register root
r := Root{
......@@ -56,8 +76,32 @@ func APP(shutdown chan os.Signal, log *log.Logger, staticDir, templateDir string
app.Handle("GET", "/index.html", r.Index)
app.Handle("GET", "/", r.Index)
// Register health check endpoint. This route is not authenticated.
check := Check{
MasterDB: masterDB,
Redis: redis,
Renderer: renderer,
}
app.Handle("GET", "/v1/health", check.Health)
static := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
err := web.StaticHandler(ctx, w, r, params, staticDir, "")
if err != nil {
if os.IsNotExist(err) {
rmsg := fmt.Sprintf("%s %s not found", r.Method, r.RequestURI)
err = weberror.NewErrorMessage(ctx, err, http.StatusNotFound, rmsg)
} else {
err = weberror.NewError(ctx, err, http.StatusInternalServerError)
}
return web.RenderError(ctx, w, r, err, renderer, tmplLayoutBase, tmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
}
return nil
}
// Static file server
app.Handle("GET", "/*", web.Static(staticDir, ""))
app.Handle("GET", "/*", static)
return app
}
package handlers
import (
"context"
"net/http"
"time"
"geeks-accelerator/oss/saas-starter-kit/internal/account"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"geeks-accelerator/oss/saas-starter-kit/internal/signup"
"geeks-accelerator/oss/saas-starter-kit/internal/user"
"github.com/gorilla/schema"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
)
// Signup represents the Signup API method handler set.
type Signup struct {
MasterDB *sqlx.DB
Renderer web.Renderer
Authenticator *auth.Authenticator
}
// Step1 handles collecting the first detailed needed to create a new account.
func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
//
req := new(signup.SignupRequest)
data := make(map[string]interface{})
f := func() error {
claims, _ := auth.ClaimsFromContext(ctx)
if r.Method == http.MethodPost {
err := r.ParseForm()
if err != nil {
return err
}
decoder := schema.NewDecoder()
if err := decoder.Decode(req, r.PostForm); err != nil {
return err
}
// Execute the account / user signup.
res, err := signup.Signup(ctx, claims, h.MasterDB, *req, time.Now())
if err != nil {
switch errors.Cause(err) {
case account.ErrForbidden:
return web.RespondError(ctx, w, weberror.NewError(ctx, err, http.StatusForbidden))
default:
if verr, ok := weberror.NewValidationError(ctx, err); ok {
data["validationErrors"] = verr.(*weberror.Error)
} else {
return err
}
}
} else {
// Authenticated the new user.
userAuth, err := user.Authenticate(ctx, h.MasterDB, h.Authenticator, res.User.Email, req.User.Password, time.Hour, time.Now())
if err != nil {
return err
}
_ = userAuth.Expiry
_ = userAuth.AccessToken
}
}
return nil
}
if err := f(); err != nil {
return web.RenderError(ctx, w, r, err, h.Renderer, tmplLayoutBase, tmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
}
data["form"] = req
if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(signup.SignupRequest{})); ok {
data["validationDefaults"] = verr.(*weberror.Error)
}
return h.Renderer.Render(ctx, w, r, tmplLayoutBase, "signup-step1.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
}
......@@ -2,6 +2,7 @@ package handlers
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"net/http"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
......@@ -10,13 +11,25 @@ import (
// User represents the User API method handler set.
type User struct {
MasterDB *sqlx.DB
Renderer web.Renderer
// ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE.
MasterDB *sqlx.DB
Renderer web.Renderer
Authenticator *auth.Authenticator
}
// List returns all the existing users in the system.
func (u *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
return u.Renderer.Render(ctx, w, r, baseLayoutTmpl, "user-login.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-login.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
}
// List returns all the existing users in the system.
func (u *User) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-logout.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
}
// List returns all the existing users in the system.
func (u *User) ForgotPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-forgot-password.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
}
......@@ -6,6 +6,9 @@ import (
"encoding/json"
"expvar"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"html/template"
"log"
"net"
......@@ -341,10 +344,25 @@ func main() {
}
defer masterDb.Close()
// =========================================================================
// Init new Authenticator
var authenticator *auth.Authenticator
if cfg.Auth.UseAwsSecretManager {
secretName := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "authenticator")
authenticator, err = auth.NewAuthenticatorAws(awsSession, secretName, time.Now().UTC(), cfg.Auth.KeyExpiration)
} else {
authenticator, err = auth.NewAuthenticatorFile("", time.Now().UTC(), cfg.Auth.KeyExpiration)
}
if err != nil {
log.Fatalf("main : Constructing authenticator : %+v", err)
}
// =========================================================================
// Load middlewares that need to be configured specific for the service.
var serviceMiddlewares []web.Middleware
var serviceMiddlewares = []web.Middleware{
mid.Translator(webcontext.UniversalTranslator()),
}
// Init redirect middleware to ensure all requests go to the primary domain contained in the base URL.
if primaryServiceHost != "127.0.0.1" && primaryServiceHost != "localhost" {
......@@ -402,16 +420,6 @@ func main() {
var staticUrlFormatter func(string) string
if cfg.Service.StaticFiles.S3Enabled || cfg.Service.StaticFiles.CloudFrontEnabled {
staticUrlFormatter = staticS3UrlFormatter
} else {
baseUrl, err := url.Parse(cfg.Service.BaseUrl)
if err != nil {
log.Fatalf("main : url Parse(%s) : %+v", cfg.Service.BaseUrl, err)
}
staticUrlFormatter = func(p string) string {
baseUrl.Path = p
return baseUrl.String()
}
}
// =========================================================================
......@@ -504,6 +512,98 @@ func main() {
}
return u
},
"ValidationErrorHasField": func(err interface{}, fieldName string) bool {
if err == nil {
return false
}
verr, ok := err.(*weberror.Error)
if !ok {
return false
}
for _, e := range verr.Fields {
if e.Field == fieldName || e.FormField == fieldName {
return true
}
}
return false
},
"ValidationFieldErrors": func(err interface{}, fieldName string) []weberror.FieldError {
if err == nil {
return []weberror.FieldError{}
}
verr, ok := err.(*weberror.Error)
if !ok {
return []weberror.FieldError{}
}
var l []weberror.FieldError
for _, e := range verr.Fields {
if e.Field == fieldName || e.FormField == fieldName {
l = append(l, e)
}
}
return l
},
"ValidationFieldClass": func(err interface{}, fieldName string) string {
if err == nil {
return ""
}
verr, ok := err.(*weberror.Error)
if !ok || len(verr.Fields) == 0 {
return ""
}
for _, e := range verr.Fields {
if e.Field == fieldName || e.FormField == fieldName {
return "is-invalid"
}
}
return "is-valid"
},
"ErrorMessage": func(ctx context.Context, err error) string {
werr, ok := err.(*weberror.Error)
if ok {
if werr.Message != "" {
return werr.Message
}
return werr.Error()
}
return fmt.Sprintf("%s", err)
},
"ErrorDetails": func(ctx context.Context, err error) string {
var displayFullError bool
switch webcontext.ContextEnv(ctx) {
case webcontext.Env_Dev, webcontext.Env_Stage:
displayFullError = true
}
if !displayFullError {
return ""
}
werr, ok := err.(*weberror.Error)
if ok {
if werr.Cause != nil {
return fmt.Sprintf("%s\n%+v", werr.Error(), werr.Cause)
}
return fmt.Sprintf("%+v", werr.Error())
}
return fmt.Sprintf("%+v", err)
},
}
imgUrlFormatter := staticUrlFormatter
if imgUrlFormatter == nil {
baseUrl, err := url.Parse(cfg.Service.BaseUrl)
if err != nil {
log.Fatalf("main : url Parse(%s) : %+v", cfg.Service.BaseUrl, err)
}
imgUrlFormatter = func(p string) string {
baseUrl.Path = p
return baseUrl.String()
}
}
// Image Formatter - additional functions exposed to templates for resizing images
......@@ -511,7 +611,7 @@ func main() {
imgResizeS3KeyPrefix := filepath.Join(cfg.Service.StaticFiles.S3Prefix, "images/responsive")
imgSrcAttr := func(ctx context.Context, p string, sizes []int, includeOrig bool) template.HTMLAttr {
u := staticUrlFormatter(p)
u := imgUrlFormatter(p)
var srcAttr string
if cfg.Service.StaticFiles.ImgResizeEnabled {
srcAttr, _ = img_resize.S3ImgSrc(ctx, redisClient, staticS3UrlFormatter, awsSession, cfg.Aws.S3BucketPublic, imgResizeS3KeyPrefix, u, sizes, includeOrig)
......@@ -543,7 +643,7 @@ func main() {
return imgSrcAttr(ctx, p, sizes, true)
}
tmplFuncs["S3ImgUrl"] = func(ctx context.Context, p string, size int) string {
imgUrl := staticUrlFormatter(p)
imgUrl := imgUrlFormatter(p)
if cfg.Service.StaticFiles.ImgResizeEnabled {
imgUrl, _ = img_resize.S3ImgUrl(ctx, redisClient, staticS3UrlFormatter, awsSession, cfg.Aws.S3BucketPublic, imgResizeS3KeyPrefix, imgUrl, size)
}
......@@ -635,7 +735,7 @@ func main() {
if cfg.HTTP.Host != "" {
api := http.Server{
Addr: cfg.HTTP.Host,
Handler: handlers.APP(shutdown, log, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, renderer, serviceMiddlewares...),
Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, renderer, serviceMiddlewares...),
ReadTimeout: cfg.HTTP.ReadTimeout,
WriteTimeout: cfg.HTTP.WriteTimeout,
MaxHeaderBytes: 1 << 20,
......@@ -652,7 +752,7 @@ func main() {
if cfg.HTTPS.Host != "" {
api := http.Server{
Addr: cfg.HTTPS.Host,
Handler: handlers.APP(shutdown, log, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, renderer, serviceMiddlewares...),
Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, renderer, serviceMiddlewares...),
ReadTimeout: cfg.HTTPS.ReadTimeout,
WriteTimeout: cfg.HTTPS.WriteTimeout,
MaxHeaderBytes: 1 << 20,
......
{{define "title"}}Error {{ .statusCode }}{{end}}
{{define "style"}}
{{end}}
{{ define "partials/page-wrapper" }}
<div class="container-fluid">
<!-- Error Text -->
<div class="text-center">
<div class="error mx-auto" data-text="{{ .statusCode }}">{{ .statusCode }}</div>
<p class="lead text-gray-800 mb-5">{{ .errorMessage }}</p>
{{ if .fullError }}
<p class="text-gray-500 mb-0">{{ .fullError }}</p>
{{ end }}
</div>
</div>
{{ end }}
\ No newline at end of file
{{define "title"}}Crean an Account{{end}}
{{define "style"}}
{{end}}
{{ define "partials/page-wrapper" }}
<div class="container">
<div class="card o-hidden border-0 shadow-lg my-5">
<div class="card-body p-0">
<!-- Nested Row within Card Body -->
<div class="row">
<div class="col-lg-5 d-none d-lg-block bg-register-image"></div>
<div class="col-lg-7">
<div class="p-5">
<div class="text-center">
<h1 class="h4 text-gray-900 mb-4">Create an Account!</h1>
</div>
{{ template "top-error" . }}
<form class="user" method="post" novalidate>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Name" }}" name="Account.Name" value="{{ $.form.Account.Name }}" placeholder="Company Name" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Name" }}
</div>
</div>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Address1" }}" name="Account.Address1" value="{{ $.form.Account.Address1 }}" placeholder="Address Line 1" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Address1" }}
</div>
<div class="col-sm-6">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Address2" }}" name="Account.Address2" value="{{ $.form.Account.Address2 }}" placeholder="Address Line 2">
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Address2" }}
</div>
</div>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Zipcode" }}" name="Account.Zipcode" value="{{ $.form.Account.Zipcode }}" placeholder="Zipcode" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Zipcode" }}
</div>
</div>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Country" }}" name="Account.Country" value="{{ $.form.Account.Country }}" placeholder="Country" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Country" }}
</div>
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Region" }}" name="Account.Region" value="{{ $.form.Account.Region }}" placeholder="Region" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Region" }}
</div>
</div>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.City" }}" name="Account.City" value="{{ $.form.Account.City }}" placeholder="City" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.City" }}
</div>
</div>
<hr>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "User.FirstName" }}" name="User.FirstName" value="{{ $.form.User.FirstName }}" placeholder="First Name" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "User.FirstName" }}
</div>
<div class="col-sm-6">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "User.LastName" }}" name="User.LastName" value="{{ $.form.User.LastName }}" placeholder="Last Name" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "User.LastName" }}
</div>
</div>
<div class="form-group">
<input type="email" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "User.Email" }}" name="User.Email" value="{{ $.form.User.Email }}" placeholder="Email Address" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "User.Email" }}
</div>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "User.Password" }}" name="User.Password" value="{{ $.form.User.Password }}" placeholder="Password" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "User.Password" }}
</div>
<div class="col-sm-6">
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "User.PasswordConfirm" }}" name="User.PasswordConfirm" value="{{ $.form.User.PasswordConfirm }}" placeholder="Repeat Password" required>
{{template