Use a “proper” struct for the login form

Similar to the profile form, the login form now parses and validates
itself, with the InputField structs that the templates expect.

I realized that i was doing more work than necessary when parsing fields
fro the profile form because i was repeating the operation and the field
name, so now it is a function of InputField.

This time i needed extra attributes for the login form.  I am not sure
that the Go source code needs to know about HTML attributes, but it was
the easiest way to pass them to the template.
This commit is contained in:
jordi fita mas 2023-02-01 11:02:32 +01:00
parent 75fd12bf1c
commit ff5b76b4f5
7 changed files with 212 additions and 133 deletions

View File

@ -2,16 +2,27 @@ package pkg
import ( import (
"context" "context"
"net/http"
"net/mail" "net/mail"
"strings"
) )
type Attribute struct {
Key, Val string
}
type InputField struct { type InputField struct {
Name string Name string
Label string Label string
Type string Type string
Value string Value string
Required bool Required bool
Errors []error Attributes []*Attribute
Errors []error
}
func (field *InputField) FillValue(r *http.Request) {
field.Value = strings.TrimSpace(r.FormValue(field.Name))
} }
func (field *InputField) Equals(other *InputField) bool { func (field *InputField) Equals(other *InputField) bool {
@ -40,6 +51,10 @@ type SelectField struct {
Errors []error Errors []error
} }
func (field *SelectField) FillValue(r *http.Request) {
field.Selected = r.FormValue(field.Name)
}
func (field *SelectField) HasValidOption() bool { func (field *SelectField) HasValidOption() bool {
for _, option := range field.Options { for _, option := range field.Options {
if option.Value == field.Selected { if option.Value == field.Selected {

View File

@ -2,6 +2,7 @@ package pkg
import ( import (
"context" "context"
"errors"
"net" "net"
"net/http" "net/http"
"time" "time"
@ -17,17 +18,66 @@ const (
defaultRole = "guest" defaultRole = "guest"
) )
type LoginPage struct { type loginForm struct {
LoginError bool locale *Locale
Email string Errors []error
Password string Email *InputField
Password *InputField
Valid bool
} }
type AppUser struct { func newLoginForm(locale *Locale) *loginForm {
Email string return &loginForm{
LoggedIn bool locale: locale,
Role string Email: &InputField{
Language language.Tag Name: "email",
Label: pgettext("input", "Email", locale),
Type: "email",
Required: true,
Attributes: []*Attribute{
{"autofocus", "autofocus"},
{"autocomplete", "username"},
{"autocapitalize", "none"},
},
},
Password: &InputField{
Name: "password",
Label: pgettext("input", "Password", locale),
Type: "password",
Required: true,
Attributes: []*Attribute{
{"autocomplete", "current-password"},
},
},
}
}
func (form *loginForm) Parse(r *http.Request) error {
err := r.ParseForm()
if err != nil {
return err
}
form.Email.FillValue(r)
form.Password.FillValue(r)
return nil
}
func (form *loginForm) Validate() bool {
form.Valid = true
if form.Email.IsEmpty() {
form.AppendInputError(form.Email, errors.New(gettext("Email can not be empty.", form.locale)))
} else if !form.Email.HasValidEmail() {
form.AppendInputError(form.Email, errors.New(gettext("This value is not a valid email. It should be like name@domain.com.", form.locale)))
}
if form.Password.IsEmpty() {
form.AppendInputError(form.Password, errors.New(gettext("Password can not be empty.", form.locale)))
}
return form.Valid
}
func (form *loginForm) AppendInputError(field *InputField, err error) {
field.Errors = append(field.Errors, err)
form.Valid = false
} }
func LoginHandler() http.Handler { func LoginHandler() http.Handler {
@ -37,25 +87,28 @@ func LoginHandler() http.Handler {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
r.ParseForm() locale := getLocale(r)
page := LoginPage{ form := newLoginForm(locale)
Email: r.FormValue("email"),
Password: r.FormValue("password"),
}
if r.Method == "POST" { if r.Method == "POST" {
conn := getConn(r) if err := form.Parse(r); err != nil {
cookie := conn.MustGetText(r.Context(), "", "select login($1, $2, $3)", page.Email, page.Password, remoteAddr(r)) http.Error(w, err.Error(), http.StatusBadRequest)
if cookie != "" {
setSessionCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
w.WriteHeader(http.StatusUnauthorized) if form.Validate() {
page.LoginError = true conn := getConn(r)
} else { cookie := conn.MustGetText(r.Context(), "", "select login($1, $2, $3)", form.Email.Value, form.Password.Value, remoteAddr(r))
w.WriteHeader(http.StatusOK) if cookie != "" {
setSessionCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
form.Errors = append(form.Errors, errors.New(gettext("Invalid user or password.", locale)))
w.WriteHeader(http.StatusUnauthorized)
} else {
w.WriteHeader(http.StatusUnprocessableEntity)
}
} }
mustRenderWebTemplate(w, r, "login.gohtml", page) mustRenderWebTemplate(w, r, "login.gohtml", form)
}) })
} }
@ -91,6 +144,13 @@ func createSessionCookie(value string, duration time.Duration) *http.Cookie {
} }
} }
type AppUser struct {
Email string
LoggedIn bool
Role string
Language language.Tag
}
func CheckLogin(db *Db, next http.Handler) http.Handler { func CheckLogin(db *Db, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ctx = r.Context() var ctx = r.Context()

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"strings"
) )
type LanguageOption struct { type LanguageOption struct {
@ -13,6 +12,7 @@ type LanguageOption struct {
} }
type profileForm struct { type profileForm struct {
locale *Locale
Name *InputField Name *InputField
Email *InputField Email *InputField
Password *InputField Password *InputField
@ -25,6 +25,7 @@ func newProfileForm(ctx context.Context, conn *Conn, locale *Locale) *profileFor
automaticOption := pgettext("language option", "Automatic", locale) automaticOption := pgettext("language option", "Automatic", locale)
languages := MustGetOptions(ctx, conn, "select 'und', $1 union all select lang_tag, endonym from language where selectable", automaticOption) languages := MustGetOptions(ctx, conn, "select 'und', $1 union all select lang_tag, endonym from language where selectable", automaticOption)
return &profileForm{ return &profileForm{
locale: locale,
Name: &InputField{ Name: &InputField{
Name: "name", Name: "name",
Label: pgettext("input", "User name", locale), Label: pgettext("input", "User name", locale),
@ -59,29 +60,29 @@ func (form *profileForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
return err return err
} }
form.Email.Value = strings.TrimSpace(r.FormValue("email")) form.Email.FillValue(r)
form.Name.Value = strings.TrimSpace(r.FormValue("name")) form.Name.FillValue(r)
form.Password.Value = strings.TrimSpace(r.FormValue("password")) form.Password.FillValue(r)
form.PasswordConfirm.Value = strings.TrimSpace(r.FormValue("password_confirm")) form.PasswordConfirm.FillValue(r)
form.Language.Selected = r.FormValue("language") form.Language.FillValue(r)
return nil return nil
} }
func (form *profileForm) Validate(locale *Locale) bool { func (form *profileForm) Validate() bool {
form.Valid = true form.Valid = true
if form.Email.IsEmpty() { if form.Email.IsEmpty() {
form.AppendInputError(form.Email, errors.New(gettext("Email can not be empty.", locale))) form.AppendInputError(form.Email, errors.New(gettext("Email can not be empty.", form.locale)))
} else if !form.Email.HasValidEmail() { } else if !form.Email.HasValidEmail() {
form.AppendInputError(form.Email, errors.New(gettext("This value is not a valid email. It should be like name@domain.com.", locale))) form.AppendInputError(form.Email, errors.New(gettext("This value is not a valid email. It should be like name@domain.com.", form.locale)))
} }
if form.Name.IsEmpty() { if form.Name.IsEmpty() {
form.AppendInputError(form.Name, errors.New(gettext("Name can not be empty.", locale))) form.AppendInputError(form.Name, errors.New(gettext("Name can not be empty.", form.locale)))
} }
if !form.PasswordConfirm.Equals(form.Password) { if !form.PasswordConfirm.Equals(form.Password) {
form.AppendInputError(form.PasswordConfirm, errors.New(gettext("Confirmation does not match password.", locale))) form.AppendInputError(form.PasswordConfirm, errors.New(gettext("Confirmation does not match password.", form.locale)))
} }
if !form.Language.HasValidOption() { if !form.Language.HasValidOption() {
form.AppendSelectError(form.Language, errors.New(gettext("Selected language is not valid.", locale))) form.AppendSelectError(form.Language, errors.New(gettext("Selected language is not valid.", form.locale)))
} }
return form.Valid return form.Valid
} }
@ -107,7 +108,7 @@ func ProfileHandler() http.Handler {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
if ok := form.Validate(locale); ok { if ok := form.Validate(); ok {
//goland:noinspection SqlWithoutWhere //goland:noinspection SqlWithoutWhere
cookie := conn.MustGetText(r.Context(), "", "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", form.Name.Value, form.Email.Value, form.Language.Selected) cookie := conn.MustGetText(r.Context(), "", "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", form.Name.Value, form.Email.Value, form.Language.Selected)
setSessionCookie(w, cookie) setSessionCookie(w, cookie)

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-02-01 10:11+0100\n" "POT-Creation-Date: 2023-02-01 10:52+0100\n"
"PO-Revision-Date: 2023-01-18 17:08+0100\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -47,27 +47,12 @@ msgctxt "nav"
msgid "Contacts" msgid "Contacts"
msgstr "Contactes" msgstr "Contactes"
#: web/template/login.gohtml:2 web/template/login.gohtml:13 #: web/template/login.gohtml:2 web/template/login.gohtml:15
msgctxt "title" msgctxt "title"
msgid "Login" msgid "Login"
msgstr "Entrada" msgstr "Entrada"
#: web/template/login.gohtml:9 #: web/template/login.gohtml:19
msgid "Invalid user or password"
msgstr "Nom dusuari o contrasenya incorrectes"
#: web/template/login.gohtml:17 web/template/tax-details.gohtml:27
#: web/template/contacts-new.gohtml:27 pkg/profile.go:36
msgctxt "input"
msgid "Email"
msgstr "Correu-e"
#: web/template/login.gohtml:22 pkg/profile.go:42
msgctxt "input"
msgid "Password"
msgstr "Contrasenya"
#: web/template/login.gohtml:25
msgctxt "action" msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entra" msgstr "Entra"
@ -156,6 +141,12 @@ msgctxt "input"
msgid "Phone" msgid "Phone"
msgstr "Telèfon" msgstr "Telèfon"
#: web/template/tax-details.gohtml:27 web/template/contacts-new.gohtml:27
#: pkg/login.go:34 pkg/profile.go:37
msgctxt "input"
msgid "Email"
msgstr "Correu-e"
#: web/template/tax-details.gohtml:31 web/template/contacts-new.gohtml:31 #: web/template/tax-details.gohtml:31 web/template/contacts-new.gohtml:31
msgctxt "input" msgctxt "input"
msgid "Web" msgid "Web"
@ -230,43 +221,56 @@ msgctxt "title"
msgid "New Contact" msgid "New Contact"
msgstr "Nou contacte" msgstr "Nou contacte"
#: pkg/login.go:45 pkg/profile.go:43
msgctxt "input"
msgid "Password"
msgstr "Contrasenya"
#: pkg/login.go:68 pkg/profile.go:74
msgid "Email can not be empty."
msgstr "No podeu deixar el correu-e en blanc."
#: pkg/login.go:70 pkg/profile.go:76
msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Aquest valor no és un correu-e vàlid. Hauria de ser similar a nom@domini.cat."
#: pkg/login.go:73
msgid "Password can not be empty."
msgstr "No podeu deixar la contrasenya en blanc."
#: pkg/login.go:105
msgid "Invalid user or password."
msgstr "Nom dusuari o contrasenya incorrectes."
#: pkg/profile.go:25 #: pkg/profile.go:25
msgctxt "language option" msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automàtic" msgstr "Automàtic"
#: pkg/profile.go:30 #: pkg/profile.go:31
msgctxt "input" msgctxt "input"
msgid "User name" msgid "User name"
msgstr "Nom dusuari" msgstr "Nom dusuari"
#: pkg/profile.go:47 #: pkg/profile.go:48
msgctxt "input" msgctxt "input"
msgid "Password Confirmation" msgid "Password Confirmation"
msgstr "Confirmació contrasenya" msgstr "Confirmació contrasenya"
#: pkg/profile.go:52 #: pkg/profile.go:53
msgctxt "input" msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: pkg/profile.go:73 #: pkg/profile.go:79
msgid "Email can not be empty."
msgstr "No podeu deixar el correu-e en blanc."
#: pkg/profile.go:75
msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Aquest valor no és un correu-e vàlid. Hauria de ser similar a nom@domini.cat."
#: pkg/profile.go:78
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc." msgstr "No podeu deixar el nom en blanc."
#: pkg/profile.go:81 #: pkg/profile.go:82
msgid "Confirmation does not match password." msgid "Confirmation does not match password."
msgstr "La confirmació no és igual a la contrasenya." msgstr "La confirmació no és igual a la contrasenya."
#: pkg/profile.go:84 #: pkg/profile.go:85
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Heu seleccionat un idioma que no és vàlid." msgstr "Heu seleccionat un idioma que no és vàlid."

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-02-01 10:11+0100\n" "POT-Creation-Date: 2023-02-01 10:52+0100\n"
"PO-Revision-Date: 2023-01-18 17:45+0100\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -47,27 +47,12 @@ msgctxt "nav"
msgid "Contacts" msgid "Contacts"
msgstr "Contactos" msgstr "Contactos"
#: web/template/login.gohtml:2 web/template/login.gohtml:13 #: web/template/login.gohtml:2 web/template/login.gohtml:15
msgctxt "title" msgctxt "title"
msgid "Login" msgid "Login"
msgstr "Entrada" msgstr "Entrada"
#: web/template/login.gohtml:9 #: web/template/login.gohtml:19
msgid "Invalid user or password"
msgstr "Nombre de usuario o contraseña inválido"
#: web/template/login.gohtml:17 web/template/tax-details.gohtml:27
#: web/template/contacts-new.gohtml:27 pkg/profile.go:36
msgctxt "input"
msgid "Email"
msgstr "Correo-e"
#: web/template/login.gohtml:22 pkg/profile.go:42
msgctxt "input"
msgid "Password"
msgstr "Contraseña"
#: web/template/login.gohtml:25
msgctxt "action" msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entrar" msgstr "Entrar"
@ -156,6 +141,12 @@ msgctxt "input"
msgid "Phone" msgid "Phone"
msgstr "Teléfono" msgstr "Teléfono"
#: web/template/tax-details.gohtml:27 web/template/contacts-new.gohtml:27
#: pkg/login.go:34 pkg/profile.go:37
msgctxt "input"
msgid "Email"
msgstr "Correo-e"
#: web/template/tax-details.gohtml:31 web/template/contacts-new.gohtml:31 #: web/template/tax-details.gohtml:31 web/template/contacts-new.gohtml:31
msgctxt "input" msgctxt "input"
msgid "Web" msgid "Web"
@ -230,43 +221,56 @@ msgctxt "title"
msgid "New Contact" msgid "New Contact"
msgstr "Nuevo contacto" msgstr "Nuevo contacto"
#: pkg/login.go:45 pkg/profile.go:43
msgctxt "input"
msgid "Password"
msgstr "Contraseña"
#: pkg/login.go:68 pkg/profile.go:74
msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco."
#: pkg/login.go:70 pkg/profile.go:76
msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Este valor no es un correo-e válido. Tiene que ser parecido a nombre@dominio.es."
#: pkg/login.go:73
msgid "Password can not be empty."
msgstr "No podéis dejar la contaseña en blanco."
#: pkg/login.go:105
msgid "Invalid user or password."
msgstr "Nombre de usuario o contraseña inválido."
#: pkg/profile.go:25 #: pkg/profile.go:25
msgctxt "language option" msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automático" msgstr "Automático"
#: pkg/profile.go:30 #: pkg/profile.go:31
msgctxt "input" msgctxt "input"
msgid "User name" msgid "User name"
msgstr "Nombre de usuario" msgstr "Nombre de usuario"
#: pkg/profile.go:47 #: pkg/profile.go:48
msgctxt "input" msgctxt "input"
msgid "Password Confirmation" msgid "Password Confirmation"
msgstr "Confirmación contrasenya" msgstr "Confirmación contrasenya"
#: pkg/profile.go:52 #: pkg/profile.go:53
msgctxt "input" msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: pkg/profile.go:73 #: pkg/profile.go:79
msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco."
#: pkg/profile.go:75
msgid "This value is not a valid email. It should be like name@domain.com."
msgstr "Este valor no es un correo-e válido. Tiene que ser parecido a nombre@dominio.es."
#: pkg/profile.go:78
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco." msgstr "No podéis dejar el nombre en blanco."
#: pkg/profile.go:81 #: pkg/profile.go:82
msgid "Confirmation does not match password." msgid "Confirmation does not match password."
msgstr "La confirmación no corresponde con la contraseña." msgstr "La confirmación no corresponde con la contraseña."
#: pkg/profile.go:84 #: pkg/profile.go:85
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Habéis escogido un idioma que no es válido." msgstr "Habéis escogido un idioma que no es válido."

View File

@ -1,15 +1,16 @@
{{ define "input-field" -}} {{ define "input-field" -}}
<div class="input {{ if .Errors }}has-errors{{ end }}"> <div class="input {{ if .Errors }}has-errors{{ end }}">
<input type="{{ .Type }}" name="{{ .Name }}" id="{{ .Name }}-field" <input type="{{ .Type }}" name="{{ .Name }}" id="{{ .Name }}-field"
{{ if .Required }}required="required"{{ end }} value="{{ .Value }}" placeholder="{{ .Label }}"> {{- range $attribute := .Attributes }} {{$attribute.Key}}="{{$attribute.Val}}" {{ end -}}
{{ if .Required }}required="required"{{ end }} value="{{ .Value }}" placeholder="{{ .Label }}">
<label for="{{ .Name }}-field">{{ .Label }}</label> <label for="{{ .Name }}-field">{{ .Label }}</label>
{{ if .Errors }} {{- if .Errors }}
<ul> <ul>
{{- range $error := .Errors }} {{- range $error := .Errors }}
<li>{{ . }}</li> <li>{{ . }}</li>
{{- end }} {{- end }}
</ul> </ul>
{{ end }} {{- end }}
</div> </div>
{{- end }} {{- end }}

View File

@ -1,28 +1,22 @@
{{ define "title" -}} {{ define "title" -}}
{{( pgettext "Login" "title" )}} {{( pgettext "Login" "title" )}}
{{- end }} {{- end }}
{{ define "content" }} {{ define "content" }}
<h1><img src="/static/numerus.svg" alt="Numerus" width="620" height="77"></h1> <h1><img src="/static/numerus.svg" alt="Numerus" width="620" height="77"></h1>
{{ if .LoginError -}} {{ if .Errors -}}
<div class="error" role="alert"> <div class="error" role="alert">
<p>{{( gettext "Invalid user or password" )}}</p> {{ range $error := .Errors -}}
</div> <p>{{ $error }}</p>
{{- end }} {{- end }}
<section id="login"> </div>
<h2>{{( pgettext "Login" "title" )}}</h2> {{- end }}
<form method="POST" action="/login"> <section id="login">
<div class="input"> <h2>{{( pgettext "Login" "title" )}}</h2>
<input id="user_email" type="email" required autofocus name="email" autocapitalize="none" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}"> <form method="POST" action="/login">
<label for="user_email">{{( pgettext "Email" "input" )}}</label> {{ template "input-field" .Email }}
</div> {{ template "input-field" .Password }}
<button type="submit">{{( pgettext "Login" "action" )}}</button>
<div class="input"> </form>
<input id="user_password" type="password" required name="password" autocomplete="current-password" value="{{ .Password }}" placeholder="{{( pgettext "Password" "input" )}}"> </section>
<label for="user_password">{{( pgettext "Password" "input" )}}</label>
</div>
<button type="submit">{{( pgettext "Login" "action" )}}</button>
</form>
</section>
{{- end }} {{- end }}