Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ generate: ## Regenerate sqlc (and templ, once apps/web/ui exists)
$(TOOL) sqlc generate -f packages/database/sqlc.yaml
@if [ -d apps/web/ui ] && $(GO) tool -n templ >/dev/null 2>&1; then $(GO) tool templ generate -path apps/web/ui; fi

.PHONY: css
css: ## Rebuild apps/web/ui/static/app.css (Tailwind standalone CLI, pinned + checksum-verified)
"$$(apps/web/ui/scripts/tailwind.sh)" -i apps/web/ui/styles/input.css -o apps/web/ui/static/app.css --minify

.PHONY: check-generated
check-generated: generate ## Fail if generated code is out of date
@git diff --exit-code -- '*.go' || (echo 'generated code is stale: run make generate and commit' && exit 1)
Expand Down
5 changes: 5 additions & 0 deletions apps/web/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/jayleaton/ai-patterns-go/apps/web/internal/config"
"github.com/jayleaton/ai-patterns-go/apps/web/internal/server"
"github.com/jayleaton/ai-patterns-go/apps/web/internal/services"
"github.com/jayleaton/ai-patterns-go/apps/web/ui"
"github.com/jayleaton/ai-patterns-go/packages/auth"
"github.com/jayleaton/ai-patterns-go/packages/database"
)
Expand Down Expand Up @@ -101,6 +102,10 @@ func serve(ctx context.Context, cfg config.Config, log *slog.Logger) error {
DB: pool,
Auth: authn,
Users: api.NewUsers(users, server.CallerFrom),
UI: ui.New(ui.Deps{
Auth: authn, Users: users, Features: cfg.Features,
SecureCookies: cfg.PublicURL.Scheme == "https", Logger: log,
}).Handler(),
})

srv := &http.Server{
Expand Down
1 change: 1 addition & 0 deletions apps/web/ui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/bin/
97 changes: 97 additions & 0 deletions apps/web/ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# apps/web/ui — HTML transport

Server-rendered [templ](https://templ.guide) pages, [Datastar](https://data-star.dev) 1.x actions over SSE, Tailwind v4.
Ported from the original `apps/web-app` (same pages, same Tailwind classes, same messages).

| Package | What |
|---|---|
| `ui` | `ui.New(deps)` (`Routes(mux)` / `Handler()`), the `Auth`/`Users` interfaces, handlers, auth + dashboard pages |
| `ui/view` | layout shell (header: logout, theme, language), shared inputs/buttons/alerts/skeletons |
| `ui/welcome` | template welcome landing + `/why` (feature-flagged, deletable; see its README) |
| `ui/i18n` | `messages/{en,ja}.json`, locale negotiation, `i18n.T(ctx, key)` |
| `ui/routes` | the UI's own action endpoints (logout, locale, live stream, settings, delete, static); pages use `config.AppRoutes` |
| `ui/static` | embedded `app.css` (generated), pinned `datastar-1.0.4.js`, favicon |
| `ui/uifake` | in-memory `Auth`/`Users` for tests and the preview |
| `ui/cmd/preview` | all pages with fakes on `0.0.0.0:3001` |

## Commands (for the Makefile)

Run from the repo root. Both outputs are committed; CI regenerates them and fails on a diff.

```sh
# templ -> *_templ.go (templ is a go.mod tool directive)
go tool templ generate -path apps/web/ui

# Tailwind standalone CLI v4.3.3, downloaded once into apps/web/ui/bin/ (gitignored), SHA-256 verified
TAILWIND="$(apps/web/ui/scripts/tailwind.sh)"
"$TAILWIND" -i apps/web/ui/styles/input.css -o apps/web/ui/static/app.css --minify

# order matters: templ first, then CSS (Tailwind scans the .templ and .go files for classes)

# preview every page with fakes (demo@example.com / password123; email links are logged)
go run ./apps/web/ui/cmd/preview # dev behavior: no email verification
go run ./apps/web/ui/cmd/preview -verify # production behavior

go test -race ./apps/web/ui/...
```

Watch mode while editing: `go tool templ generate -path apps/web/ui -watch` and `"$TAILWIND" ... --watch`.

## Mounting (server wiring)

```go
ui := ui.New(ui.Deps{
Auth: authService, // *auth.Auth satisfies ui.Auth as is
Users: userService, // *services.UserService satisfies ui.Users as is
Features: cfg.Features,
SecureCookies: cfg.IsProduction,
Logger: log,
// UserFrom defaults to auth.UserFrom (the session auth.Middleware loaded).
})
deps.UI = ui.Handler() // server.Deps.UI, mounted at "/" inside auth.Middleware
```

`Routes(mux)` registers `GET /{$}`, the pages in `config.AppRoutes`, the UI actions in `ui/routes`, `GET /static/` and
`GET /favicon.ico`; `Handler()` returns a mux with them. POST actions are wrapped in `http.CrossOriginProtection`
(stdlib); keep that if a global CSRF layer is added. Compile-time assertions in `deps.go` keep both interfaces in step
with `packages/auth` and the user service.

`ui.Auth` uses the auth types directly (`ui.User` = `auth.User`, ...) and the handlers map `auth.Err*` values to
messages (`errorKeys`); unknown errors show "Something went wrong" and are logged. Sign-up and sign-in pass
`CallbackURL: config.AppRoutes.VerifyEmailSuccess`; password reset passes `config.AppRoutes.ResetPassword` as
`redirectTo`. Auth joins app paths with its `BaseURL`, so no origin is hardcoded here. The verify link may land after
auth's own `GET /api/auth/verify-email` already set the cookie (the page then checks the session) or carry `?token=`
(the page POSTs it to `VerifyEmail`, so link scanners doing GETs cannot spend it).

`ui.Users` is the method set of `*services.UserService` (`UpdateUserSettings`, `DeleteAllUserData`). The settings form
posts only the name: email changes belong to the auth flows, so the email is shown read-only. The form is validated
with `validate.UpdateUserSettings` before the service call; validation and `KindInvalid` errors show
`errors.invalidInput`.

## Patterns

- **Forms**: real `<form method="post" action=...>` plus `data-on:submit="@post(path, {contentType: 'form'})"`.
Datastar requests (`Datastar-Request: true`) get SSE: patched fragments by id (`#form-error`, `#settings-status`,
...) or a redirect. Plain posts (no JS) get the full page with the error, or a 303. Same handler, `u.patch`/`u.redirect`.
- **Loading states**: `data-indicator:_loading` on the form drives the button label and `disabled`; this replaces
`loading.tsx`/Suspense fallbacks. The dashboard live card shows a pulse skeleton until its stream connects; the
verify-email success page shows the spinner until its POST returns.
- **Server push**: `GET /dashboard/live` streams the clock and session expiry every second, and patches the account
card whenever the user changes from any tab (notice shown only in the other tabs). Fan-out is an in-process hub;
swap for Postgres `LISTEN/NOTIFY` when running more than one instance.
- **Client JS**: only Datastar attributes. Local UI state uses `_`-prefixed signals (not sent to the server). The theme
toggle writes a `theme` cookie; the server renders `<html class="dark|light">`, and with no cookie the CSS follows
`prefers-color-scheme`, so there is no flash.
- **i18n**: messages are flat strings with no plurals or placeholders, so `i18n` is a map lookup (no library; add
go-i18n when a message needs plural rules). Locale = `locale` cookie, then the highest-weighted supported
`Accept-Language`, then `en`. Keys must be string literals: `TestUsedKeysExistInEveryLocale` scans the templates
and fails when any locale is missing one, and the render tests fail on any key that renders as itself.
New keys vs the original (same structure, additions only): `common.home`, `components.themeToggle.*`,
`pages.home.*`, `pages.dashboard.{settingsForm,deleteAccount,live}.*`,
`errors.{emailTaken,invalidInput,passwordTooLong,tooManyRequests}`.

## Updating Datastar

Download `bundles/datastar.js` for the new tag from `github.com/starfederation/datastar`, save it as
`static/datastar-<version>.js`, and update `DatastarVersion`, `DatastarFile`, `DatastarSHA256` and the `go:embed` line in
`static/static.go`. `TestDatastarPin` checks the hash and the version header. Keep `datastar-go` on a matching 1.x.
57 changes: 57 additions & 0 deletions apps/web/ui/cmd/preview/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Command preview serves every UI page with in-memory fakes, so the UI can be
// checked before the real server, auth and database are wired in.
//
// go run ./apps/web/ui/cmd/preview # 0.0.0.0:3001
// go run ./apps/web/ui/cmd/preview -verify # production-like email verification
//
// Sign in as demo@example.com / password123. Email links are logged.
package main

import (
"errors"
"flag"
"log/slog"
"net/http"
"os"
"time"

"github.com/jayleaton/ai-patterns-go/apps/web/internal/config"
"github.com/jayleaton/ai-patterns-go/apps/web/ui"
"github.com/jayleaton/ai-patterns-go/apps/web/ui/uifake"
)

func main() {
addr := flag.String("addr", "0.0.0.0:3001", "listen address")
verify := flag.Bool("verify", false, "require email verification (production behavior)")
welcome := flag.Bool("welcome", true, "show the template welcome page at /")
flag.Parse()

log := slog.New(slog.NewTextHandler(os.Stderr, nil))
store := uifake.NewStore()
store.RequireEmailVerification = *verify
store.OnMail = func(m uifake.Mail) {
log.Info("email (not sent)", "kind", m.Kind, "to", m.To, "link", m.URL)
}
store.AddUser("Demo User", "demo@example.com", "password123", true)

fakeAuth := uifake.Auth{S: store}
mux := http.NewServeMux()
ui.New(ui.Deps{
Auth: fakeAuth,
Users: uifake.Users{S: store},
UserFrom: fakeAuth.UserFrom,
Features: config.Features{EmailVerification: *verify, TemplateWelcome: *welcome},
Logger: log,
}).Routes(mux)

srv := &http.Server{
Addr: *addr,
Handler: fakeAuth.Middleware(mux),
ReadHeaderTimeout: 10 * time.Second,
}
log.Info("ui preview listening", "addr", *addr, "login", "demo@example.com / password123")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("preview", "err", err)
os.Exit(1)
}
}
91 changes: 91 additions & 0 deletions apps/web/ui/deps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Package ui is the HTML transport: templ pages plus Datastar handlers.
// It calls auth and the user service in-process through the interfaces below,
// so it never imports the database or the auth implementation.
//
// Mount it with ui.New(deps).Routes(mux).
package ui

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

"github.com/jayleaton/ai-patterns-go/apps/web/internal/config"
"github.com/jayleaton/ai-patterns-go/apps/web/internal/services"
"github.com/jayleaton/ai-patterns-go/apps/web/internal/validate"
"github.com/jayleaton/ai-patterns-go/packages/auth"
)

// The auth types, used as they are so *auth.Auth satisfies Auth directly.
type (
User = auth.User
Session = auth.Session
SignUpInput = auth.SignUpInput
SignInInput = auth.SignInInput
)

// Auth is the slice of *auth.Auth the HTML transport calls; *auth.Auth
// satisfies it. The session of a request is read with Deps.UserFrom.
type Auth interface {
// SignUpEmail returns a zero Session when email verification is required.
SignUpEmail(ctx context.Context, in SignUpInput) (User, Session, error)
SignInEmail(ctx context.Context, in SignInInput) (User, Session, error)
SignOut(ctx context.Context, sessionToken string) error
// RequestPasswordReset emails a reset link that ends at redirectTo?token=
// (an app path; required).
RequestPasswordReset(ctx context.Context, email, redirectTo string) error
ResetPassword(ctx context.Context, token, newPassword string) error
// VerifyEmail returns a zero Session when the email was already verified.
VerifyEmail(ctx context.Context, token string) (User, Session, error)
SendVerificationEmail(ctx context.Context, email, callbackURL string) error
SetSessionCookie(w http.ResponseWriter, s Session)
ClearSessionCookie(w http.ResponseWriter)
}

// Users is the user service the dashboard calls; *services.UserService
// satisfies it.
type Users interface {
UpdateUserSettings(ctx context.Context, actor services.Actor, userID string, in validate.UpdateUserSettings) (services.User, error)
DeleteAllUserData(ctx context.Context, actor services.Actor) error
}

// The real implementations plug in without adapters.
var (
_ Auth = (*auth.Auth)(nil)
_ Users = (*services.UserService)(nil)
)

// errorKeys maps auth errors to message keys. Any other auth error shows
// errors.somethingWentWrong and is logged.
var errorKeys = []struct {
err error
key string
}{
{auth.ErrInvalidEmail, "errors.emailInvalid"},
{auth.ErrInvalidEmailOrPassword, "errors.invalidCredentials"},
{auth.ErrPasswordTooShort, "errors.passwordTooShort"},
{auth.ErrPasswordTooLong, "errors.passwordTooLong"},
{auth.ErrUserAlreadyExists, "errors.emailTaken"},
{auth.ErrEmailNotVerified, "pages.verifyEmail.description"},
{auth.ErrInvalidToken, "pages.verifyEmail.invalidToken"},
{auth.ErrTokenExpired, "pages.verifyEmail.invalidToken"},
{auth.ErrTooManyRequests, "errors.tooManyRequests"},
}

// Deps are the in-process dependencies of the UI.
type Deps struct {
Auth Auth
Users Users
// UserFrom reads the session auth.Middleware stored on the request;
// defaults to auth.UserFrom.
UserFrom func(ctx context.Context) (User, Session, bool)
Features config.Features
// SecureCookies marks UI cookies (locale) Secure; true behind HTTPS.
SecureCookies bool
Logger *slog.Logger
// Now is the clock for the live dashboard; defaults to time.Now.
Now func() time.Time
// LiveInterval is the dashboard clock tick; defaults to one second.
LiveInterval time.Duration
}
Loading
Loading