Compare commits

...

8 Commits

Author SHA1 Message Date
jordi fita mas c84f3f9e80 Allow guest access to user_profile with an empty profile
I want this so that the Go application does not need to know the exact
details of the settings that the database sets when applying the cookie;
it just needs to select from the user_profile that already knows this.

Also, that way i can get the user’s language from its profile with a
single select, without having to check whether we are guest or
authenticated.

With that, i can skip the content negotiation if the user already told
us what language they want.
2023-01-23 01:18:47 +01:00
jordi fita mas b5968b1179 Use current_app_user to logout
Do not want people being able to logout other users just by setting a
number in a setting.
2023-01-23 01:18:05 +01:00
jordi fita mas c6eb1ef24e Change input field to be “Material-like”, as per design 2023-01-23 00:41:54 +01:00
jordi fita mas 1675ada70b Make the menu work as a menu 2023-01-22 22:30:15 +01:00
jordi fita mas 5505fa41c3 Use “layouts” for the common HTML between pages
Had to call xgettext on Go source files because now the title comes from
there, as i assume i will have titles like "Invoice #INVxxxx" that have
to come from the database that the template does not know.
2023-01-22 21:41:50 +01:00
jordi fita mas fa6ddc70b3 Prefix with “Must” all functions that panic
Just following what the standard library does.
2023-01-22 20:37:43 +01:00
jordi fita mas 7e5e6121ac Gofmt recover.go 2023-01-22 20:37:34 +01:00
jordi fita mas 6f2da865c0 Reduce HTML’s font-size to (usually) have 1rem = 10px
It is far easier for me to “see” the sizes if they are multiples of 10,
especially given that the designs we do in Penpot use a 10 × 10 pixels
grid.
2023-01-22 20:27:43 +01:00
24 changed files with 368 additions and 213 deletions

View File

@ -1,8 +1,10 @@
INPUT_FILES := $(shell find web -name *.html) HTML_FILES := $(shell find web -name *.html)
GO_FILES := $(shell find . -name *.go)
DEFAULT_DOMAIN = numerus DEFAULT_DOMAIN = numerus
POT_FILE = po/$(DEFAULT_DOMAIN).pot POT_FILE = po/$(DEFAULT_DOMAIN).pot
LINGUAS = ca es LINGUAS = ca es
MO_FILES = $(patsubst %,locales/%/LC_MESSAGES/$(DEFAULT_DOMAIN).mo,$(LINGUAS)) MO_FILES = $(patsubst %,locales/%/LC_MESSAGES/$(DEFAULT_DOMAIN).mo,$(LINGUAS))
XGETTEXTFLAGS = --no-wrap --from-code=UTF-8 --package-name=numerus --msgid-bugs-address=jordi@tandem.blog
locales: $(MO_FILES) locales: $(MO_FILES)
@ -13,8 +15,9 @@ locales/%/LC_MESSAGES/numerus.mo: po/%.po
po/%.po: $(POT_FILE) po/%.po: $(POT_FILE)
msgmerge --no-wrap --update --backup=off $@ $< msgmerge --no-wrap --update --backup=off $@ $<
$(POT_FILE): $(INPUT_FILES) $(POT_FILE): $(HTML_FILES) $(GO_FILES)
xgettext --no-wrap --language=Scheme --from-code=UTF-8 --output=$@ --keyword=pgettext:1,2c --package-name=numerus --msgid-bugs-address=jordi@tandem.blog $^ xgettext $(XGETTEXTFLAGS) --language=Scheme --output=$@ --keyword=pgettext:1,2c $(HTML_FILES)
xgettext $(XGETTEXTFLAGS) --language=C --output=$@ --join-existing $(GO_FILES)
test-deploy: test-deploy:
sqitch deploy --db-name $(PGDATABASE) sqitch deploy --db-name $(PGDATABASE)

View File

@ -1,6 +1,7 @@
-- Deploy numerus:logout to pg -- Deploy numerus:logout to pg
-- requires: schema_auth -- requires: schema_auth
-- requires: user -- requires: user
-- requires: current_app_user
begin; begin;
@ -11,7 +12,9 @@ $$
update "user" update "user"
set cookie = default set cookie = default
, cookie_expires_at = default , cookie_expires_at = default
where user_id = current_setting('request.user.id', true)::integer where cookie = current_app_user()
and cookie_expires_at > current_timestamp
and length(cookie) > 30
$$ $$
language sql language sql
security definer security definer

View File

@ -17,8 +17,33 @@ select user_id
, lang_tag , lang_tag
from auth."user" from auth."user"
where cookie = current_app_user() where cookie = current_app_user()
and cookie_expires_at > current_timestamp
and length(cookie) > 30
union all
select 0
, null::email
, ''
, 'guest'::name
, 'und'
where not exists (
select 1
from auth."user"
where cookie = current_app_user()
and cookie_expires_at > current_timestamp
and length(cookie) > 30
);
create rule update_user_profile as on update to user_profile
do instead update auth."user"
set email = new.email
, name = new.name
, lang_tag = new.lang_tag
where cookie = current_app_user()
and cookie_expires_at > current_timestamp
and length(cookie) > 30
; ;
grant select on table user_profile to guest;
grant select, update(email, name, lang_tag) on table user_profile to invoicer; grant select, update(email, name, lang_tag) on table user_profile to invoicer;
grant select, update(email, name, lang_tag) on table user_profile to admin; grant select, update(email, name, lang_tag) on table user_profile to admin;

View File

@ -29,9 +29,9 @@ func NewDatabase(ctx context.Context, connString string) (*Db, error) {
cookie = value cookie = value
} }
if _, err := conn.Exec(ctx, "select set_cookie($1)", cookie); err != nil { if _, err := conn.Exec(ctx, "select set_cookie($1)", cookie); err != nil {
log.Printf("ERROR - Failed to set role: %v", err) log.Printf("ERROR - Failed to set role: %v", err)
return false return false
} }
return true return true
} }
@ -58,11 +58,19 @@ func (db *Db) Acquire(ctx context.Context) (*Conn, error) {
return &Conn{conn}, nil return &Conn{conn}, nil
} }
func (db *Db) MustAcquire(ctx context.Context) *Conn {
conn, err := db.Acquire(ctx)
if err != nil {
panic(err)
}
return conn
}
type Conn struct { type Conn struct {
*pgxpool.Conn *pgxpool.Conn
} }
func (c *Conn) Text(ctx context.Context, def string, sql string, args ...interface{}) string { func (c *Conn) MustGetText(ctx context.Context, def string, sql string, args ...interface{}) string {
var result string var result string
if err := c.Conn.QueryRow(ctx, sql, args...).Scan(&result); err != nil { if err := c.Conn.QueryRow(ctx, sql, args...).Scan(&result); err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
@ -74,7 +82,7 @@ func (c *Conn) Text(ctx context.Context, def string, sql string, args ...interfa
return result return result
} }
func (c *Conn) Exec(ctx context.Context, sql string, args ...interface{}) { func (c *Conn) MustExec(ctx context.Context, sql string, args ...interface{}) {
if _, err := c.Conn.Exec(ctx, sql, args...); err != nil { if _, err := c.Conn.Exec(ctx, sql, args...); err != nil {
panic(err) panic(err)
} }

View File

@ -11,7 +11,7 @@ import (
const contextLocaleKey = "numerus-locale" const contextLocaleKey = "numerus-locale"
func Locale(db *Db, next http.Handler) http.Handler { func Locale(db *Db, next http.Handler) http.Handler {
availableLanguages := getAvailableLanguages(db) availableLanguages := mustGetAvailableLanguages(db)
var matcher = language.NewMatcher(availableLanguages) var matcher = language.NewMatcher(availableLanguages)
locales := map[language.Tag]*gotext.Locale{} locales := map[language.Tag]*gotext.Locale{}
@ -24,14 +24,18 @@ func Locale(db *Db, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var locale *gotext.Locale var locale *gotext.Locale
t, _, err := language.ParseAcceptLanguage(r.Header.Get("Accept-Language")) user := getUser(r)
if err == nil { locale = locales[user.Language]
tag, _, _ := matcher.Match(t...) if locale == nil {
var ok bool t, _, err := language.ParseAcceptLanguage(r.Header.Get("Accept-Language"))
locale, ok = locales[tag] if err == nil {
for !ok && !tag.IsRoot() { tag, _, _ := matcher.Match(t...)
tag = tag.Parent() var ok bool
locale, ok = locales[tag] locale, ok = locales[tag]
for !ok && !tag.IsRoot() {
tag = tag.Parent()
locale, ok = locales[tag]
}
} }
} }
if locale == nil { if locale == nil {
@ -46,7 +50,11 @@ func getLocale(r *http.Request) *gotext.Locale {
return r.Context().Value(contextLocaleKey).(*gotext.Locale) return r.Context().Value(contextLocaleKey).(*gotext.Locale)
} }
func getAvailableLanguages(db *Db) []language.Tag { func pgettext(context string, str string, locale *gotext.Locale) string {
return locale.GetC(str, context)
}
func mustGetAvailableLanguages(db *Db) []language.Tag {
rows, err := db.Query(context.Background(), "select lang_tag from language where selectable") rows, err := db.Query(context.Background(), "select lang_tag from language where selectable")
if err != nil { if err != nil {
panic(err) panic(err)

View File

@ -5,6 +5,8 @@ import (
"net" "net"
"net/http" "net/http"
"time" "time"
"golang.org/x/text/language"
) )
const ( const (
@ -25,6 +27,7 @@ type AppUser struct {
Email string Email string
LoggedIn bool LoggedIn bool
Role string Role string
Language language.Tag
} }
func LoginHandler() http.Handler { func LoginHandler() http.Handler {
@ -41,7 +44,7 @@ func LoginHandler() http.Handler {
} }
if r.Method == "POST" { if r.Method == "POST" {
conn := getConn(r) conn := getConn(r)
cookie := conn.Text(r.Context(), "", "select login($1, $2, $3)", page.Email, page.Password, remoteAddr(r)) cookie := conn.MustGetText(r.Context(), "", "select login($1, $2, $3)", page.Email, page.Password, remoteAddr(r))
if cookie != "" { if cookie != "" {
http.SetCookie(w, createSessionCookie(cookie, 8766*24*time.Hour)) http.SetCookie(w, createSessionCookie(cookie, 8766*24*time.Hour))
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@ -52,7 +55,7 @@ func LoginHandler() http.Handler {
} else { } else {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
renderTemplate(w, r, "login.html", page) mustRenderWebTemplate(w, r, "login.html", page)
}) })
} }
@ -61,7 +64,7 @@ func LogoutHandler() http.Handler {
user := getUser(r) user := getUser(r)
if user.LoggedIn { if user.LoggedIn {
conn := getConn(r) conn := getConn(r)
conn.Exec(r.Context(), "select logout()") conn.MustExec(r.Context(), "select logout()")
http.SetCookie(w, createSessionCookie("", -24*time.Hour)) http.SetCookie(w, createSessionCookie("", -24*time.Hour))
} }
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
@ -94,10 +97,7 @@ func CheckLogin(db *Db, next http.Handler) http.Handler {
ctx = context.WithValue(ctx, ContextCookieKey, cookie.Value) ctx = context.WithValue(ctx, ContextCookieKey, cookie.Value)
} }
conn, err := db.Acquire(ctx) conn := db.MustAcquire(ctx)
if err != nil {
panic(err)
}
defer conn.Release() defer conn.Release()
ctx = context.WithValue(ctx, ContextConnKey, conn) ctx = context.WithValue(ctx, ContextConnKey, conn)
@ -106,11 +106,13 @@ func CheckLogin(db *Db, next http.Handler) http.Handler {
LoggedIn: false, LoggedIn: false,
Role: defaultRole, Role: defaultRole,
} }
row := conn.QueryRow(ctx, "select current_setting('request.user.email', true), current_user") row := conn.QueryRow(ctx, "select coalesce(email, ''), role, lang_tag from user_profile")
if err := row.Scan(&user.Email, &user.Role); err != nil { var langTag string
if err := row.Scan(&user.Email, &user.Role, &langTag); err != nil {
panic(err) panic(err)
} }
user.LoggedIn = user.Email != "" user.LoggedIn = user.Email != ""
user.Language, _ = language.Parse(langTag)
ctx = context.WithValue(ctx, ContextUserKey, user) ctx = context.WithValue(ctx, ContextUserKey, user)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))

View File

@ -11,6 +11,7 @@ type LanguageOption struct {
} }
type ProfilePage struct { type ProfilePage struct {
Title string
Name string Name string
Email string Email string
Password string Password string
@ -27,9 +28,11 @@ func ProfileHandler() http.Handler {
return return
} }
conn := getConn(r) conn := getConn(r)
locale := getLocale(r)
page := ProfilePage{ page := ProfilePage{
Title: pgettext("title", "User Settings", locale),
Email: user.Email, Email: user.Email,
Languages: getLanguageOptions(r.Context(), conn), Languages: mustGetLanguageOptions(r.Context(), conn),
} }
if r.Method == "POST" { if r.Method == "POST" {
r.ParseForm() r.ParseForm()
@ -38,17 +41,19 @@ func ProfileHandler() http.Handler {
page.Password = r.FormValue("password") page.Password = r.FormValue("password")
page.PasswordConfirm = r.FormValue("password_confirm") page.PasswordConfirm = r.FormValue("password_confirm")
page.Language = r.FormValue("language") page.Language = r.FormValue("language")
conn.Exec(r.Context(), "update user_profile set name = $1, email = $2, lang_tag = $3", page.Name, page.Email, page.Language); conn.MustExec(r.Context(), "update user_profile set name = $1, email = $2, lang_tag = $3", page.Name, page.Email, page.Language)
http.Redirect(w, r, "/profile", http.StatusSeeOther);
return;
} else { } else {
if err := conn.QueryRow(r.Context(), "select name, lang_tag from user_profile").Scan(&page.Name, &page.Language); err != nil { if err := conn.QueryRow(r.Context(), "select name, lang_tag from user_profile").Scan(&page.Name, &page.Language); err != nil {
panic(nil) panic(nil)
} }
} }
renderTemplate(w, r, "profile.html", page) mustRenderAppTemplate(w, r, "profile.html", page)
}) })
} }
func getLanguageOptions(ctx context.Context, conn *Conn) []LanguageOption { func mustGetLanguageOptions(ctx context.Context, conn *Conn) []LanguageOption {
rows, err := conn.Query(ctx, "select lang_tag, endonym from language where selectable") rows, err := conn.Query(ctx, "select lang_tag, endonym from language where selectable")
if err != nil { if err != nil {
panic(err) panic(err)

View File

@ -1,10 +1,10 @@
package pkg package pkg
import ( import (
"fmt"
"log"
"net/http" "net/http"
"runtime" "runtime"
"log"
"fmt"
) )
func Recoverer(next http.Handler) http.Handler { func Recoverer(next http.Handler) http.Handler {
@ -12,18 +12,18 @@ func Recoverer(next http.Handler) http.Handler {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
if r == http.ErrAbortHandler { if r == http.ErrAbortHandler {
panic(r); panic(r)
} }
err, ok := r.(error) err, ok := r.(error)
if ! ok { if !ok {
err = fmt.Errorf("%v", r); err = fmt.Errorf("%v", r)
} }
stack := make([]byte, 4 << 10); stack := make([]byte, 4<<10)
length := runtime.Stack(stack, true) length := runtime.Stack(stack, true)
log.Printf("PANIC - %v %s", err, stack[:length]) log.Printf("PANIC - %v %s", err, stack[:length])
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
}(); }()
next.ServeHTTP(w, r); next.ServeHTTP(w, r)
}); })
} }

View File

@ -13,7 +13,7 @@ func NewRouter(db *Db) http.Handler {
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
user := getUser(r) user := getUser(r)
if user.LoggedIn { if user.LoggedIn {
renderTemplate(w, r, "index.html", nil) mustRenderAppTemplate(w, r, "dashboard.html", nil)
} else { } else {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
} }

View File

@ -6,17 +6,29 @@ import (
"net/http" "net/http"
) )
func renderTemplate(wr io.Writer, r *http.Request, filename string, data interface{}) { func templateFile (name string) string {
return "web/template/" + name
}
func mustRenderTemplate(wr io.Writer, r *http.Request, layout string, filename string, data interface{}) {
locale := getLocale(r) locale := getLocale(r)
t := template.New(filename) t := template.New(filename)
t.Funcs(template.FuncMap{ t.Funcs(template.FuncMap{
"gettext": locale.Get, "gettext": locale.Get,
"pgettext": locale.GetC, "pgettext": locale.GetC,
}) })
if _, err := t.ParseFiles("web/template/" + filename); err != nil { if _, err := t.ParseFiles(templateFile(filename), templateFile(layout)); err != nil {
panic(err) panic(err)
} }
if err := t.Execute(wr, data); err != nil { if err := t.ExecuteTemplate(wr, layout, data); err != nil {
panic(err) panic(err)
} }
} }
func mustRenderAppTemplate(w io.Writer, r *http.Request, filename string, data interface{}) {
mustRenderTemplate(w, r, "app.html", filename, data);
}
func mustRenderWebTemplate(w io.Writer, r *http.Request, filename string, data interface{}) {
mustRenderTemplate(w, r, "web.html", filename, data);
}

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-01-22 02:20+0100\n" "POT-Creation-Date: 2023-01-23 00:41+0100\n"
"PO-Revision-Date: 2023-01-18 17:08+0100\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -17,75 +17,75 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
#: web/template/login.html:6 web/template/login.html:19 #: web/template/web.html:6 web/template/login.html:9
msgctxt "title" msgctxt "title"
msgid "Login" msgid "Login"
msgstr "Entrada" msgstr "Entrada"
#: web/template/login.html:14 #: web/template/login.html:5
msgid "Invalid user or password" msgid "Invalid user or password"
msgstr "Nom dusuari o contrasenya incorrectes" msgstr "Nom dusuari o contrasenya incorrectes"
#: web/template/login.html:21 web/template/profile.html:29 #: web/template/login.html:13 web/template/profile.html:14
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correu electrònic" msgstr "Correu-e"
#: web/template/login.html:24 web/template/profile.html:35 #: web/template/login.html:18 web/template/profile.html:22
msgctxt "input" msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contrasenya" msgstr "Contrasenya"
#: web/template/login.html:27 #: web/template/login.html:21
msgctxt "action" msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entra" msgstr "Entra"
#: web/template/profile.html:6 web/template/profile.html:21 #: web/template/profile.html:2 pkg/profile.go:33
msgctxt "title" msgctxt "title"
msgid "User Settings" msgid "User Settings"
msgstr "Configuració usuari" msgstr "Configuració usuari"
#: web/template/profile.html:15 web/template/index.html:15 #: web/template/profile.html:5
msgid "Account"
msgstr "Compte"
#: web/template/profile.html:16 web/template/index.html:16
msgctxt "action"
msgid "Logout"
msgstr "Surt"
#: web/template/profile.html:24
msgctxt "title" msgctxt "title"
msgid "User Access Data" msgid "User Access Data"
msgstr "Dades accés usuari" msgstr "Dades accés usuari"
#: web/template/profile.html:26 #: web/template/profile.html:9
msgctxt "input" msgctxt "input"
msgid "User name" msgid "User name"
msgstr "Nom dusuari" msgstr "Nom dusuari"
#: web/template/profile.html:33 #: web/template/profile.html:18
msgctxt "title" msgctxt "title"
msgid "Password Change" msgid "Password Change"
msgstr "Canvi contrasenya" msgstr "Canvi contrasenya"
#: web/template/profile.html:38 #: web/template/profile.html:27
msgctxt "input" msgctxt "input"
msgid "Password Confirmation" msgid "Password Confirmation"
msgstr "Confirmació contrasenya" msgstr "Confirmació contrasenya"
#: web/template/profile.html:42 #: web/template/profile.html:31
msgctxt "input" msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: web/template/profile.html:44 #: web/template/profile.html:33
msgctxt "language option" msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automàtic" msgstr "Automàtic"
#: web/template/profile.html:49 #: web/template/profile.html:38
msgctxt "action" msgctxt "action"
msgid "Save changes" msgid "Save changes"
msgstr "Desa canvis" msgstr "Desa canvis"
#: web/template/app.html:16
msgid "Account"
msgstr "Compte"
#: web/template/app.html:19
msgctxt "action"
msgid "Logout"
msgstr "Surt"

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: numerus\n" "Project-Id-Version: numerus\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-01-22 02:20+0100\n" "POT-Creation-Date: 2023-01-23 00:41+0100\n"
"PO-Revision-Date: 2023-01-18 17:45+0100\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -17,75 +17,75 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: web/template/login.html:6 web/template/login.html:19 #: web/template/web.html:6 web/template/login.html:9
msgctxt "title" msgctxt "title"
msgid "Login" msgid "Login"
msgstr "Entrada" msgstr "Entrada"
#: web/template/login.html:14 #: web/template/login.html:5
msgid "Invalid user or password" msgid "Invalid user or password"
msgstr "Nombre de usuario o contraseña inválido" msgstr "Nombre de usuario o contraseña inválido"
#: web/template/login.html:21 web/template/profile.html:29 #: web/template/login.html:13 web/template/profile.html:14
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correo electrónico" msgstr "Correo-e"
#: web/template/login.html:24 web/template/profile.html:35 #: web/template/login.html:18 web/template/profile.html:22
msgctxt "input" msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contraseña" msgstr "Contraseña"
#: web/template/login.html:27 #: web/template/login.html:21
msgctxt "action" msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entrar" msgstr "Entrar"
#: web/template/profile.html:6 web/template/profile.html:21 #: web/template/profile.html:2 pkg/profile.go:33
msgctxt "title" msgctxt "title"
msgid "User Settings" msgid "User Settings"
msgstr "Configuración usuario" msgstr "Configuración usuario"
#: web/template/profile.html:15 web/template/index.html:15 #: web/template/profile.html:5
msgid "Account"
msgstr "Cuenta"
#: web/template/profile.html:16 web/template/index.html:16
msgctxt "action"
msgid "Logout"
msgstr "Salir"
#: web/template/profile.html:24
msgctxt "title" msgctxt "title"
msgid "User Access Data" msgid "User Access Data"
msgstr "Datos acceso usuario" msgstr "Datos acceso usuario"
#: web/template/profile.html:26 #: web/template/profile.html:9
msgctxt "input" msgctxt "input"
msgid "User name" msgid "User name"
msgstr "Nombre de usuario" msgstr "Nombre de usuario"
#: web/template/profile.html:33 #: web/template/profile.html:18
msgctxt "title" msgctxt "title"
msgid "Password Change" msgid "Password Change"
msgstr "Cambio de contraseña" msgstr "Cambio de contraseña"
#: web/template/profile.html:38 #: web/template/profile.html:27
msgctxt "input" msgctxt "input"
msgid "Password Confirmation" msgid "Password Confirmation"
msgstr "Confirmación contrasenya" msgstr "Confirmación contrasenya"
#: web/template/profile.html:42 #: web/template/profile.html:31
msgctxt "input" msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: web/template/profile.html:44 #: web/template/profile.html:33
msgctxt "language option" msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automático" msgstr "Automático"
#: web/template/profile.html:49 #: web/template/profile.html:38
msgctxt "action" msgctxt "action"
msgid "Save changes" msgid "Save changes"
msgstr "Guardar cambios" msgstr "Guardar cambios"
#: web/template/app.html:16
msgid "Account"
msgstr "Cuenta"
#: web/template/app.html:19
msgctxt "action"
msgid "Logout"
msgstr "Salir"

View File

@ -2,6 +2,6 @@
begin; begin;
delete from numerus.language; delete from public.language;
commit; commit;

View File

@ -16,8 +16,8 @@ encrypt_password [schema_auth user extension_pgcrypto] 2023-01-13T00:14:30Z jord
login_attempt [schema_auth] 2023-01-17T14:05:49Z jordi fita mas <jordi@tandem.blog> # Add table to log login attempts login_attempt [schema_auth] 2023-01-17T14:05:49Z jordi fita mas <jordi@tandem.blog> # Add table to log login attempts
login [roles schema_numerus schema_auth extension_pgcrypto email user login_attempt] 2023-01-13T00:32:32Z jordi fita mas <jordi@tandem.blog> # Add function to login login [roles schema_numerus schema_auth extension_pgcrypto email user login_attempt] 2023-01-13T00:32:32Z jordi fita mas <jordi@tandem.blog> # Add function to login
check_cookie [schema_public user] 2023-01-17T17:48:49Z jordi fita mas <jordi@tandem.blog> # Add function to check if a user cookie is valid check_cookie [schema_public user] 2023-01-17T17:48:49Z jordi fita mas <jordi@tandem.blog> # Add function to check if a user cookie is valid
logout [schema_auth user] 2023-01-17T19:10:21Z jordi fita mas <jordi@tandem.blog> # Add function to logout
set_cookie [schema_public check_cookie] 2023-01-19T11:00:22Z jordi fita mas <jordi@tandem.blog> # Add function to set the role based on the cookie
current_app_user [schema_numerus] 2023-01-21T20:16:28Z jordi fita mas <jordi@tandem.blog> # Add function to get the ID of the current Numerus user current_app_user [schema_numerus] 2023-01-21T20:16:28Z jordi fita mas <jordi@tandem.blog> # Add function to get the ID of the current Numerus user
logout [schema_auth current_app_user user] 2023-01-17T19:10:21Z jordi fita mas <jordi@tandem.blog> # Add function to logout
set_cookie [schema_public check_cookie] 2023-01-19T11:00:22Z jordi fita mas <jordi@tandem.blog> # Add function to set the role based on the cookie
available_languages [schema_numerus language] 2023-01-21T21:11:08Z jordi fita mas <jordi@tandem.blog> # Add the initial available languages available_languages [schema_numerus language] 2023-01-21T21:11:08Z jordi fita mas <jordi@tandem.blog> # Add the initial available languages
user_profile [schema_numerus user current_app_user] 2023-01-21T23:18:20Z jordi fita mas <jordi@tandem.blog> # Add view for user profile user_profile [schema_numerus user current_app_user] 2023-01-21T23:18:20Z jordi fita mas <jordi@tandem.blog> # Add view for user profile

View File

@ -12,7 +12,7 @@ set search_path to numerus, public;
select has_domain('email'); select has_domain('email');
select domain_type_is('email', 'citext'); select domain_type_is('email', 'citext');
select lives_ok($$ SELECT 'test@tandem.com'::email $$, 'Should be able to cast strings to email'); select lives_ok($$ select 'test@tandem.com'::email $$, 'Should be able to cast strings to email');
select throws_ok( select throws_ok(
$$ SELECT 'test@tandem,,co.uk'::email $$, $$ SELECT 'test@tandem,,co.uk'::email $$,

View File

@ -32,7 +32,7 @@ prepare user_cookies as
select cookie, cookie_expires_at from "user" order by user_id select cookie, cookie_expires_at from "user" order by user_id
; ;
select set_config('request.user.id', '0', false); select set_config('request.user.cookie', '', false);
select lives_ok( $$ select * from logout() $$, 'Can logout “nobody”' ); select lives_ok( $$ select * from logout() $$, 'Can logout “nobody”' );
select results_eq( select results_eq(
@ -43,7 +43,7 @@ select results_eq(
'Nothing changed' 'Nothing changed'
); );
select set_config('request.user.id', '1', false); select set_config('request.user.cookie', '8c23d4a8d777775f8fc507676a0d99d3dfa54b03b1b257c838', false);
select lives_ok( $$ select * from logout() $$, 'Can logout the first user' ); select lives_ok( $$ select * from logout() $$, 'Can logout the first user' );
select results_eq( select results_eq(
@ -54,7 +54,7 @@ select results_eq(
'The first user logged out' 'The first user logged out'
); );
select set_config('request.user.id', '12', false); select set_config('request.user.cookie', '0169e5f668eec1e6749fd25388b057997358efa8dfd697961a', false);
select lives_ok( $$ select * from logout() $$, 'Can logout the second user' ); select lives_ok( $$ select * from logout() $$, 'Can logout the second user' );
select results_eq( select results_eq(

View File

@ -10,42 +10,42 @@ select plan(47);
set search_path to numerus, auth, public; set search_path to numerus, auth, public;
select has_view('user_profile'); select has_view('user_profile');
select table_privs_are('user_profile', 'guest', array []::text[]); select table_privs_are('user_profile', 'guest', array ['SELECT']);
select table_privs_are('user_profile', 'invoicer', array['SELECT']); select table_privs_are('user_profile', 'invoicer', array['SELECT']);
select table_privs_are('user_profile', 'admin', array['SELECT']); select table_privs_are('user_profile', 'admin', array['SELECT']);
select table_privs_are('user_profile', 'authenticator', array[]::text[]); select table_privs_are('user_profile', 'authenticator', array[]::text[]);
select has_column('user_profile', 'user_id'); select has_column('user_profile', 'user_id');
select col_type_is('user_profile', 'user_id', 'integer'); select col_type_is('user_profile', 'user_id', 'integer');
select column_privs_are('user_profile', 'user_id', 'guest', array []::text[]); select column_privs_are('user_profile', 'user_id', 'guest', array ['SELECT']);
select column_privs_are('user_profile', 'user_id', 'invoicer', array['SELECT']); select column_privs_are('user_profile', 'user_id', 'invoicer', array['SELECT']);
select column_privs_are('user_profile', 'user_id', 'admin', array['SELECT']); select column_privs_are('user_profile', 'user_id', 'admin', array['SELECT']);
select column_privs_are('user_profile', 'user_id', 'authenticator', array[]::text[]); select column_privs_are('user_profile', 'user_id', 'authenticator', array[]::text[]);
select has_column('user_profile', 'email'); select has_column('user_profile', 'email');
select col_type_is('user_profile', 'email', 'email'); select col_type_is('user_profile', 'email', 'email');
select column_privs_are('user_profile', 'email', 'guest', array []::text[]); select column_privs_are('user_profile', 'email', 'guest', array ['SELECT']);
select column_privs_are('user_profile', 'email', 'invoicer', array['SELECT', 'UPDATE']); select column_privs_are('user_profile', 'email', 'invoicer', array['SELECT', 'UPDATE']);
select column_privs_are('user_profile', 'email', 'admin', array['SELECT', 'UPDATE']); select column_privs_are('user_profile', 'email', 'admin', array['SELECT', 'UPDATE']);
select column_privs_are('user_profile', 'email', 'authenticator', array[]::text[]); select column_privs_are('user_profile', 'email', 'authenticator', array[]::text[]);
select has_column('user_profile', 'name'); select has_column('user_profile', 'name');
select col_type_is('user_profile', 'name', 'text'); select col_type_is('user_profile', 'name', 'text');
select column_privs_are('user_profile', 'name', 'guest', array []::text[]); select column_privs_are('user_profile', 'name', 'guest', array ['SELECT']);
select column_privs_are('user_profile', 'name', 'invoicer', array['SELECT', 'UPDATE']); select column_privs_are('user_profile', 'name', 'invoicer', array['SELECT', 'UPDATE']);
select column_privs_are('user_profile', 'name', 'admin', array['SELECT', 'UPDATE']); select column_privs_are('user_profile', 'name', 'admin', array['SELECT', 'UPDATE']);
select column_privs_are('user_profile', 'name', 'authenticator', array[]::text[]); select column_privs_are('user_profile', 'name', 'authenticator', array[]::text[]);
select has_column('user_profile', 'role'); select has_column('user_profile', 'role');
select col_type_is('user_profile', 'role', 'name'); select col_type_is('user_profile', 'role', 'name');
select column_privs_are('user_profile', 'role', 'guest', array []::text[]); select column_privs_are('user_profile', 'role', 'guest', array ['SELECT']);
select column_privs_are('user_profile', 'role', 'invoicer', array['SELECT']); select column_privs_are('user_profile', 'role', 'invoicer', array['SELECT']);
select column_privs_are('user_profile', 'role', 'admin', array['SELECT']); select column_privs_are('user_profile', 'role', 'admin', array['SELECT']);
select column_privs_are('user_profile', 'role', 'authenticator', array[]::text[]); select column_privs_are('user_profile', 'role', 'authenticator', array[]::text[]);
select has_column('user_profile', 'lang_tag'); select has_column('user_profile', 'lang_tag');
select col_type_is('user_profile', 'lang_tag', 'text'); select col_type_is('user_profile', 'lang_tag', 'text');
select column_privs_are('user_profile', 'lang_tag', 'guest', array []::text[]); select column_privs_are('user_profile', 'lang_tag', 'guest', array ['SELECT']);
select column_privs_are('user_profile', 'lang_tag', 'invoicer', array['SELECT', 'UPDATE']); select column_privs_are('user_profile', 'lang_tag', 'invoicer', array['SELECT', 'UPDATE']);
select column_privs_are('user_profile', 'lang_tag', 'admin', array['SELECT', 'UPDATE']); select column_privs_are('user_profile', 'lang_tag', 'admin', array['SELECT', 'UPDATE']);
select column_privs_are('user_profile', 'lang_tag', 'authenticator', array[]::text[]); select column_privs_are('user_profile', 'lang_tag', 'authenticator', array[]::text[]);
@ -58,13 +58,20 @@ reset client_min_messages;
insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at, lang_tag) insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at, lang_tag)
values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month', 'ca') values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month', 'ca')
, (5, 'admin@tandem.blog', 'Admin', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month', 'es') , (5, 'admin@tandem.blog', 'Admin', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month', 'es')
, (7, 'another@tandem.blog', 'Another Admin', 'test', 'admin', default, default, default)
; ;
prepare profile as prepare profile as
select user_id, email, name, role, lang_tag select user_id, email, name, role, lang_tag
from user_profile; from user_profile;
select is_empty( 'profile', 'Should be empty when no user is logger in' ); select set_config('request.user.cookie', '', false);
select results_eq(
'profile',
$$ values (0, null::email, '', 'guest'::name, 'und') $$,
'Should be set up with the guest user when no user logged in yet.'
);
select set_cookie( '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog' ); select set_cookie( '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog' );
@ -144,6 +151,7 @@ select results_eq(
$$ select user_id, email, name, lang_tag from auth."user" order by user_id $$, $$ select user_id, email, name, lang_tag from auth."user" order by user_id $$,
$$ values (1, 'demo+update@tandem.blog'::email, 'Demo Update', 'es') $$ values (1, 'demo+update@tandem.blog'::email, 'Demo Update', 'es')
, (5, 'admin+update@tandem.blog'::email, 'Admin Update', 'ca') , (5, 'admin+update@tandem.blog'::email, 'Admin Update', 'ca')
, (7, 'another@tandem.blog'::email, 'Another Admin', 'und')
$$, $$,
'Should have updated the base tables data' 'Should have updated the base tables data'
); );

View File

@ -152,7 +152,7 @@
--numerus--color--hay: #ffe673; --numerus--color--hay: #ffe673;
--numerus--text-color: var(--numerus--color--black); --numerus--text-color: var(--numerus--color--black);
--numerus--background-color: var(--numerus--color-white); --numerus--background-color: var(--numerus--color--white);
--numerus--font-family: 'JetBrains Mono'; --numerus--font-family: 'JetBrains Mono';
--numerus--header--background-color: #ede9e5; --numerus--header--background-color: #ede9e5;
@ -164,11 +164,13 @@ html, body {
html { html {
font-family: var(--numerus--font-family), monospace; font-family: var(--numerus--font-family), monospace;
font-size: 62.5%;
} }
body { body {
color: var(--numerus--text-color);
background-color: var(--numerus--background-color); background-color: var(--numerus--background-color);
color: var(--numerus--text-color);
font-size: 1.6rem;
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@ -203,17 +205,19 @@ input[type="submit"]:active, button:active {
} }
.web { .web {
margin: 5.3125rem 2.5rem; margin: 8.5rem 4rem;
background-color: var(--numerus--header--background-color); background-color: var(--numerus--header--background-color);
} }
.web h1 { .web h1 {
margin-bottom: 1.875em; padding-bottom: .9375em;
border-bottom: 1px solid var(--numerus--color--black);
margin-bottom: .625em;
} }
#login { #login {
background-color: var(--numerus--color--hay); background-color: var(--numerus--color--hay);
padding: 1.5625em; padding: 1.25em;
} }
#login h2 { #login h2 {
@ -221,7 +225,7 @@ input[type="submit"]:active, button:active {
} }
div[role="alert"].error { div[role="alert"].error {
padding: 1.3125em; padding: 1.25em;
background-color: var(--numerus--color--red); background-color: var(--numerus--color--red);
} }
@ -230,16 +234,60 @@ header {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background-color: var(--numerus--header--background-color); background-color: var(--numerus--header--background-color);
padding: .625rem 1.875rem; padding: 0rem 3rem;
} }
nav { main {
padding: 3rem;
}
.input {
position: relative;
display: inline-block;
}
input[type="text"], input[type="password"], input[type="email"], select {
background-color: var(--numerus--background-color);
border: 1px solid var(--numerus--color--black);
border-radius: 0;
padding: 1rem 2rem;
min-width: 30rem;
}
.input input::placeholder {
color: transparent;
}
.input label, .input input:focus ~ label {
position: absolute;
font-style: italic;
pointer-events: none;
}
.input input:placeholder-shown ~ label {
font-size: 1em;
background-color: initial;
left: 2rem;
top: 1rem;
}
.input label, .input input:focus ~ label {
background-color: var(--numerus--background-color);
top: -.9rem;
left: 1rem;
font-size: 0.8em;
padding: 0 .25rem;
transition: 0.2s;
}
.relative {
position: relative; position: relative;
} }
nav > button { #profilebutton {
width: 4.375rem; width: 7rem;
height: 4.375rem; height: 7rem;
margin: 1rem 0;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -247,36 +295,69 @@ nav > button {
border: none; border: none;
} }
nav button { #profilebutton, #profilemenu button {
cursor: pointer; cursor: pointer;
} }
nav ul { #profilemenu {
list-style: none; list-style: none;
padding: none;
position: absolute; position: absolute;
right: -1.875em; right: -1.875em;
top: calc(100% + .625rem); top: 100%;
padding: 1rem 2rem;
background-color: var(--numerus--color--white);
display: none;
opacity: 0;
z-index: 10;
} }
nav ul button, nav ul a { header div:hover #profilemenu {
height: 5.25rem; opacity: 1;
width: 31.25rem; display: initial;
padding: 0 1.875rem; }
header .overlay {
background-color: var(--numerus--header--background-color);
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0;
display: none;
pointer-events: none;
mix-blend-mode: multiply;
}
header div:hover + .overlay {
display: block;
opacity: 1;
}
#profilemenu li + li {
border-top: 1px solid var(--numerus--color--black);
}
#profilemenu button, #profilemenu a {
font-size: 2rem;
font-style: italic;
height: 8rem;
width: 46rem;
padding-left: 2rem;
display: flex; display: flex;
align-items: center; align-items: center;
border: 0; border: 0;
background-color: var(--numerus--color--white);
color: var(--numerus--text-color); color: var(--numerus--text-color);
text-decoration: none; text-decoration: none;
text-transform: initial;
} }
nav ul i[class^='ri-'] { #profilemenu i[class^='ri-'] {
font-size: 1.25em; margin-right: 2rem;
margin-right: 1.25rem; color: var(--numerus--color--dark-gray);
} }
nav ul button:hover, nav ul a:hover { #profilemenu button:hover, #profilemenu a:hover {
background-color: var(--numerus--color--light-gray); background-color: var(--numerus--color--light-gray);
} }

29
web/template/app.html Normal file
View File

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }} — Numerus</title>
<link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css">
</head>
<body>
<header>
<h1><img src="/static/numerus.svg" alt="Numerus" width="261" height="33"></h1>
<div class="relative">
<button id="profilebutton" aria-controls="profilemenu" aria-haspopup="true"><i class="ri-eye-close-line ri-3x"></i></button>
<ul id="profilemenu" role="menu" aria-labelledby="profilebutton">
<li role="presentation">
<a role="menuitem" href="/profile"><i class="ri-account-circle-line"></i> {{( gettext "Account" )}}</a>
</li>
<li role="presentation">
<form method="POST" action="/logout"><button type="submit" role="menuitem"><i class="ri-logout-circle-line"></i> {{( pgettext "Logout" "action" )}}</button></form>
</li>
</ul>
</div>
<div class="overlay"></div>
</header>
<main>
{{- template "content" . }}
</main>
</body>
</html>

View File

@ -0,0 +1,2 @@
{{ define "content" }}
{{- end }}

View File

@ -1,23 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Numerus</title>
<link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css">
</head>
<body>
<header>
<h1><img src="/static/numerus.svg" alt="Numerus" width="261" height="33"></h1>
<nav role="navigation">
<button aria-haspopup="true"><i class="ri-eye-close-line ri-3x"></i></button>
<ul>
<li><a href="/profile"><i class="ri-account-circle-line"></i> {{( gettext "Account" )}}</a></li>
<li><form method="POST" action="/logout"><button type="submit"><i class="ri-logout-circle-line"></i> {{( pgettext "Logout" "action" )}}</button></form></li>
</ul>
</nav>
</header>
<main>
</main>
</body>
</html>

View File

@ -1,31 +1,24 @@
<!doctype html> {{ define "content" }}
<html lang="en"> <h1><img src="/static/numerus.svg" alt="Numerus" width="620" height="77"></h1>
<head> {{ if .LoginError -}}
<meta charset="utf-8"> <div class="error" role="alert">
<meta name="viewport" content="width=device-width, initial-scale=1"> <p>{{( gettext "Invalid user or password" )}}</p>
<title>{{( pgettext "Login" "title" )}} — Numerus</title> </div>
<link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css"> {{- end }}
</head> <section id="login">
<body class="web"> <h2>{{( pgettext "Login" "title" )}}</h2>
<h1><img src="/static/numerus.svg" alt="Numerus" width="620" height="77"></h1> <form method="POST" action="/login">
<div class="input">
{{ if .LoginError }} <input id="user_email" type="email" required autofocus name="email" autocapitalize="none" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
<div class="error" role="alert">
<p>{{( gettext "Invalid user or password" )}}</p>
</div>
{{ end }}
<section id="login">
<h2>{{( pgettext "Login" "title" )}}</h2>
<form method="POST" action="/login">
<label for="user_email">{{( pgettext "Email" "input" )}}</label> <label for="user_email">{{( pgettext "Email" "input" )}}</label>
<input id="user_email" type="email" required autofocus name="email" autocapitalize="none" value="{{ .Email }}"> </input>
<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> <label for="user_password">{{( pgettext "Password" "input" )}}</label>
<input id="user_password" type="password" required name="password" autocomplete="current-password" value="{{ .Password }}"> </div>
<button type="submit">{{( pgettext "Login" "action" )}}</button> <button type="submit">{{( pgettext "Login" "action" )}}</button>
</form> </form>
</section> </section>
</body> {{- end }}
</html>

View File

@ -1,53 +1,40 @@
<!doctype html> {{ define "content" }}
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{(pgettext "User Settings" "title")}} — Numerus</title>
<link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css">
</head>
<body>
<header>
<h1><img src="/static/numerus.svg" alt="Numerus" width="261" height="33"></h1>
<nav role="navigation">
<button aria-haspopup="true"><i class="ri-eye-close-line ri-3x"></i></button>
<ul>
<li><a href="/profile"><i class="ri-account-circle-line"></i> {{( gettext "Account" )}}</a></li>
<li><form method="POST" action="/logout"><button type="submit"><i class="ri-logout-circle-line"></i> {{( pgettext "Logout" "action" )}}</button></form></li>
</ul>
</nav>
</header>
<main>
<h2>{{(pgettext "User Settings" "title")}}</h2> <h2>{{(pgettext "User Settings" "title")}}</h2>
<form method="POST" action="/profile"> <form method="POST" action="/profile">
<fieldset> <fieldset>
<legend>{{( pgettext "User Access Data" "title" )}}</legend> <legend>{{( pgettext "User Access Data" "title" )}}</legend>
<label for="name">{{( pgettext "User name" "input" )}}</label> <div class="input">
<input type="text" name="name" id="name" required="required" value="{{ .Name }}"> <input type="text" name="name" id="name" required="required" value="{{ .Name }}" placeholder="{{( pgettext "User name" "input" )}}">
<label for="name">{{( pgettext "User name" "input" )}}</label>
</div>
<label for="email">{{( pgettext "Email" "input" )}}</label> <div class="input">
<input type="email" name="email" id="email" required="required" value="{{ .Email }}"> <input type="email" name="email" id="email" required="required" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
<label for="email">{{( pgettext "Email" "input" )}}</label>
</div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{{( pgettext "Password Change" "title" )}}</legend> <legend>{{( pgettext "Password Change" "title" )}}</legend>
<label for="password">{{( pgettext "Password" "input" )}}</label> <div class="input">
<input type="password" name="password" id="password" value="{{ .Password }}"> <input type="password" name="password" id="password" value="{{ .Password }}" placeholder="{{( pgettext "Password" "input" )}}">
<label for="password">{{( pgettext "Password" "input" )}}</label>
</div>
<label for="password_confirm">{{( pgettext "Password Confirmation" "input" )}}</label> <div class="input">
<input type="password" name="password_confirm" id="password_confirm" value="{{ .PasswordConfirm }}"> <input type="password" name="password_confirm" id="password_confirm" value="{{ .PasswordConfirm }}" placeholder="{{( pgettext "Password Confirmation" "input" )}}">
<label for="password_confirm">{{( pgettext "Password Confirmation" "input" )}}</label>
</div>
</fieldset> </fieldset>
<label for="language">{{( pgettext "Language" "input" )}}</label> <label for="language">{{( pgettext "Language" "input" )}}</label>
<select id="language" name="language"> <select id="language" name="language">
<option value="und">{{( pgettext "Automatic" "language option" )}}</option> <option value="und">{{( pgettext "Automatic" "language option" )}}</option>
{{ range $language := .Languages }} {{- range $language := .Languages }}
<option value="{{ .Tag }}" {{ if eq .Tag $.Language }}selected="selected"{{ end }}>{{ .Name }}</option> <option value="{{ .Tag }}" {{ if eq .Tag $.Language }}selected="selected"{{ end }}>{{ .Name }}</option>
{{ end }} {{- end }}
</select> </select>
<button type="submit">{{( pgettext "Save changes" "action" )}}</button> <button type="submit">{{( pgettext "Save changes" "action" )}}</button>
</form> </form>
</main> {{- end }}
</body>
</html>

12
web/template/web.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{( pgettext "Login" "title" )}} — Numerus</title>
<link rel="stylesheet" type="text/css" media="screen" href="/static/numerus.css">
</head>
<body class="web">
{{- template "content" . }}
</body>
</html>