diff --git a/pkg/form.go b/pkg/form.go new file mode 100644 index 0000000..bfebf97 --- /dev/null +++ b/pkg/form.go @@ -0,0 +1,73 @@ +package pkg + +import ( + "context" + "net/mail" +) + +type InputField struct { + Name string + Label string + Type string + Value string + Required bool + Errors []error +} + +func (field *InputField) Equals(other *InputField) bool { + return field.Value == other.Value +} + +func (field *InputField) IsEmpty() bool { + return field.Value == "" +} + +func (field *InputField) HasValidEmail() bool { + _, err := mail.ParseAddress(field.Value) + return err == nil +} + +type SelectOption struct { + Value string + Label string +} + +type SelectField struct { + Name string + Label string + Selected string + Options []*SelectOption + Errors []error +} + +func (field *SelectField) HasValidOption() bool { + for _, option := range field.Options { + if option.Value == field.Selected { + return true + } + } + return false +} + +func MustGetOptions(ctx context.Context, conn *Conn, sql string, args ...interface{}) []*SelectOption { + rows, err := conn.Query(ctx, sql, args...) + if err != nil { + panic(err) + } + defer rows.Close() + + var options []*SelectOption + for rows.Next() { + option := &SelectOption{} + err = rows.Scan(&option.Value, &option.Label) + if err != nil { + panic(err) + } + options = append(options, option) + } + if rows.Err() != nil { + panic(rows.Err()) + } + + return options +} diff --git a/pkg/locale.go b/pkg/locale.go index bd632f8..eea6d09 100644 --- a/pkg/locale.go +++ b/pkg/locale.go @@ -66,6 +66,10 @@ func pgettext(context string, str string, locale *Locale) string { return locale.GetC(str, context) } +func gettext(str string, locale *Locale) string { + return locale.Get(str) +} + func mustGetAvailableLanguages(db *Db) []language.Tag { rows, err := db.Query(context.Background(), "select lang_tag from language where selectable") if err != nil { diff --git a/pkg/profile.go b/pkg/profile.go index 60e8734..0a9aa0f 100644 --- a/pkg/profile.go +++ b/pkg/profile.go @@ -2,7 +2,9 @@ package pkg import ( "context" + "errors" "net/http" + "strings" ) type LanguageOption struct { @@ -10,66 +12,117 @@ type LanguageOption struct { Name string } -type ProfilePage struct { - Name string - Email string - Password string - PasswordConfirm string - Language string - Languages []LanguageOption +type profileForm struct { + Name *InputField + Email *InputField + Password *InputField + PasswordConfirm *InputField + Language *SelectField + Valid bool +} + +func newProfileForm(ctx context.Context, conn *Conn, locale *Locale) *profileForm { + 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{ + Name: &InputField{ + Name: "name", + Label: pgettext("input", "User name", locale), + Type: "text", + Required: true, + }, + Email: &InputField{ + Name: "email", + Label: pgettext("input", "Email", locale), + Type: "email", + Required: true, + }, + Password: &InputField{ + Name: "password", + Label: pgettext("input", "Password", locale), + Type: "password", + }, + PasswordConfirm: &InputField{ + Name: "password_confirm", + Label: pgettext("input", "Password Confirmation", locale), + Type: "password", + }, + Language: &SelectField{ + Name: "language", + Label: pgettext("input", "Language", locale), + Options: languages, + }, + } +} + +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") + return nil +} + +func (form *profileForm) Validate(locale *Locale) bool { + form.Valid = true + if form.Email.IsEmpty() { + form.AppendInputError(form.Email, errors.New(gettext("Email can not be empty.", 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))) + } + if form.Name.IsEmpty() { + form.AppendInputError(form.Name, errors.New(gettext("Name can not be empty.", locale))) + } + if !form.PasswordConfirm.Equals(form.Password) { + form.AppendInputError(form.PasswordConfirm, errors.New(gettext("Confirmation does not match password.", locale))) + } + if !form.Language.HasValidOption() { + form.AppendSelectError(form.Language, errors.New(gettext("Selected language is not valid.", locale))) + } + return form.Valid +} + +func (form *profileForm) AppendInputError(field *InputField, err error) { + field.Errors = append(field.Errors, err) + form.Valid = false +} + +func (form *profileForm) AppendSelectError(field *SelectField, err error) { + field.Errors = append(field.Errors, err) + form.Valid = false } func ProfileHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user := getUser(r) conn := getConn(r) - page := ProfilePage{ - Email: user.Email, - Language: user.Language.String(), - } + locale := getLocale(r) + form := newProfileForm(r.Context(), conn, locale) if r.Method == "POST" { - r.ParseForm() - page.Email = r.FormValue("email") - page.Name = r.FormValue("name") - page.Password = r.FormValue("password") - page.PasswordConfirm = r.FormValue("password_confirm") - page.Language = r.FormValue("language") - cookie := conn.MustGetText(r.Context(), "", "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", page.Name, page.Email, page.Language) - setSessionCookie(w, cookie) - if page.Password != "" && page.Password == page.PasswordConfirm { - conn.MustExec(r.Context(), "select change_password($1)", page.Password) + if err := form.Parse(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } - http.Redirect(w, r, "/profile", http.StatusSeeOther) - return + if ok := form.Validate(locale); ok { + 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) + if form.Password.Value != "" { + conn.MustExec(r.Context(), "select change_password($1)", form.Password.Value) + } + company := getCompany(r) + http.Redirect(w, r, "/company/"+company.Slug+"/profile", http.StatusSeeOther) + return + } + w.WriteHeader(http.StatusUnprocessableEntity) } else { - page.Languages = mustGetLanguageOptions(r.Context(), conn) - if err := conn.QueryRow(r.Context(), "select name from user_profile").Scan(&page.Name); err != nil { - panic(nil) - } + form.Name.Value = conn.MustGetText(r.Context(), "", "select name from user_profile") + form.Email.Value = user.Email + form.Language.Selected = user.Language.String() } - mustRenderAppTemplate(w, r, "profile.gohtml", page) + mustRenderAppTemplate(w, r, "profile.gohtml", form) }) } - -func mustGetLanguageOptions(ctx context.Context, conn *Conn) []LanguageOption { - rows, err := conn.Query(ctx, "select lang_tag, endonym from language where selectable") - if err != nil { - panic(err) - } - defer rows.Close() - - var langs []LanguageOption - for rows.Next() { - var lang LanguageOption - err = rows.Scan(&lang.Tag, &lang.Name) - if err != nil { - panic(err) - } - langs = append(langs, lang) - } - if rows.Err() != nil { - panic(rows.Err()) - } - - return langs -} diff --git a/pkg/template.go b/pkg/template.go index de1d0d3..d6a34ee 100644 --- a/pkg/template.go +++ b/pkg/template.go @@ -24,7 +24,7 @@ func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename s return "/company/" + company.Slug + uri }, }) - if _, err := t.ParseFiles(templateFile(filename), templateFile(layout)); err != nil { + if _, err := t.ParseFiles(templateFile(filename), templateFile(layout), templateFile("form.gohtml")); err != nil { panic(err) } if err := t.ExecuteTemplate(wr, layout, data); err != nil { diff --git a/po/ca.po b/po/ca.po index 85c882e..68e31a6 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-01-31 13:28+0100\n" +"POT-Creation-Date: 2023-01-31 15:17+0100\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -56,13 +56,13 @@ msgstr "Entrada" msgid "Invalid user or password" msgstr "Nom d’usuari o contrasenya incorrectes" -#: web/template/login.gohtml:17 web/template/profile.gohtml:19 -#: web/template/tax-details.gohtml:27 web/template/contacts-new.gohtml:27 +#: web/template/login.gohtml:17 web/template/tax-details.gohtml:27 +#: web/template/contacts-new.gohtml:27 pkg/profile.go:37 msgctxt "input" msgid "Email" msgstr "Correu-e" -#: web/template/login.gohtml:22 web/template/profile.gohtml:27 +#: web/template/login.gohtml:22 pkg/profile.go:43 msgctxt "input" msgid "Password" msgstr "Contrasenya" @@ -82,32 +82,17 @@ msgctxt "title" msgid "User Access Data" msgstr "Dades accés usuari" -#: web/template/profile.gohtml:14 -msgctxt "input" -msgid "User name" -msgstr "Nom d’usuari" - -#: web/template/profile.gohtml:23 +#: web/template/profile.gohtml:16 msgctxt "title" msgid "Password Change" msgstr "Canvi contrasenya" -#: web/template/profile.gohtml:32 -msgctxt "input" -msgid "Password Confirmation" -msgstr "Confirmació contrasenya" - -#: web/template/profile.gohtml:37 -msgctxt "input" +#: web/template/profile.gohtml:23 +msgctxt "title" msgid "Language" msgstr "Idioma" -#: web/template/profile.gohtml:40 -msgctxt "language option" -msgid "Automatic" -msgstr "Automàtic" - -#: web/template/profile.gohtml:46 web/template/tax-details.gohtml:133 +#: web/template/profile.gohtml:27 web/template/tax-details.gohtml:133 msgctxt "action" msgid "Save changes" msgstr "Desa canvis" @@ -244,3 +229,43 @@ msgstr "Afegeix nou impost" msgctxt "title" msgid "New Contact" msgstr "Nou contacte" + +#: pkg/profile.go:26 +msgctxt "language option" +msgid "Automatic" +msgstr "Automàtic" + +#: pkg/profile.go:31 +msgctxt "input" +msgid "User name" +msgstr "Nom d’usuari" + +#: pkg/profile.go:48 +msgctxt "input" +msgid "Password Confirmation" +msgstr "Confirmació contrasenya" + +#: pkg/profile.go:53 +msgctxt "input" +msgid "Language" +msgstr "Idioma" + +#: pkg/profile.go:74 +msgid "Email can not be empty." +msgstr "No podeu deixar el correu-e en blanc." + +#: 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/profile.go:79 +msgid "Name can not be empty." +msgstr "No podeu deixar el nom en blanc." + +#: pkg/profile.go:82 +msgid "Confirmation does not match password." +msgstr "La confirmació no és igual a la contrasenya." + +#: pkg/profile.go:85 +msgid "Selected language is not valid." +msgstr "Heu seleccionat un idioma que no és vàlid." diff --git a/po/es.po b/po/es.po index 0547e44..0af9c0a 100644 --- a/po/es.po +++ b/po/es.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-01-31 13:28+0100\n" +"POT-Creation-Date: 2023-01-31 15:17+0100\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -56,13 +56,13 @@ msgstr "Entrada" msgid "Invalid user or password" msgstr "Nombre de usuario o contraseña inválido" -#: web/template/login.gohtml:17 web/template/profile.gohtml:19 -#: web/template/tax-details.gohtml:27 web/template/contacts-new.gohtml:27 +#: web/template/login.gohtml:17 web/template/tax-details.gohtml:27 +#: web/template/contacts-new.gohtml:27 pkg/profile.go:37 msgctxt "input" msgid "Email" msgstr "Correo-e" -#: web/template/login.gohtml:22 web/template/profile.gohtml:27 +#: web/template/login.gohtml:22 pkg/profile.go:43 msgctxt "input" msgid "Password" msgstr "Contraseña" @@ -82,32 +82,17 @@ msgctxt "title" msgid "User Access Data" msgstr "Datos acceso usuario" -#: web/template/profile.gohtml:14 -msgctxt "input" -msgid "User name" -msgstr "Nombre de usuario" - -#: web/template/profile.gohtml:23 +#: web/template/profile.gohtml:16 msgctxt "title" msgid "Password Change" msgstr "Cambio de contraseña" -#: web/template/profile.gohtml:32 -msgctxt "input" -msgid "Password Confirmation" -msgstr "Confirmación contrasenya" - -#: web/template/profile.gohtml:37 -msgctxt "input" +#: web/template/profile.gohtml:23 +msgctxt "title" msgid "Language" msgstr "Idioma" -#: web/template/profile.gohtml:40 -msgctxt "language option" -msgid "Automatic" -msgstr "Automático" - -#: web/template/profile.gohtml:46 web/template/tax-details.gohtml:133 +#: web/template/profile.gohtml:27 web/template/tax-details.gohtml:133 msgctxt "action" msgid "Save changes" msgstr "Guardar cambios" @@ -244,3 +229,43 @@ msgstr "Añadir nuevo impuesto" msgctxt "title" msgid "New Contact" msgstr "Nuevo contacto" + +#: pkg/profile.go:26 +msgctxt "language option" +msgid "Automatic" +msgstr "Automático" + +#: pkg/profile.go:31 +msgctxt "input" +msgid "User name" +msgstr "Nombre de usuario" + +#: pkg/profile.go:48 +msgctxt "input" +msgid "Password Confirmation" +msgstr "Confirmación contrasenya" + +#: pkg/profile.go:53 +msgctxt "input" +msgid "Language" +msgstr "Idioma" + +#: pkg/profile.go:74 +msgid "Email can not be empty." +msgstr "No podéis dejar el correo-e en blanco." + +#: 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/profile.go:79 +msgid "Name can not be empty." +msgstr "No podéis dejar el nombre en blanco." + +#: pkg/profile.go:82 +msgid "Confirmation does not match password." +msgstr "La confirmación no corresponde con la contraseña." + +#: pkg/profile.go:85 +msgid "Selected language is not valid." +msgstr "Habéis escogido un idioma que no es válido." diff --git a/web/static/numerus.css b/web/static/numerus.css index 60b9949..3859ec3 100644 --- a/web/static/numerus.css +++ b/web/static/numerus.css @@ -320,6 +320,12 @@ input.width-2x { top: 1rem; } +.input ul { + font-size: .8em; + padding-left: 1em; + color: var(--numerus--color--red); +} + [lang="en"] input:not([required]) + label::after { content: " (optional)" } diff --git a/web/template/form.gohtml b/web/template/form.gohtml new file mode 100644 index 0000000..d66827d --- /dev/null +++ b/web/template/form.gohtml @@ -0,0 +1,33 @@ +{{ define "input-field" -}} +
+ + + {{ if .Errors }} +
    + {{- range $error := .Errors }} +
  • {{ . }}
  • + {{- end }} +
+ {{ end }} +
+{{- end }} + +{{ define "select-field" -}} +
+ + + {{ if .Errors }} +
    + {{- range $error := .Errors }} +
  • {{ . }}
  • + {{- end }} +
+ {{ end }} +
+{{- end }} diff --git a/web/template/profile.gohtml b/web/template/profile.gohtml index d0dc2a1..b475324 100644 --- a/web/template/profile.gohtml +++ b/web/template/profile.gohtml @@ -1,50 +1,31 @@ {{ define "title" -}} -{{( pgettext "User Settings" "title" )}} + {{( pgettext "User Settings" "title" )}} {{- end }} {{ define "content" }} -
-

{{(pgettext "User Settings" "title")}}

-
-
- {{( pgettext "User Access Data" "title" )}} +
+

{{(pgettext "User Settings" "title")}}

+ +
+ {{( pgettext "User Access Data" "title" )}} -
- - -
+ {{ template "input-field" .Name }} + {{ template "input-field" .Email }} +
+
+ {{( pgettext "Password Change" "title" )}} -
- - -
-
-
- {{( pgettext "Password Change" "title" )}} + {{ template "input-field" .Password }} + {{ template "input-field" .PasswordConfirm }} +
-
- - -
+
+ {{( pgettext "Language" "title" )}} -
- - -
-
+ {{ template "select-field" .Language }} -
- {{( pgettext "Language" "input" )}} - - - - -
- -
+ +
+ +
{{- end }}