Authentication

The most convenient way to handle authentication here is to wrap it in reactive state.

The model handler gives us access to the framework's session storage. That storage is shared across all app instances in the same browser session.

The relevant Doors pieces here are Storage and Auth and Session and Instance.

Session

Start by adding a field for the session state:

app.gox

type App struct {
	/* ... */
	session doors.Source[driver.Session] 
}

main.go

func main() {
	r := doors.NewRouter()

	doors.UseModel(r, func(r doors.RequestModel, s doors.Source[Path]) doors.Response {
		/* ... */
        // initialize session-backed state
		session := r.SessionStore().Init("session", func() any {
            // initialize the state from the current cookie, if present
			var s driver.Session
			c, err := r.GetCookie("session")
			if err == nil {
				s = driver.Sessions.Get(c.Value)
			}
			return doors.NewSource(s)
		}).(doors.Source[driver.Session])
		return doors.ResponseComp(App{
			city:     city,
			settings: settings,
			session:  session,
		})
	})

	if err := http.ListenAndServe(":8080", r); err != nil {
		panic(err)
	}
}

Init(...) accepts a constructor. It runs only when there is no value under the provided key.

This is different from your own application session. The framework manages its runtime session automatically, and SessionStore is attached to that runtime session.

Login

Next, add the login component:

The form hooks in this section come from Events. The page title still uses the same Head and Status support introduced earlier.

login.gox

package main

import (
	"context"
	"net/http"
	"time"

	"github.com/doors-dev/doors"
	"github.com/doors-dev/gox"
	"github.com/doors-dev/tutorial/driver"
)

// login credentials for the tutorial
const userLogin = "admin"
const userPassword = "password123"
const sessionDuration = time.Hour * 24

// Login mutates shared session state.
func Login(session doors.Source[driver.Session]) gox.Comp {
	return login{
		session: session,
		message: new(doors.Door),
	}
}

type login struct {
	session doors.Source[driver.Session]
	message *doors.Door
}

// decode the form data into a Go value
type loginData struct {
	Login string `form:"login"`
	Password string `form:"password"`
}

// handle form submission
func (l login) submit(ctx context.Context, r doors.RequestForm[loginData]) bool {
	// check credentials
	if r.Data().Login != userLogin || r.Data().Password != userPassword {
		// show the error inline
		l.message.Update(ctx, <p><mark>wrong password or login</mark></p>)
		return false
	}
	l.message.Clear(ctx)
	// create an app session
	session := driver.Sessions.Add(r.Data().Login, sessionDuration)
	// set the browser cookie
	r.SetCookie(&http.Cookie{
		Name:     "session",
		Value:    session.Token,
		Expires:  time.Now().Add(sessionDuration),
		Path:     "/",
		HttpOnly: true,
	})
	// make the framework session expire with the app session
	doors.SessionExpire(ctx, sessionDuration)
	// finally update shared reactive state
	l.session.Update(ctx, session)
	return true
}

elem (l login) Main() {
	<title>Log In</title>
	<h1>Log In</h1>
	<form
		// attach the submit handler
		(doors.ASubmit[loginData]{
			// prevent repeated submits while the request is running
			Scope: doors.ScopeOnlyBlocking(),
			// reflect the pending state on the submit button
			Indicator: doors.IndicatorOnlyAttrQuery("#login-submit", "aria-busy", "true"),
			On: l.submit,
		})>
		<fieldset>
			<label>
				Login
				<input
					name="login"
					required="true"/>
			</label>
			<label>
				Password
				<input
					type="password"
					name="password"
					required="true"/>
			</label>
			~(l.message)
		</fieldset>
		<button id="login-submit" role="submit">Log In</button>
	</form>
}

Logout

Now add a logout action to the app:

This reuses the same Events API plus the session-lifetime controls described in Session and Instance.

app.gox

elem (a App) logout(s driver.Session) {
    <hr>
	<section>
		~// attach the click handler (~> is proxy syntax; the result is the same)
		~>doors.AClick{
			// prevent double clicks
			Scope: doors.ScopeOnlyBlocking(),
			// reflect the pending state on the link itself
			Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
			PreventDefault: true,
			On: func(ctx context.Context, r doors.RequestPointer) bool {
				// remove the cookie
				r.SetCookie(&http.Cookie{
					Name: "session",
					Path: "/",
					MaxAge: -1,
				})
				// remove the stored session
				driver.Sessions.Remove(s.Token)
				// reset the framework session expiration
				doors.SessionExpire(ctx, 0)
				// update shared state
				a.session.Update(ctx, driver.Session{})
				return true
			},
        } <a class="contrast" href="#">Log Out</a>
	</section>
}

~> is GoX proxy syntax. It proxies the following element through a value that implements gox.Proxy, and all doors.A... values do. In practice, proxy syntax and attribute-modifier syntax are mostly interchangeable here.

App

Finally, wire it into the app: app.gox

elem (a App) Main() {
	/* ... */
			<main class="container">
                ~// subscribe to the session state
				~(doors.Sub(a.session, elem(s driver.Session) {
					~(if s.IsValid() {
						~(a.content())
						~(a.logout(s))
					} else {
						~(Login(a.session))
					})
				}))
			</main>
		</body>
	/* ... */
}

elem (a App) content() {
	~(doors.Sub(a.city, elem(city int) {
		~(if city == -1 {
			~(LocationSelector(func(ctx context.Context, city int) {
				a.path.Mutate(ctx, func(p Path) Path {
					p.Home = false
					p.Dashboard = true
					p.CityID = city
					return p
				})
			}))
		} else {
			~(Dashboard(city, a.settings))
		})
	}))
}

Result:

Login flow with session-backed state

And because we used session-scoped reactive state, logout (and login) affects all tabs simultaniously:

Login and logout synchronized across multiple tabs

Code

main.go

package main

import (
	"net/http"

	"github.com/doors-dev/doors"
	"github.com/doors-dev/tutorial/driver"
)

type Path struct {
	Home      bool `path:"/"`
	Dashboard bool `path:"/:CityID"`
	CityID    int
	Units     *driver.Units `query:"units"`
	Days      *int          `query:"days"`
}

func NewSettigns(p Path) Settings {
	s := Settings{
		Days:  7,
		Units: driver.Metric,
	}
	if p.Days != nil {
		s.Days = max(*p.Days, 1)
	}
	if p.Units != nil && *p.Units == driver.Imperial {
		s.Units = driver.Imperial
	}
	return s
}

type Settings struct {
	Units driver.Units
	Days  int
}

func (s Settings) DaysQuery() *int {
	if s.Days == 7 {
		return nil
	}
	return &s.Days
}

func (s Settings) UnitsQuery() *driver.Units {
	if s.Units == driver.Metric {
		return nil
	}
	return &s.Units
}

func main() {
	r := doors.NewRouter()

	doors.UseModel(r, func(r doors.RequestModel, s doors.Source[Path]) doors.Response {
		city := doors.NewBeam(s, func(p Path) int {
			if p.Home {
				return -1
			}
			return p.CityID
		})
		settings := doors.NewBeam(s, NewSettigns)
		session := r.SessionStore().Init("session", func() any {
			var s driver.Session
			c, err := r.GetCookie("session")
			if err == nil {
				s = driver.Sessions.Get(c.Value)
			}
			return doors.NewSource(s)
		}).(doors.Source[driver.Session])
		return doors.ResponseComp(App{
			path:     s,
			city:     city,
			settings: settings,
			session:  session,
		})
	})

	if err := http.ListenAndServe(":8080", r); err != nil {
		panic(err)
	}
}

app.gox

package main

import (
	"context"
	_"embed"
	"net/http"
	
	"github.com/doors-dev/doors"
	"github.com/doors-dev/gox"
	"github.com/doors-dev/tutorial/driver"
)

//go:embed style.css
var styles []byte

type App struct {
	path doors.Source[Path]
	city doors.Beam[int]
	settings doors.Beam[Settings]
	session doors.Source[driver.Session]
}

elem (a App) Main() {
	<!doctype html>
	<html lang="en" data-theme="dark">
		<head>
			<meta charset="utf-8">
			<meta name="viewport" content="width=device-width, initial-scale=1">
			<link
				(doors.ResourceExternal("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"))
				rel="stylesheet">
			<link href=(styles) rel="stylesheet" cache>
		</head>
		<body>
			<main class="container">
				~(doors.Sub(a.session, elem(s driver.Session) {
					~(if s.IsValid() {
						~(a.content())
						~(a.logout(s))
					} else {
						~(Login(a.session))
					})
				}))
			</main>
		</body>
	</html>
}

elem (a App) content() {
	~(doors.Sub(a.city, elem(city int) {
		~(if city == -1 {
			~(LocationSelector(func(ctx context.Context, city int) {
				a.path.Mutate(ctx, func(p Path) Path {
					p.Home = false
					p.Dashboard = true
					p.CityID = city
					return p
				})
			}))
		} else {
			~(Dashboard(city, a.settings))
		})
	}))
}

elem (a App) logout(s driver.Session) {
	<hr>
	<section>
		~>doors.AClick{
			Scope: doors.ScopeOnlyBlocking(),
			Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
			PreventDefault: true,
			On: func(ctx context.Context, r doors.RequestPointer) bool {
				r.SetCookie(&http.Cookie{
					Name: "session",
					Path: "/",
					MaxAge: -1,
				})
				driver.Sessions.Remove(s.Token)
				doors.SessionExpire(ctx, 0)
				a.session.Update(ctx, driver.Session{})
				return true
			},
		} <a class="contrast" href="#">Log Out</a>
	</section>
}

login.gox

package main

import (
	"context"
	"net/http"
	"time"

	"github.com/doors-dev/doors"
	"github.com/doors-dev/gox"
	"github.com/doors-dev/tutorial/driver"
)

const userLogin = "admin"
const userPassword = "password123"
const sessionDuration = time.Hour * 24

func Login(session doors.Source[driver.Session]) gox.Comp {
	return login{
		session: session,
		message: new(doors.Door),
	}
}

type login struct {
	session doors.Source[driver.Session]
	message *doors.Door
}

type loginData struct {
	Login string `form:"login"`
	Password string `form:"password"`
}

func (l login) submit(ctx context.Context, r doors.RequestForm[loginData]) bool {
	if r.Data().Login != userLogin || r.Data().Password != userPassword {
		l.message.Update(ctx, <p><mark>wrong password or login</mark></p>)
		return false
	}
	l.message.Clear(ctx)
	session := driver.Sessions.Add(r.Data().Login, sessionDuration)
	r.SetCookie(&http.Cookie{
		Name: "session",
		Value: session.Token,
		Expires: time.Now().Add(sessionDuration),
		Path: "/",
		HttpOnly: true,
	})
	doors.SessionExpire(ctx, sessionDuration)
	l.session.Update(ctx, session)
	return true
}

elem (l login) Main() {
	<title>Log In</title>
	<h1>Log In</h1>
	<form
		(doors.ASubmit[loginData]{
			Scope: doors.ScopeOnlyBlocking(),
			Indicator: doors.IndicatorOnlyAttrQuery("#login-submit", "aria-busy", "true"),
			On: l.submit,
		})>
		<fieldset>
			<label>
				Login
				<input
					name="login"
					required="true"/>
			</label>
			<label>
				Password
				<input
					type="password"
					name="password"
					required="true"/>
			</label>
			~(l.message)
		</fieldset>
		<button id="login-submit" role="submit">Log In</button>
	</form>
}