Allow users to update their profile images
I do not see the profile image as an “integral part” of the user (i.e., no need for constraints), hence i do not want to store it in the database, as would do for the identification image during check-in. By default, i store the avatars in /var/lib/camper/avatars, but it is a variable to allow packagers change this value using the linker. This is also served as a test bed for uploading files to the server, that now has a better interface and uses less resources that what i did to Numerus. Now the profile handler needs to keep a variable to know the path to the avatars’ directory, thus i had to change it to a struct nested in app, much like the fileHandler does. It still has to return the HandlerFunc, however, as this function needs to close over the user and connection variables. Part of #7.
This commit is contained in:
parent
bf0c86a796
commit
b03ef35a95
|
@ -18,6 +18,8 @@ import (
|
|||
"dev.tandem.ws/tandem/camper/pkg/database"
|
||||
)
|
||||
|
||||
var avatarsDir = "/var/lib/camper/avatars"
|
||||
|
||||
func main() {
|
||||
db, err := database.New(context.Background(), os.Getenv("CAMPER_DATABASE_URL"))
|
||||
if err != nil {
|
||||
|
@ -25,9 +27,13 @@ func main() {
|
|||
}
|
||||
defer db.Close()
|
||||
|
||||
handler, err := app.New(db, avatarsDir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
srv := http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: app.New(db),
|
||||
Handler: handler,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
|
|
|
@ -36,16 +36,23 @@ func methodNotAllowed(w http.ResponseWriter, _ *http.Request, allowed ...string)
|
|||
type App struct {
|
||||
db *database.DB
|
||||
fileHandler http.Handler
|
||||
profile *profileHandler
|
||||
locales locale.Locales
|
||||
defaultLocale *locale.Locale
|
||||
languageMatcher language.Matcher
|
||||
}
|
||||
|
||||
func New(db *database.DB) http.Handler {
|
||||
func New(db *database.DB, avatarsDir string) (http.Handler, error) {
|
||||
locales := locale.MustGetAll(db)
|
||||
static := http.FileServer(http.Dir("web/static"))
|
||||
profile, err := newProfileHandler(static, avatarsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app := &App{
|
||||
db: db,
|
||||
fileHandler: http.FileServer(http.Dir("web/static")),
|
||||
fileHandler: static,
|
||||
profile: profile,
|
||||
locales: locales,
|
||||
defaultLocale: locales[language.Catalan],
|
||||
languageMatcher: language.NewMatcher(locales.Tags()),
|
||||
|
@ -54,7 +61,7 @@ func New(db *database.DB) http.Handler {
|
|||
var handler http.Handler = app
|
||||
handler = httplib.RecoverPanic(handler)
|
||||
handler = httplib.LogRequest(handler)
|
||||
return handler
|
||||
return handler, nil
|
||||
}
|
||||
|
||||
func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -97,7 +104,7 @@ func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
switch head {
|
||||
case "me":
|
||||
profileHandler(user, conn)(w, r)
|
||||
h.profile.Handler(user, conn).ServeHTTP(w, r)
|
||||
case "":
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
|
|
110
pkg/app/user.go
110
pkg/app/user.go
|
@ -7,7 +7,12 @@ package app
|
|||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
|
||||
|
@ -30,9 +35,9 @@ func (h *App) getUser(r *http.Request, conn *database.Conn) (*auth.User, error)
|
|||
LoggedIn: false,
|
||||
Role: "guest",
|
||||
}
|
||||
row := conn.QueryRow(r.Context(), "select coalesce(email, ''), email is not null, role, lang_tag, csrf_token from user_profile")
|
||||
row := conn.QueryRow(r.Context(), "select user_id, coalesce(email, ''), email is not null, role, lang_tag, csrf_token from user_profile")
|
||||
var langTag string
|
||||
if err := row.Scan(&user.Email, &user.LoggedIn, &user.Role, &langTag, &user.CSRFToken); err != nil {
|
||||
if err := row.Scan(&user.ID, &user.Email, &user.LoggedIn, &user.Role, &langTag, &user.CSRFToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lang, err := language.Parse(langTag); err == nil {
|
||||
|
@ -57,12 +62,37 @@ func (h *App) matchLocale(r *http.Request) *locale.Locale {
|
|||
return l
|
||||
}
|
||||
|
||||
func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
|
||||
type profileHandler struct {
|
||||
fileHandle http.Handler
|
||||
avatarHandle http.Handler
|
||||
avatarsDir string
|
||||
}
|
||||
|
||||
func newProfileHandler(static http.Handler, avatarsDir string) (*profileHandler, error) {
|
||||
if err := os.MkdirAll(avatarsDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handler := &profileHandler{
|
||||
fileHandle: static,
|
||||
avatarsDir: avatarsDir,
|
||||
avatarHandle: http.FileServer(http.Dir(avatarsDir)),
|
||||
}
|
||||
return handler, nil
|
||||
}
|
||||
|
||||
func (h *profileHandler) Handler(user *auth.User, conn *database.Conn) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var head string
|
||||
head, r.URL.Path = shiftPath(r.URL.Path)
|
||||
|
||||
switch head {
|
||||
case "avatar":
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.serveAvatar(w, r, user)
|
||||
default:
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
}
|
||||
case "session":
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
|
@ -75,7 +105,7 @@ func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
|
|||
case http.MethodGet:
|
||||
serveProfileForm(w, r, user, conn)
|
||||
case http.MethodPut:
|
||||
updateProfile(w, r, user, conn)
|
||||
h.updateProfile(w, r, user, conn)
|
||||
default:
|
||||
methodNotAllowed(w, r, http.MethodGet, http.MethodPut)
|
||||
}
|
||||
|
@ -85,6 +115,21 @@ func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *profileHandler) serveAvatar(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
||||
avatarPath := h.avatarPath(user)
|
||||
if _, err := os.Stat(avatarPath); err == nil {
|
||||
r.URL.Path = path.Base(avatarPath)
|
||||
h.avatarHandle.ServeHTTP(w, r)
|
||||
} else {
|
||||
r.URL.Path = "default_avatar.svg"
|
||||
h.fileHandle.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *profileHandler) avatarPath(user *auth.User) string {
|
||||
return filepath.Join(h.avatarsDir, strconv.Itoa(user.ID))
|
||||
}
|
||||
|
||||
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")
|
||||
|
@ -93,12 +138,13 @@ func serveProfileForm(w http.ResponseWriter, r *http.Request, user *auth.User, c
|
|||
profile.MustRender(w, r, user)
|
||||
}
|
||||
|
||||
func updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
|
||||
func (h *profileHandler) 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 {
|
||||
if err := profile.Parse(w, r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer profile.Close()
|
||||
if err := user.VerifyCSRFToken(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
|
@ -116,10 +162,18 @@ func updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, conn
|
|||
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 {
|
||||
redirect := user.Language.String() != profile.Language.String()
|
||||
if profile.HasAvatarFile() {
|
||||
redirect = true
|
||||
if err := profile.SaveAvatarTo(h.avatarPath(user)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if redirect {
|
||||
httplib.Redirect(w, r, "/me", http.StatusSeeOther)
|
||||
} else {
|
||||
httplib.Relocate(w, r, "/me", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -129,6 +183,7 @@ type profileForm struct {
|
|||
Password *form.Input
|
||||
PasswordConfirm *form.Input
|
||||
Language *form.Select
|
||||
Avatar *form.File
|
||||
}
|
||||
|
||||
func newProfileForm(ctx context.Context, l *locale.Locale, conn *database.Conn) *profileForm {
|
||||
|
@ -151,11 +206,17 @@ func newProfileForm(ctx context.Context, l *locale.Locale, conn *database.Conn)
|
|||
Name: "language",
|
||||
Options: languages,
|
||||
},
|
||||
Avatar: &form.File{
|
||||
Name: "avatar",
|
||||
MaxSize: 1 << 20,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *profileForm) Parse(r *http.Request) error {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
func (f *profileForm) Parse(w http.ResponseWriter, r *http.Request) error {
|
||||
maxSize := f.Avatar.MaxSize + 1024
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
|
||||
if err := r.ParseMultipartForm(maxSize); err != nil {
|
||||
return err
|
||||
}
|
||||
f.Email.FillValue(r)
|
||||
|
@ -163,9 +224,16 @@ func (f *profileForm) Parse(r *http.Request) error {
|
|||
f.Password.FillValue(r)
|
||||
f.PasswordConfirm.FillValue(r)
|
||||
f.Language.FillValue(r)
|
||||
if err := f.Avatar.FillValue(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *profileForm) Close() error {
|
||||
return f.Avatar.Close()
|
||||
}
|
||||
|
||||
func (f *profileForm) Valid(l *locale.Locale) bool {
|
||||
v := form.NewValidator(l)
|
||||
if v.CheckRequired(f.Email, l.GettextNoop("Email can not be empty.")) {
|
||||
|
@ -174,9 +242,29 @@ func (f *profileForm) Valid(l *locale.Locale) bool {
|
|||
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."))
|
||||
if f.HasAvatarFile() {
|
||||
v.CheckImageFile(f.Avatar, l.GettextNoop("File must be a valid PNG or JPEG image."))
|
||||
}
|
||||
return v.AllOK
|
||||
}
|
||||
|
||||
func (f *profileForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
||||
template.MustRender(w, r, user, "profile.gohtml", f)
|
||||
}
|
||||
|
||||
func (f *profileForm) HasAvatarFile() bool {
|
||||
return f.Avatar.HasData()
|
||||
}
|
||||
|
||||
func (f *profileForm) SaveAvatarTo(avatarPath string) error {
|
||||
if !f.HasAvatarFile() {
|
||||
return nil
|
||||
}
|
||||
avatar, err := os.OpenFile(avatarPath, os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer avatar.Close()
|
||||
_, err = io.Copy(avatar, f.Avatar)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ const (
|
|||
)
|
||||
|
||||
type User struct {
|
||||
ID int
|
||||
Email string
|
||||
LoggedIn bool
|
||||
Role string
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package form
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
Name string
|
||||
MaxSize int64
|
||||
Val string
|
||||
Error error
|
||||
ContentType string
|
||||
file multipart.File
|
||||
header *multipart.FileHeader
|
||||
buffer *bufio.Reader
|
||||
}
|
||||
|
||||
func (f *File) setError(err error) {
|
||||
f.Error = err
|
||||
}
|
||||
|
||||
func (f *File) FillValue(r *http.Request) error {
|
||||
var err error
|
||||
f.file, f.header, err = r.FormFile(f.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, http.ErrMissingFile) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
f.buffer = bufio.NewReader(f.file)
|
||||
sniff, _ := f.buffer.Peek(512)
|
||||
f.ContentType = http.DetectContentType(sniff)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *File) Close() error {
|
||||
if !f.HasData() {
|
||||
return nil
|
||||
}
|
||||
return f.file.Close()
|
||||
}
|
||||
|
||||
func (f *File) HasData() bool {
|
||||
return f.file != nil
|
||||
}
|
||||
|
||||
func (f *File) Read(p []byte) (int, error) {
|
||||
return f.buffer.Read(p)
|
||||
}
|
||||
|
||||
func (f *File) WriteTo(w io.Writer) (int64, error) {
|
||||
return f.buffer.WriteTo(w)
|
||||
}
|
|
@ -40,6 +40,10 @@ func (v *Validator) CheckSelectedOptions(field *Select, message string) bool {
|
|||
return v.check(field, field.validOptionsSelected(), message)
|
||||
}
|
||||
|
||||
func (v *Validator) CheckImageFile(field *File, message string) bool {
|
||||
return v.check(field, field.ContentType == "image/png" || field.ContentType == "image/jpeg", message)
|
||||
}
|
||||
|
||||
type field interface {
|
||||
setError(error)
|
||||
}
|
||||
|
|
40
po/ca.po
40
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 20:36+0200\n"
|
||||
"POT-Creation-Date: 2023-07-28 20:01+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/profile.gohtml:26
|
||||
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:35
|
||||
msgctxt "input"
|
||||
msgid "Email"
|
||||
msgstr "Correu-e"
|
||||
|
||||
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:36
|
||||
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:46
|
||||
msgctxt "input"
|
||||
msgid "Password"
|
||||
msgstr "Contrasenya"
|
||||
|
@ -50,21 +50,31 @@ msgid "Profile"
|
|||
msgstr "Perfil"
|
||||
|
||||
#: web/templates/profile.gohtml:17
|
||||
msgctxt "inut"
|
||||
msgid "Profile Image"
|
||||
msgstr "Imatge del perfil"
|
||||
|
||||
#: web/templates/profile.gohtml:26
|
||||
msgctxt "input"
|
||||
msgid "Name"
|
||||
msgstr "Nom"
|
||||
|
||||
#: web/templates/profile.gohtml:45
|
||||
#: web/templates/profile.gohtml:43
|
||||
msgctxt "legend"
|
||||
msgid "Change password"
|
||||
msgstr "Canvi de contrasenya"
|
||||
|
||||
#: web/templates/profile.gohtml:55
|
||||
msgctxt "input"
|
||||
msgid "Password Confirmation"
|
||||
msgstr "Confirmació de la contrasenya"
|
||||
|
||||
#: web/templates/profile.gohtml:55
|
||||
#: web/templates/profile.gohtml:65
|
||||
msgctxt "input"
|
||||
msgid "Language"
|
||||
msgstr "Idioma"
|
||||
|
||||
#: web/templates/profile.gohtml:65
|
||||
#: web/templates/profile.gohtml:75
|
||||
msgctxt "action"
|
||||
msgid "Save changes"
|
||||
msgstr "Desa els canvis"
|
||||
|
@ -83,11 +93,11 @@ msgctxt "action"
|
|||
msgid "Logout"
|
||||
msgstr "Surt"
|
||||
|
||||
#: pkg/app/login.go:56 pkg/app/user.go:170
|
||||
#: pkg/app/login.go:56 pkg/app/user.go:239
|
||||
msgid "Email can not be empty."
|
||||
msgstr "No podeu deixar el correu en blanc."
|
||||
|
||||
#: pkg/app/login.go:57 pkg/app/user.go:171
|
||||
#: pkg/app/login.go:57 pkg/app/user.go:240
|
||||
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."
|
||||
|
||||
|
@ -99,23 +109,27 @@ msgstr "No podeu deixar la contrasenya en blanc."
|
|||
msgid "Invalid user or password."
|
||||
msgstr "Nom d’usuari o contrasenya incorrectes."
|
||||
|
||||
#: pkg/app/user.go:135
|
||||
#: pkg/app/user.go:190
|
||||
msgctxt "language option"
|
||||
msgid "Automatic"
|
||||
msgstr "Automàtic"
|
||||
|
||||
#: pkg/app/user.go:173
|
||||
#: pkg/app/user.go:242
|
||||
msgid "Name can not be empty."
|
||||
msgstr "No podeu deixar el nom en blanc."
|
||||
|
||||
#: pkg/app/user.go:174
|
||||
#: pkg/app/user.go:243
|
||||
msgid "Confirmation does not match password."
|
||||
msgstr "La confirmació no es correspon amb la contrasenya."
|
||||
|
||||
#: pkg/app/user.go:175
|
||||
#: pkg/app/user.go:244
|
||||
msgid "Selected language is not valid."
|
||||
msgstr "L’idioma escollit no és vàlid."
|
||||
|
||||
#: pkg/auth/user.go:39
|
||||
#: pkg/app/user.go:246
|
||||
msgid "File must be a valid PNG or JPEG image."
|
||||
msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida."
|
||||
|
||||
#: pkg/auth/user.go:40
|
||||
msgid "Cross-site request forgery detected."
|
||||
msgstr "S’ha detectat un intent de falsificació de petició a llocs creuats."
|
||||
|
|
40
po/es.po
40
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 20:36+0200\n"
|
||||
"POT-Creation-Date: 2023-07-28 20:02+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/profile.gohtml:26
|
||||
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:35
|
||||
msgctxt "input"
|
||||
msgid "Email"
|
||||
msgstr "Correo-e"
|
||||
|
||||
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:36
|
||||
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:46
|
||||
msgctxt "input"
|
||||
msgid "Password"
|
||||
msgstr "Contraseña"
|
||||
|
@ -50,21 +50,31 @@ msgid "Profile"
|
|||
msgstr "Perfil"
|
||||
|
||||
#: web/templates/profile.gohtml:17
|
||||
msgctxt "inut"
|
||||
msgid "Profile Image"
|
||||
msgstr "Imagen del perfil"
|
||||
|
||||
#: web/templates/profile.gohtml:26
|
||||
msgctxt "input"
|
||||
msgid "Name"
|
||||
msgstr "Nombre"
|
||||
|
||||
#: web/templates/profile.gohtml:45
|
||||
#: web/templates/profile.gohtml:43
|
||||
msgctxt "legend"
|
||||
msgid "Change password"
|
||||
msgstr "Cambio de contraseña"
|
||||
|
||||
#: web/templates/profile.gohtml:55
|
||||
msgctxt "input"
|
||||
msgid "Password Confirmation"
|
||||
msgstr "Confirmación de la contraseña"
|
||||
|
||||
#: web/templates/profile.gohtml:55
|
||||
#: web/templates/profile.gohtml:65
|
||||
msgctxt "input"
|
||||
msgid "Language"
|
||||
msgstr "Idioma"
|
||||
|
||||
#: web/templates/profile.gohtml:65
|
||||
#: web/templates/profile.gohtml:75
|
||||
msgctxt "action"
|
||||
msgid "Save changes"
|
||||
msgstr "Guardar los cambios"
|
||||
|
@ -83,11 +93,11 @@ msgctxt "action"
|
|||
msgid "Logout"
|
||||
msgstr "Salir"
|
||||
|
||||
#: pkg/app/login.go:56 pkg/app/user.go:170
|
||||
#: pkg/app/login.go:56 pkg/app/user.go:239
|
||||
msgid "Email can not be empty."
|
||||
msgstr "No podéis dejar el correo-e en blanco."
|
||||
|
||||
#: pkg/app/login.go:57 pkg/app/user.go:171
|
||||
#: pkg/app/login.go:57 pkg/app/user.go:240
|
||||
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."
|
||||
|
||||
|
@ -99,23 +109,27 @@ msgstr "No podéis dejar la contraseña en blanco."
|
|||
msgid "Invalid user or password."
|
||||
msgstr "Usuario o contraseña incorrectos."
|
||||
|
||||
#: pkg/app/user.go:135
|
||||
#: pkg/app/user.go:190
|
||||
msgctxt "language option"
|
||||
msgid "Automatic"
|
||||
msgstr "Automático"
|
||||
|
||||
#: pkg/app/user.go:173
|
||||
#: pkg/app/user.go:242
|
||||
msgid "Name can not be empty."
|
||||
msgstr "No podéis dejar el nombre en blanco."
|
||||
|
||||
#: pkg/app/user.go:174
|
||||
#: pkg/app/user.go:243
|
||||
msgid "Confirmation does not match password."
|
||||
msgstr "La confirmación no se corresponde con la contraseña."
|
||||
|
||||
#: pkg/app/user.go:175
|
||||
#: pkg/app/user.go:244
|
||||
msgid "Selected language is not valid."
|
||||
msgstr "El idioma escogido no es válido."
|
||||
|
||||
#: pkg/auth/user.go:39
|
||||
#: pkg/app/user.go:246
|
||||
msgid "File must be a valid PNG or JPEG image."
|
||||
msgstr "El archivo tiene que ser una imagen PNG o JPEG válida."
|
||||
|
||||
#: pkg/auth/user.go:40
|
||||
msgid "Cross-site request forgery detected."
|
||||
msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados."
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
<nav>
|
||||
<details>
|
||||
<summary>
|
||||
<img src="/static/default_avatar.svg" alt="{{( gettext "Avatar" )}}" width="70" height="70">
|
||||
<img src="/me/avatar" alt="{{( gettext "Avatar" )}}" width="70" height="70">
|
||||
<span>{{( pgettext "User Menu" "title" )}}</span>
|
||||
</summary>
|
||||
<ul role="menu">
|
||||
|
|
|
@ -8,10 +8,19 @@
|
|||
|
||||
{{ define "content" -}}
|
||||
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/app.profileForm*/ -}}
|
||||
<form data-hx-put="/me">
|
||||
<form data-hx-put="/me" enctype="multipart/form-data">
|
||||
<h2>{{( pgettext "Profile" "title" )}}</h2>
|
||||
{{ CSRFInput }}
|
||||
<fieldset>
|
||||
{{ with .Avatar -}}
|
||||
<label>
|
||||
{{( pgettext "Profile Image" "inut" )}}<br>
|
||||
<input type="file" name="{{ .Name }}"
|
||||
accept="image/png image/jpeg"
|
||||
{{ template "error-attrs" . }}><br>
|
||||
</label>
|
||||
{{ template "error-message" . }}
|
||||
{{- end }}
|
||||
{{ with .Name -}}
|
||||
<label>
|
||||
{{( pgettext "Name" "input" )}}<br>
|
||||
|
@ -31,6 +40,7 @@
|
|||
{{ template "error-message" . }}
|
||||
{{- end }}
|
||||
<fieldset>
|
||||
<legend>{{( pgettext "Change password" "legend" )}}</legend>
|
||||
{{ with .Password -}}
|
||||
<label>
|
||||
{{( pgettext "Password" "input" )}}<br>
|
||||
|
|
Loading…
Reference in New Issue