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

View File

@ -2,6 +2,7 @@ package pkg
import (
"context"
"errors"
"net"
"net/http"
"time"
@ -17,17 +18,66 @@ const (
defaultRole = "guest"
)
type LoginPage struct {
LoginError bool
Email string
Password string
type loginForm struct {
locale *Locale
Errors []error
Email *InputField
Password *InputField
Valid bool
}
type AppUser struct {
Email string
LoggedIn bool
Role string
Language language.Tag
func newLoginForm(locale *Locale) *loginForm {
return &loginForm{
locale: locale,
Email: &InputField{
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 {
@ -37,25 +87,28 @@ func LoginHandler() http.Handler {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
r.ParseForm()
page := LoginPage{
Email: r.FormValue("email"),
Password: r.FormValue("password"),
}
locale := getLocale(r)
form := newLoginForm(locale)
if r.Method == "POST" {
if err := form.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if form.Validate() {
conn := getConn(r)
cookie := conn.MustGetText(r.Context(), "", "select login($1, $2, $3)", page.Email, page.Password, remoteAddr(r))
cookie := conn.MustGetText(r.Context(), "", "select login($1, $2, $3)", form.Email.Value, form.Password.Value, remoteAddr(r))
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)
page.LoginError = true
} else {
w.WriteHeader(http.StatusOK)
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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ctx = r.Context()

View File

@ -4,7 +4,6 @@ import (
"context"
"errors"
"net/http"
"strings"
)
type LanguageOption struct {
@ -13,6 +12,7 @@ type LanguageOption struct {
}
type profileForm struct {
locale *Locale
Name *InputField
Email *InputField
Password *InputField
@ -25,6 +25,7 @@ func newProfileForm(ctx context.Context, conn *Conn, locale *Locale) *profileFor
automaticOption := pgettext("language option", "Automatic", locale)
languages := MustGetOptions(ctx, conn, "select 'und', $1 union all select lang_tag, endonym from language where selectable", automaticOption)
return &profileForm{
locale: locale,
Name: &InputField{
Name: "name",
Label: pgettext("input", "User name", locale),
@ -59,29 +60,29 @@ func (form *profileForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
form.Email.Value = strings.TrimSpace(r.FormValue("email"))
form.Name.Value = strings.TrimSpace(r.FormValue("name"))
form.Password.Value = strings.TrimSpace(r.FormValue("password"))
form.PasswordConfirm.Value = strings.TrimSpace(r.FormValue("password_confirm"))
form.Language.Selected = r.FormValue("language")
form.Email.FillValue(r)
form.Name.FillValue(r)
form.Password.FillValue(r)
form.PasswordConfirm.FillValue(r)
form.Language.FillValue(r)
return nil
}
func (form *profileForm) Validate(locale *Locale) bool {
func (form *profileForm) Validate() bool {
form.Valid = true
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() {
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() {
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) {
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() {
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
}
@ -107,7 +108,7 @@ func ProfileHandler() http.Handler {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if ok := form.Validate(locale); ok {
if ok := form.Validate(); ok {
//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)
setSessionCookie(w, cookie)

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: numerus\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"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n"
@ -47,27 +47,12 @@ msgctxt "nav"
msgid "Contacts"
msgstr "Contactes"
#: web/template/login.gohtml:2 web/template/login.gohtml:13
#: web/template/login.gohtml:2 web/template/login.gohtml:15
msgctxt "title"
msgid "Login"
msgstr "Entrada"
#: web/template/login.gohtml:9
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
#: web/template/login.gohtml:19
msgctxt "action"
msgid "Login"
msgstr "Entra"
@ -156,6 +141,12 @@ msgctxt "input"
msgid "Phone"
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
msgctxt "input"
msgid "Web"
@ -230,43 +221,56 @@ msgctxt "title"
msgid "New Contact"
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
msgctxt "language option"
msgid "Automatic"
msgstr "Automàtic"
#: pkg/profile.go:30
#: pkg/profile.go:31
msgctxt "input"
msgid "User name"
msgstr "Nom dusuari"
#: pkg/profile.go:47
#: pkg/profile.go:48
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmació contrasenya"
#: pkg/profile.go:52
#: pkg/profile.go:53
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: pkg/profile.go:73
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
#: pkg/profile.go:79
msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc."
#: pkg/profile.go:81
#: pkg/profile.go:82
msgid "Confirmation does not match password."
msgstr "La confirmació no és igual a la contrasenya."
#: pkg/profile.go:84
#: pkg/profile.go:85
msgid "Selected language is not valid."
msgstr "Heu seleccionat un idioma que no és vàlid."

View File

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: numerus\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"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n"
@ -47,27 +47,12 @@ msgctxt "nav"
msgid "Contacts"
msgstr "Contactos"
#: web/template/login.gohtml:2 web/template/login.gohtml:13
#: web/template/login.gohtml:2 web/template/login.gohtml:15
msgctxt "title"
msgid "Login"
msgstr "Entrada"
#: web/template/login.gohtml:9
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
#: web/template/login.gohtml:19
msgctxt "action"
msgid "Login"
msgstr "Entrar"
@ -156,6 +141,12 @@ msgctxt "input"
msgid "Phone"
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
msgctxt "input"
msgid "Web"
@ -230,43 +221,56 @@ msgctxt "title"
msgid "New Contact"
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
msgctxt "language option"
msgid "Automatic"
msgstr "Automático"
#: pkg/profile.go:30
#: pkg/profile.go:31
msgctxt "input"
msgid "User name"
msgstr "Nombre de usuario"
#: pkg/profile.go:47
#: pkg/profile.go:48
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmación contrasenya"
#: pkg/profile.go:52
#: pkg/profile.go:53
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: pkg/profile.go:73
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
#: pkg/profile.go:79
msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco."
#: pkg/profile.go:81
#: pkg/profile.go:82
msgid "Confirmation does not match password."
msgstr "La confirmación no corresponde con la contraseña."
#: pkg/profile.go:84
#: pkg/profile.go:85
msgid "Selected language is not valid."
msgstr "Habéis escogido un idioma que no es válido."

View File

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

View File

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