P1-23 / P1-28: base layout, login, session-aware nav + Tailwind build
P1-28: Tailwind standalone CLI wired into the Makefile. `make tailwind` downloads the pinned v3.4.17 binary into bin/tailwindcss (gitignored), builds web/styles/input.css → web/static/css/styles.css. `make build` now runs the CSS pass first; `make tailwind-watch` for dev. Output is embedded in the binary via web.FS — single static binary, no Node. The CSS source carries every component class the v1 mockups defined (status dots, buttons, host row, log viewer, progress bar, fields, chips, snippet panel, empty state) so screens that land later can just reach for them. P1-23: html/template tree at web/templates with two layouts (base with chrome, chromeless for login + bootstrap), one nav partial, and two pages (dashboard placeholder, login). internal/server/ui parses the tree at startup; ui_handlers.go in the http package wires: GET / dashboard (303 → /login when unauthed) GET /login sign-in form POST /login consume form, mint session cookie, 303 → / POST /logout drop cookie, 303 → /login GET /static/* embedded Tailwind bundle The HTML login flow shares store/session logic with /api/auth/login via a new authenticateAndSession helper — same security guarantees, two surface representations (HTML form / JSON). Verified end-to-end: bootstrap → form-login → authed dashboard → sign-out → 303 cycle works in the browser; Tailwind output emits only the component classes referenced in the live templates (9.6kB minified). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,23 +34,35 @@ func (s *Server) handleLogin(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
u, err := s.deps.Store.GetUserByUsername(r.Context(), req.Username)
|
||||
u, err := s.authenticateAndSession(w, r, req.Username, req.Password)
|
||||
if err != nil {
|
||||
// Same response for unknown user vs bad password — don't leak
|
||||
// existence to a probing attacker.
|
||||
writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_credentials", "")
|
||||
return
|
||||
}
|
||||
if err := auth.VerifyPassword(u.PasswordHash, req.Password); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_credentials", "")
|
||||
return
|
||||
writeJSON(w, stdhttp.StatusOK, loginResponse{UserID: u.ID, Role: string(u.Role)})
|
||||
}
|
||||
|
||||
// authenticateAndSession verifies credentials, mints a session cookie,
|
||||
// records the login + audit, and returns the user. Any failure
|
||||
// (unknown user, wrong password, db error) is collapsed into a single
|
||||
// error — the caller decides how to surface it. Shared by JSON and
|
||||
// HTML login flows.
|
||||
func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Request,
|
||||
username, password string,
|
||||
) (*store.User, error) {
|
||||
u, err := s.deps.Store.GetUserByUsername(r.Context(), username)
|
||||
if err != nil {
|
||||
// Same response for unknown user vs bad password — don't leak
|
||||
// existence to a probing attacker.
|
||||
return nil, errInvalidCredentials
|
||||
}
|
||||
if err := auth.VerifyPassword(u.PasswordHash, password); err != nil {
|
||||
return nil, errInvalidCredentials
|
||||
}
|
||||
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
sess := store.Session{
|
||||
@@ -61,8 +73,7 @@ func (s *Server) handleLogin(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
UA: r.UserAgent(),
|
||||
}
|
||||
if err := s.deps.Store.CreateSession(r.Context(), sess, auth.HashToken(token)); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
_ = s.deps.Store.MarkUserLogin(r.Context(), u.ID, now)
|
||||
|
||||
@@ -83,10 +94,17 @@ func (s *Server) handleLogin(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
Action: "auth.login",
|
||||
TS: now,
|
||||
})
|
||||
|
||||
writeJSON(w, stdhttp.StatusOK, loginResponse{UserID: u.ID, Role: string(u.Role)})
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// errInvalidCredentials is the sentinel returned by
|
||||
// authenticateAndSession for any failure that maps to a 401 in HTTP.
|
||||
var errInvalidCredentials = errAuth("invalid_credentials")
|
||||
|
||||
type errAuth string
|
||||
|
||||
func (e errAuth) Error() string { return string(e) }
|
||||
|
||||
func (s *Server) handleLogout(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
if c, err := r.Cookie(sessionCookieName); err == nil {
|
||||
_ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value))
|
||||
|
||||
@@ -6,7 +6,6 @@ package http
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
stdhttp "net/http"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
@@ -26,6 +26,10 @@ type Deps struct {
|
||||
Store *store.Store
|
||||
AEAD *crypto.AEAD
|
||||
Hub *ws.Hub
|
||||
UI *ui.Renderer
|
||||
// Version is the binary's build version, surfaced in the chrome.
|
||||
// Empty falls back to "dev".
|
||||
Version string
|
||||
// BootstrapToken (optional, populated only on first run) is the raw
|
||||
// admin-bootstrap token printed in the server logs. While set, the
|
||||
// /bootstrap endpoint accepts it to create the first admin user.
|
||||
@@ -112,10 +116,18 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Get("/agent/binary", s.handleAgentBinary)
|
||||
r.Get("/install/*", s.handleInstallAsset)
|
||||
|
||||
// UI handlers will hang off / — Phase 1 will add them.
|
||||
r.Get("/", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
|
||||
_, _ = fmt.Fprint(w, "restic-manager — UI not yet implemented")
|
||||
})
|
||||
// Static assets (Tailwind CSS bundle, future favicon).
|
||||
r.Mount("/static/", staticHandler())
|
||||
|
||||
// HTML UI. The renderer is required — fail loud if the binary
|
||||
// was built without templates (impossible in practice given
|
||||
// embed, but guards bad test wiring).
|
||||
if s.deps.UI != nil {
|
||||
r.Get("/", s.handleUIDashboard)
|
||||
r.Get("/login", s.handleUILoginGet)
|
||||
r.Post("/login", s.handleUILoginPost)
|
||||
r.Post("/logout", s.handleUILogoutPost)
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins listening. Blocks until ListenAndServe returns
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/web"
|
||||
)
|
||||
|
||||
// ----- static assets (Tailwind CSS, future favicon, etc) -------------
|
||||
|
||||
// staticHandler serves files embedded under web/static/ at /static/*.
|
||||
// Returns 404 for anything missing rather than the fs default 500.
|
||||
func staticHandler() stdhttp.Handler {
|
||||
sub, err := fs.Sub(web.FS, "static")
|
||||
if err != nil {
|
||||
// Embed.FS panics live at compile time; if Sub fails the binary
|
||||
// is genuinely broken — surface it loudly.
|
||||
panic("web: static subtree missing: " + err.Error())
|
||||
}
|
||||
return stdhttp.StripPrefix("/static/", stdhttp.FileServer(stdhttp.FS(sub)))
|
||||
}
|
||||
|
||||
// ----- session helpers ------------------------------------------------
|
||||
|
||||
// sessionUser resolves the request's session cookie to a User, or
|
||||
// (nil, nil) if the cookie is missing/expired/invalid. A non-nil
|
||||
// error means an underlying store failure; treat that as 500.
|
||||
func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value))
|
||||
if err != nil {
|
||||
// Treat "not found" / "expired" as "no session", not as fatal.
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
u, err := s.deps.Store.GetUserByID(r.Context(), sess.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil
|
||||
}
|
||||
|
||||
// requireUIUser resolves the session and 303-redirects to /login if
|
||||
// there isn't one. Returns nil + emits the redirect when unauthed.
|
||||
// (HTML twin of jobs.go's API-style requireUser, which returns 401.)
|
||||
func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui.User {
|
||||
u, err := s.sessionUser(r)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return nil
|
||||
}
|
||||
if u == nil {
|
||||
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// baseView populates the fields the nav partial needs on every
|
||||
// authenticated page.
|
||||
func (s *Server) baseView(u *ui.User, active string) ui.ViewData {
|
||||
return ui.ViewData{
|
||||
User: u,
|
||||
Active: active,
|
||||
Version: s.version(),
|
||||
}
|
||||
}
|
||||
|
||||
// version returns the binary's build version — passed in via Deps so
|
||||
// cmd/server's `var version` ends up here.
|
||||
func (s *Server) version() string {
|
||||
if s.deps.Version != "" {
|
||||
return s.deps.Version
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
// ----- handlers -------------------------------------------------------
|
||||
|
||||
// handleUIDashboard is the root page. Auth-gated; falls through to
|
||||
// /login if there is no session.
|
||||
func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
view := s.baseView(u, "dashboard")
|
||||
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
|
||||
slog.Error("ui: render dashboard", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUILoginGet renders the login form. If the user is already
|
||||
// signed in we redirect them home — login is for the unauthenticated.
|
||||
func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
if u, _ := s.sessionUser(r); u != nil {
|
||||
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
view := ui.ViewData{Version: s.version()}
|
||||
if err := s.deps.UI.Render(w, "login", view); err != nil {
|
||||
slog.Error("ui: render login", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUILoginPost consumes the form, validates, mints a session,
|
||||
// and either redirects to / on success or re-renders the form with
|
||||
// an error banner on failure.
|
||||
func (s *Server) handleUILoginPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := r.PostForm.Get("username")
|
||||
password := r.PostForm.Get("password")
|
||||
|
||||
if _, err := s.authenticateAndSession(w, r, username, password); err != nil {
|
||||
// Re-render the form. Single generic message — see
|
||||
// authenticateAndSession's note on not leaking user existence.
|
||||
view := ui.ViewData{
|
||||
Version: s.version(),
|
||||
Username: username,
|
||||
Error: "Invalid username or password.",
|
||||
}
|
||||
w.WriteHeader(stdhttp.StatusUnauthorized)
|
||||
if err := s.deps.UI.Render(w, "login", view); err != nil {
|
||||
slog.Error("ui: render login (post-fail)", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleUILogoutPost is the form-submit twin of /api/auth/logout. It
|
||||
// drops the session cookie and redirects to /login.
|
||||
func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
if c, err := r.Cookie(sessionCookieName); err == nil {
|
||||
_ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value))
|
||||
}
|
||||
stdhttp.SetCookie(w, &stdhttp.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.deps.Cfg.CookieSecure,
|
||||
SameSite: stdhttp.SameSiteLaxMode,
|
||||
})
|
||||
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
|
||||
}
|
||||
Reference in New Issue
Block a user