Add user_profile view to update the profile with form

Since users do not have access to the auth scheme, i had to add a view
that selects only the data that they can see of themselves (i.e., no
password or cookie).

I wanted to use the `request.user.id` setting that i set in
check_cookie, but this would be bad because anyone can change that
parameter and, since the view is created by the owner, could see and
*change* the values of everyone just by knowing their id.  Thus, now i
use the cookie instead, because it is way harder to figure out, and if
you already have it you can just set to your browser and the user is
fucked anyway; the database can not help here.

I **am** going to use the user id in row level security policies, but
not the value coming for the setting but instaed the one in the
`user_profile`, since it already is “derived” from the cookie, that’s
why i added that column to the view.

The profile includes the language, that i do not use it yet to switch
the locale, so i had to add a relation of the available languages, for
constraint purposes.  There is no NULL language, and instead i added the
“Undefined” language, with ‘und’ tag’, to represent “do not know/use
content negotiation”.

The languages in that relation are the same i used to have inside
locale.go, because there is no point on having options for languages i
do not have the translation for, so i now configure the list of
available languages user in content negotiation from that relation.

Finally, i have added all font from RemixIcon because that’s what we
used in the design and i am going to use quite a lot of them.

There is duplication in the views; i will address that in a different
commit.
This commit is contained in:
jordi fita mas 2023-01-22 02:23:09 +01:00
parent 052c9c8caa
commit ea9e830a75
40 changed files with 26075 additions and 44 deletions

186
debian/copyright vendored
View File

@ -10,13 +10,19 @@ Copyright:
License: AGPL-3.0-only License: AGPL-3.0-only
Files: Files:
web/static/fonts/* web/static/fonts/JetBrainsMono*
Copyright: Copyright:
2020 Philipp Nurullin 2020 Philipp Nurullin
2020 Konstantin Bulenkov 2020 Konstantin Bulenkov
2020 The JetBrains Mono Project Authors 2020 The JetBrains Mono Project Authors
License: OFL-1.1 License: OFL-1.1
Files:
web/static/fonts/remixicon*
Copyright:
2020 RemixIcon.com
License: Apache-2.0
License: OFL-1.1 License: OFL-1.1
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
. .
@ -111,3 +117,181 @@ License: OFL-1.1
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE. OTHER DEALINGS IN THE FONT SOFTWARE.
License: Apache-2.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
.
1. Definitions.
.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
.
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
.
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
.
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
.
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
.
END OF TERMS AND CONDITIONS

View File

@ -0,0 +1,13 @@
-- Deploy numerus:available_languages to pg
-- requires: schema_numerus
-- requires: language
begin;
insert into public.language (lang_tag, name, endonym, selectable)
values ('und', 'Undefined', 'Undefined', false)
, ('ca', 'Catalan', 'català', true)
, ('es', 'Spanish', 'español', true)
;
commit;

View File

@ -12,9 +12,10 @@ declare
uid text; uid text;
user_email text; user_email text;
user_role name; user_role name;
user_cookie text;
begin begin
select user_id::text, email::text, role select user_id::text, email::text, role, cookie
into uid, user_email, user_role into uid, user_email, user_role, user_cookie
from "user" from "user"
where email = split_part(input_cookie, '/', 2) where email = split_part(input_cookie, '/', 2)
and cookie_expires_at > current_timestamp and cookie_expires_at > current_timestamp
@ -24,10 +25,12 @@ begin
if user_role is null then if user_role is null then
uid := '0'; uid := '0';
user_email := ''; user_email := '';
user_cookie := '';
user_role := 'guest'::name; user_role := 'guest'::name;
end if; end if;
perform set_config('request.user.id', uid, false); perform set_config('request.user.id', uid, false);
perform set_config('request.user.email', user_email, false); perform set_config('request.user.email', user_email, false);
perform set_config('request.user.cookie', user_cookie, false);
return user_role; return user_role;
end; end;
$$ $$

View File

@ -0,0 +1,23 @@
-- Deploy numerus:current_app_user to pg
-- requires: schema_numerus
begin;
set search_path to numerus;
create or replace function current_app_user() returns text as
$$
select current_setting('request.user.cookie', true);
$$
language sql
stable;
comment on function current_app_user() is
'Returns the ID of the current Numerus user';
revoke execute on function current_app_user() from public;
grant execute on function current_app_user() to guest;
grant execute on function current_app_user() to invoicer;
grant execute on function current_app_user() to admin;
commit;

29
deploy/language.sql Normal file
View File

@ -0,0 +1,29 @@
-- Deploy numerus:language to pg
-- requires: schema_numerus
begin;
set search_path to public;
create table language (
lang_tag text primary key check (length(lang_tag) < 36), -- RFC5646 recommends 35 at least
name text not null,
endonym text not null,
selectable boolean not null
);
grant select on table language to guest;
grant select on table language to invoicer;
grant select on table language to admin;
grant select on table language to authenticator;
comment on table language is
'Languages/locales available in Numerus';
comment on column language.lang_tag is
'BCP-47 language tag';
comment on column language.selectable is
'Whether the language should be a option in a user-facing select control.';
commit;

View File

@ -2,10 +2,11 @@
-- requires: roles -- requires: roles
-- requires: schema_auth -- requires: schema_auth
-- requires: email -- requires: email
-- requires: language
begin; begin;
set search_path to auth, numerus; set search_path to auth, numerus, public;
create table "user" ( create table "user" (
user_id serial primary key, user_id serial primary key,
@ -13,6 +14,7 @@ create table "user" (
name text not null, name text not null,
password text not null check (length(password) < 512), password text not null check (length(password) < 512),
role name not null check (length(role) < 512), role name not null check (length(role) < 512),
lang_tag text not null default 'und' references language,
cookie text not null default '', cookie text not null default '',
cookie_expires_at timestamptz not null default '-infinity'::timestamp, cookie_expires_at timestamptz not null default '-infinity'::timestamp,
created_at timestamptz not null default current_timestamp created_at timestamptz not null default current_timestamp

25
deploy/user_profile.sql Normal file
View File

@ -0,0 +1,25 @@
-- Deploy numerus:user_profile to pg
-- requires: schema_numerus
-- requires: user
-- requires: current_app_user
begin;
set search_path to numerus, public;
create or replace view user_profile
with (security_barrier)
as
select user_id
, email
, name
, role
, lang_tag
from auth."user"
where cookie = current_app_user()
;
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;
commit;

View File

@ -10,15 +10,12 @@ import (
const contextLocaleKey = "numerus-locale" const contextLocaleKey = "numerus-locale"
func Locale(next http.Handler) http.Handler { func Locale(db *Db, next http.Handler) http.Handler {
supportedLanguages := []language.Tag{ availableLanguages := getAvailableLanguages(db)
language.Catalan, var matcher = language.NewMatcher(availableLanguages)
language.Spanish,
}
var matcher = language.NewMatcher(supportedLanguages)
locales := map[language.Tag]*gotext.Locale{} locales := map[language.Tag]*gotext.Locale{}
for _, lang := range supportedLanguages { for _, lang := range availableLanguages {
locale := gotext.NewLocale("locales", lang.String()) locale := gotext.NewLocale("locales", lang.String())
locale.AddDomain("numerus") locale.AddDomain("numerus")
locales[lang] = locale locales[lang] = locale
@ -48,3 +45,26 @@ func Locale(next http.Handler) http.Handler {
func getLocale(r *http.Request) *gotext.Locale { 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 {
rows, err := db.Query(context.Background(), "select lang_tag from language where selectable")
if err != nil {
panic(err)
}
defer rows.Close()
var langs []language.Tag
for rows.Next() {
var lang_tag string
err = rows.Scan(&lang_tag)
if err != nil {
panic(err)
}
langs = append(langs, language.MustParse(lang_tag))
}
if rows.Err() != nil {
panic(rows.Err())
}
return langs
}

View File

@ -8,11 +8,11 @@ import (
) )
const ( const (
ContextUserKey = "numerus-user" ContextUserKey = "numerus-user"
ContextCookieKey = "numerus-cookie" ContextCookieKey = "numerus-cookie"
ContextConnKey = "numerus-database" ContextConnKey = "numerus-database"
sessionCookie = "numerus-session" sessionCookie = "numerus-session"
defaultRole = "guest" defaultRole = "guest"
) )
type LoginPage struct { type LoginPage struct {
@ -89,16 +89,16 @@ func createSessionCookie(value string, duration time.Duration) *http.Cookie {
func CheckLogin(db *Db, next http.Handler) http.Handler { func CheckLogin(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 ctx = r.Context(); var ctx = r.Context()
if cookie, err := r.Cookie(sessionCookie); err == nil { if cookie, err := r.Cookie(sessionCookie); err == nil {
ctx = context.WithValue(ctx, ContextCookieKey, cookie.Value); ctx = context.WithValue(ctx, ContextCookieKey, cookie.Value)
} }
conn, err := db.Acquire(ctx) conn, err := db.Acquire(ctx)
if err != nil { if err != nil {
panic(err); panic(err)
} }
defer conn.Release(); defer conn.Release()
ctx = context.WithValue(ctx, ContextConnKey, conn) ctx = context.WithValue(ctx, ContextConnKey, conn)
user := &AppUser{ user := &AppUser{
@ -108,7 +108,7 @@ func CheckLogin(db *Db, next http.Handler) http.Handler {
} }
row := conn.QueryRow(ctx, "select current_setting('request.user.email', true), current_user") row := conn.QueryRow(ctx, "select current_setting('request.user.email', true), current_user")
if err := row.Scan(&user.Email, &user.Role); err != nil { if err := row.Scan(&user.Email, &user.Role); err != nil {
panic(err) panic(err)
} }
user.LoggedIn = user.Email != "" user.LoggedIn = user.Email != ""
ctx = context.WithValue(ctx, ContextUserKey, user) ctx = context.WithValue(ctx, ContextUserKey, user)

72
pkg/profile.go Normal file
View File

@ -0,0 +1,72 @@
package pkg
import (
"context"
"net/http"
)
type LanguageOption struct {
Tag string
Name string
}
type ProfilePage struct {
Name string
Email string
Password string
PasswordConfirm string
Language string
Languages []LanguageOption
}
func ProfileHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUser(r)
if !user.LoggedIn {
http.Redirect(w, r, "/login", http.StatusUnauthorized)
return
}
conn := getConn(r)
page := ProfilePage{
Email: user.Email,
Languages: getLanguageOptions(r.Context(), conn),
}
if r.Method == "POST" {
r.ParseForm()
page.Email = r.FormValue("email")
page.Name = r.FormValue("name")
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);
} 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)
})
}
func getLanguageOptions(ctx context.Context, conn *Conn) []LanguageOption {
rows, err := conn.Query(ctx, "select lang_tag, endonym from language where selectable")
if err != nil {
panic(err)
}
defer rows.Close()
var langs []LanguageOption
for rows.Next() {
var lang LanguageOption
err = rows.Scan(&lang.Tag, &lang.Name)
if err != nil {
panic(err)
}
langs = append(langs, lang)
}
if rows.Err() != nil {
panic(rows.Err())
}
return langs
}

View File

@ -9,6 +9,7 @@ func NewRouter(db *Db) http.Handler {
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
router.Handle("/login", LoginHandler()) router.Handle("/login", LoginHandler())
router.Handle("/logout", LogoutHandler()) router.Handle("/logout", LogoutHandler())
router.Handle("/profile", ProfileHandler())
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 {
@ -18,7 +19,7 @@ func NewRouter(db *Db) http.Handler {
} }
}) })
var handler http.Handler = router var handler http.Handler = router
handler = Locale(handler) handler = Locale(db, handler)
handler = CheckLogin(db, handler) handler = CheckLogin(db, handler)
handler = Recoverer(handler) handler = Recoverer(handler)
handler = Logger(handler) handler = Logger(handler)

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-18 17:54+0100\n" "POT-Creation-Date: 2023-01-22 02:20+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"
@ -26,12 +26,12 @@ msgstr "Entrada"
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/login.html:21 web/template/profile.html:29
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correu electrònic" msgstr "Correu electrònic"
#: web/template/login.html:24 #: web/template/login.html:24 web/template/profile.html:35
msgctxt "input" msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contrasenya" msgstr "Contrasenya"
@ -41,7 +41,51 @@ msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entra" msgstr "Entra"
#: web/template/index.html:16 #: web/template/profile.html:6 web/template/profile.html:21
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" msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Surt" msgstr "Surt"
#: web/template/profile.html:24
msgctxt "title"
msgid "User Access Data"
msgstr "Dades accés usuari"
#: web/template/profile.html:26
msgctxt "input"
msgid "User name"
msgstr "Nom dusuari"
#: web/template/profile.html:33
msgctxt "title"
msgid "Password Change"
msgstr "Canvi contrasenya"
#: web/template/profile.html:38
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmació contrasenya"
#: web/template/profile.html:42
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: web/template/profile.html:44
msgctxt "language option"
msgid "Automatic"
msgstr "Automàtic"
#: web/template/profile.html:49
msgctxt "action"
msgid "Save changes"
msgstr "Desa canvis"

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-18 17:54+0100\n" "POT-Creation-Date: 2023-01-22 02:20+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"
@ -26,12 +26,12 @@ msgstr "Entrada"
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/login.html:21 web/template/profile.html:29
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correo electrónico" msgstr "Correo electrónico"
#: web/template/login.html:24 #: web/template/login.html:24 web/template/profile.html:35
msgctxt "input" msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contraseña" msgstr "Contraseña"
@ -41,7 +41,51 @@ msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entrar" msgstr "Entrar"
#: web/template/index.html:16 #: web/template/profile.html:6 web/template/profile.html:21
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" msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Salir" msgstr "Salir"
#: web/template/profile.html:24
msgctxt "title"
msgid "User Access Data"
msgstr "Datos acceso usuario"
#: web/template/profile.html:26
msgctxt "input"
msgid "User name"
msgstr "Nombre de usuario"
#: web/template/profile.html:33
msgctxt "title"
msgid "Password Change"
msgstr "Cambio de contraseña"
#: web/template/profile.html:38
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmación contrasenya"
#: web/template/profile.html:42
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: web/template/profile.html:44
msgctxt "language option"
msgid "Automatic"
msgstr "Automático"
#: web/template/profile.html:49
msgctxt "action"
msgid "Save changes"
msgstr "Guardar cambios"

View File

@ -0,0 +1,7 @@
-- Revert numerus:available_languages from pg
begin;
delete from numerus.language;
commit;

View File

@ -0,0 +1,7 @@
-- Revert numerus:current_app_user from pg
begin;
drop function if exists numerus.current_app_user();
commit;

7
revert/language.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:language from pg
begin;
drop table if exists public.language;
commit;

7
revert/user_profile.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:user_profile from pg
begin;
drop view if exists numerus.user_profile;
commit;

View File

@ -8,7 +8,8 @@ schema_public [roles] 2023-01-12T19:24:29Z jordi fita mas <jordi@tandem.blog> #
schema_numerus [roles] 2023-01-12T22:57:22Z jordi fita mas <jordi@tandem.blog> # Add application schema schema_numerus [roles] 2023-01-12T22:57:22Z jordi fita mas <jordi@tandem.blog> # Add application schema
extension_citext [schema_public] 2023-01-12T23:03:33Z jordi fita mas <jordi@tandem.blog> # Add citext extension extension_citext [schema_public] 2023-01-12T23:03:33Z jordi fita mas <jordi@tandem.blog> # Add citext extension
email [schema_numerus extension_citext] 2023-01-12T23:09:59Z jordi fita mas <jordi@tandem.blog> # Add email domain email [schema_numerus extension_citext] 2023-01-12T23:09:59Z jordi fita mas <jordi@tandem.blog> # Add email domain
user [roles schema_auth email] 2023-01-12T23:44:03Z jordi fita mas <jordi@tandem.blog> # Create user table language [schema_numerus] 2023-01-21T20:55:49Z jordi fita mas <jordi@tandem.blog> # Add relation of available languages
user [roles schema_auth email language] 2023-01-12T23:44:03Z jordi fita mas <jordi@tandem.blog> # Create user table
ensure_role_exists [schema_auth user] 2023-01-12T23:57:59Z jordi fita mas <jordi@tandem.blog> # Add trigger to ensure the users role exists ensure_role_exists [schema_auth user] 2023-01-12T23:57:59Z jordi fita mas <jordi@tandem.blog> # Add trigger to ensure the users role exists
extension_pgcrypto [schema_auth] 2023-01-13T00:11:50Z jordi fita mas <jordi@tandem.blog> # Add pgcrypto extension extension_pgcrypto [schema_auth] 2023-01-13T00:11:50Z jordi fita mas <jordi@tandem.blog> # Add pgcrypto extension
encrypt_password [schema_auth user extension_pgcrypto] 2023-01-13T00:14:30Z jordi fita mas <jordi@tandem.blog> # Add trigger to encrypt users password encrypt_password [schema_auth user extension_pgcrypto] 2023-01-13T00:14:30Z jordi fita mas <jordi@tandem.blog> # Add trigger to encrypt users password
@ -17,3 +18,6 @@ login [roles schema_numerus schema_auth extension_pgcrypto email user login_atte
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 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 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
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

View File

@ -29,7 +29,10 @@ values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4b
; ;
prepare user_info as prepare user_info as
select current_setting('request.user.id', true)::integer, current_setting('request.user.email', true); select current_setting('request.user.id', true)::integer
, current_setting('request.user.email', true)
, current_setting('request.user.cookie', true)
;
select is ( select is (
check_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog'), check_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog'),
@ -39,7 +42,7 @@ select is (
select results_eq ( select results_eq (
'user_info', 'user_info',
$$ values (1, 'demo@tandem.blog') $$, $$ values (1, 'demo@tandem.blog', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e') $$,
'Should have updated the settings with the user info' 'Should have updated the settings with the user info'
); );
@ -51,7 +54,7 @@ select is (
select results_eq ( select results_eq (
'user_info', 'user_info',
$$ values (9, 'admin@tandem.blog') $$, $$ values (9, 'admin@tandem.blog', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524') $$,
'Should have updated the settings with the other user info' 'Should have updated the settings with the other user info'
); );
@ -63,7 +66,7 @@ select is (
select results_eq ( select results_eq (
'user_info', 'user_info',
$$ values (0, '') $$, $$ values (0, '', '') $$,
'Should have updated the settings with a guest user' 'Should have updated the settings with a guest user'
); );
@ -75,7 +78,7 @@ select is (
select results_eq ( select results_eq (
'user_info', 'user_info',
$$ values (0, '') $$, $$ values (0, '', '') $$,
'Should have left the settings with a guest user' 'Should have left the settings with a guest user'
); );
@ -89,7 +92,7 @@ select is (
select results_eq ( select results_eq (
'user_info', 'user_info',
$$ values (0, '') $$, $$ values (0, '', '') $$,
'Should have left the settings with a guest user' 'Should have left the settings with a guest user'
); );
@ -101,7 +104,7 @@ select is (
select results_eq ( select results_eq (
'user_info', 'user_info',
$$ values (0, '') $$, $$ values (0, '', '') $$,
'Should have left the settings with a guest user' 'Should have left the settings with a guest user'
); );

62
test/current_app_user.sql Normal file
View File

@ -0,0 +1,62 @@
-- Test current_app_user
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
set search_path to numerus, auth, public;
select plan(15);
select has_function('numerus', 'current_app_user', array []::name[]);
select function_lang_is('numerus', 'current_app_user', array []::name[], 'sql');
select function_returns('numerus', 'current_app_user', array []::name[], 'text');
select isnt_definer('numerus', 'current_app_user', array []::name[]);
select volatility_is('numerus', 'current_app_user', array []::name[], 'stable');
select function_privs_are('numerus', 'current_app_user', array []::name[], 'guest', array ['EXECUTE']);
select function_privs_are('numerus', 'current_app_user', array []::name[], 'invoicer', array ['EXECUTE']);
select function_privs_are('numerus', 'current_app_user', array []::name[], 'admin', array ['EXECUTE']);
select function_privs_are('numerus', 'current_app_user', array []::name[], 'authenticator', array []::text []);
set client_min_messages to warning;
truncate auth."user" cascade;
reset client_min_messages;
insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at)
values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month')
, (5, 'admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month')
;
select lives_ok(
$$ select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog') $$,
'Should change ok for the first user'
);
select is(current_app_user(), '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', 'Should be running as the first user');
reset role;
select lives_ok(
$$ select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog') $$,
'Should change ok for the second user'
);
select is(current_app_user(), '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', 'Should be running as the second user');
reset role;
select lives_ok(
$$ select set_cookie('') $$,
'Should change ok for a guest user'
);
select is(current_app_user(), '', 'Should be running as the first user');
reset role;
select *
from finish();
rollback;

43
test/language.sql Normal file
View File

@ -0,0 +1,43 @@
-- Test language
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
set search_path to public;
select plan(23);
select has_table('language');
select has_pk('language');
select table_privs_are('language', 'guest', array ['SELECT']);
select table_privs_are('language', 'invoicer', array ['SELECT']);
select table_privs_are('language', 'admin', array ['SELECT']);
select table_privs_are('language', 'authenticator', array ['SELECT']::text[]);
select has_column('language', 'lang_tag');
select col_is_pk('language', 'lang_tag');
select col_type_is('language', 'lang_tag', 'text');
select col_not_null('language', 'lang_tag');
select col_hasnt_default('language', 'lang_tag');
select has_column('language', 'name');
select col_type_is('language', 'name', 'text');
select col_not_null('language', 'name');
select col_hasnt_default('language', 'name');
select has_column('language', 'endonym');
select col_type_is('language', 'endonym', 'text');
select col_not_null('language', 'endonym');
select col_hasnt_default('language', 'endonym');
select has_column('language', 'selectable');
select col_type_is('language', 'selectable', 'boolean');
select col_not_null('language', 'selectable');
select col_hasnt_default('language', 'selectable');
select *
from finish();
rollback;

View File

@ -5,7 +5,7 @@ reset client_min_messages;
begin; begin;
select plan(44); select plan(51);
set search_path to auth, public; set search_path to auth, public;
@ -44,6 +44,14 @@ select col_type_is('user', 'role', 'name');
select col_not_null('user', 'role'); select col_not_null('user', 'role');
select col_hasnt_default('user', 'role'); select col_hasnt_default('user', 'role');
select has_column('user', 'lang_tag');
select col_is_fk('user', 'lang_tag');
select fk_ok('user', 'lang_tag', 'language', 'lang_tag');
select col_type_is('user', 'lang_tag', 'text');
select col_not_null('user', 'lang_tag');
select col_has_default('user', 'lang_tag');
select col_default_is('user', 'lang_tag', 'und');
select has_column('user', 'cookie'); select has_column('user', 'cookie');
select col_type_is('user', 'cookie', 'text'); select col_type_is('user', 'cookie', 'text');
select col_not_null('user', 'cookie'); select col_not_null('user', 'cookie');

154
test/user_profile.sql Normal file
View File

@ -0,0 +1,154 @@
-- Test user_profile
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
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', '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', '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', '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', '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', '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', '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[]);
set client_min_messages to warning;
truncate auth."user" cascade;
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')
;
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_cookie( '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog' );
select results_eq(
'profile',
$$ values (1, 'demo@tandem.blog'::email, 'Demo', 'invoicer'::name, 'ca') $$,
'Should only see the profile of the first user'
);
select lives_ok( $$
update user_profile
set email = 'demo+update@tandem.blog'
, name = 'Demo Update'
, lang_tag = 'es';
$$,
'Should be able to update the first profile'
);
select throws_ok(
$$ update user_profile set user_id = 123 $$,
'42501', 'permission denied for view user_profile',
'Should not be able to change the ID'
);
select throws_ok(
$$ update user_profile set role = 'admin' $$,
'42501', 'permission denied for view user_profile',
'Should not be able to change the ID'
);
select results_eq(
'profile',
$$ values (1, 'demo+update@tandem.blog'::email, 'Demo Update', 'invoicer'::name, 'es') $$,
'Should see the changed profile of the first user'
);
reset role;
select set_cookie( '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog' );
select results_eq(
'profile',
$$ values (5, 'admin@tandem.blog'::email, 'Admin', 'admin'::name, 'es') $$,
'Should only see the profile of the second user'
);
select lives_ok( $$
update user_profile
set email = 'admin+update@tandem.blog'
, name = 'Admin Update'
, lang_tag = 'ca';
$$,
'Should be able to update the second profile'
);
select throws_ok(
$$ update user_profile set user_id = 123 $$,
'42501', 'permission denied for view user_profile',
'Should not be able to change the ID'
);
select throws_ok(
$$ update user_profile set role = 'invoicer' $$,
'42501', 'permission denied for view user_profile',
'Should not be able to change the ID'
);
select results_eq(
'profile',
$$ values (5, 'admin+update@tandem.blog'::email, 'Admin Update', 'admin'::name, 'ca') $$,
'Should see the changed profile of the first user'
);
reset role;
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')
$$,
'Should have updated the base tables data'
);
select *
from finish();
rollback;

View File

@ -0,0 +1,28 @@
-- Verify numerus:available_languages on pg
begin;
set search_path to public;
select 1 / count(*)
from language
where lang_tag = 'und'
and name = 'Undefined'
and endonym = 'Undefined'
and not selectable;
select 1 / count(*)
from language
where lang_tag = 'ca'
and name = 'Catalan'
and endonym = 'català'
and selectable;
select 1 / count(*)
from language
where lang_tag = 'es'
and name = 'Spanish'
and endonym = 'español'
and selectable;
rollback;

View File

@ -0,0 +1,7 @@
-- Verify numerus:current_app_user on pg
begin;
select has_function_privilege('numerus.current_app_user()', 'execute');
rollback;

12
verify/language.sql Normal file
View File

@ -0,0 +1,12 @@
-- Verify numerus:language on pg
begin;
select lang_tag
, name
, endonym
, selectable
from public.language
where false;
rollback;

View File

@ -8,6 +8,7 @@ select
, name , name
, password , password
, role , role
, lang_tag
, cookie , cookie
, cookie_expires_at , cookie_expires_at
, created_at , created_at

14
verify/user_profile.sql Normal file
View File

@ -0,0 +1,14 @@
-- Verify numerus:user_profile on pg
begin;
select
user_id
, email
, name
, role
, lang_tag
from numerus.user_profile
where false;
rollback;

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -10,10 +10,10 @@
<header> <header>
<h1><img src="/static/numerus.svg" alt="Numerus" width="261" height="33"></h1> <h1><img src="/static/numerus.svg" alt="Numerus" width="261" height="33"></h1>
<nav role="navigation"> <nav role="navigation">
<button aria-haspopup="true"> <button aria-haspopup="true"><i class="ri-eye-close-line ri-3x"></i></button>
<svg role="image" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="50" height="50"><path fill="none" d="M0 0h24v24H0z"/><path d="M9.342 18.782l-1.931-.518.787-2.939a10.988 10.988 0 0 1-3.237-1.872l-2.153 2.154-1.415-1.415 2.154-2.153a10.957 10.957 0 0 1-2.371-5.07l1.968-.359C3.903 10.812 7.579 14 12 14c4.42 0 8.097-3.188 8.856-7.39l1.968.358a10.957 10.957 0 0 1-2.37 5.071l2.153 2.153-1.415 1.415-2.153-2.154a10.988 10.988 0 0 1-3.237 1.872l.787 2.94-1.931.517-.788-2.94a11.072 11.072 0 0 1-3.74 0l-.788 2.94z"/></svg></button>
<ul> <ul>
<li><form method="POST" action="/logout"><button type="submit">{{( pgettext "Logout" "action" )}}</button></form></li> <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> </ul>
</nav> </nav>
</header> </header>

53
web/template/profile.html Normal file
View File

@ -0,0 +1,53 @@
<!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>
<h2>{{(pgettext "User Settings" "title")}}</h2>
<form method="POST" action="/profile">
<fieldset>
<legend>{{( pgettext "User Access Data" "title" )}}</legend>
<label for="name">{{( pgettext "User name" "input" )}}</label>
<input type="text" name="name" id="name" required="required" value="{{ .Name }}">
<label for="email">{{( pgettext "Email" "input" )}}</label>
<input type="email" name="email" id="email" required="required" value="{{ .Email }}">
</fieldset>
<fieldset>
<legend>{{( pgettext "Password Change" "title" )}}</legend>
<label for="password">{{( pgettext "Password" "input" )}}</label>
<input type="password" name="password" id="password" value="{{ .Password }}">
<label for="password_confirm">{{( pgettext "Password Confirmation" "input" )}}</label>
<input type="password" name="password_confirm" id="password_confirm" value="{{ .PasswordConfirm }}">
</fieldset>
<label for="language">{{( pgettext "Language" "input" )}}</label>
<select id="language" name="language">
<option value="und">{{( pgettext "Automatic" "language option" )}}</option>
{{ range $language := .Languages }}
<option value="{{ .Tag }}" {{ if eq .Tag $.Language }}selected="selected"{{ end }}>{{ .Name }}</option>
{{ end }}
</select>
<button type="submit">{{( pgettext "Save changes" "action" )}}</button>
</form>
</main>
</body>
</html>