Login: form-based session auth

Most LiveTemplate code is reactive — actions arrive over a WebSocket, state mutates, the framework patches the DOM. Login flow is the one place that doesn't fit that mould: the browser hasn't authenticated yet, so there's no WebSocket to ride. Either you reach for a separate auth middleware (and lose framework-native flash messages, validation tags, and lifecycle hooks), or you let LiveTemplate handle it the way it handles everything else — as a controller action.

This recipe shows the second path. A login form posts to the same handler that renders the dashboard. The controller's Login method validates the credentials, sets an HttpOnly cookie via ctx.SetCookie, and 303-redirects. When the browser follows the redirect and the WebSocket connects, an OnConnect lifecycle hook spawns a goroutine that pushes a welcome message back to the client via session.TriggerAction. Logout mirrors the login shape — a POST that deletes the cookie and redirects.

Three pieces of the framework that are easy to miss until you need them: cookies as first-class context primitives, forms that opt out of WebSocket interception for the auth round-trip, and server-initiated state updates for everything after the page loads.

Try it in a new tab — any username; the password is secret. After the dashboard loads, watch for the welcome message: it's pushed from the server ~500ms after the WebSocket connects, no client poll involved.

Launch the login demo →

The demo opens at its own URL rather than embedding inline because lvt-form:no-intercept posts to the current URL — inside an inline embed that would be the docs page, not the recipe handler, so the Login action would never run. The same constraint applies to any recipe that needs real browser navigation semantics (cookies on a redirect, POST-Redirect-GET).

The state struct

The state holds only what the template needs to render either the login form or the dashboard. lvt:"persist" tags survive WebSocket reconnects via the framework's client-side state checksum:

// AuthController holds shared state and dependencies.
// This is a singleton that persists across sessions.
type AuthController struct {
	// For server-initiated updates (per-session)
	sessions map[string]livetemplate.Session
	mu       sync.Mutex

	// mountPath is the absolute URL prefix where this recipe is mounted
	// (e.g. "/apps/login/" in prod, "/" in the e2e suite). It is the
	// redirect target after Login/Logout: livetemplate.Context.Redirect
	// requires an absolute path, and http.StripPrefix strips the prefix
	// from r.URL.Path before the recipe sees it — so the recipe can't
	// reconstruct its own mount from the request alone. The caller (the
	// mount site in cmd/site or the e2e test-server) supplies it.
	mountPath string
}

// AuthState is pure data, cloned per session.
// Contains only serializable fields for the auth UI.
type AuthState struct {
	Username      string    `lvt:"persist"`
	IsLoggedIn    bool      `lvt:"persist"`
	ServerMessage string
	LoginTime     time.Time `lvt:"persist"`
}

controller.go:14-39

ServerMessage is intentionally not persisted — it's set every time the WebSocket connects and re-derived on reconnect, so persisting it would mean stale welcome text.

Login: HTTP POST, cookie, redirect

The login form in the template opts out of WebSocket interception with lvt-form:no-intercept. When the user clicks Login, the browser submits a standard HTTP POST to the page URL with Accept: text/html, and the framework routes the request to the controller's Login method:

// Login handles the "login" action.
func (c *AuthController) Login(state AuthState, ctx *livetemplate.Context) (AuthState, error) {
	username := ctx.GetString("username")
	password := ctx.GetString("password")

	// Field-level validation
	if username == "" {
		return state, livetemplate.NewFieldError("username", fmt.Errorf("username is required"))
	}
	if password == "" {
		return state, livetemplate.NewFieldError("password", fmt.Errorf("password is required"))
	}

	// Demo: accept any username with password "secret"
	if password != "secret" {
		ctx.SetFlash("error", "Invalid credentials")
		return state, nil
	}
	state.Username = username
	state.IsLoggedIn = true
	state.LoginTime = time.Now()
	state.ServerMessage = "" // Will be set when WebSocket connects

	// Set HttpOnly session cookie
	err := ctx.SetCookie(&http.Cookie{
		Name:     "session_token",
		Value:    fmt.Sprintf("session_%s_%d", username, time.Now().Unix()),
		Path:     "/",
		HttpOnly: true,
		Secure:   false, // Set to true in production with HTTPS
		SameSite: http.SameSiteStrictMode,
		MaxAge:   3600, // 1 hour
	})
	if err != nil {
		return state, fmt.Errorf("failed to set cookie: %w", err)
	}

	// Redirect to the recipe's mount path (POST-Redirect-GET). The new
	// GET re-renders the template; IsLoggedIn=true is now persisted, so
	// the dashboard branch renders and the WebSocket then connects.
	return state, ctx.Redirect(c.mountPath, http.StatusSeeOther)
}

controller.go:43-85

Three framework primitives carry the auth weight here:

The flash message API works the same way it does for non-auth flows — ctx.SetFlash("error", "Invalid credentials") stashes the message in a cookie, and the next render reads it via {{.lvt.FlashTag "error"}}.

The form: opting out of WebSocket interception

The login form looks like a normal HTML form with one extra attribute:

<article>
    <h1>Login</h1>
    {{.lvt.FlashTag "error"}}
    <form method="POST" lvt-form:no-intercept>
        <label for="username">Username
            <input type="text" id="username" name="username" placeholder="Enter username" required {{.lvt.AriaInvalid "username"}}>
            {{.lvt.ErrorTag "username"}}
        </label>
        <label for="password">Password
            <input type="password" id="password" name="password" placeholder="Enter password" required {{.lvt.AriaInvalid "password"}}>
            {{.lvt.ErrorTag "password"}}
        </label>
        <button type="submit" name="login" {{.lvt.AriaDisabled "username" "password"}}>Login</button>
    </form>
    <small>
        Demo: Use any username with password <strong>secret</strong><br>
        After login, the server will push a welcome message via WebSocket.
    </small>
</article>

auth.html:40-58

lvt-form:no-intercept tells the LiveTemplate JS client not to intercept the submit. The form posts the natural browser way — application/x-www-form-urlencoded body, button-name routing (login) — and the response is a 303 redirect. This is what makes the auth flow work identically with and without JavaScript, and what makes the Set-Cookie header land on a real navigation response rather than a WebSocket frame.

Server-push welcome: OnConnect + session.TriggerAction

After the redirect, the dashboard page loads, the JS client connects the WebSocket, and the framework calls the controller's OnConnect lifecycle method:

// OnConnect is called when a WebSocket connection is established.
// This is a lifecycle method on the controller.
func (c *AuthController) OnConnect(state AuthState, ctx *livetemplate.Context) (AuthState, error) {
	session := ctx.Session()

	log.Printf("WebSocket connected (user: %s, logged_in: %v)", state.Username, state.IsLoggedIn)

	// Store session for server-initiated updates
	if state.IsLoggedIn && session != nil {
		c.mu.Lock()
		c.sessions[state.Username] = session
		c.mu.Unlock()

		// Send a welcome message from the server.
		// This demonstrates server-initiated updates after WebSocket connects.
		go c.sendWelcomeMessage(state.Username, session)
	}

	return state, nil
}

// OnDisconnect is called when a WebSocket connection is closed.
func (c *AuthController) OnDisconnect() {
	log.Printf("WebSocket disconnected")
}

controller.go:116-141

Two things to notice. First, ctx.Session() returns the live session handle — the same one that ctx.Publish peer fan-outs and session.TriggerAction server-pushes flow through. Storing it in the controller's sessions map is a stand-in for what a real app would do via SessionStore and a background pub/sub channel.

Second, the actual welcome push happens in a goroutine. OnConnect returns immediately so the framework can finish the WebSocket handshake; the goroutine sleeps long enough for the dashboard to paint, then fires:

// sendWelcomeMessage sends a server-initiated welcome message via WebSocket.
// This demonstrates pushing updates from server to client without user action.
func (c *AuthController) sendWelcomeMessage(username string, session livetemplate.Session) {
	// Small delay so the page fully renders first
	time.Sleep(500 * time.Millisecond)

	// Trigger server-initiated action that returns modified state with the welcome data.
	// This updates the state and sends the update to all user's connections.
	if err := session.TriggerAction("serverWelcome", map[string]interface{}{
		"message": fmt.Sprintf("Welcome %s! This message was pushed from the server at %s",
			username, time.Now().Format("15:04:05")),
	}); err != nil {
		log.Printf("Failed to send welcome message: %v", err)
	} else {
		log.Printf("Server-initiated welcome message sent to %s", username)
	}
}

controller.go:145-162

session.TriggerAction("serverWelcome", data) enqueues an action on the session as if the client had dispatched it. The framework routes it to the controller's ServerWelcome method, runs the standard state-mutation-and-diff pipeline, and patches the dashboard — specifically the <ins id="server-welcome-message"> block — with the new message. The client never asked for it; the server decided to update.

The same pattern is how you push subscription updates, completed background-job results, or any "the server learned something new" event. For the deeper model of when to use Publish (peer connections in the same session group that opted in via Subscribe) versus TriggerAction (server-owned work on one session), see Pubsub and Server push.

Logout: symmetric to login

Logout mirrors login's shape — another lvt-form:no-intercept form, another action method, another redirect:

// Logout handles the "logout" action.
func (c *AuthController) Logout(state AuthState, ctx *livetemplate.Context) (AuthState, error) {
	state.Username = ""
	state.IsLoggedIn = false
	state.ServerMessage = ""

	// Delete session cookie
	err := ctx.DeleteCookie("session_token")
	if err != nil {
		return state, fmt.Errorf("failed to delete cookie: %w", err)
	}

	// Redirect back to the login form at the recipe's mount path.
	return state, ctx.Redirect(c.mountPath, http.StatusSeeOther)
}

controller.go:89-104

ctx.DeleteCookie writes Set-Cookie: session_token=; MaxAge=-1, the browser drops the cookie, the redirect lands on the login form, and the cycle starts over.

Where this recipe stops

Three things a production auth flow needs that this recipe deliberately doesn't have:

The framework primitives shown here — cookies, no-intercept forms, OnConnect, TriggerAction — compose cleanly with all three additions. The auth shape doesn't change as you harden it; the implementations of the cookie validator, password compare, and Authenticate method swap in.

For a header-driven alternative that does have an Authenticator, see the Shared notepad recipe — BasicAuth instead of form login, with ctx.UserID() driving per-user state isolation.

What next?

source: livetemplate/docs · path: content/recipes/login/index.md