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
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)

View File

@ -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

View File

@ -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;

View File

@ -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)
}

View File

@ -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)

View File

@ -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))

View File

@ -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)

View File

@ -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);
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)
})
}

View File

@ -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)
}

View File

@ -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);
}

View File

@ -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 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"
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 dusuari"
#: 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"

View File

@ -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"

View File

@ -2,6 +2,6 @@
begin;
delete from numerus.language;
delete from public.language;
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 [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

View File

@ -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 $$,

View File

@ -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(

View File

@ -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 tables data'
);

View File

@ -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);
}

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>
<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 }}

View File

@ -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 }}

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>