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() {
	app := doors.NewApp(func(ctx context.Context, r doors.Request) gox.Comp {
		// Initialize our session object in the framework's session storage.
		session := doors.SessionStore(ctx).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 App{
			session: session,
		}
	})

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

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

The Doors session 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.Inner(ctx, <p><mark>wrong password or login</mark></p>)
		return false
	}
	l.message.Inner(ctx, nil)
	// 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: new(doors.ScopeBlocking),
			// Reflect the pending state on the submit button.
			Indicator: doors.IndicateAttrQuery("#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) {
	~// Attach the click handler. (~> is proxy syntax; the result is the same.)
	~>doors.AClick{
		// Prevent double clicks.
		Scope: new(doors.ScopeBlocking),
		// Reflect the pending state on the link itself.
		Indicator: doors.IndicateAttr("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>
}

~> 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.

App

Finally, wire it into the app:

app.gox

elem (a App) Main() {
	/* ... */
			<main class="container">
				~(a.session.Bind(elem(s driver.Session) {
					~(if s.IsValid() {
						<section>
							~(doors.Route(
								doors.RouteModel(a.content),
								doors.RouteLocationDefaultComp(<>
									<title>Not Found</title>
									~(doors.Status(404))
									<h1>Location Not Found</h1>
								</>),
							))
						</section>
						<hr>
						<section>
							~(a.logout(s))
						</section>
					} else {
						~(Login(a.session))
					})
				}))
			</main>
	/* ... */
}

Result:

Login flow with session-backed state

And because we used session-scoped reactive state, logout and login affect all tabs simultaneously:

Login and logout synchronized across multiple tabs

Code

main.go

package main

import (
	"context"
	"net/http"

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

func main() {
	app := doors.NewApp(func(ctx context.Context, r doors.Request) gox.Comp {
		session := doors.SessionStore(ctx).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 App{
			session: session,
		}
	})

	if err := http.ListenAndServe(":8080", app); 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 Path struct {
	Route Route `path:"/ | /:CityID"`
	CityID int
	Units *driver.Units `query:"units"`
	Days *int `query:"days"`
}

func (p Path) days() int {
	if p.Days == nil {
		return 7
	}
	return min(max(*p.Days, 1), 7)
}

func daysQuery(days int) *int {
	if days == 7 {
		return nil
	}
	return &days
}

func (p Path) units() driver.Units {
	if p.Units == nil || *p.Units != driver.Imperial {
		return driver.Metric
	}
	return driver.Imperial
}

func unitsQuery(units driver.Units) *driver.Units {
	if units == driver.Metric {
		return nil
	}
	return &units
}

type Route int

const (
	Selector Route = iota
	Dashboard
)

type App struct {
	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">
		</head>
		<body>
			<main class="container">
				~(a.session.Bind(elem(s driver.Session) {
					~(if s.IsValid() {
						<section>
							~(doors.Route(
								doors.RouteModel(a.content),
								doors.RouteLocationDefaultComp(<>
									<title>Not Found</title>
									~(doors.Status(404))
									<h1>Location Not Found</h1>
								</>),
							))
						</section>
						<hr>
						<section>
							~(a.logout(s))
						</section>
					} else {
						~(Login(a.session))
					})
				}))
			</main>
		</body>
	</html>
}

elem (a App) content(path doors.Source[Path]) {
	~(path.Route(
		doors.RouteMatch(func(p Path) bool {
			return p.Route == Selector
		}).Comp(<>
			<title>Select Location</title>
			~(LocationSelector(func(ctx context.Context, city int) {
				path.Update(ctx, Path{
					Route: Dashboard,
					CityID: city,
				})
			}))
		</>),
		doors.RouteDefault(WeatherDashboard),
	))
}

elem (a App) logout(s driver.Session) {
	~>doors.AClick{
		Scope: new(doors.ScopeBlocking),
		Indicator: doors.IndicateAttr("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>
}

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.Inner(ctx, <p><mark>wrong password or login</mark></p>)
		return false
	}
	l.message.Inner(ctx, nil)
	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: new(doors.ScopeBlocking),
			Indicator: doors.IndicateAttrQuery("#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>
}