Add profile form, inside a user menu

This is the first form that uses HTMx, and can not return a 400 error
code because otherwise HTMx does not render the content.

Since now there are pages that do not render the whole html, with header
and body, i need a different layout for these, and moved the common code
to handle forms and such a new template file that both layouts can use.
I also need the request in template.MustRender to check which layout i
should use.

Closes #7.
This commit is contained in:
jordi fita mas 2023-07-26 20:46:09 +02:00
parent 341520def3
commit f963f54839
16 changed files with 468 additions and 35 deletions

View File

@ -17,7 +17,7 @@ po/%.po: $(POT_FILE)
$(POT_FILE): $(HTML_FILES) $(GO_FILES)
xgettext $(XGETTEXTFLAGS) --language=Scheme --output=$@ --keyword=pgettext:1,2c $(HTML_FILES)
xgettext $(XGETTEXTFLAGS) --language=C --output=$@ --keyword=Gettext:1 --keyword=GettextNoop:1 --join-existing $(GO_FILES)
xgettext $(XGETTEXTFLAGS) --language=C --output=$@ --keyword=Gettext:1 --keyword=GettextNoop:1 --keyword=Pgettext:1,2c --join-existing $(GO_FILES)
test-deploy:
sqitch deploy --db-name $(PGDATABASE)

View File

@ -112,6 +112,6 @@ func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
func (h *App) serveDashboard(w http.ResponseWriter, _ *http.Request, user *auth.User) {
template.MustRender(w, user, "dashboard.gohtml", nil)
func (h *App) serveDashboard(w http.ResponseWriter, r *http.Request, user *auth.User) {
template.MustRender(w, r, user, "dashboard.gohtml", nil)
}

View File

@ -60,10 +60,14 @@ func (f *loginForm) Valid(l *locale.Locale) bool {
return v.AllOK
}
func serveLoginForm(w http.ResponseWriter, _ *http.Request, user *auth.User, redirectPath string) {
func (f *loginForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User) {
template.MustRender(w, r, user, "login.gohtml", f)
}
func serveLoginForm(w http.ResponseWriter, r *http.Request, user *auth.User, redirectPath string) {
login := newLoginForm()
login.Redirect.Val = redirectPath
template.MustRender(w, user, "login.gohtml", login)
login.MustRender(w, r, user)
}
func handleLogin(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
@ -84,7 +88,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request, user *auth.User, conn *
} else {
w.WriteHeader(http.StatusUnprocessableEntity)
}
template.MustRender(w, user, "login.gohtml", login)
login.MustRender(w, r, user)
}
func handleLogout(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {

View File

@ -1,13 +1,22 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package app
import (
"context"
"net/http"
"golang.org/x/text/language"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
)
func (h *App) getUser(r *http.Request, conn *database.Conn) (*auth.User, error) {
@ -61,8 +70,113 @@ func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
default:
methodNotAllowed(w, r, http.MethodDelete)
}
case "":
switch r.Method {
case http.MethodGet:
serveProfileForm(w, r, user, conn)
case http.MethodPut:
updateProfile(w, r, user, conn)
default:
methodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
default:
http.NotFound(w, r)
}
}
}
func serveProfileForm(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
profile := newProfileForm(r.Context(), user.Locale, conn)
profile.Name.Val = conn.MustGetText(r.Context(), "select name from user_profile")
profile.Email.Val = user.Email
profile.Language.Selected = []string{user.Language.String()}
profile.MustRender(w, r, user)
}
func updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
profile := newProfileForm(r.Context(), user.Locale, conn)
if err := profile.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !profile.Valid(user.Locale) {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
profile.MustRender(w, r, user)
return
}
//goland:noinspection SqlWithoutWhere
cookie := conn.MustGetText(r.Context(), "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", profile.Name, profile.Email, profile.Language)
auth.SetSessionCookie(w, cookie)
if profile.Password.Val != "" {
conn.MustExec(r.Context(), "select change_password($1)", profile.Password)
}
if user.Language.String() == profile.Language.String() {
httplib.Relocate(w, r, "/me", http.StatusSeeOther)
} else {
httplib.Redirect(w, r, "/me", http.StatusSeeOther)
}
}
type profileForm struct {
Name *form.Input
Email *form.Input
Password *form.Input
PasswordConfirm *form.Input
Language *form.Select
}
func newProfileForm(ctx context.Context, l *locale.Locale, conn *database.Conn) *profileForm {
automaticOption := l.Pgettext("Automatic", "language option")
languages := form.MustGetOptions(ctx, conn, "select 'und', $1 union all select lang_tag, endonym from language where selectable", automaticOption)
return &profileForm{
Name: &form.Input{
Name: "name",
},
Email: &form.Input{
Name: "email",
},
Password: &form.Input{
Name: "password",
},
PasswordConfirm: &form.Input{
Name: "password_confirm",
},
Language: &form.Select{
Name: "language",
Options: languages,
},
}
}
func (f *profileForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Email.FillValue(r)
f.Name.FillValue(r)
f.Password.FillValue(r)
f.PasswordConfirm.FillValue(r)
f.Language.FillValue(r)
return nil
}
func (f *profileForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
if v.CheckRequired(f.Email, l.GettextNoop("Email can not be empty.")) {
v.CheckValidEmail(f.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com."))
}
v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty."))
v.CheckPasswordConfirmation(f.Password, f.PasswordConfirm, l.GettextNoop("Confirmation does not match password."))
v.CheckSelectedOptions(f.Language, l.GettextNoop("Selected language is not valid."))
return v.AllOK
}
func (f *profileForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User) {
template.MustRender(w, r, user, "profile.gohtml", f)
}

View File

@ -17,6 +17,10 @@ type Input struct {
Error error
}
func (input *Input) setError(err error) {
input.Error = err
}
func (input *Input) FillValue(r *http.Request) {
input.Val = strings.TrimSpace(r.FormValue(input.Name))
}

95
pkg/form/select.go Normal file
View File

@ -0,0 +1,95 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package form
import (
"context"
"database/sql/driver"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/database"
)
type Select struct {
Name string
Selected []string
Options []*Option
Error error
}
func (s *Select) setError(err error) {
s.Error = err
}
func (s *Select) Value() (driver.Value, error) {
return s.String(), nil
}
func (s *Select) String() string {
if s.Selected == nil {
return ""
}
return s.Selected[0]
}
func (s *Select) FillValue(r *http.Request) {
s.Selected = r.Form[s.Name]
}
func (s *Select) validOptionsSelected() bool {
for _, selected := range s.Selected {
if !s.isValidOption(selected) {
return false
}
}
return true
}
func (s *Select) isValidOption(selected string) bool {
for _, option := range s.Options {
if option.Value == selected {
return true
}
}
return false
}
func (s *Select) IsSelected(v string) bool {
for _, selected := range s.Selected {
if selected == v {
return true
}
}
return false
}
type Option struct {
Value string
Label string
}
func MustGetOptions(ctx context.Context, conn *database.Conn, sql string, args ...interface{}) []*Option {
rows, err := conn.Query(ctx, sql, args...)
if err != nil {
panic(err)
}
defer rows.Close()
var options []*Option
for rows.Next() {
option := &Option{}
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
}

View File

@ -32,9 +32,21 @@ func (v *Validator) CheckValidEmail(input *Input, message string) bool {
return v.check(input, err == nil, message)
}
func (v *Validator) check(field *Input, ok bool, message string) bool {
func (v *Validator) CheckPasswordConfirmation(password *Input, confirm *Input, message string) bool {
return v.check(confirm, password.Val == confirm.Val, message)
}
func (v *Validator) CheckSelectedOptions(field *Select, message string) bool {
return v.check(field, field.validOptionsSelected(), message)
}
type field interface {
setError(error)
}
func (v *Validator) check(field field, ok bool, message string) bool {
if !ok {
field.Error = errors.New(v.l.Get(message))
field.setError(errors.New(v.l.Get(message)))
v.AllOK = false
}
return ok

View File

@ -54,6 +54,10 @@ func (l *Locale) Gettext(str string) string {
return l.GetD(l.GetDomain(), str)
}
func (l *Locale) Pgettext(str string, ctx string) string {
return l.GetDC(l.GetDomain(), str, ctx)
}
func (l *Locale) GettextNoop(str string) string {
return str
}

View File

@ -12,14 +12,18 @@ import (
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
)
func templateFile(name string) string {
return "web/templates/" + name
}
func MustRender(w io.Writer, user *auth.User, filename string, data interface{}) {
func MustRender(w io.Writer, r *http.Request, user *auth.User, filename string, data interface{}) {
layout := "layout.gohtml"
if httplib.IsHTMxRequest(r) {
layout = "htmx.gohtml"
}
mustRenderLayout(w, user, layout, filename, data)
}
@ -41,7 +45,7 @@ func mustRenderLayout(w io.Writer, user *auth.User, layout string, filename stri
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, auth.CSRFTokenField, user.CSRFToken))
},
})
if _, err := t.ParseFiles(templateFile(layout), templateFile(filename)); err != nil {
if _, err := t.ParseFiles(templateFile(layout), templateFile("form.gohtml"), templateFile(filename)); err != nil {
panic(err)
}
if rw, ok := w.(http.ResponseWriter); ok {

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-07-26 13:28+0200\n"
"POT-Creation-Date: 2023-07-26 20:36+0200\n"
"PO-Revision-Date: 2023-07-22 23:45+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n"
@ -28,12 +28,12 @@ msgctxt "title"
msgid "Login"
msgstr "Entrada"
#: web/templates/login.gohtml:22
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:26
msgctxt "input"
msgid "Email"
msgstr "Correu-e"
#: web/templates/login.gohtml:31
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:36
msgctxt "input"
msgid "Password"
msgstr "Contrasenya"
@ -43,20 +43,51 @@ msgctxt "action"
msgid "Login"
msgstr "Entra"
#: web/templates/layout.gohtml:16
#: web/templates/profile.gohtml:6 web/templates/profile.gohtml:12
#: web/templates/layout.gohtml:28
msgctxt "title"
msgid "Profile"
msgstr "Perfil"
#: web/templates/profile.gohtml:17
msgctxt "input"
msgid "Name"
msgstr "Nom"
#: web/templates/profile.gohtml:45
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmació de la contrasenya"
#: web/templates/profile.gohtml:55
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: web/templates/profile.gohtml:65
msgctxt "action"
msgid "Save changes"
msgstr "Desa els canvis"
#: web/templates/layout.gohtml:17
msgid "Skip to main content"
msgstr "Salta al contingut principal"
#: web/templates/layout.gohtml:20
#: web/templates/layout.gohtml:24
msgctxt "title"
msgid "User Menu"
msgstr "Menú dusuari"
#: web/templates/layout.gohtml:32
msgctxt "action"
msgid "Logout"
msgstr "Surt"
#: pkg/app/login.go:56
#: pkg/app/login.go:56 pkg/app/user.go:170
msgid "Email can not be empty."
msgstr "No podeu deixar el correu en blanc."
#: pkg/app/login.go:57
#: pkg/app/login.go:57 pkg/app/user.go:171
msgid "This email is not valid. It should be like name@domain.com."
msgstr "Aquest correu-e no és vàlid. Hauria de ser similar a nom@domini.com."
@ -64,10 +95,27 @@ msgstr "Aquest correu-e no és vàlid. Hauria de ser similar a nom@domini.com."
msgid "Password can not be empty."
msgstr "No podeu deixar la contrasenya en blanc."
#: pkg/app/login.go:82
#: pkg/app/login.go:86
msgid "Invalid user or password."
msgstr "Nom dusuari o contrasenya incorrectes."
#: pkg/app/user.go:135
msgctxt "language option"
msgid "Automatic"
msgstr "Automàtic"
#: pkg/app/user.go:173
msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc."
#: pkg/app/user.go:174
msgid "Confirmation does not match password."
msgstr "La confirmació no es correspon amb la contrasenya."
#: pkg/app/user.go:175
msgid "Selected language is not valid."
msgstr "Lidioma escollit no és vàlid."
#: pkg/auth/user.go:39
msgid "Cross-site request forgery detected."
msgstr "Sha detectat un intent de falsificació de petició a llocs creuats."

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-07-26 13:28+0200\n"
"POT-Creation-Date: 2023-07-26 20:36+0200\n"
"PO-Revision-Date: 2023-07-22 23:46+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n"
@ -28,12 +28,12 @@ msgctxt "title"
msgid "Login"
msgstr "Entrada"
#: web/templates/login.gohtml:22
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:26
msgctxt "input"
msgid "Email"
msgstr "Correo-e"
#: web/templates/login.gohtml:31
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:36
msgctxt "input"
msgid "Password"
msgstr "Contraseña"
@ -43,20 +43,51 @@ msgctxt "action"
msgid "Login"
msgstr "Entrar"
#: web/templates/layout.gohtml:16
#: web/templates/profile.gohtml:6 web/templates/profile.gohtml:12
#: web/templates/layout.gohtml:28
msgctxt "title"
msgid "Profile"
msgstr "Perfil"
#: web/templates/profile.gohtml:17
msgctxt "input"
msgid "Name"
msgstr "Nombre"
#: web/templates/profile.gohtml:45
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmación de la contraseña"
#: web/templates/profile.gohtml:55
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: web/templates/profile.gohtml:65
msgctxt "action"
msgid "Save changes"
msgstr "Guardar los cambios"
#: web/templates/layout.gohtml:17
msgid "Skip to main content"
msgstr "Saltar al contenido principal"
#: web/templates/layout.gohtml:20
#: web/templates/layout.gohtml:24
msgctxt "title"
msgid "User Menu"
msgstr "Menú de usuario"
#: web/templates/layout.gohtml:32
msgctxt "action"
msgid "Logout"
msgstr "Salir"
#: pkg/app/login.go:56
#: pkg/app/login.go:56 pkg/app/user.go:170
msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco."
#: pkg/app/login.go:57
#: pkg/app/login.go:57 pkg/app/user.go:171
msgid "This email is not valid. It should be like name@domain.com."
msgstr "Este correo-e no es válido. Tiene que ser parecido a nombre@dominio.com."
@ -64,10 +95,27 @@ msgstr "Este correo-e no es válido. Tiene que ser parecido a nombre@dominio.com
msgid "Password can not be empty."
msgstr "No podéis dejar la contraseña en blanco."
#: pkg/app/login.go:82
#: pkg/app/login.go:86
msgid "Invalid user or password."
msgstr "Usuario o contraseña incorrectos."
#: pkg/app/user.go:135
msgctxt "language option"
msgid "Automatic"
msgstr "Automático"
#: pkg/app/user.go:173
msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco."
#: pkg/app/user.go:174
msgid "Confirmation does not match password."
msgstr "La confirmación no se corresponde con la contraseña."
#: pkg/app/user.go:175
msgid "Selected language is not valid."
msgstr "El idioma escogido no es válido."
#: pkg/auth/user.go:39
msgid "Cross-site request forgery detected."
msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados."

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.0009 17C15.6633 17 18.8659 18.5751 20.608 20.9247L18.766 21.796C17.3482 20.1157 14.8483 19 12.0009 19C9.15346 19 6.6535 20.1157 5.23577 21.796L3.39453 20.9238C5.13673 18.5747 8.33894 17 12.0009 17ZM12.0009 2C14.7623 2 17.0009 4.23858 17.0009 7V10C17.0009 12.6888 14.8786 14.8818 12.2178 14.9954L12.0009 15C9.23945 15 7.00087 12.7614 7.00087 10V7C7.00087 4.31125 9.12318 2.11818 11.784 2.00462L12.0009 2ZM12.0009 4C10.4032 4 9.09721 5.24892 9.00596 6.82373L9.00087 7V10C9.00087 11.6569 10.344 13 12.0009 13C13.5986 13 14.9045 11.7511 14.9958 10.1763L15.0009 10V7C15.0009 5.34315 13.6577 4 12.0009 4Z"/></svg>

After

Width:  |  Height:  |  Size: 681 B

17
web/templates/form.gohtml Normal file
View File

@ -0,0 +1,17 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "error-attrs" }}{{ if .Error }}aria-invalid="true" aria-errormessage="{{ .Name }}-error"{{ end }}{{ end }}
{{ define "error-message" -}}
{{ if .Error -}}
<span id="{{ .Name }}-error" class="error">{{ .Error }}</span><br>
{{- end }}
{{- end }}
{{ define "list-options" -}}
{{- range .Options }}
<option value="{{ .Value }}" {{ if $.IsSelected .Value }} selected="selected"{{ end }}>{{ .Label }}</option>
{{- end }}
{{- end }}

View File

@ -0,0 +1,6 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
<title>{{ template "title" . }} — Camper</title>
{{ template "content" . }}

View File

@ -17,8 +17,23 @@
<a href="#main">{{( gettext "Skip to main content" )}}</a>
<h1>camper _ws</h1>
{{ if isLoggedIn -}}
<button data-hx-delete="/me/session" data-hx-headers='{ {{ CSRFHeader }} }'
>{{( pgettext "Logout" "action" )}}</button>
<nav>
<details>
<summary>
<img src="/static/default_avatar.svg" alt="{{( gettext "Avatar" )}}" width="70" height="70">
<span>{{( pgettext "User Menu" "title" )}}</span>
</summary>
<ul role="menu">
<li role="presentation" class="icon_profile">
<a role="menuitem" href="/me">{{( pgettext "Profile" "title" )}}</a>
</li>
<li role="presentation" class="icon_logout">
<button role="menuitem" data-hx-delete="/me/session" data-hx-headers='{ {{ CSRFHeader }} }'
>{{( pgettext "Logout" "action" )}}</button>
</li>
</ul>
</details>
</nav>
{{- end }}
</header>
<main id="main">
@ -26,10 +41,3 @@
</main>
</body>
</html>
{{ define "error-attrs" }}{{ if .Error }}aria-invalid="true" aria-errormessage="{{ .Name }}-error"{{ end }}{{ end }}
{{ define "error-message" -}}
{{ if .Error -}}
<span id="{{ .Name }}-error" class="error">{{ .Error }}</span><br>
{{- end }}
{{- end }}

View File

@ -0,0 +1,68 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{( pgettext "Profile" "title" )}}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/app.profileForm*/ -}}
<form data-hx-put="/me">
<h2>{{( pgettext "Profile" "title" )}}</h2>
{{ CSRFInput }}
<fieldset>
{{ with .Name -}}
<label>
{{( pgettext "Name" "input" )}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
autocomplete="name" required
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Email -}}
<label>
{{( pgettext "Email" "input" )}}<br>
<input type="email" name="{{ .Name }}" value="{{ .Val }}"
autocomplete="username" required
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
<fieldset>
{{ with .Password -}}
<label>
{{( pgettext "Password" "input" )}}<br>
<input type="password" name="{{ .Name }}" value="{{ .Val }}"
autocomplete="new-password"
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .PasswordConfirm -}}
<label>
{{( pgettext "Password Confirmation" "input" )}}<br>
<input type="password" name="{{ .Name }}" value="{{ .Val }}"
autocomplete="new-password"
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
</fieldset>
{{ with .Language -}}
<label>
{{( pgettext "Language" "input" )}}<br>
<select name="{{ .Name }}"
required
{{ template "error-attrs" . }}>{{ template "list-options" . }}
</select><br>
</label>
{{ template "error-message" . }}
{{- end }}
</fieldset>
<footer>
<button type="submit">{{( pgettext "Save changes" "action" )}}</button>
</footer>
</form>
{{- end }}