Form & Authentication
Let’s make our dashboard protected.
1. Database
Basic session storage with sqlite:
./driver/sessions.db
package driver
import (
"database/sql"
"github.com/doors-dev/doors"
"time"
)
func newSessionsDb(db *sql.DB) *SessionsDb {
initQuery := `
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
login TEXT NOT NULL,
expire DATETIME NOT NULL
);
`
if _, err := db.Exec(initQuery); err != nil {
panic("Failed to create sessions table: " + err.Error())
}
s := &SessionsDb{
db: db,
}
go s.cleanup()
return s
}
type Session struct {
Token string `json:"token"`
Login string `json:"login"`
Expire time.Time `json:"expire"`
}
type SessionsDb struct {
db *sql.DB
}
func (d *SessionsDb) cleanup() {
for {
<-time.After(10 * time.Minute)
_, err := d.db.Exec("DELETE FROM sessions WHERE expire <= ?", time.Now())
if err != nil {
panic("Failed to cleanup expired sessions: " + err.Error())
}
}
}
func (d *SessionsDb) Add(login string, dur time.Duration) Session {
token := doors.RandId()
expire := time.Now().Add(dur)
_, err := d.db.Exec(
"INSERT INTO sessions (token, login, expire) VALUES (?, ?, ?)",
token, login, expire,
)
if err != nil {
panic("Failed to create session: " + err.Error())
}
return Session{
Token: token,
Login: login,
Expire: expire,
}
}
func (d *SessionsDb) Get(token string) (Session, bool) {
var session Session
err := d.db.QueryRow(
"SELECT token, login, expire FROM sessions WHERE token = ? AND expire > ?",
token, time.Now(),
).Scan(&session.Token, &session.Login, &session.Expire)
if err != nil {
if err == sql.ErrNoRows {
// Return empty session and false if not found or expired
return Session{}, false
}
panic("Failed to get session: " + err.Error())
}
return session, true
}
func (d *SessionsDb) Remove(token string) bool {
result, err := d.db.Exec("DELETE FROM sessions WHERE token = ?", token)
if err != nil {
panic("Failed to remove session: " + err.Error())
}
rowsAffected, err := result.RowsAffected()
if err != nil {
panic("Failed to get rows affected: " + err.Error())
}
return rowsAffected > 0
}
Initialization:
./driver/driver.go
/* ... */
var Sessions *SessionsDb
func init() {
/* ... */
sessions, err := sql.Open("sqlite3", "./sqlite/sessions.sqlite3")
if err != nil {
log.Fatal("Failed to open database:", err)
}
Sessions = newSessionsDb(sessions)
}
1. Login Fragment
./login.templ
package main
import (
"context"
"github.com/derstruct/doors-dashboard/driver"
"github.com/doors-dev/doors"
"net/http"
"time"
)
func login() templ.Component {
return doors.F(&loginFragment{
// create dynamic attribute "class" with value "hide"
// to use on the login error message
messageClass: doors.NewADyn("class", "hide", true),
})
}
// login credentials ("tutorial style")
const userLogin = "admin"
const userPassword = "password123"
const sessionDuration = time.Hour * 24
type loginFragment struct {
messageClass doors.ADyn
}
// type to decode the form data
type loginData struct {
Login string `form:"login"`
Password string `form:"password"`
}
func (f *loginFragment) submit(ctx context.Context, r doors.RForm[loginData]) bool {
// check user credentials
if r.Data().Login != userLogin || r.Data().Password != userPassword {
// unset dynamic class attribute, show error message
f.messageClass.Enable(ctx, false)
return false
}
// add session
session := driver.Sessions.Add(r.Data().Login, sessionDuration)
// set cookie
r.SetCookie(&http.Cookie{
Name: "session",
Value: session.Token,
Expires: time.Now().Add(sessionDuration),
Path: "/",
HttpOnly: true,
})
// perform actions after the request on the client side
r.After([]doors.Action{
// reload the page
doors.ActionLocationReload{},
// pro tip: keep showing indication while the page is reloading
doors.ActionIndicate{
Duration: 10 * time.Second,
Indicator: doors.IndicatorOnlyAttrQuery("#login-submit", "aria-busy", "true"),
},
})
doors.SessionExpire(ctx, sessionDuration)
return true
}
templ (f *loginFragment) Render() {
<h1>Log In</h1>
// form submit handler
@doors.ASubmit[loginData]{
// to prevent repeated submission
Scope: doors.ScopeOnlyBlocking(),
// indicate on the button
Indicator: doors.IndicatorOnlyAttrQuery("#login-submit", "aria-busy", "true"),
// handle
On: f.submit,
}
<form>
<fieldset>
<label>
Login
<input
name="login"
required="true"
/>
</label>
<label>
Password
<input
type="password"
name="password"
required="true"
/>
</label>
@f.errorMessage()
</fieldset>
<button id="login-submit" role="submit">Log In</button>
</form>
}
// error message component
templ (l *loginFragment) errorMessage() {
@doors.Style() {
<style>
.hide {
display: none
}
</style>
}
// attach dynamic attribute
@l.messageClass
<p><mark>wrong password or login</mark></p>
}
doors.SessionExpireis a safe precaution to ensure that opened pages with access to authorized functionality will not outlive the authorization session.
2. Read the Session in the Model Handler
./app.templ
// read cookies and obtain the session from the database
func getSession(r doors.R) *driver.Session {
c, err := r.GetCookie("session")
if err != nil {
return nil
}
s, found := driver.Sessions.Get(c.Value)
if !found {
return nil
}
return &s
}
func Handler(m doors.doors.ModelRouter[Path], r doors.RModel[Path]) doors.ModelRoute {
return m.App(&app{
session: getSession(r),
})
}
3. Show the Login Form
templ (a *app) Body() {
if a.session == nil {
@login()
} else {
@doors.Sub(a.id, func(id int) templ.Component {
if id == -1 {
return locationSelector(func(ctx context.Context, city driver.Place) {
a.path.Mutate(ctx, func(p Path) Path {
p.Selector = false
p.Dashboard = true
p.Id = city.Id
return p
})
})
}
// render dashboard component
return dashboard(id, a.path)
})
}
}
4. Adapt the Title
templ (a *app) Head() {
if a.session == nil {
<title>Login</title>
} else {
@doors.Head(a.id, func(id int) doors.HeadData {
if id == -1 {
return doors.HeadData{
Title: "Select Location",
}
}
city, _ := driver.Cities.Get(id)
if city.Name == "" {
return doors.HeadData{
Title: "Location Not Found",
}
}
return doors.HeadData{
Title: "Weather in " + city.Name + ", " + city.Country.Name,
}
})
}
}
5. Log Out Button
templ (a *app) logout() {
<section>
@doors.AClick{
On: func(ctx context.Context, r doors.REvent[doors.PointerEvent]) bool {
// remove the coolie
r.SetCookie(&http.Cookie{
Name: "session",
Path: "/",
MaxAge: -1,
})
// remove the db entry
driver.Sessions.Remove(a.session.Token)
// end internal framework session
// to end all authiorized instances (pages)
doors.SessionEnd(ctx)
return true
},
}
<button class="secondary">Log Out</button>
</section>
}
Result:

Code
./app.templ
package main
import (
"context"
"github.com/derstruct/doors-dashboard/driver"
"github.com/doors-dev/doors"
"net/http"
)
type Path struct {
Selector bool `path:"/"`
Dashboard bool `path:"/:Id"`
Id int
Units *driver.Units `query:"units"`
Days *int `query:"days"`
}
// read cookies and obtain the session from the database
func getSession(r doors.R) *driver.Session {
c, err := r.GetCookie("session")
if err != nil {
return nil
}
s, found := driver.Sessions.Get(c.Value)
if !found {
return nil
}
return &s
}
func Handler(m doors.doors.ModelRouter[Path], r doors.RModel[Path]) doors.ModelRoute {
return m.App(&app{
session: getSession(r),
})
}
type app struct {
// add the session property
session *driver.Session
path doors.SourceBeam[Path]
id doors.Beam[int]
}
templ (a *app) Body() {
if a.session == nil {
@login()
} else {
@doors.Sub(a.id, func(id int) templ.Component {
if id == -1 {
return locationSelector(func(ctx context.Context, city driver.Place) {
a.path.Mutate(ctx, func(p Path) Path {
p.Selector = false
p.Dashboard = true
p.Id = city.Id
return p
})
})
}
// render dashboard component
return dashboard(id, a.path)
})
}
}
templ (a *app) Head() {
if a.session == nil {
<title>Login</title>
} else {
@doors.Head(a.id, func(id int) doors.HeadData {
if id == -1 {
return doors.HeadData{
Title: "Select Location",
}
}
city, _ := driver.Cities.Get(id)
if city.Name == "" {
return doors.HeadData{
Title: "Location Not Found",
}
}
return doors.HeadData{
Title: "Weather in " + city.Name + ", " + city.Country.Name,
}
})
}
}
templ (a *app) Body() {
if a.session == nil {
@login()
} else {
@doors.Sub(a.id, func(id int) templ.Component {
if id == -1 {
return locationSelector(func(ctx context.Context, city driver.Place) {
a.path.Mutate(ctx, func(p Path) Path {
p.Selector = false
p.Dashboard = true
p.Id = city.Id
return p
})
})
}
// render dashboard component
return dashboard(id, a.path)
})
}
}
templ (a *app) logout() {
<section>
@doors.AClick{
On: func(ctx context.Context, r doors.REvent[doors.PointerEvent]) bool {
// remove the coolie
r.SetCookie(&http.Cookie{
Name: "session",
Path: "/",
MaxAge: -1,
})
// remove the db entry
driver.Sessions.Remove(a.session.Token)
// end internal framework session
// to end all authiorized instances (pages)
doors.SessionEnd(ctx)
return true
},
}
<button class="secondary">Log Out</button>
</section>
}
./login.templ
package main
import (
"context"
"github.com/derstruct/doors-dashboard/driver"
"github.com/doors-dev/doors"
"net/http"
"time"
)
func login() templ.Component {
return doors.F(&loginFragment{
// create dynamic attribute "class" with value "hide"
// to use on the login error message
messageClass: doors.NewADyn("class", "hide", true),
})
}
// login credentials ("tutorial style")
const userLogin = "admin"
const userPassword = "password123"
const sessionDuration = time.Hour * 24
type loginFragment struct {
messageClass doors.ADyn
}
// type to decode the form data
type loginData struct {
Login string `form:"login"`
Password string `form:"password"`
}
func (f *loginFragment) submit(ctx context.Context, r doors.RForm[loginData]) bool {
// check user credentials
if r.Data().Login != userLogin || r.Data().Password != userPassword {
// unset dynamic class attribute, show error message
f.messageClass.Enable(ctx, false)
return false
}
// add session
session := driver.Sessions.Add(r.Data().Login, sessionDuration)
// set cookie
r.SetCookie(&http.Cookie{
Name: "session",
Value: session.Token,
Expires: time.Now().Add(sessionDuration),
Path: "/",
HttpOnly: true,
})
// perform actions after the request on the client side
r.After([]doors.Action{
// reload the page
doors.ActionLocationReload{},
// pro tip: keep showing indication while the page is reloading
doors.ActionIndicate{
Duration: 10 * time.Second,
Indicator: doors.IndicatorOnlyAttrQuery("#login-submit", "aria-busy", "true"),
},
})
// set doors session expiration (to not outlive authorization)
doors.SessionExpire(ctx, sessionDuration)
return true
}
templ (f *loginFragment) Render() {
<h1>Log In</h1>
// form submit handler
@doors.ASubmit[loginData]{
// to prevent repeated submission
Scope: doors.ScopeOnlyBlocking(),
// indicate on the button
Indicator: doors.IndicatorOnlyAttrQuery("#login-submit", "aria-busy", "true"),
// handle
On: f.submit,
}
<form>
<fieldset>
<label>
Login
<input
name="login"
required="true"
/>
</label>
<label>
Password
<input
type="password"
name="password"
required="true"
/>
</label>
@f.errorMessage()
</fieldset>
<button id="login-submit" role="submit">Log In</button>
</form>
}
// error message component
templ (l *loginFragment) errorMessage() {
@doors.Style() {
<style>
.hide {
display: none
}
</style>
}
// attach dynamic attribute
@l.messageClass
<p><mark>wrong password or login</mark></p>
}