Compare commits
8 Commits
8fa3367f6c
...
c84f3f9e80
Author | SHA1 | Date |
---|---|---|
jordi fita mas | c84f3f9e80 | |
jordi fita mas | b5968b1179 | |
jordi fita mas | c6eb1ef24e | |
jordi fita mas | 1675ada70b | |
jordi fita mas | 5505fa41c3 | |
jordi fita mas | fa6ddc70b3 | |
jordi fita mas | 7e5e6121ac | |
jordi fita mas | 6f2da865c0 |
9
Makefile
9
Makefile
|
@ -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
|
||||
POT_FILE = po/$(DEFAULT_DOMAIN).pot
|
||||
LINGUAS = ca es
|
||||
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)
|
||||
|
||||
|
@ -13,8 +15,9 @@ locales/%/LC_MESSAGES/numerus.mo: po/%.po
|
|||
po/%.po: $(POT_FILE)
|
||||
msgmerge --no-wrap --update --backup=off $@ $<
|
||||
|
||||
$(POT_FILE): $(INPUT_FILES)
|
||||
xgettext --no-wrap --language=Scheme --from-code=UTF-8 --output=$@ --keyword=pgettext:1,2c --package-name=numerus --msgid-bugs-address=jordi@tandem.blog $^
|
||||
$(POT_FILE): $(HTML_FILES) $(GO_FILES)
|
||||
xgettext $(XGETTEXTFLAGS) --language=Scheme --output=$@ --keyword=pgettext:1,2c $(HTML_FILES)
|
||||
xgettext $(XGETTEXTFLAGS) --language=C --output=$@ --join-existing $(GO_FILES)
|
||||
|
||||
test-deploy:
|
||||
sqitch deploy --db-name $(PGDATABASE)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
-- Deploy numerus:logout to pg
|
||||
-- requires: schema_auth
|
||||
-- requires: user
|
||||
-- requires: current_app_user
|
||||
|
||||
begin;
|
||||
|
||||
|
@ -11,7 +12,9 @@ $$
|
|||
update "user"
|
||||
set cookie = 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
|
||||
security definer
|
||||
|
|
|
@ -17,8 +17,33 @@ select user_id
|
|||
, lang_tag
|
||||
from auth."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 admin;
|
||||
|
||||
|
|
12
pkg/db.go
12
pkg/db.go
|
@ -58,11 +58,19 @@ func (db *Db) Acquire(ctx context.Context) (*Conn, error) {
|
|||
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 {
|
||||
*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
|
||||
if err := c.Conn.QueryRow(ctx, sql, args...).Scan(&result); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
|
@ -74,7 +82,7 @@ func (c *Conn) Text(ctx context.Context, def string, sql string, args ...interfa
|
|||
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 {
|
||||
panic(err)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import (
|
|||
const contextLocaleKey = "numerus-locale"
|
||||
|
||||
func Locale(db *Db, next http.Handler) http.Handler {
|
||||
availableLanguages := getAvailableLanguages(db)
|
||||
availableLanguages := mustGetAvailableLanguages(db)
|
||||
var matcher = language.NewMatcher(availableLanguages)
|
||||
|
||||
locales := map[language.Tag]*gotext.Locale{}
|
||||
|
@ -24,6 +24,9 @@ func Locale(db *Db, next http.Handler) http.Handler {
|
|||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var locale *gotext.Locale
|
||||
user := getUser(r)
|
||||
locale = locales[user.Language]
|
||||
if locale == nil {
|
||||
t, _, err := language.ParseAcceptLanguage(r.Header.Get("Accept-Language"))
|
||||
if err == nil {
|
||||
tag, _, _ := matcher.Match(t...)
|
||||
|
@ -34,6 +37,7 @@ func Locale(db *Db, next http.Handler) http.Handler {
|
|||
locale, ok = locales[tag]
|
||||
}
|
||||
}
|
||||
}
|
||||
if locale == nil {
|
||||
locale = defaultLocale
|
||||
}
|
||||
|
@ -46,7 +50,11 @@ func getLocale(r *http.Request) *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")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
|
20
pkg/login.go
20
pkg/login.go
|
@ -5,6 +5,8 @@ import (
|
|||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -25,6 +27,7 @@ type AppUser struct {
|
|||
Email string
|
||||
LoggedIn bool
|
||||
Role string
|
||||
Language language.Tag
|
||||
}
|
||||
|
||||
func LoginHandler() http.Handler {
|
||||
|
@ -41,7 +44,7 @@ func LoginHandler() http.Handler {
|
|||
}
|
||||
if r.Method == "POST" {
|
||||
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 != "" {
|
||||
http.SetCookie(w, createSessionCookie(cookie, 8766*24*time.Hour))
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
@ -52,7 +55,7 @@ func LoginHandler() http.Handler {
|
|||
} else {
|
||||
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)
|
||||
if user.LoggedIn {
|
||||
conn := getConn(r)
|
||||
conn.Exec(r.Context(), "select logout()")
|
||||
conn.MustExec(r.Context(), "select logout()")
|
||||
http.SetCookie(w, createSessionCookie("", -24*time.Hour))
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
conn, err := db.Acquire(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
conn := db.MustAcquire(ctx)
|
||||
defer conn.Release()
|
||||
ctx = context.WithValue(ctx, ContextConnKey, conn)
|
||||
|
||||
|
@ -106,11 +106,13 @@ func CheckLogin(db *Db, next http.Handler) http.Handler {
|
|||
LoggedIn: false,
|
||||
Role: defaultRole,
|
||||
}
|
||||
row := conn.QueryRow(ctx, "select current_setting('request.user.email', true), current_user")
|
||||
if err := row.Scan(&user.Email, &user.Role); err != nil {
|
||||
row := conn.QueryRow(ctx, "select coalesce(email, ''), role, lang_tag from user_profile")
|
||||
var langTag string
|
||||
if err := row.Scan(&user.Email, &user.Role, &langTag); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
user.LoggedIn = user.Email != ""
|
||||
user.Language, _ = language.Parse(langTag)
|
||||
ctx = context.WithValue(ctx, ContextUserKey, user)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
|
|
@ -11,6 +11,7 @@ type LanguageOption struct {
|
|||
}
|
||||
|
||||
type ProfilePage struct {
|
||||
Title string
|
||||
Name string
|
||||
Email string
|
||||
Password string
|
||||
|
@ -27,9 +28,11 @@ func ProfileHandler() http.Handler {
|
|||
return
|
||||
}
|
||||
conn := getConn(r)
|
||||
locale := getLocale(r)
|
||||
page := ProfilePage{
|
||||
Title: pgettext("title", "User Settings", locale),
|
||||
Email: user.Email,
|
||||
Languages: getLanguageOptions(r.Context(), conn),
|
||||
Languages: mustGetLanguageOptions(r.Context(), conn),
|
||||
}
|
||||
if r.Method == "POST" {
|
||||
r.ParseForm()
|
||||
|
@ -38,17 +41,19 @@ func ProfileHandler() http.Handler {
|
|||
page.Password = r.FormValue("password")
|
||||
page.PasswordConfirm = r.FormValue("password_confirm")
|
||||
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 {
|
||||
if err := conn.QueryRow(r.Context(), "select name, lang_tag from user_profile").Scan(&page.Name, &page.Language); err != 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")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
package pkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"log"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func Recoverer(next http.Handler) http.Handler {
|
||||
|
@ -12,18 +12,18 @@ func Recoverer(next http.Handler) http.Handler {
|
|||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if r == http.ErrAbortHandler {
|
||||
panic(r);
|
||||
panic(r)
|
||||
}
|
||||
err, ok := r.(error)
|
||||
if ! ok {
|
||||
err = fmt.Errorf("%v", r);
|
||||
if !ok {
|
||||
err = fmt.Errorf("%v", r)
|
||||
}
|
||||
stack := make([]byte, 4 << 10);
|
||||
stack := make([]byte, 4<<10)
|
||||
length := runtime.Stack(stack, true)
|
||||
log.Printf("PANIC - %v %s", err, stack[:length])
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}();
|
||||
next.ServeHTTP(w, r);
|
||||
});
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ func NewRouter(db *Db) http.Handler {
|
|||
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
user := getUser(r)
|
||||
if user.LoggedIn {
|
||||
renderTemplate(w, r, "index.html", nil)
|
||||
mustRenderAppTemplate(w, r, "dashboard.html", nil)
|
||||
} else {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
|
|
@ -6,17 +6,29 @@ import (
|
|||
"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)
|
||||
t := template.New(filename)
|
||||
t.Funcs(template.FuncMap{
|
||||
"gettext": locale.Get,
|
||||
"pgettext": locale.GetC,
|
||||
})
|
||||
if _, err := t.ParseFiles("web/template/" + filename); err != nil {
|
||||
if _, err := t.ParseFiles(templateFile(filename), templateFile(layout)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := t.Execute(wr, data); err != nil {
|
||||
if err := t.ExecuteTemplate(wr, layout, data); err != nil {
|
||||
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);
|
||||
}
|
||||
|
|
48
po/ca.po
48
po/ca.po
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: numerus\n"
|
||||
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
|
||||
"POT-Creation-Date: 2023-01-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"
|
||||
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
|
||||
"Language-Team: Catalan <ca@dodds.net>\n"
|
||||
|
@ -17,75 +17,75 @@ msgstr ""
|
|||
"Content-Type: text/plain; charset=UTF-8\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"
|
||||
msgid "Login"
|
||||
msgstr "Entrada"
|
||||
|
||||
#: web/template/login.html:14
|
||||
#: web/template/login.html:5
|
||||
msgid "Invalid user or password"
|
||||
msgstr "Nom d’usuari 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"
|
||||
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"
|
||||
msgid "Password"
|
||||
msgstr "Contrasenya"
|
||||
|
||||
#: web/template/login.html:27
|
||||
#: web/template/login.html:21
|
||||
msgctxt "action"
|
||||
msgid "Login"
|
||||
msgstr "Entra"
|
||||
|
||||
#: web/template/profile.html:6 web/template/profile.html:21
|
||||
#: web/template/profile.html:2 pkg/profile.go:33
|
||||
msgctxt "title"
|
||||
msgid "User Settings"
|
||||
msgstr "Configuració usuari"
|
||||
|
||||
#: web/template/profile.html:15 web/template/index.html:15
|
||||
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
|
||||
#: web/template/profile.html:5
|
||||
msgctxt "title"
|
||||
msgid "User Access Data"
|
||||
msgstr "Dades accés usuari"
|
||||
|
||||
#: web/template/profile.html:26
|
||||
#: web/template/profile.html:9
|
||||
msgctxt "input"
|
||||
msgid "User name"
|
||||
msgstr "Nom d’usuari"
|
||||
|
||||
#: web/template/profile.html:33
|
||||
#: web/template/profile.html:18
|
||||
msgctxt "title"
|
||||
msgid "Password Change"
|
||||
msgstr "Canvi contrasenya"
|
||||
|
||||
#: web/template/profile.html:38
|
||||
#: web/template/profile.html:27
|
||||
msgctxt "input"
|
||||
msgid "Password Confirmation"
|
||||
msgstr "Confirmació contrasenya"
|
||||
|
||||
#: web/template/profile.html:42
|
||||
#: web/template/profile.html:31
|
||||
msgctxt "input"
|
||||
msgid "Language"
|
||||
msgstr "Idioma"
|
||||
|
||||
#: web/template/profile.html:44
|
||||
#: web/template/profile.html:33
|
||||
msgctxt "language option"
|
||||
msgid "Automatic"
|
||||
msgstr "Automàtic"
|
||||
|
||||
#: web/template/profile.html:49
|
||||
#: web/template/profile.html:38
|
||||
msgctxt "action"
|
||||
msgid "Save changes"
|
||||
msgstr "Desa canvis"
|
||||
|
||||
#: web/template/app.html:16
|
||||
msgid "Account"
|
||||
msgstr "Compte"
|
||||
|
||||
#: web/template/app.html:19
|
||||
msgctxt "action"
|
||||
msgid "Logout"
|
||||
msgstr "Surt"
|
||||
|
|
48
po/es.po
48
po/es.po
|
@ -7,7 +7,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: numerus\n"
|
||||
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
|
||||
"POT-Creation-Date: 2023-01-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"
|
||||
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
|
||||
"Language-Team: Spanish <es@tp.org.es>\n"
|
||||
|
@ -17,75 +17,75 @@ msgstr ""
|
|||
"Content-Transfer-Encoding: 8bit\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"
|
||||
msgid "Login"
|
||||
msgstr "Entrada"
|
||||
|
||||
#: web/template/login.html:14
|
||||
#: web/template/login.html:5
|
||||
msgid "Invalid user or password"
|
||||
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"
|
||||
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"
|
||||
msgid "Password"
|
||||
msgstr "Contraseña"
|
||||
|
||||
#: web/template/login.html:27
|
||||
#: web/template/login.html:21
|
||||
msgctxt "action"
|
||||
msgid "Login"
|
||||
msgstr "Entrar"
|
||||
|
||||
#: web/template/profile.html:6 web/template/profile.html:21
|
||||
#: web/template/profile.html:2 pkg/profile.go:33
|
||||
msgctxt "title"
|
||||
msgid "User Settings"
|
||||
msgstr "Configuración usuario"
|
||||
|
||||
#: web/template/profile.html:15 web/template/index.html:15
|
||||
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
|
||||
#: web/template/profile.html:5
|
||||
msgctxt "title"
|
||||
msgid "User Access Data"
|
||||
msgstr "Datos acceso usuario"
|
||||
|
||||
#: web/template/profile.html:26
|
||||
#: web/template/profile.html:9
|
||||
msgctxt "input"
|
||||
msgid "User name"
|
||||
msgstr "Nombre de usuario"
|
||||
|
||||
#: web/template/profile.html:33
|
||||
#: web/template/profile.html:18
|
||||
msgctxt "title"
|
||||
msgid "Password Change"
|
||||
msgstr "Cambio de contraseña"
|
||||
|
||||
#: web/template/profile.html:38
|
||||
#: web/template/profile.html:27
|
||||
msgctxt "input"
|
||||
msgid "Password Confirmation"
|
||||
msgstr "Confirmación contrasenya"
|
||||
|
||||
#: web/template/profile.html:42
|
||||
#: web/template/profile.html:31
|
||||
msgctxt "input"
|
||||
msgid "Language"
|
||||
msgstr "Idioma"
|
||||
|
||||
#: web/template/profile.html:44
|
||||
#: web/template/profile.html:33
|
||||
msgctxt "language option"
|
||||
msgid "Automatic"
|
||||
msgstr "Automático"
|
||||
|
||||
#: web/template/profile.html:49
|
||||
#: web/template/profile.html:38
|
||||
msgctxt "action"
|
||||
msgid "Save changes"
|
||||
msgstr "Guardar cambios"
|
||||
|
||||
#: web/template/app.html:16
|
||||
msgid "Account"
|
||||
msgstr "Cuenta"
|
||||
|
||||
#: web/template/app.html:19
|
||||
msgctxt "action"
|
||||
msgid "Logout"
|
||||
msgstr "Salir"
|
||||
|
|
|
@ -2,6 +2,6 @@
|
|||
|
||||
begin;
|
||||
|
||||
delete from numerus.language;
|
||||
delete from public.language;
|
||||
|
||||
commit;
|
||||
|
|
|
@ -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 [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
|
||||
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
|
||||
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
|
||||
user_profile [schema_numerus user current_app_user] 2023-01-21T23:18:20Z jordi fita mas <jordi@tandem.blog> # Add view for user profile
|
||||
|
|
|
@ -12,7 +12,7 @@ set search_path to numerus, public;
|
|||
select has_domain('email');
|
||||
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 'test@tandem,,co.uk'::email $$,
|
||||
|
|
|
@ -32,7 +32,7 @@ prepare user_cookies as
|
|||
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 results_eq(
|
||||
|
@ -43,7 +43,7 @@ select results_eq(
|
|||
'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 results_eq(
|
||||
|
@ -54,7 +54,7 @@ select results_eq(
|
|||
'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 results_eq(
|
||||
|
|
|
@ -10,42 +10,42 @@ select plan(47);
|
|||
set search_path to numerus, auth, public;
|
||||
|
||||
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', 'admin', array['SELECT']);
|
||||
select table_privs_are('user_profile', 'authenticator', array[]::text[]);
|
||||
|
||||
select has_column('user_profile', 'user_id');
|
||||
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', 'admin', array['SELECT']);
|
||||
select column_privs_are('user_profile', 'user_id', 'authenticator', array[]::text[]);
|
||||
|
||||
select has_column('user_profile', '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', 'admin', array['SELECT', 'UPDATE']);
|
||||
select column_privs_are('user_profile', 'email', 'authenticator', array[]::text[]);
|
||||
|
||||
select has_column('user_profile', 'name');
|
||||
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', 'admin', array['SELECT', 'UPDATE']);
|
||||
select column_privs_are('user_profile', 'name', 'authenticator', array[]::text[]);
|
||||
|
||||
select has_column('user_profile', 'role');
|
||||
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', 'admin', array['SELECT']);
|
||||
select column_privs_are('user_profile', 'role', 'authenticator', array[]::text[]);
|
||||
|
||||
select has_column('user_profile', 'lang_tag');
|
||||
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', 'admin', array['SELECT', 'UPDATE']);
|
||||
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)
|
||||
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')
|
||||
, (7, 'another@tandem.blog', 'Another Admin', 'test', 'admin', default, default, default)
|
||||
;
|
||||
|
||||
prepare profile as
|
||||
select user_id, email, name, role, lang_tag
|
||||
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' );
|
||||
|
||||
|
@ -144,6 +151,7 @@ select results_eq(
|
|||
$$ select user_id, email, name, lang_tag from auth."user" order by user_id $$,
|
||||
$$ values (1, 'demo+update@tandem.blog'::email, 'Demo Update', 'es')
|
||||
, (5, 'admin+update@tandem.blog'::email, 'Admin Update', 'ca')
|
||||
, (7, 'another@tandem.blog'::email, 'Another Admin', 'und')
|
||||
$$,
|
||||
'Should have updated the base table’s data'
|
||||
);
|
||||
|
|
|
@ -152,7 +152,7 @@
|
|||
--numerus--color--hay: #ffe673;
|
||||
|
||||
--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--header--background-color: #ede9e5;
|
||||
|
@ -164,11 +164,13 @@ html, body {
|
|||
|
||||
html {
|
||||
font-family: var(--numerus--font-family), monospace;
|
||||
font-size: 62.5%;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--numerus--text-color);
|
||||
background-color: var(--numerus--background-color);
|
||||
color: var(--numerus--text-color);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
@ -203,17 +205,19 @@ input[type="submit"]:active, button:active {
|
|||
}
|
||||
|
||||
.web {
|
||||
margin: 5.3125rem 2.5rem;
|
||||
margin: 8.5rem 4rem;
|
||||
background-color: var(--numerus--header--background-color);
|
||||
}
|
||||
|
||||
.web h1 {
|
||||
margin-bottom: 1.875em;
|
||||
padding-bottom: .9375em;
|
||||
border-bottom: 1px solid var(--numerus--color--black);
|
||||
margin-bottom: .625em;
|
||||
}
|
||||
|
||||
#login {
|
||||
background-color: var(--numerus--color--hay);
|
||||
padding: 1.5625em;
|
||||
padding: 1.25em;
|
||||
}
|
||||
|
||||
#login h2 {
|
||||
|
@ -221,7 +225,7 @@ input[type="submit"]:active, button:active {
|
|||
}
|
||||
|
||||
div[role="alert"].error {
|
||||
padding: 1.3125em;
|
||||
padding: 1.25em;
|
||||
background-color: var(--numerus--color--red);
|
||||
}
|
||||
|
||||
|
@ -230,16 +234,60 @@ header {
|
|||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
nav > button {
|
||||
width: 4.375rem;
|
||||
height: 4.375rem;
|
||||
#profilebutton {
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@ -247,36 +295,69 @@ nav > button {
|
|||
border: none;
|
||||
}
|
||||
|
||||
nav button {
|
||||
#profilebutton, #profilemenu button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
#profilemenu {
|
||||
list-style: none;
|
||||
padding: none;
|
||||
position: absolute;
|
||||
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 {
|
||||
height: 5.25rem;
|
||||
width: 31.25rem;
|
||||
padding: 0 1.875rem;
|
||||
header div:hover #profilemenu {
|
||||
opacity: 1;
|
||||
display: initial;
|
||||
}
|
||||
|
||||
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;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background-color: var(--numerus--color--white);
|
||||
color: var(--numerus--text-color);
|
||||
text-decoration: none;
|
||||
text-transform: initial;
|
||||
}
|
||||
|
||||
nav ul i[class^='ri-'] {
|
||||
font-size: 1.25em;
|
||||
margin-right: 1.25rem;
|
||||
#profilemenu i[class^='ri-'] {
|
||||
margin-right: 2rem;
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,2 @@
|
|||
{{ define "content" }}
|
||||
{{- end }}
|
|
@ -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>
|
|
@ -1,31 +1,24 @@
|
|||
<!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">
|
||||
{{ define "content" }}
|
||||
<h1><img src="/static/numerus.svg" alt="Numerus" width="620" height="77"></h1>
|
||||
|
||||
{{ if .LoginError }}
|
||||
{{ if .LoginError -}}
|
||||
<div class="error" role="alert">
|
||||
<p>{{( gettext "Invalid user or password" )}}</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{- end }}
|
||||
<section id="login">
|
||||
<h2>{{( pgettext "Login" "title" )}}</h2>
|
||||
<form method="POST" action="/login">
|
||||
<div class="input">
|
||||
<input id="user_email" type="email" required autofocus name="email" autocapitalize="none" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
|
||||
<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>
|
||||
<input id="user_password" type="password" required name="password" autocomplete="current-password" value="{{ .Password }}">
|
||||
</div>
|
||||
|
||||
<button type="submit">{{( pgettext "Login" "action" )}}</button>
|
||||
</form>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
{{- end }}
|
||||
|
|
|
@ -1,53 +1,40 @@
|
|||
<!doctype html>
|
||||
<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>
|
||||
{{ define "content" }}
|
||||
<h2>{{(pgettext "User Settings" "title")}}</h2>
|
||||
<form method="POST" action="/profile">
|
||||
<fieldset>
|
||||
<legend>{{( pgettext "User Access Data" "title" )}}</legend>
|
||||
|
||||
<div class="input">
|
||||
<input type="text" name="name" id="name" required="required" value="{{ .Name }}" placeholder="{{( pgettext "User name" "input" )}}">
|
||||
<label for="name">{{( pgettext "User name" "input" )}}</label>
|
||||
<input type="text" name="name" id="name" required="required" value="{{ .Name }}">
|
||||
</div>
|
||||
|
||||
<div class="input">
|
||||
<input type="email" name="email" id="email" required="required" value="{{ .Email }}" placeholder="{{( pgettext "Email" "input" )}}">
|
||||
<label for="email">{{( pgettext "Email" "input" )}}</label>
|
||||
<input type="email" name="email" id="email" required="required" value="{{ .Email }}">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{{( pgettext "Password Change" "title" )}}</legend>
|
||||
|
||||
<div class="input">
|
||||
<input type="password" name="password" id="password" value="{{ .Password }}" placeholder="{{( pgettext "Password" "input" )}}">
|
||||
<label for="password">{{( pgettext "Password" "input" )}}</label>
|
||||
<input type="password" name="password" id="password" value="{{ .Password }}">
|
||||
</div>
|
||||
|
||||
<div class="input">
|
||||
<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>
|
||||
<input type="password" name="password_confirm" id="password_confirm" value="{{ .PasswordConfirm }}">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label for="language">{{( pgettext "Language" "input" )}}</label>
|
||||
<select id="language" name="language">
|
||||
<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>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</select>
|
||||
<button type="submit">{{( pgettext "Save changes" "action" )}}</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{- end }}
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue