diff --git a/Makefile b/Makefile index f21beaa..e5e0a0c 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/pkg/app/app.go b/pkg/app/app.go index 4288419..8150070 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -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) } diff --git a/pkg/app/login.go b/pkg/app/login.go index 987210f..a7bd26a 100644 --- a/pkg/app/login.go +++ b/pkg/app/login.go @@ -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) { diff --git a/pkg/app/user.go b/pkg/app/user.go index 1696f08..74f27a6 100644 --- a/pkg/app/user.go +++ b/pkg/app/user.go @@ -1,13 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2023 jordi fita mas + * 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) +} diff --git a/pkg/form/input.go b/pkg/form/input.go index 4f98595..e0713bc 100644 --- a/pkg/form/input.go +++ b/pkg/form/input.go @@ -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)) } diff --git a/pkg/form/select.go b/pkg/form/select.go new file mode 100644 index 0000000..03e0492 --- /dev/null +++ b/pkg/form/select.go @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2023 jordi fita mas + * 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 +} diff --git a/pkg/form/validator.go b/pkg/form/validator.go index 07d9840..38a9882 100644 --- a/pkg/form/validator.go +++ b/pkg/form/validator.go @@ -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 diff --git a/pkg/locale/locale.go b/pkg/locale/locale.go index 1074ad6..ee460f4 100644 --- a/pkg/locale/locale.go +++ b/pkg/locale/locale.go @@ -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 } diff --git a/pkg/template/render.go b/pkg/template/render.go index 5546639..fb74ceb 100644 --- a/pkg/template/render.go +++ b/pkg/template/render.go @@ -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(``, 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 { diff --git a/po/ca.po b/po/ca.po index 05ba008..700e7b2 100644 --- a/po/ca.po +++ b/po/ca.po @@ -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 \n" "Language-Team: Catalan \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ú d’usuari" + +#: 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 d’usuari 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 "L’idioma escollit no és vàlid." + #: pkg/auth/user.go:39 msgid "Cross-site request forgery detected." msgstr "S’ha detectat un intent de falsificació de petició a llocs creuats." diff --git a/po/es.po b/po/es.po index 67f090a..8faa109 100644 --- a/po/es.po +++ b/po/es.po @@ -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 \n" "Language-Team: Spanish \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." diff --git a/web/static/default_avatar.svg b/web/static/default_avatar.svg new file mode 100644 index 0000000..687a4c8 --- /dev/null +++ b/web/static/default_avatar.svg @@ -0,0 +1 @@ + diff --git a/web/templates/form.gohtml b/web/templates/form.gohtml new file mode 100644 index 0000000..9e0a01c --- /dev/null +++ b/web/templates/form.gohtml @@ -0,0 +1,17 @@ + +{{ define "error-attrs" }}{{ if .Error }}aria-invalid="true" aria-errormessage="{{ .Name }}-error"{{ end }}{{ end }} + +{{ define "error-message" -}} + {{ if .Error -}} + {{ .Error }}
+ {{- end }} +{{- end }} + +{{ define "list-options" -}} + {{- range .Options }} + + {{- end }} +{{- end }} diff --git a/web/templates/htmx.gohtml b/web/templates/htmx.gohtml new file mode 100644 index 0000000..a632f57 --- /dev/null +++ b/web/templates/htmx.gohtml @@ -0,0 +1,6 @@ + +{{ template "title" . }} — Camper +{{ template "content" . }} diff --git a/web/templates/layout.gohtml b/web/templates/layout.gohtml index d3dd90c..f96a338 100644 --- a/web/templates/layout.gohtml +++ b/web/templates/layout.gohtml @@ -17,8 +17,23 @@ {{( gettext "Skip to main content" )}}

camper _ws

{{ if isLoggedIn -}} - + {{- end }}
@@ -26,10 +41,3 @@
- -{{ define "error-attrs" }}{{ if .Error }}aria-invalid="true" aria-errormessage="{{ .Name }}-error"{{ end }}{{ end }} -{{ define "error-message" -}} - {{ if .Error -}} - {{ .Error }}
- {{- end }} -{{- end }} diff --git a/web/templates/profile.gohtml b/web/templates/profile.gohtml new file mode 100644 index 0000000..48f126f --- /dev/null +++ b/web/templates/profile.gohtml @@ -0,0 +1,68 @@ + +{{ define "title" -}} + {{( pgettext "Profile" "title" )}} +{{- end }} + +{{ define "content" -}} + {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/app.profileForm*/ -}} +
+

{{( pgettext "Profile" "title" )}}

+ {{ CSRFInput }} +
+ {{ with .Name -}} + + {{ template "error-message" . }} + {{- end }} + {{ with .Email -}} + + {{ template "error-message" . }} + {{- end }} +
+ {{ with .Password -}} + + {{ template "error-message" . }} + {{- end }} + {{ with .PasswordConfirm -}} + + {{ template "error-message" . }} + {{- end }} +
+ {{ with .Language -}} + + {{ template "error-message" . }} + {{- end }} +
+ +
+{{- end }}