Merge pull request 'Phase 4 — P4-03/04: RBAC + user management' (#14) from p4-03-04-rbac-user-mgmt into main

Reviewed-on: #14
This commit is contained in:
2026-05-05 10:01:43 +00:00
35 changed files with 3064 additions and 275 deletions
+9
View File
@@ -2,10 +2,19 @@
Project-specific rules for Claude when working in this repo.
## Commands
Is the user types in any of the following, follow the instructions in the table
| Command | Action |
| --- | --- |
| :release | trigger subagent to commit (if needed), push (if needed), raise PR, wait for PR to pass or fail. If fail, report back. If pass, merge in to main |
## Repo
The repo lives inside a Gitea instance; `tea` CLI is available for use by agents
## Run `go vet` before every commit
CI runs `go vet ./...` and will fail the build on any vet error.
+8
View File
@@ -186,6 +186,14 @@ func (e *Engine) handleHostOnline(ctx context.Context, hostID string) {
// task. The KindStaleSchedule constant is exported so UI code can
// reference the tag string today.
func (e *Engine) tick(ctx context.Context, now time.Time) {
// User-management cleanup piggy-backed here for now. Setup tokens
// have a 1h expiry; the alert engine tick is the cheapest existing
// 60s loop. If more housekeeping queries appear, extract a
// dedicated maintenance loop.
if _, err := e.store.CleanupExpiredSetupTokens(ctx, now); err != nil {
slog.Warn("alert: cleanup expired setup tokens", "err", err)
}
hosts, err := e.store.ListHosts(ctx)
if err != nil {
slog.Warn("alert: tick list hosts", "err", err)
+391
View File
@@ -0,0 +1,391 @@
// api_users.go — JSON handlers for the user-management surface.
//
// All endpoints in this file are admin-only; gating happens at the
// route-mount site (server.go's admin band).
package http
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"log/slog"
stdhttp "net/http"
"net/mail"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
type listUsersResponse struct {
Users []apiUser `json:"users"`
}
type apiUser struct {
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Email *string `json:"email,omitempty"`
Disabled bool `json:"disabled"`
MustChangePassword bool `json:"must_change_password"`
CreatedAt string `json:"created_at"`
LastLoginAt *string `json:"last_login_at,omitempty"`
}
func (s *Server) handleAPIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{})
if err != nil {
slog.Error("api users: list", "err", err)
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
out := make([]apiUser, len(users))
for i, u := range users {
var lastLogin *string
if u.LastLoginAt != nil {
s := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z")
lastLogin = &s
}
out[i] = apiUser{
ID: u.ID, Username: u.Username, Role: string(u.Role),
Email: u.Email, Disabled: u.DisabledAt != nil,
MustChangePassword: u.MustChangePassword,
CreatedAt: u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
LastLoginAt: lastLogin,
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(listUsersResponse{Users: out})
}
type createUserRequest struct {
Username string `json:"username"`
Email string `json:"email,omitempty"`
Role string `json:"role"`
}
type createUserResponse struct {
ID string `json:"id"`
SetupURL string `json:"setup_url"`
}
// generateSetupToken returns 32 random bytes hex-encoded (64 chars).
func generateSetupToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
// validRole maps a wire role string to the typed constant. Returns
// ("", false) for anything unknown.
func validRole(r string) (store.Role, bool) {
switch r {
case "admin":
return store.RoleAdmin, true
case "operator":
return store.RoleOperator, true
case "viewer":
return store.RoleViewer, true
}
return "", false
}
func (s *Server) handleAPIUserCreate(w stdhttp.ResponseWriter, r *stdhttp.Request) {
actor, _ := s.requireUser(r) // already gated by middleware
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
return
}
uname := strings.ToLower(strings.TrimSpace(req.Username))
if uname == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "username_required", "")
return
}
role, ok := validRole(req.Role)
if !ok {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "")
return
}
if req.Email != "" {
if _, err := mail.ParseAddress(req.Email); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error())
return
}
}
// Check for collision against existing user (case-insensitive).
existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname)
if err == nil {
body := map[string]any{
"error": "username_taken",
"existing_user_id": existing.ID,
"disabled": existing.DisabledAt != nil,
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(stdhttp.StatusConflict)
_ = json.NewEncoder(w).Encode(body)
return
} else if !errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
id := ulid.Make().String()
now := time.Now().UTC()
var emailPtr *string
if req.Email != "" {
em := strings.ToLower(strings.TrimSpace(req.Email))
emailPtr = &em
}
if err := s.deps.Store.CreateUser(r.Context(), store.User{
ID: id, Username: uname, PasswordHash: "",
Role: role, Email: emailPtr, CreatedAt: now,
MustChangePassword: true,
}); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
rawToken, err := generateSetupToken()
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
var actorID *string
if actor != nil {
actorID = &actor.ID
}
if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
UserID: id, TokenHash: hashSetupToken(rawToken),
ExpiresAt: now.Add(time.Hour),
CreatedAt: now, CreatedBy: actorID,
}); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: actorID, Actor: "user",
Action: "user.created", TargetKind: ptr("user"), TargetID: &id,
TS: now,
})
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(stdhttp.StatusCreated)
_ = json.NewEncoder(w).Encode(createUserResponse{
ID: id,
SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken,
})
}
func (s *Server) handleAPIUserGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
id := chi.URLParam(r, "id")
u, err := s.deps.Store.GetUserByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
return
}
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
out := apiUser{
ID: u.ID, Username: u.Username, Role: string(u.Role),
Email: u.Email, Disabled: u.DisabledAt != nil,
MustChangePassword: u.MustChangePassword,
CreatedAt: u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if u.LastLoginAt != nil {
ll := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z")
out.LastLoginAt = &ll
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(out)
}
type patchUserRequest struct {
Role *string `json:"role,omitempty"`
Email *string `json:"email,omitempty"`
}
func (s *Server) handleAPIUserPatch(w stdhttp.ResponseWriter, r *stdhttp.Request) {
actor, _ := s.requireUser(r)
id := chi.URLParam(r, "id")
u, err := s.deps.Store.GetUserByID(r.Context(), id)
if err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
return
}
var req patchUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.Role != nil {
newRole, ok := validRole(*req.Role)
if !ok {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "")
return
}
// Last-admin guard: cannot demote the only enabled admin.
if u.Role == store.RoleAdmin && newRole != store.RoleAdmin && u.DisabledAt == nil {
n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
if n <= 1 {
writeJSONError(w, stdhttp.StatusConflict, "last_admin", "")
return
}
}
if err := s.deps.Store.SetUserRole(r.Context(), id, newRole); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
}
if req.Email != nil {
em := strings.TrimSpace(*req.Email)
if em != "" {
if _, err := mail.ParseAddress(em); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error())
return
}
}
if err := s.deps.Store.SetUserEmail(r.Context(), id, em); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
}
var actorID *string
if actor != nil {
actorID = &actor.ID
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: actorID, Actor: "user",
Action: "user.updated", TargetKind: ptr("user"), TargetID: &id,
TS: time.Now().UTC(),
})
w.WriteHeader(stdhttp.StatusOK)
}
func (s *Server) handleAPIUserDisable(w stdhttp.ResponseWriter, r *stdhttp.Request) {
actor, _ := s.requireUser(r)
id := chi.URLParam(r, "id")
u, err := s.deps.Store.GetUserByID(r.Context(), id)
if err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
return
}
if u.Role == store.RoleAdmin && u.DisabledAt == nil {
n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
if n <= 1 {
writeJSONError(w, stdhttp.StatusConflict, "last_admin", "")
return
}
}
now := time.Now().UTC()
if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
// Kick existing sessions so the user is bounced immediately.
_, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
var actorID *string
if actor != nil {
actorID = &actor.ID
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: actorID, Actor: "user",
Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id,
TS: now,
})
w.WriteHeader(stdhttp.StatusOK)
}
func (s *Server) handleAPIUserEnable(w stdhttp.ResponseWriter, r *stdhttp.Request) {
actor, _ := s.requireUser(r)
id := chi.URLParam(r, "id")
if err := s.deps.Store.EnableUser(r.Context(), id); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
var actorID *string
if actor != nil {
actorID = &actor.ID
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: actorID, Actor: "user",
Action: "user.enabled", TargetKind: ptr("user"), TargetID: &id,
TS: time.Now().UTC(),
})
w.WriteHeader(stdhttp.StatusOK)
}
type regenerateSetupResponse struct {
SetupURL string `json:"setup_url"`
}
func (s *Server) handleAPIUserRegenerateSetup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
actor, _ := s.requireUser(r)
id := chi.URLParam(r, "id")
if _, err := s.deps.Store.GetUserByID(r.Context(), id); err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
return
}
rawToken, err := generateSetupToken()
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
now := time.Now().UTC()
var actorID *string
if actor != nil {
actorID = &actor.ID
}
if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
UserID: id, TokenHash: hashSetupToken(rawToken),
ExpiresAt: now.Add(time.Hour),
CreatedAt: now, CreatedBy: actorID,
}); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
if err := s.deps.Store.SetMustChangePassword(r.Context(), id, true); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: actorID, Actor: "user",
Action: "user.setup_token.regenerated",
TargetKind: ptr("user"), TargetID: &id, TS: now,
})
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(regenerateSetupResponse{
SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken,
})
}
func (s *Server) handleAPIUserForceLogout(w stdhttp.ResponseWriter, r *stdhttp.Request) {
actor, _ := s.requireUser(r)
id := chi.URLParam(r, "id")
n, err := s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
var actorID *string
if actor != nil {
actorID = &actor.ID
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: actorID, Actor: "user",
Action: "user.force_logout",
TargetKind: ptr("user"), TargetID: &id,
TS: time.Now().UTC(),
})
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]int64{"sessions_killed": n})
}
+3
View File
@@ -59,6 +59,9 @@ func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Req
if err := auth.VerifyPassword(u.PasswordHash, password); err != nil {
return nil, errInvalidCredentials
}
if u.DisabledAt != nil {
return nil, errInvalidCredentials
}
token, err := auth.NewToken()
if err != nil {
+6
View File
@@ -152,6 +152,12 @@ func (s *Server) requireUser(r *stdhttp.Request) (*store.User, bool) {
if err != nil {
return nil, false
}
if u.DisabledAt != nil {
// Disabled mid-session — kill the session and reject the
// request as if it were unauthenticated.
_ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value))
return nil, false
}
return u, true
}
+87
View File
@@ -0,0 +1,87 @@
package http
import (
stdhttp "net/http"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// rank maps each role to a numeric tier so 'A is at least B' becomes
// 'rank[A] >= rank[B] && both are known'. Unknown roles return 0 →
// fail-closed against either argument.
var roleRank = map[store.Role]int{
store.RoleViewer: 1,
store.RoleOperator: 2,
store.RoleAdmin: 3,
}
// roleAtLeast reports whether `have` meets or exceeds `min` in the
// admin > operator > viewer hierarchy. Either side being an unknown
// role returns false.
func roleAtLeast(have, min store.Role) bool {
h, hok := roleRank[have]
m, mok := roleRank[min]
if !hok || !mok {
return false
}
return h >= m
}
// requireRole returns chi middleware that 403s any request whose
// session-resolved user doesn't meet the minimum role. Unauthenticated
// requests return 401 (JSON) or 303 → /login (HTML) so the caller
// gets a usable error rather than a confusing 403.
//
// The middleware re-reads the user row on every request — by the time
// you read this you might be tempted to cache; don't. SQLite's WAL
// makes the lookup cheap and admin-driven changes (disable, role
// change) need to land immediately.
func (s *Server) requireRole(min store.Role) func(stdhttp.Handler) stdhttp.Handler {
return func(next stdhttp.Handler) stdhttp.Handler {
return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u, ok := s.requireUser(r)
if !ok {
if isAPIPath(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
return
}
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
return
}
if !roleAtLeast(u.Role, min) {
if isAPIPath(r) {
writeJSONError(w, stdhttp.StatusForbidden, "insufficient_role", "")
return
}
renderForbiddenHTML(s, w, r, u, min)
return
}
next.ServeHTTP(w, r)
})
}
}
// isAPIPath reports whether the path lives under /api/. Lets one
// middleware return JSON or HTML appropriately without two near-
// identical wrappers.
func isAPIPath(r *stdhttp.Request) bool {
p := r.URL.Path
return len(p) >= 5 && p[:5] == "/api/"
}
// renderForbiddenHTML emits a small "you don't have permission"
// panel inside the chrome so the user keeps their nav and can
// move away to a page they can see.
func renderForbiddenHTML(s *Server, w stdhttp.ResponseWriter, r *stdhttp.Request, u *store.User, min store.Role) {
w.WriteHeader(stdhttp.StatusForbidden)
view := s.baseView(r, &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)})
view.Title = "Forbidden · restic-manager"
view.Page = struct {
Required string
Have string
}{Required: string(min), Have: string(u.Role)}
if err := s.deps.UI.Render(w, "forbidden", view); err != nil {
_, _ = w.Write([]byte("403 Forbidden — your role does not permit this page."))
}
}
+162
View File
@@ -0,0 +1,162 @@
package http
import (
"bytes"
"encoding/json"
stdhttp "net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func TestRoleAtLeast(t *testing.T) {
t.Parallel()
cases := []struct {
have store.Role
min store.Role
want bool
}{
{store.RoleViewer, store.RoleViewer, true},
{store.RoleOperator, store.RoleViewer, true},
{store.RoleAdmin, store.RoleViewer, true},
{store.RoleAdmin, store.RoleOperator, true},
{store.RoleAdmin, store.RoleAdmin, true},
{store.RoleViewer, store.RoleOperator, false},
{store.RoleViewer, store.RoleAdmin, false},
{store.RoleOperator, store.RoleAdmin, false},
{store.Role("nonsense"), store.RoleViewer, false},
{store.RoleAdmin, store.Role("nonsense"), false},
}
for _, c := range cases {
got := roleAtLeast(c.have, c.min)
if got != c.want {
t.Errorf("have=%q min=%q: got %v want %v", c.have, c.min, got, c.want)
}
}
}
func TestRequireRoleViewerAdmits(t *testing.T) {
t.Parallel()
srv, _ := newTestServer(t, false)
uid := makeUser(t, srv, "viewer1", store.RoleViewer)
cookie := loginAs(t, srv, uid)
mid := srv.requireRole(store.RoleViewer)
h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
w.WriteHeader(stdhttp.StatusOK)
}))
rr := httptest.NewRecorder()
req, _ := stdhttp.NewRequest("GET", "/api/dummy", nil)
req.AddCookie(cookie)
h.ServeHTTP(rr, req)
if rr.Code != stdhttp.StatusOK {
t.Errorf("status: got %d want 200", rr.Code)
}
}
func TestRequireRoleViewerRejectedFromOperator(t *testing.T) {
t.Parallel()
srv, _ := newTestServer(t, false)
uid := makeUser(t, srv, "viewer2", store.RoleViewer)
cookie := loginAs(t, srv, uid)
mid := srv.requireRole(store.RoleOperator)
h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
w.WriteHeader(stdhttp.StatusOK)
}))
rr := httptest.NewRecorder()
req, _ := stdhttp.NewRequest("GET", "/api/dummy", nil)
req.AddCookie(cookie)
h.ServeHTTP(rr, req)
if rr.Code != stdhttp.StatusForbidden {
t.Errorf("status: got %d want 403", rr.Code)
}
if !strings.Contains(rr.Body.String(), "insufficient_role") {
t.Errorf("body: got %q", rr.Body.String())
}
}
func TestRequireRoleUnauthenticated401OnAPI(t *testing.T) {
t.Parallel()
srv, _ := newTestServer(t, false)
mid := srv.requireRole(store.RoleViewer)
h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
w.WriteHeader(stdhttp.StatusOK)
}))
rr := httptest.NewRecorder()
req, _ := stdhttp.NewRequest("GET", "/api/dummy", nil)
h.ServeHTTP(rr, req)
if rr.Code != stdhttp.StatusUnauthorized {
t.Errorf("status: got %d want 401", rr.Code)
}
}
func TestRequireRoleRejectsDisabledMidSession(t *testing.T) {
t.Parallel()
srv, urlBase := newTestServer(t, false)
uid := makeUser(t, srv, "victim", store.RoleOperator)
cookie := loginAs(t, srv, uid)
// Disable the user *while their session is still valid*.
if err := srv.deps.Store.DisableUser(t.Context(), uid, time.Now().UTC()); err != nil {
t.Fatalf("disable: %v", err)
}
req, _ := stdhttp.NewRequest("GET", urlBase+"/api/hosts", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusUnauthorized {
t.Errorf("status: got %d want 401", res.StatusCode)
}
}
func TestLoginRejectsDisabledUser(t *testing.T) {
t.Parallel()
srv, urlBase := newTestServer(t, false)
uid := makeUser(t, srv, "disabled1", store.RoleOperator)
if err := srv.deps.Store.DisableUser(t.Context(), uid, time.Now().UTC()); err != nil {
t.Fatalf("disable: %v", err)
}
body, _ := json.Marshal(map[string]string{
"username": "disabled1", "password": "test-password",
})
res, err := stdhttp.Post(urlBase+"/api/auth/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusUnauthorized {
t.Errorf("status: got %d want 401", res.StatusCode)
}
}
func TestAdminBandRejectsOperator(t *testing.T) {
t.Parallel()
srv, urlBase := newTestServer(t, false)
makeUser(t, srv, "admin1", store.RoleAdmin)
opID := makeUser(t, srv, "op1", store.RoleOperator)
cookie := loginAs(t, srv, opID)
req, _ := stdhttp.NewRequest("GET", urlBase+"/api/users", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusForbidden {
t.Errorf("status: got %d want 403", res.StatusCode)
}
}
+156 -216
View File
@@ -85,11 +85,6 @@ func New(deps Deps) *Server {
r.Use(middleware.Recoverer)
r.Use(requestLogger)
// Health endpoint — unauthenticated, no audit, deliberately cheap.
r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
w.WriteHeader(stdhttp.StatusNoContent)
})
s := &Server{
deps: deps,
drainLocks: make(map[string]*sync.Mutex),
@@ -113,132 +108,17 @@ func New(deps Deps) *Server {
// routes wires the API tree. Subtrees live in this file by area so a
// reader can scan one place and see the surface.
func (s *Server) routes(r chi.Router) {
r.Route("/api", func(r chi.Router) {
r.Post("/auth/login", s.handleLogin)
r.Post("/auth/logout", s.handleLogout)
r.Post("/bootstrap", s.handleBootstrap)
// Agent enrollment (open endpoint — token is the credential).
r.Post("/agents/enroll", s.handleAgentEnroll)
// Announce-and-approve enrolment (open endpoint — fingerprint
// comparison in the UI is the gate). Per-IP rate-limited and
// globally capped (P2-18).
r.Post("/agents/announce", s.handleAnnounce)
// Pending host management — admin-only (gated inside the handler).
r.Post("/pending-hosts/{id}/accept", s.handleAcceptPendingHost)
r.Post("/pending-hosts/{id}/reject", s.handleRejectPendingHost)
// Operator → server (authenticated). Spec.md §6.1's
// /hosts/{id}/enrollment-token (regenerate) lands when the
// host page can call it; for now just the create endpoint.
r.Post("/enrollment-tokens", s.handleCreateEnrollmentToken)
// Fleet read endpoints — back the dashboard.
r.Get("/hosts", s.handleListHosts)
r.Get("/fleet/summary", s.handleFleetSummary)
// Run-now: dispatch a job to a host's agent.
r.Post("/hosts/{id}/jobs", s.handleRunNow)
// Snapshot projection (refreshed by the agent after each backup).
r.Get("/hosts/{id}/snapshots", s.handleListHostSnapshots)
// Repo credentials — operator can edit after enrollment. The
// initial set is supplied at token-mint time (see enrollment.go).
// GET returns a redacted view (URL, username, has_password).
r.Get("/hosts/{id}/repo-credentials", s.handleGetHostCredentials)
r.Put("/hosts/{id}/repo-credentials", s.handleSetHostCredentials)
// Admin credentials — the prune-capable slot (separate from the
// everyday repo creds). Optional: hosts that don't prune against
// a rest-server repo with a separate admin user never need this.
r.Get("/hosts/{id}/admin-credentials", s.handleGetAdminCredentials)
r.Put("/hosts/{id}/admin-credentials", s.handleSetAdminCredentials)
r.Delete("/hosts/{id}/admin-credentials", s.handleDeleteAdminCredentials)
// Per-host schedule CRUD. Mutations bump host_schedule_version
// and async-push to a connected agent (see schedule_push.go).
r.Get("/hosts/{id}/schedules", s.handleListSchedules)
r.Post("/hosts/{id}/schedules", s.handleCreateSchedule)
r.Put("/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule)
r.Delete("/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule)
// Source-group CRUD. A group is "what gets backed up" — paths,
// excludes, retention, retry. Group name doubles as the
// snapshot tag (restic --tag <name>).
r.Get("/hosts/{id}/source-groups", s.handleListSourceGroups)
r.Post("/hosts/{id}/source-groups", s.handleCreateSourceGroup)
r.Get("/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup)
r.Put("/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup)
r.Delete("/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup)
// Repo maintenance cadences (forget / prune / check). Driven
// by the server-side ticker (P2R-06), not the agent's cron.
r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance)
r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance)
// Host-wide bandwidth caps (host.bandwidth_up_kbps /
// bandwidth_down_kbps). Apply to every restic invocation.
r.Put("/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth)
// Per-source-group Run-now (JSON variant). HTMX action is
// mounted at the equivalent path outside /api below — both
// resolve to the same handler, which sniffs HX-Request.
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
// Repo-level run-now: prune (needs admin creds), check, unlock.
// HTMX forms are also mounted outside /api below.
r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune)
r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck)
r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
// Cancel a running job. Operator-driven, sends command.cancel
// to the agent which kills the restic subprocess; the agent's
// resulting job.finished (status=canceled) is what flips the
// job row.
r.Post("/jobs/{id}/cancel", s.handleCancelJob)
// Snapshot diff (P3-09). Dispatches a JobDiff against two
// snapshots; output streams to the standard live job page.
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
// Alert list (JSON variant). Same filter shape as the UI page.
r.Get("/alerts", s.handleAPIAlerts)
// Audit log (JSON variant).
r.Get("/audit", s.handleAPIAudit)
// Notification channel test-fire. Dispatches a synthetic payload
// through a single named channel; returns JSON result.
r.Post("/notifications/{id}/test", s.handleAPINotificationTest)
// Public, unauthenticated.
r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
w.WriteHeader(stdhttp.StatusNoContent)
})
// HTMX form variant of diff (mounted outside /api so HTMX forms
// can post against it without the api/ prefix).
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
// Per-source-group Run-now (HTMX form action). Available even
// when the server is started without UI templates so REST callers
// against the non-/api path also work.
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
// Repo-level run-now (HTMX form actions). Same handlers as the /api
// variants — wantsHTML sniff distinguishes JSON vs HTMX response.
r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune)
r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck)
r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
// Retired routes — see ui_handlers.go for the messages. Mounted
// outside the UI gate so cached browser tabs get a clear 410
// even if the server runs without templates.
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone)
r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone)
// Pending-host WebSocket (announce-and-approve, P2-18b). Mounted
// before /ws/agent so the more-specific route matches first.
r.Get("/ws/agent/pending", s.handlePendingWS)
// Agent ↔ server WebSocket. Bearer-authenticated inside the handler.
r.Post("/api/auth/login", s.handleLogin)
r.Post("/api/auth/logout", s.handleLogout)
r.Post("/api/bootstrap", s.handleBootstrap)
r.Post("/api/agents/enroll", s.handleAgentEnroll)
r.Post("/api/agents/announce", s.handleAnnounce)
r.Get("/agent/binary", s.handleAgentBinary)
r.Get("/install/*", s.handleInstallAsset)
if s.deps.Hub != nil {
r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{
Hub: s.deps.Hub,
@@ -250,101 +130,161 @@ func (s *Server) routes(r chi.Router) {
OnScheduleFire: s.dispatchScheduledJob,
}))
}
// Agent binaries + install scripts. Open endpoints — content is
// unprivileged on its own, gating happens via the enrollment
// token. See agent_assets.go.
r.Get("/agent/binary", s.handleAgentBinary)
r.Get("/install/*", s.handleInstallAsset)
// Static assets (Tailwind CSS bundle, future favicon).
r.Get("/ws/agent/pending", s.handlePendingWS)
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)
// Per-host Run-now and manual Init-repo are mounted at the
// outer router (so they reply 410 even without UI). Per-
// source-group Run-now lives there too — same reason.
// Add host flow.
r.Get("/hosts/new", s.handleUIAddHostGet)
r.Post("/hosts/new", s.handleUIAddHostPost)
// Durable post-Add-host page (operator can refresh / come
// back; password decrypted from the token row each render).
// Polled fragment under /awaiting flips to "connected" once
// the agent enrols.
r.Get("/hosts/pending/{token}", s.handleUIPendingHost)
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
// Host detail (Snapshots tab is the default).
r.Get("/hosts/{id}", s.handleUIHostDetail)
// Sources tab + source-group CRUD forms.
r.Get("/hosts/{id}/sources", s.handleUIHostSources)
r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet)
r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave)
r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet)
r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave)
r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete)
// Repo tab — connection / bandwidth / maintenance. Three
// independent forms so saving one doesn't touch the others.
r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave)
r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave)
r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
// Admin credentials form (separate slot for prune-capable user).
r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave)
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
// Schedules tab + create/edit/delete forms.
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet)
r.Post("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleSave)
r.Post("/hosts/{id}/schedules/{sid}/delete", s.handleUIScheduleDelete)
r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun)
// Live job log.
r.Get("/jobs/{id}", s.handleUIJobDetail)
// Restore wizard (P3-01/P3-02). Two GET variants land on the
// same handler; the second deep-links a chosen snapshot.
r.Get("/hosts/{id}/restore", s.handleUIRestoreGet)
r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet)
r.Post("/hosts/{id}/restore", s.handleUIRestorePost)
r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree)
// Alerts list + operator actions.
r.Get("/alerts", s.handleUIAlerts)
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
// Audit log (read-only).
r.Get("/audit", s.handleUIAudit)
r.Get("/audit.csv", s.handleUIAuditCSV)
// Settings shell + Notifications sub-tab CRUD.
r.Get("/settings", s.handleUISettings)
r.Get("/settings/notifications", s.handleUINotificationsList)
r.Get("/settings/notifications/new", s.handleUINotificationNewGet)
r.Post("/settings/notifications/new", s.handleUINotificationNewPost)
r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet)
r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost)
r.Post("/settings/notifications/{id}/delete", s.handleUINotificationDelete)
r.Post("/settings/notifications/{id}/toggle", s.handleUINotificationToggle)
r.Get("/setup", s.handleUISetupGet)
r.Post("/setup", s.handleUISetupPost)
}
// Browser job-log stream (separate from /ws/agent so the auth
// layer is session-cookie not bearer). Mounted regardless of
// whether the UI is up — JSON callers may also subscribe.
if s.deps.JobHub != nil {
r.Get("/api/jobs/{id}/stream", s.handleJobStream)
}
// Viewer band — anyone authenticated can read.
r.Group(func(r chi.Router) {
r.Use(s.requireRole(store.RoleViewer))
// Job log download (txt + ndjson). Source of truth is the
// persisted job_logs table; safe to call any time, no pause
// needed against the live stream.
r.Get("/api/jobs/{id}/log.{format:txt|ndjson}", s.handleJobLogDownload)
// Read APIs.
r.Get("/api/hosts", s.handleListHosts)
r.Get("/api/fleet/summary", s.handleFleetSummary)
r.Get("/api/hosts/{id}/snapshots", s.handleListHostSnapshots)
r.Get("/api/hosts/{id}/repo-credentials", s.handleGetHostCredentials)
r.Get("/api/hosts/{id}/admin-credentials", s.handleGetAdminCredentials)
r.Get("/api/hosts/{id}/schedules", s.handleListSchedules)
r.Get("/api/hosts/{id}/source-groups", s.handleListSourceGroups)
r.Get("/api/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup)
r.Get("/api/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance)
r.Get("/api/alerts", s.handleAPIAlerts)
r.Get("/api/audit", s.handleAPIAudit)
r.Post("/api/account/password", s.handleAPIAccountPassword)
// Job log stream + download (read-only; any authenticated user).
if s.deps.JobHub != nil {
r.Get("/api/jobs/{id}/stream", s.handleJobStream)
}
r.Get("/api/jobs/{id}/log.{format:txt|ndjson}", s.handleJobLogDownload)
if s.deps.UI != nil {
r.Get("/", s.handleUIDashboard)
r.Get("/hosts/{id}", s.handleUIHostDetail)
r.Get("/hosts/{id}/sources", s.handleUIHostSources)
r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet)
r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet)
r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet)
r.Get("/jobs/{id}", s.handleUIJobDetail)
r.Get("/hosts/{id}/restore", s.handleUIRestoreGet)
r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet)
r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree)
r.Get("/alerts", s.handleUIAlerts)
r.Get("/audit", s.handleUIAudit)
r.Get("/audit.csv", s.handleUIAuditCSV)
r.Get("/settings/account", s.handleUIAccountGet)
r.Post("/settings/account", s.handleUIAccountPost)
}
})
// Operator band — mutating endpoints up to backup ops.
r.Group(func(r chi.Router) {
r.Use(s.requireRole(store.RoleOperator))
// Pending hosts approval.
r.Post("/api/pending-hosts/{id}/accept", s.handleAcceptPendingHost)
r.Post("/api/pending-hosts/{id}/reject", s.handleRejectPendingHost)
r.Post("/api/enrollment-tokens", s.handleCreateEnrollmentToken)
// Run-now, restore, repo ops (JSON).
r.Post("/api/hosts/{id}/jobs", s.handleRunNow)
r.Put("/api/hosts/{id}/repo-credentials", s.handleSetHostCredentials)
r.Put("/api/hosts/{id}/admin-credentials", s.handleSetAdminCredentials)
r.Delete("/api/hosts/{id}/admin-credentials", s.handleDeleteAdminCredentials)
r.Post("/api/hosts/{id}/schedules", s.handleCreateSchedule)
r.Put("/api/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule)
r.Delete("/api/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule)
r.Post("/api/hosts/{id}/source-groups", s.handleCreateSourceGroup)
r.Put("/api/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup)
r.Delete("/api/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup)
r.Put("/api/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance)
r.Put("/api/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth)
r.Post("/api/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
r.Post("/api/hosts/{id}/repo/prune", s.handleRunRepoPrune)
r.Post("/api/hosts/{id}/repo/check", s.handleRunRepoCheck)
r.Post("/api/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
r.Post("/api/jobs/{id}/cancel", s.handleCancelJob)
r.Post("/api/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
// HTMX form variants outside /api.
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune)
r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck)
r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone)
r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone)
if s.deps.UI != nil {
r.Get("/hosts/new", s.handleUIAddHostGet)
r.Post("/hosts/new", s.handleUIAddHostPost)
r.Get("/hosts/pending/{token}", s.handleUIPendingHost)
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave)
r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave)
r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete)
r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave)
r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave)
r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave)
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
r.Post("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleSave)
r.Post("/hosts/{id}/schedules/{sid}/delete", s.handleUIScheduleDelete)
r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun)
r.Post("/hosts/{id}/restore", s.handleUIRestorePost)
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
}
})
// Admin band — channels, server-shape config.
r.Group(func(r chi.Router) {
r.Use(s.requireRole(store.RoleAdmin))
r.Get("/api/users", s.handleAPIUsersList)
r.Post("/api/users", s.handleAPIUserCreate)
r.Get("/api/users/{id}", s.handleAPIUserGet)
r.Patch("/api/users/{id}", s.handleAPIUserPatch)
r.Post("/api/users/{id}/disable", s.handleAPIUserDisable)
r.Post("/api/users/{id}/enable", s.handleAPIUserEnable)
r.Post("/api/users/{id}/regenerate-setup", s.handleAPIUserRegenerateSetup)
r.Post("/api/users/{id}/force-logout", s.handleAPIUserForceLogout)
r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest)
if s.deps.UI != nil {
r.Get("/settings", s.handleUISettings)
r.Get("/settings/users", s.handleUIUsersList)
r.Get("/settings/users/new", s.handleUIUserNewGet)
r.Post("/settings/users/new", s.handleUIUserNewPost)
r.Get("/settings/users/{id}/edit", s.handleUIUserEditGet)
r.Post("/settings/users/{id}/edit", s.handleUIUserEditPost)
r.Post("/settings/users/{id}/disable", s.handleUIUserDisablePost)
r.Post("/settings/users/{id}/enable", s.handleUIUserEnablePost)
r.Post("/settings/users/{id}/regenerate-setup", s.handleUIUserRegenerateSetupPost)
r.Post("/settings/users/{id}/force-logout", s.handleUIUserForceLogoutPost)
r.Get("/settings/users/{id}/setup-link", s.handleUIUserSetupLinkGet)
r.Get("/settings/notifications", s.handleUINotificationsList)
r.Get("/settings/notifications/new", s.handleUINotificationNewGet)
r.Post("/settings/notifications/new", s.handleUINotificationNewPost)
r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet)
r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost)
r.Post("/settings/notifications/{id}/delete", s.handleUINotificationDelete)
r.Post("/settings/notifications/{id}/toggle", s.handleUINotificationToggle)
}
})
}
// Start begins listening. Blocks until ListenAndServe returns
+177
View File
@@ -0,0 +1,177 @@
// setup_handler.go — public landing page for the user-setup link
// emitted by the admin's "+ Add user" / "Regenerate setup link" flow.
//
// Routes (wired in server.go):
//
// GET /setup → handleUISetupGet
// POST /setup → handleUISetupPost (lands in Task D2)
//
// The token in the querystring (`?token=<raw>`) is the credential.
// Auth middleware does not run on these routes.
package http
import (
"crypto/sha256"
"encoding/hex"
"log/slog"
stdhttp "net/http"
"time"
"github.com/oklog/ulid/v2"
"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"
)
type setupPage struct {
Username string
Token string // round-tripped to the POST form
Error string // displayed when password validation fails or token is invalid
}
// hashSetupToken is the canonical hashing for setup tokens. Must
// match what the admin handler uses when SetSetupToken is called,
// so the digest at rest matches what GET /setup hashes.
func hashSetupToken(raw string) string {
h := sha256.Sum256([]byte(raw))
return hex.EncodeToString(h[:])
}
func (s *Server) handleUISetupGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
raw := r.URL.Query().Get("token")
if raw == "" {
s.renderSetupExpired(w, r)
return
}
tok, err := s.deps.Store.LookupSetupToken(r.Context(), hashSetupToken(raw))
if err != nil {
s.renderSetupExpired(w, r)
return
}
if tok.ExpiresAt.Before(time.Now().UTC()) {
s.renderSetupExpired(w, r)
return
}
u, err := s.deps.Store.GetUserByID(r.Context(), tok.UserID)
if err != nil {
s.renderSetupExpired(w, r)
return
}
view := s.baseView(r, nil)
view.Title = "Set your password · restic-manager"
view.Page = setupPage{Username: u.Username, Token: raw}
if err := s.deps.UI.Render(w, "setup", view); err != nil {
slog.Error("ui setup: render", "err", err)
}
}
func (s *Server) renderSetupExpired(w stdhttp.ResponseWriter, r *stdhttp.Request) {
w.WriteHeader(stdhttp.StatusGone)
view := s.baseView(r, nil)
view.Title = "Link expired · restic-manager"
view.Page = setupPage{Error: "expired"}
_ = s.deps.UI.Render(w, "setup", view)
_ = ui.User{} // keep ui import alive
}
func (s *Server) handleUISetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
raw := r.PostForm.Get("token")
pw := r.PostForm.Get("password")
pw2 := r.PostForm.Get("password_confirm")
if raw == "" {
s.renderSetupExpired(w, r)
return
}
if pw == "" || pw2 == "" || pw != pw2 || len(pw) < 12 {
s.renderSetupForm(w, r, raw, "Passwords must match and be at least 12 characters.")
return
}
tok, err := s.deps.Store.LookupSetupToken(r.Context(), hashSetupToken(raw))
if err != nil || tok.ExpiresAt.Before(time.Now().UTC()) {
s.renderSetupExpired(w, r)
return
}
u, err := s.deps.Store.GetUserByID(r.Context(), tok.UserID)
if err != nil {
s.renderSetupExpired(w, r)
return
}
hash, err := auth.HashPassword(pw)
if err != nil {
slog.Error("setup: hash password", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err := s.deps.Store.SetPasswordHash(r.Context(), u.ID, hash); err != nil {
slog.Error("setup: set password", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err := s.deps.Store.DeleteSetupToken(r.Context(), u.ID); err != nil {
slog.Warn("setup: delete token", "err", err)
// Non-fatal — password is set, audit will reflect it.
}
// Drop a session cookie so the user lands authenticated on /.
rawSession, err := auth.NewToken()
if err != nil {
slog.Error("setup: session token", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
hashed := auth.HashToken(rawSession)
now := time.Now().UTC()
if err := s.deps.Store.CreateSession(r.Context(), store.Session{
ID: hashed, UserID: u.ID, CreatedAt: now,
ExpiresAt: now.Add(8 * time.Hour),
}, hashed); err != nil {
slog.Error("setup: create session", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
stdhttp.SetCookie(w, &stdhttp.Cookie{
Name: sessionCookieName, Value: rawSession,
Path: "/", HttpOnly: true,
SameSite: stdhttp.SameSiteLaxMode,
Secure: s.deps.Cfg.CookieSecure,
Expires: now.Add(8 * time.Hour),
})
// Record the login so the users-list "Last login" column shows
// the moment they completed setup (the regular /login path does
// the same; we'd otherwise leave the row showing "never").
_ = s.deps.Store.MarkUserLogin(r.Context(), u.ID, now)
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(),
UserID: &u.ID,
Actor: "user",
Action: "user.setup_completed",
TargetKind: ptr("user"),
TargetID: &u.ID,
TS: now,
})
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
}
// renderSetupForm re-renders the setup page with an inline error
// (e.g. password mismatch). 200 OK with the form intact so the user
// can correct without losing the token.
func (s *Server) renderSetupForm(w stdhttp.ResponseWriter, r *stdhttp.Request, token, errMsg string) {
view := s.baseView(r, nil)
view.Title = "Set your password · restic-manager"
username := ""
if tok, err := s.deps.Store.LookupSetupToken(r.Context(), hashSetupToken(token)); err == nil {
if u, err := s.deps.Store.GetUserByID(r.Context(), tok.UserID); err == nil {
username = u.Username
}
}
view.Page = setupPage{Username: username, Token: token, Error: errMsg}
_ = s.deps.UI.Render(w, "setup", view)
}
+152
View File
@@ -0,0 +1,152 @@
package http
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
stdhttp "net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func sha256Hex(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
func TestSetupGetValidToken(t *testing.T) {
t.Parallel()
// /setup renders HTML, so we need a real UI renderer.
srv, ts, _ := rawTestServerWithUI(t)
urlBase := ts.URL
now := time.Now().UTC()
uid := ulid.Make().String()
if err := srv.deps.Store.CreateUser(t.Context(), store.User{
ID: uid, Username: "newbie", PasswordHash: "",
Role: store.RoleOperator, CreatedAt: now,
MustChangePassword: true,
}); err != nil {
t.Fatalf("create: %v", err)
}
raw := "raw-token-1234567890"
hash := sha256Hex(raw)
if err := srv.deps.Store.SetSetupToken(context.Background(), store.SetupToken{
UserID: uid, TokenHash: hash,
ExpiresAt: now.Add(time.Hour), CreatedAt: now,
}); err != nil {
t.Fatalf("set token: %v", err)
}
res, err := stdhttp.Get(urlBase + "/setup?token=" + raw)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
t.Errorf("status: got %d want 200", res.StatusCode)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "newbie") {
t.Errorf("expected username in body: %s", body)
}
}
func TestSetupGetExpiredToken(t *testing.T) {
t.Parallel()
// /setup renders HTML, so we need a real UI renderer.
srv, ts, _ := rawTestServerWithUI(t)
urlBase := ts.URL
now := time.Now().UTC()
uid := ulid.Make().String()
_ = srv.deps.Store.CreateUser(t.Context(), store.User{
ID: uid, Username: "stale",
PasswordHash: "", Role: store.RoleViewer, CreatedAt: now,
MustChangePassword: true,
})
raw := "expired-token"
_ = srv.deps.Store.SetSetupToken(context.Background(), store.SetupToken{
UserID: uid, TokenHash: sha256Hex(raw),
ExpiresAt: now.Add(-time.Minute), CreatedAt: now.Add(-2 * time.Hour),
})
res, err := stdhttp.Get(urlBase + "/setup?token=" + raw)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusGone {
t.Errorf("status: got %d want 410", res.StatusCode)
}
}
func TestSetupPostHappyPath(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
urlBase := ts.URL
now := time.Now().UTC()
uid := ulid.Make().String()
_ = srv.deps.Store.CreateUser(t.Context(), store.User{
ID: uid, Username: "newbie",
PasswordHash: "", Role: store.RoleOperator, CreatedAt: now,
MustChangePassword: true,
})
raw := "happy-token"
_ = srv.deps.Store.SetSetupToken(t.Context(), store.SetupToken{
UserID: uid, TokenHash: sha256Hex(raw),
ExpiresAt: now.Add(time.Hour), CreatedAt: now,
})
form := url.Values{}
form.Set("token", raw)
form.Set("password", "averylongpassword")
form.Set("password_confirm", "averylongpassword")
req, _ := stdhttp.NewRequest("POST", urlBase+"/setup",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
c := &stdhttp.Client{CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error {
return stdhttp.ErrUseLastResponse
}}
res, err := c.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusSeeOther {
t.Errorf("status: got %d want 303", res.StatusCode)
}
if res.Header.Get("Location") != "/" {
t.Errorf("location: got %q want /", res.Header.Get("Location"))
}
// Token is consumed.
if _, err := srv.deps.Store.LookupSetupToken(t.Context(), sha256Hex(raw)); err == nil {
t.Error("token should be deleted after consumption")
}
// User can now log in via the normal route.
logBody, _ := json.Marshal(map[string]string{
"username": "newbie", "password": "averylongpassword",
})
loginRes, _ := stdhttp.Post(urlBase+"/api/auth/login",
"application/json", bytes.NewReader(logBody))
defer loginRes.Body.Close()
if loginRes.StatusCode != stdhttp.StatusOK {
body, _ := io.ReadAll(loginRes.Body)
t.Errorf("login: %d %s", loginRes.StatusCode, body)
}
}
+154
View File
@@ -0,0 +1,154 @@
// ui_account.go — self-service account surface (password change).
//
// Routes (wired in server.go):
//
// POST /api/account/password — JSON change-password (mounted in viewer band)
// GET /settings/account — page (lands in Task F4)
// POST /settings/account — page submit (lands in Task F4)
package http
import (
"encoding/json"
stdhttp "net/http"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
type passwordChangeRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
func (s *Server) handleAPIAccountPassword(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u, ok := s.requireUser(r)
if !ok {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
return
}
var req passwordChangeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
return
}
if len(req.NewPassword) < 12 {
writeJSONError(w, stdhttp.StatusBadRequest, "password_too_short", "min 12 chars")
return
}
// Skip current-password check when must_change_password is set —
// the user has no current password to know (only matters for the
// legacy reset-password path; setup-token path doesn't use this).
if !u.MustChangePassword {
if err := auth.VerifyPassword(u.PasswordHash, req.CurrentPassword); err != nil {
writeJSONError(w, stdhttp.StatusUnauthorized, "current_password_wrong", "")
return
}
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
if err := s.deps.Store.SetPasswordHash(r.Context(), u.ID, hash); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "user.password_changed",
TargetKind: ptr("user"), TargetID: &u.ID,
TS: time.Now().UTC(),
})
w.WriteHeader(stdhttp.StatusOK)
}
type accountPage struct {
Username string
Role string
MustChange bool
Error string
Saved bool
}
func (s *Server) handleUIAccountGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
full, err := s.deps.Store.GetUserByID(r.Context(), u.ID)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
view := s.baseView(r, u)
view.Title = "Account · restic-manager"
view.Active = "settings"
view.Page = accountPage{
Username: full.Username, Role: string(full.Role),
MustChange: full.MustChangePassword,
}
_ = s.deps.UI.Render(w, "account", view)
}
func (s *Server) handleUIAccountPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
cur := r.PostForm.Get("current_password")
pw := r.PostForm.Get("new_password")
pw2 := r.PostForm.Get("confirm_password")
full, err := s.deps.Store.GetUserByID(r.Context(), u.ID)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
render := func(errMsg string, saved bool) {
view := s.baseView(r, u)
view.Title = "Account · restic-manager"
view.Active = "settings"
view.Page = accountPage{
Username: full.Username, Role: string(full.Role),
MustChange: full.MustChangePassword,
Error: errMsg, Saved: saved,
}
_ = s.deps.UI.Render(w, "account", view)
}
if pw == "" || pw != pw2 || len(pw) < 12 {
render("Passwords must match and be at least 12 characters.", false)
return
}
if !full.MustChangePassword {
if err := auth.VerifyPassword(full.PasswordHash, cur); err != nil {
render("Current password is incorrect.", false)
return
}
}
hash, err := auth.HashPassword(pw)
if err != nil {
render("Internal error.", false)
return
}
if err := s.deps.Store.SetPasswordHash(r.Context(), u.ID, hash); err != nil {
render("Internal error.", false)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "user.password_changed",
TargetKind: ptr("user"), TargetID: &u.ID,
TS: time.Now().UTC(),
})
full.MustChangePassword = false
render("", true)
}
+2 -2
View File
@@ -159,7 +159,7 @@ func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
SortHrefs: hrefs,
CSVHref: csvHref,
}
if users, err := s.deps.Store.ListUsers(r.Context()); err == nil {
if users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{}); err == nil {
for _, ux := range users {
page.UserNames[ux.ID] = ux.Username
}
@@ -220,7 +220,7 @@ func (s *Server) handleUIAuditCSV(w stdhttp.ResponseWriter, r *stdhttp.Request)
// Resolve user_id → username and host_id → name once for the
// human-friendly columns.
userNames := map[string]string{}
if users, err := s.deps.Store.ListUsers(r.Context()); err == nil {
if users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{}); err == nil {
for _, ux := range users {
userNames[ux.ID] = ux.Username
}
+4
View File
@@ -66,6 +66,10 @@ func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) {
}
return nil, err
}
if u.DisabledAt != nil {
_ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value))
return nil, nil
}
return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil
}
+493
View File
@@ -0,0 +1,493 @@
// ui_users.go — Settings → Users HTML handlers (admin-only).
//
// Routes (wired in server.go's admin band):
//
// GET /settings/users → handleUIUsersList (this task)
// GET /settings/users/new → F2
// POST /settings/users/new → F2
// GET /settings/users/{id}/edit → F3
// POST /settings/users/{id}/edit → F3
// GET /settings/users/{id}/setup-link → F2
// POST /settings/users/{id}/disable → F3
// POST /settings/users/{id}/enable → F3
// POST /settings/users/{id}/regenerate-setup → F3
// POST /settings/users/{id}/force-logout → F3
package http
import (
"errors"
"log/slog"
stdhttp "net/http"
"net/mail"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
type usersPage struct {
Users []userRow
ShowDisabled bool
Sort string // "username" | "email" | "role" | "last_login_at"
Dir string // "asc" | "desc"
// SortHrefs is a fully-encoded /settings/users?…&sort=COL&dir=…
// for each sortable column. Built server-side because constructing
// the querystring inside <a href="…"> in html/template applies
// URL-attribute escaping to '=' (turning 'show_disabled=1' into
// 'show_disabled%3D1'), which silently drops every filter on click.
// Same shape as the audit page's SortHrefs.
SortHrefs map[string]string
}
type userRow struct {
ID string
Username string
Email string
Role string
LastLoginAt string // pre-formatted "2006-01-02 15:04:05" or "never"
Disabled bool
MustChangePassword bool
}
func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
q := r.URL.Query()
showDisabled := q.Get("show_disabled") == "1"
// Resolve sort against the allowlist. Default: username ASC.
resolvedSort := "username"
switch q.Get("sort") {
case "username", "email", "role", "last_login_at":
resolvedSort = q.Get("sort")
}
asc := q.Get("dir") != "desc"
if q.Get("sort") == "" {
// No explicit sort param → default ASC even though dir
// querystring might be missing (fresh page load).
asc = true
}
dirStr := "desc"
if asc {
dirStr = "asc"
}
users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{
OrderBy: resolvedSort, OrderAsc: asc,
})
if err != nil {
slog.Error("ui users: list", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
rows := make([]userRow, 0, len(users))
for _, ux := range users {
if !showDisabled && ux.DisabledAt != nil {
continue
}
em := ""
if ux.Email != nil {
em = *ux.Email
}
ll := "never"
if ux.LastLoginAt != nil {
ll = ux.LastLoginAt.UTC().Format("2006-01-02 15:04:05")
}
rows = append(rows, userRow{
ID: ux.ID, Username: ux.Username, Email: em,
Role: string(ux.Role), LastLoginAt: ll,
Disabled: ux.DisabledAt != nil,
MustChangePassword: ux.MustChangePassword,
})
}
// Pre-build per-column hrefs so the template just emits them.
// Same pattern as ui_audit's SortHrefs — sidesteps html/template
// URL-attribute escaping turning '=' into '%3D'.
base := url.Values{}
if showDisabled {
base.Set("show_disabled", "1")
}
hrefs := make(map[string]string, 4)
for _, col := range []string{"username", "email", "role", "last_login_at"} {
v := url.Values{}
for k, vs := range base {
v[k] = vs
}
v.Set("sort", col)
newDir := "asc" // sensible default for unactive columns
if col == resolvedSort && asc {
newDir = "desc"
}
v.Set("dir", newDir)
hrefs[col] = "/settings/users?" + v.Encode()
}
view := s.baseView(r, u)
view.Title = "Users · restic-manager"
view.Active = "settings"
view.Page = usersPage{
Users: rows, ShowDisabled: showDisabled,
Sort: resolvedSort, Dir: dirStr,
SortHrefs: hrefs,
}
if err := s.deps.UI.Render(w, "users", view); err != nil {
slog.Error("ui users: render", "err", err)
}
}
type userFormPage struct {
Mode string // "new" | "edit" | "setup-link"
ID string
Username string
Email string
Role string
Disabled bool
HasSetup bool
SetupURL string
SetupExpAt time.Time
Error string
// Reenable is set when the admin landed here because they tried
// to add a username that already exists (disabled). Triggers a
// banner on the edit page explaining why and steering them at
// the Re-enable button. See handleUIUserNewPost's collision branch.
Reenable bool
}
func (s *Server) handleUIUserNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
view := s.baseView(r, u)
view.Title = "New user · restic-manager"
view.Active = "settings"
view.Page = userFormPage{Mode: "new", Role: "operator"}
_ = s.deps.UI.Render(w, "user_edit", view)
}
func (s *Server) handleUIUserNewPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
uname := strings.ToLower(strings.TrimSpace(r.PostForm.Get("username")))
email := strings.TrimSpace(r.PostForm.Get("email"))
role, ok := validRole(r.PostForm.Get("role"))
if uname == "" || !ok {
view := s.baseView(r, u)
view.Title = "New user · restic-manager"
view.Active = "settings"
view.Page = userFormPage{
Mode: "new", Username: uname, Email: email,
Role: r.PostForm.Get("role"),
Error: "Username is required and role must be admin/operator/viewer.",
}
_ = s.deps.UI.Render(w, "user_edit", view)
return
}
if email != "" {
if _, err := mail.ParseAddress(email); err != nil {
view := s.baseView(r, u)
view.Title = "New user · restic-manager"
view.Active = "settings"
view.Page = userFormPage{
Mode: "new", Username: uname, Email: email,
Role: r.PostForm.Get("role"),
Error: "Email is not a valid address.",
}
_ = s.deps.UI.Render(w, "user_edit", view)
return
}
}
// Same collision logic as the API.
existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname)
if err == nil {
if existing.DisabledAt != nil {
// Punt the admin to the edit page where Re-enable is one click.
stdhttp.Redirect(w, r, "/settings/users/"+existing.ID+
"/edit?reenable=1", stdhttp.StatusSeeOther)
return
}
view := s.baseView(r, u)
view.Title = "New user · restic-manager"
view.Active = "settings"
view.Page = userFormPage{
Mode: "new", Username: uname, Email: email,
Role: r.PostForm.Get("role"),
Error: "A user with that name already exists.",
}
_ = s.deps.UI.Render(w, "user_edit", view)
return
} else if !errors.Is(err, store.ErrNotFound) {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
id := ulid.Make().String()
now := time.Now().UTC()
var emailPtr *string
if email != "" {
em := strings.ToLower(email)
emailPtr = &em
}
if err := s.deps.Store.CreateUser(r.Context(), store.User{
ID: id, Username: uname, PasswordHash: "",
Role: role, Email: emailPtr, CreatedAt: now,
MustChangePassword: true,
}); err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
rawToken, err := generateSetupToken()
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
UserID: id, TokenHash: hashSetupToken(rawToken),
ExpiresAt: now.Add(time.Hour),
CreatedAt: now, CreatedBy: &u.ID,
}); err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "user.created", TargetKind: ptr("user"), TargetID: &id,
TS: now,
})
stdhttp.Redirect(w, r,
"/settings/users/"+id+"/setup-link?token="+rawToken,
stdhttp.StatusSeeOther)
}
func (s *Server) handleUIUserEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
id := chi.URLParam(r, "id")
target, err := s.deps.Store.GetUserByID(r.Context(), id)
if err != nil {
stdhttp.NotFound(w, r)
return
}
em := ""
if target.Email != nil {
em = *target.Email
}
view := s.baseView(r, u)
view.Title = "Edit user · restic-manager"
view.Active = "settings"
view.Page = userFormPage{
Mode: "edit", ID: target.ID, Username: target.Username,
Email: em, Role: string(target.Role),
Disabled: target.DisabledAt != nil,
Reenable: r.URL.Query().Get("reenable") == "1",
}
_ = s.deps.UI.Render(w, "user_edit", view)
}
func (s *Server) handleUIUserEditPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
id := chi.URLParam(r, "id")
target, err := s.deps.Store.GetUserByID(r.Context(), id)
if err != nil {
stdhttp.NotFound(w, r)
return
}
role, ok := validRole(r.PostForm.Get("role"))
if !ok {
stdhttp.Error(w, "bad role", stdhttp.StatusBadRequest)
return
}
email := strings.TrimSpace(r.PostForm.Get("email"))
if email != "" {
if _, err := mail.ParseAddress(email); err != nil {
stdhttp.Error(w, "bad email", stdhttp.StatusBadRequest)
return
}
}
if target.Role == store.RoleAdmin && role != store.RoleAdmin && target.DisabledAt == nil {
n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
if n <= 1 {
stdhttp.Error(w, "cannot demote last admin", stdhttp.StatusConflict)
return
}
}
if err := s.deps.Store.SetUserRole(r.Context(), id, role); err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err := s.deps.Store.SetUserEmail(r.Context(), id, email); err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "user.updated", TargetKind: ptr("user"), TargetID: &id,
TS: time.Now().UTC(),
})
stdhttp.Redirect(w, r, "/settings/users", stdhttp.StatusSeeOther)
}
func (s *Server) handleUIUserDisablePost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
id := chi.URLParam(r, "id")
target, err := s.deps.Store.GetUserByID(r.Context(), id)
if err != nil {
stdhttp.NotFound(w, r)
return
}
if target.Role == store.RoleAdmin && target.DisabledAt == nil {
n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
if n <= 1 {
stdhttp.Error(w, "cannot disable last admin", stdhttp.StatusConflict)
return
}
}
now := time.Now().UTC()
if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id,
TS: now,
})
stdhttp.Redirect(w, r, "/settings/users", stdhttp.StatusSeeOther)
}
func (s *Server) handleUIUserEnablePost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
id := chi.URLParam(r, "id")
if err := s.deps.Store.EnableUser(r.Context(), id); err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "user.enabled", TargetKind: ptr("user"), TargetID: &id,
TS: time.Now().UTC(),
})
stdhttp.Redirect(w, r, "/settings/users/"+id+"/edit", stdhttp.StatusSeeOther)
}
func (s *Server) handleUIUserRegenerateSetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
id := chi.URLParam(r, "id")
if _, err := s.deps.Store.GetUserByID(r.Context(), id); err != nil {
stdhttp.NotFound(w, r)
return
}
rawToken, err := generateSetupToken()
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
now := time.Now().UTC()
if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
UserID: id, TokenHash: hashSetupToken(rawToken),
ExpiresAt: now.Add(time.Hour), CreatedAt: now,
CreatedBy: &u.ID,
}); err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.SetMustChangePassword(r.Context(), id, true)
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "user.setup_token.regenerated",
TargetKind: ptr("user"), TargetID: &id, TS: now,
})
stdhttp.Redirect(w, r,
"/settings/users/"+id+"/setup-link?token="+rawToken,
stdhttp.StatusSeeOther)
}
func (s *Server) handleUIUserForceLogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
id := chi.URLParam(r, "id")
_, err := s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "user.force_logout",
TargetKind: ptr("user"), TargetID: &id,
TS: time.Now().UTC(),
})
stdhttp.Redirect(w, r, "/settings/users/"+id+"/edit", stdhttp.StatusSeeOther)
}
func (s *Server) handleUIUserSetupLinkGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
id := chi.URLParam(r, "id")
target, err := s.deps.Store.GetUserByID(r.Context(), id)
if err != nil {
stdhttp.NotFound(w, r)
return
}
rawToken := r.URL.Query().Get("token")
tok, err := s.deps.Store.GetSetupTokenByUserID(r.Context(), id)
if err != nil || rawToken == "" {
w.WriteHeader(stdhttp.StatusGone)
view := s.baseView(r, u)
view.Title = "Link expired · restic-manager"
view.Active = "settings"
view.Page = userFormPage{
Mode: "setup-link", ID: target.ID, Username: target.Username,
Error: "expired",
}
_ = s.deps.UI.Render(w, "user_edit", view)
return
}
view := s.baseView(r, u)
view.Title = "Setup link · restic-manager"
view.Active = "settings"
view.Page = userFormPage{
Mode: "setup-link", ID: target.ID, Username: target.Username,
Role: string(target.Role), HasSetup: true,
SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken,
SetupExpAt: tok.ExpiresAt,
}
_ = s.deps.UI.Render(w, "user_edit", view)
}
+301
View File
@@ -0,0 +1,301 @@
package http
import (
"bytes"
"encoding/json"
"io"
stdhttp "net/http"
"strings"
"testing"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func TestAPIUsersList(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
makeUser(t, srv, "op1", store.RoleOperator)
cookie := loginAs(t, srv, adminID)
req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/users", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Fatalf("status: got %d body=%s", res.StatusCode, body)
}
var got listUsersResponse
_ = json.NewDecoder(res.Body).Decode(&got)
if len(got.Users) != 2 {
t.Errorf("count: got %d want 2", len(got.Users))
}
}
func TestAPIUserCreate(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
cookie := loginAs(t, srv, adminID)
body, _ := json.Marshal(map[string]any{
"username": "Bob", "email": "bob@example.com", "role": "operator",
})
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users", bytes.NewReader(body))
req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusCreated {
body, _ := io.ReadAll(res.Body)
t.Fatalf("status: got %d body=%s", res.StatusCode, body)
}
var got struct {
ID string `json:"id"`
SetupURL string `json:"setup_url"`
}
_ = json.NewDecoder(res.Body).Decode(&got)
if got.ID == "" || got.SetupURL == "" {
t.Errorf("missing fields: %+v", got)
}
if !strings.Contains(got.SetupURL, "/setup?token=") {
t.Errorf("setup_url shape: %q", got.SetupURL)
}
// Verify lowercase-normalised.
u, err := srv.deps.Store.GetUserByUsername(t.Context(), "bob")
if err != nil {
t.Fatalf("get: %v", err)
}
if u.Username != "bob" {
t.Errorf("username: got %q want bob", u.Username)
}
if !u.MustChangePassword {
t.Error("must_change_password not set")
}
}
func TestAPIUserCreateRejectsDuplicateEnabled(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
makeUser(t, srv, "alice", store.RoleOperator)
cookie := loginAs(t, srv, adminID)
body, _ := json.Marshal(map[string]any{
"username": "ALICE", "role": "operator",
})
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users", bytes.NewReader(body))
req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusConflict {
t.Errorf("status: got %d want 409", res.StatusCode)
}
}
func TestAPIUserGet(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
target := makeUser(t, srv, "carol", store.RoleViewer)
cookie := loginAs(t, srv, adminID)
req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/users/"+target, nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
t.Errorf("status: got %d", res.StatusCode)
}
}
func TestAPIUserPatchRoleAndEmail(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
target := makeUser(t, srv, "carol", store.RoleViewer)
cookie := loginAs(t, srv, adminID)
body, _ := json.Marshal(map[string]any{
"role": "operator", "email": "carol@example.com",
})
req, _ := stdhttp.NewRequest("PATCH", ts.URL+"/api/users/"+target, bytes.NewReader(body))
req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PATCH: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Errorf("status: got %d body=%s", res.StatusCode, body)
}
got, _ := srv.deps.Store.GetUserByID(t.Context(), target)
if got.Role != store.RoleOperator {
t.Errorf("role: got %q", got.Role)
}
if got.Email == nil || *got.Email != "carol@example.com" {
t.Errorf("email: got %v", got.Email)
}
}
func TestAPIUserPatchRejectsLastAdminDemote(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
cookie := loginAs(t, srv, adminID)
body, _ := json.Marshal(map[string]any{"role": "viewer"})
req, _ := stdhttp.NewRequest("PATCH", ts.URL+"/api/users/"+adminID, bytes.NewReader(body))
req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PATCH: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusConflict {
t.Errorf("status: got %d want 409", res.StatusCode)
}
}
func TestAPIUserDisable(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
makeUser(t, srv, "admin2", store.RoleAdmin) // satisfy last-admin guard
target := makeUser(t, srv, "victim", store.RoleOperator)
cookie := loginAs(t, srv, adminID)
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+target+"/disable", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
t.Errorf("status: got %d", res.StatusCode)
}
u, _ := srv.deps.Store.GetUserByID(t.Context(), target)
if u.DisabledAt == nil {
t.Error("disabled_at not set")
}
}
func TestAPIUserDisableRejectsLastAdmin(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
cookie := loginAs(t, srv, adminID)
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+adminID+"/disable", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusConflict {
t.Errorf("status: got %d want 409", res.StatusCode)
}
}
func TestAPIUserRegenerateSetup(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
target := makeUser(t, srv, "newbie", store.RoleViewer)
_ = srv.deps.Store.SetMustChangePassword(t.Context(), target, true)
_ = srv.deps.Store.SetSetupToken(t.Context(), store.SetupToken{
UserID: target, TokenHash: "old", ExpiresAt: time.Now().UTC().Add(time.Hour),
CreatedAt: time.Now().UTC(),
})
cookie := loginAs(t, srv, adminID)
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+target+"/regenerate-setup", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
t.Errorf("status: got %d", res.StatusCode)
}
var got struct {
SetupURL string `json:"setup_url"`
}
_ = json.NewDecoder(res.Body).Decode(&got)
if !strings.Contains(got.SetupURL, "/setup?token=") {
t.Errorf("setup_url: %q", got.SetupURL)
}
if _, err := srv.deps.Store.LookupSetupToken(t.Context(), "old"); err == nil {
t.Error("old token should be replaced")
}
}
func TestAPIUserForceLogout(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
target := makeUser(t, srv, "victim", store.RoleOperator)
loginAs(t, srv, target) // create a session for the victim
cookie := loginAs(t, srv, adminID)
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+target+"/force-logout", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
t.Errorf("status: got %d", res.StatusCode)
}
rr, _ := srv.deps.Store.DeleteSessionsByUserID(t.Context(), target)
if rr != 0 {
t.Errorf("expected 0 remaining sessions, got %d", rr)
}
}
func TestAPIAccountPasswordChange(t *testing.T) {
t.Parallel()
srv, ts, _ := rawTestServerWithUI(t)
uid := makeUser(t, srv, "alice", store.RoleViewer)
cookie := loginAs(t, srv, uid)
body, _ := json.Marshal(map[string]string{
"current_password": "test-password",
"new_password": "averylongpassword",
})
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/account/password", bytes.NewReader(body))
req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Errorf("status: got %d body=%s", res.StatusCode, body)
}
}
@@ -0,0 +1,58 @@
package http
import (
stdhttp "net/http"
"testing"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// makeUser inserts a user with a known password ('test-password').
// Returns the user id. Used by RBAC middleware tests + the
// user-management handler tests.
//
//nolint:unused
func makeUser(t *testing.T, srv *Server, username string, role store.Role) string {
t.Helper()
id := ulid.Make().String()
hash, err := auth.HashPassword("test-password")
if err != nil {
t.Fatalf("hash: %v", err)
}
if err := srv.deps.Store.CreateUser(t.Context(), store.User{
ID: id, Username: username, PasswordHash: hash,
Role: role, CreatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("create user %s: %v", username, err)
}
return id
}
// loginAs gets a session cookie for the given user. Skips the real
// /api/auth/login handler for speed and to keep these helpers usable
// even when login validation is mid-flight elsewhere.
//
//nolint:unused
func loginAs(t *testing.T, srv *Server, userID string) *stdhttp.Cookie {
t.Helper()
rawToken, err := auth.NewToken()
if err != nil {
t.Fatalf("token: %v", err)
}
hash := auth.HashToken(rawToken)
now := time.Now().UTC()
if err := srv.deps.Store.CreateSession(t.Context(), store.Session{
ID: hash, UserID: userID, CreatedAt: now,
ExpiresAt: now.Add(8 * time.Hour),
}, hash); err != nil {
t.Fatalf("session: %v", err)
}
return &stdhttp.Cookie{
Name: sessionCookieName,
Value: rawToken,
}
}
+1 -1
View File
@@ -152,7 +152,7 @@ func (r *Renderer) RenderPartial(w io.Writer, name string, data ViewData) error
// chrome-less; everything else uses the standard navigation chrome.
func layoutFor(page string) string {
switch page {
case "login", "bootstrap":
case "login", "bootstrap", "setup":
return "chromeless"
default:
return "base"
@@ -0,0 +1,21 @@
-- 0017_users_extensions.sql
--
-- Add the columns the user-management UI needs:
-- email — optional, free-form text; format-checked
-- in Go on insert/update via net/mail.ParseAddress
-- disabled_at — soft-delete tombstone. NULL = enabled
-- must_change_password — flag set by admin-create + setup-token flow;
-- cleared by /setup or /settings/account
--
-- Plus a case-insensitive unique index so 'Alice' and 'alice' can't
-- both exist (lowercase normalisation is applied in the Go layer
-- on every CreateUser; this index defends the invariant).
--
-- Column-level ALTERs (CLAUDE.md prefers these over rebuilds; safe
-- under foreign_keys=ON).
ALTER TABLE users ADD COLUMN email TEXT;
ALTER TABLE users ADD COLUMN disabled_at TEXT;
ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0;
CREATE UNIQUE INDEX users_username_lower ON users(LOWER(username));
@@ -0,0 +1,16 @@
-- 0018_user_setup_tokens.sql
--
-- One outstanding setup token per user (PRIMARY KEY on user_id).
-- Regenerating a link is INSERT OR REPLACE — old token immediately
-- invalid. Token is stored as sha256(raw) hex, never the raw token,
-- so a DB leak doesn't leak active links.
CREATE TABLE user_setup_tokens (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX user_setup_tokens_expires ON user_setup_tokens(expires_at);
+15
View File
@@ -86,3 +86,18 @@ func (s *Store) PurgeExpiredSessions(ctx context.Context) (int64, error) {
n, _ := res.RowsAffected()
return n, nil
}
// DeleteSessionsByUserID removes every session row owned by the
// user. Returns count for caller logging. Used by:
// - admin "Force logout" button
// - admin Disable user (sessions outlive the disable flag, so we
// also clear them so the user gets bounced immediately)
func (s *Store) DeleteSessionsByUserID(ctx context.Context, userID string) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM sessions WHERE user_id = ?`, userID)
if err != nil {
return 0, fmt.Errorf("store: delete sessions by user: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"context"
"testing"
"time"
)
func TestDeleteSessionsByUserID(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
uid := "u-force"
if err := s.CreateUser(ctx, User{
ID: uid, Username: "victim",
PasswordHash: "x", Role: RoleOperator, CreatedAt: now,
}); err != nil {
t.Fatalf("create user: %v", err)
}
// Create two sessions for that user.
for i, h := range []string{"hash1", "hash2"} {
if err := s.CreateSession(ctx, Session{
ID: h,
UserID: uid,
CreatedAt: now,
ExpiresAt: now.Add(time.Hour),
}, h); err != nil {
t.Fatalf("create session %d: %v", i, err)
}
}
n, err := s.DeleteSessionsByUserID(ctx, uid)
if err != nil {
t.Fatalf("delete: %v", err)
}
if n != 2 {
t.Errorf("count: got %d want 2", n)
}
if _, err := s.LookupSession(ctx, "hash1"); err == nil {
t.Error("hash1 should be gone")
}
}
+93
View File
@@ -0,0 +1,93 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// SetSetupToken inserts a row, replacing any existing token for
// this user (single-outstanding invariant). Caller passes a hash —
// raw tokens are never persisted.
func (s *Store) SetSetupToken(ctx context.Context, t SetupToken) error {
_, err := s.db.ExecContext(ctx,
`INSERT OR REPLACE INTO user_setup_tokens
(user_id, token_hash, expires_at, created_at, created_by)
VALUES (?, ?, ?, ?, ?)`,
t.UserID, t.TokenHash,
t.ExpiresAt.UTC().Format(time.RFC3339Nano),
t.CreatedAt.UTC().Format(time.RFC3339Nano),
nullable(t.CreatedBy))
if err != nil {
return fmt.Errorf("store: set setup token: %w", err)
}
return nil
}
// LookupSetupToken resolves a token hash to its row. Returns
// ErrNotFound for missing tokens. Expiry is NOT checked here —
// callers must compare ExpiresAt themselves so they can record
// 'expired' as a distinct outcome (audit-able) from 'never existed'.
func (s *Store) LookupSetupToken(ctx context.Context, tokenHash string) (*SetupToken, error) {
row := s.db.QueryRowContext(ctx,
`SELECT user_id, token_hash, expires_at, created_at, created_by
FROM user_setup_tokens WHERE token_hash = ?`, tokenHash)
return scanSetupToken(row.Scan)
}
// GetSetupTokenByUserID returns the row for one user. Used by the
// edit page to know whether a 'Regenerate setup link' button should
// show as 'Generate' or 'Regenerate'. Returns ErrNotFound when no
// outstanding token exists.
func (s *Store) GetSetupTokenByUserID(ctx context.Context, userID string) (*SetupToken, error) {
row := s.db.QueryRowContext(ctx,
`SELECT user_id, token_hash, expires_at, created_at, created_by
FROM user_setup_tokens WHERE user_id = ?`, userID)
return scanSetupToken(row.Scan)
}
// DeleteSetupToken removes the row for a user (single-use cleanup
// after /setup completes successfully).
func (s *Store) DeleteSetupToken(ctx context.Context, userID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM user_setup_tokens WHERE user_id = ?`, userID)
if err != nil {
return fmt.Errorf("store: delete setup token: %w", err)
}
return nil
}
// CleanupExpiredSetupTokens removes rows whose expires_at has passed.
// Returns the number of rows deleted. Called from the maintenance
// ticker every minute.
func (s *Store) CleanupExpiredSetupTokens(ctx context.Context, now time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM user_setup_tokens WHERE expires_at < ?`,
now.UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, fmt.Errorf("store: cleanup setup tokens: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
func scanSetupToken(scan func(...any) error) (*SetupToken, error) {
var t SetupToken
var createdBy sql.NullString
var expiresAt, createdAt string
if err := scan(&t.UserID, &t.TokenHash, &expiresAt, &createdAt, &createdBy); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("store: scan setup token: %w", err)
}
t.ExpiresAt, _ = time.Parse(time.RFC3339Nano, expiresAt)
t.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
if createdBy.Valid {
v := createdBy.String
t.CreatedBy = &v
}
return &t, nil
}
+120
View File
@@ -0,0 +1,120 @@
package store
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/oklog/ulid/v2"
)
func newSetupTokenTestStore(t *testing.T) (*Store, string, string) {
t.Helper()
st, err := Open(context.Background(), filepath.Join(t.TempDir(), "rm.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
uid := ulid.Make().String()
creator := ulid.Make().String()
now := time.Now().UTC()
if err := st.CreateUser(context.Background(), User{
ID: creator, Username: "creator", PasswordHash: "x",
Role: RoleAdmin, CreatedAt: now,
}); err != nil {
t.Fatalf("create creator: %v", err)
}
if err := st.CreateUser(context.Background(), User{
ID: uid, Username: "target", PasswordHash: "",
Role: RoleOperator, CreatedAt: now, MustChangePassword: true,
}); err != nil {
t.Fatalf("create target: %v", err)
}
return st, uid, creator
}
func TestSetupTokenSetAndLookup(t *testing.T) {
t.Parallel()
st, uid, creator := newSetupTokenTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
if err := st.SetSetupToken(ctx, SetupToken{
UserID: uid, TokenHash: "abc123",
ExpiresAt: now.Add(time.Hour),
CreatedAt: now, CreatedBy: &creator,
}); err != nil {
t.Fatalf("set: %v", err)
}
got, err := st.LookupSetupToken(ctx, "abc123")
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got.UserID != uid {
t.Errorf("user_id: got %q want %q", got.UserID, uid)
}
}
func TestSetupTokenReplaces(t *testing.T) {
t.Parallel()
st, uid, creator := newSetupTokenTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
_ = st.SetSetupToken(ctx, SetupToken{
UserID: uid, TokenHash: "old",
ExpiresAt: now.Add(time.Hour), CreatedAt: now, CreatedBy: &creator,
})
_ = st.SetSetupToken(ctx, SetupToken{
UserID: uid, TokenHash: "new",
ExpiresAt: now.Add(time.Hour), CreatedAt: now, CreatedBy: &creator,
})
if _, err := st.LookupSetupToken(ctx, "old"); err == nil {
t.Error("old token should be gone")
}
if _, err := st.LookupSetupToken(ctx, "new"); err != nil {
t.Errorf("new token should resolve: %v", err)
}
}
func TestSetupTokenDelete(t *testing.T) {
t.Parallel()
st, uid, creator := newSetupTokenTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
_ = st.SetSetupToken(ctx, SetupToken{
UserID: uid, TokenHash: "tk",
ExpiresAt: now.Add(time.Hour), CreatedAt: now, CreatedBy: &creator,
})
if err := st.DeleteSetupToken(ctx, uid); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := st.LookupSetupToken(ctx, "tk"); err == nil {
t.Error("deleted token should not resolve")
}
}
func TestSetupTokenCleanupExpired(t *testing.T) {
t.Parallel()
st, uid, creator := newSetupTokenTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
_ = st.SetSetupToken(ctx, SetupToken{
UserID: uid, TokenHash: "stale",
ExpiresAt: now.Add(-time.Hour), CreatedAt: now.Add(-2 * time.Hour),
CreatedBy: &creator,
})
n, err := st.CleanupExpiredSetupTokens(ctx, now)
if err != nil {
t.Fatalf("cleanup: %v", err)
}
if n != 1 {
t.Errorf("cleanup count: got %d want 1", n)
}
if _, err := st.LookupSetupToken(ctx, "stale"); err == nil {
t.Error("stale token should be gone")
}
}
+20 -6
View File
@@ -9,12 +9,15 @@ import (
// User mirrors the users table.
type User struct {
ID string
Username string
PasswordHash string
Role Role
CreatedAt time.Time
LastLoginAt *time.Time
ID string
Username string
PasswordHash string
Role Role
Email *string // optional; nil = not set
DisabledAt *time.Time // nil = enabled
MustChangePassword bool
CreatedAt time.Time
LastLoginAt *time.Time
}
// Role enumerates the access tiers from spec.md §7.2.
@@ -219,3 +222,14 @@ type AuditEntry struct {
TS time.Time
Payload json.RawMessage
}
// SetupToken mirrors the user_setup_tokens table. The raw token
// itself is never stored; the field shown here is the sha256 hex
// digest of the raw token, which is what callers compare against.
type SetupToken struct {
UserID string
TokenHash string
ExpiresAt time.Time
CreatedAt time.Time
CreatedBy *string // admin user id; nil only after CASCADE SET NULL
}
+174 -43
View File
@@ -5,67 +5,104 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// CreateUser inserts a new user. The caller is responsible for
// generating an ID (typically a ULID) and hashing the password.
// CreateUser inserts a row. Username is lowercase-normalised so the
// case-insensitive unique index from migration 0017 doesn't surprise
// callers who insert 'Alice' and look up 'alice'.
func (s *Store) CreateUser(ctx context.Context, u User) error {
u.Username = strings.ToLower(strings.TrimSpace(u.Username))
must := 0
if u.MustChangePassword {
must = 1
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO users (id, username, password_hash, role, created_at)
VALUES (?, ?, ?, ?, ?)`,
u.ID, u.Username, u.PasswordHash, string(u.Role), u.CreatedAt.UTC().Format(time.RFC3339Nano))
`INSERT INTO users (id, username, password_hash, role, email,
must_change_password, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Username, u.PasswordHash, string(u.Role),
nullable(u.Email), must,
u.CreatedAt.UTC().Format(time.RFC3339Nano))
if err != nil {
return fmt.Errorf("store: create user: %w", err)
}
return nil
}
// GetUserByUsername looks up a user by their (case-sensitive) username.
// Returns ErrNotFound if no row matches.
// GetUserByUsername resolves a user case-insensitively.
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, username, password_hash, role, created_at, last_login_at
FROM users WHERE username = ?`, username)
return scanUser(row)
`SELECT id, username, password_hash, role, email, disabled_at,
must_change_password, created_at, last_login_at
FROM users WHERE LOWER(username) = LOWER(?)`, username)
return scanUser(row.Scan)
}
// GetUserByID looks up a user by id. Returns ErrNotFound on miss.
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, username, password_hash, role, created_at, last_login_at
`SELECT id, username, password_hash, role, email, disabled_at,
must_change_password, created_at, last_login_at
FROM users WHERE id = ?`, id)
return scanUser(row)
return scanUser(row.Scan)
}
// ListUsers returns every user, sorted by username. Used by surfaces
// that need to render a user-id → username map (audit log filter,
// "ack'd by" projections).
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, username, password_hash, role, created_at, last_login_at
FROM users ORDER BY username`)
// UserSort selects the column ListUsers orders by. OrderBy is
// allowlisted in usersOrderColumn so callers can't inject SQL via
// this field. Empty / unknown OrderBy falls back to "username".
type UserSort struct {
OrderBy string // "username" | "email" | "role" | "last_login_at"
OrderAsc bool // false = DESC; true = ASC
}
// usersOrderColumn validates s.OrderBy and returns the SQL fragment.
// last_login_at gets a NULL-tail trick so users who've never logged
// in sort to the bottom regardless of asc/desc — matches operator
// intuition ("show me real activity" not "show me NULLs first").
func usersOrderColumn(col string, asc bool) string {
dir := "DESC"
if asc {
dir = "ASC"
}
switch col {
case "email":
return fmt.Sprintf("email IS NULL, email %s, username", dir)
case "role":
return fmt.Sprintf("role %s, username", dir)
case "last_login_at":
return fmt.Sprintf("last_login_at IS NULL, last_login_at %s, username", dir)
default: // username (and unknown)
return fmt.Sprintf("username %s", dir)
}
}
// ListUsers returns users sorted per UserSort. Default (zero value)
// is username ASC. Used by the user-management page (sort headers)
// and by surfaces that need a user-id → username map (audit log
// filter, "ack'd by" projections) — those callers pass UserSort{}.
func (s *Store) ListUsers(ctx context.Context, sort UserSort) ([]User, error) {
asc := sort.OrderAsc
if sort.OrderBy == "" {
// Default: username ASC (alphabetical), matching pre-sort behaviour.
asc = true
}
q := `SELECT id, username, password_hash, role, email, disabled_at,
must_change_password, created_at, last_login_at
FROM users ORDER BY ` + usersOrderColumn(sort.OrderBy, asc)
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, fmt.Errorf("store: list users: %w", err)
}
defer func() { _ = rows.Close() }()
var out []User
for rows.Next() {
var u User
var role string
var lastLogin sql.NullString
var created string
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &created, &lastLogin); err != nil {
return nil, fmt.Errorf("store: scan user row: %w", err)
u, err := scanUser(rows.Scan)
if err != nil {
return nil, err
}
u.Role = Role(role)
t, _ := time.Parse(time.RFC3339Nano, created)
u.CreatedAt = t
if lastLogin.Valid {
t, _ := time.Parse(time.RFC3339Nano, lastLogin.String)
u.LastLoginAt = &t
}
out = append(out, u)
out = append(out, *u)
}
return out, rows.Err()
}
@@ -80,6 +117,19 @@ func (s *Store) CountUsers(ctx context.Context) (int, error) {
return n, nil
}
// CountEnabledAdmins returns the number of users with role='admin'
// AND disabled_at IS NULL. Used by the last-admin guard before
// disable / role-demote operations.
func (s *Store) CountEnabledAdmins(ctx context.Context) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled_at IS NULL`,
).Scan(&n); err != nil {
return 0, fmt.Errorf("store: count admins: %w", err)
}
return n, nil
}
// MarkUserLogin records a successful authentication.
func (s *Store) MarkUserLogin(ctx context.Context, id string, when time.Time) error {
_, err := s.db.ExecContext(ctx,
@@ -91,28 +141,109 @@ func (s *Store) MarkUserLogin(ctx context.Context, id string, when time.Time) er
return nil
}
func scanUser(row *sql.Row) (*User, error) {
// SetUserEmail replaces the email field. Empty string clears it.
func (s *Store) SetUserEmail(ctx context.Context, id, email string) error {
em := strings.ToLower(strings.TrimSpace(email))
var v any
if em == "" {
v = nil
} else {
v = em
}
_, err := s.db.ExecContext(ctx,
`UPDATE users SET email = ? WHERE id = ?`, v, id)
if err != nil {
return fmt.Errorf("store: set user email: %w", err)
}
return nil
}
// SetUserRole changes a user's role.
func (s *Store) SetUserRole(ctx context.Context, id string, role Role) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET role = ? WHERE id = ?`, string(role), id)
if err != nil {
return fmt.Errorf("store: set user role: %w", err)
}
return nil
}
// DisableUser sets disabled_at = when. Idempotent on already-disabled
// rows (no-op).
func (s *Store) DisableUser(ctx context.Context, id string, when time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET disabled_at = ?
WHERE id = ? AND disabled_at IS NULL`,
when.UTC().Format(time.RFC3339Nano), id)
if err != nil {
return fmt.Errorf("store: disable user: %w", err)
}
return nil
}
// EnableUser clears disabled_at.
func (s *Store) EnableUser(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET disabled_at = NULL WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: enable user: %w", err)
}
return nil
}
// SetMustChangePassword toggles the must_change_password flag.
func (s *Store) SetMustChangePassword(ctx context.Context, id string, must bool) error {
v := 0
if must {
v = 1
}
_, err := s.db.ExecContext(ctx,
`UPDATE users SET must_change_password = ? WHERE id = ?`, v, id)
if err != nil {
return fmt.Errorf("store: set must_change_password: %w", err)
}
return nil
}
// SetPasswordHash stores a new password_hash and clears the
// must_change_password flag in one go.
func (s *Store) SetPasswordHash(ctx context.Context, id, hash string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?`,
hash, id)
if err != nil {
return fmt.Errorf("store: set password: %w", err)
}
return nil
}
func scanUser(scan func(...any) error) (*User, error) {
var u User
var role string
var lastLogin sql.NullString
var email, disabledAt, lastLogin sql.NullString
var must int
var created string
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &created, &lastLogin); err != nil {
if err := scan(&u.ID, &u.Username, &u.PasswordHash, &role,
&email, &disabledAt, &must, &created, &lastLogin); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("store: scan user: %w", err)
}
u.Role = Role(role)
t, err := time.Parse(time.RFC3339Nano, created)
if err != nil {
return nil, fmt.Errorf("store: parse created_at: %w", err)
if email.Valid {
v := email.String
u.Email = &v
}
if disabledAt.Valid {
t, _ := time.Parse(time.RFC3339Nano, disabledAt.String)
u.DisabledAt = &t
}
u.MustChangePassword = must == 1
t, _ := time.Parse(time.RFC3339Nano, created)
u.CreatedAt = t
if lastLogin.Valid {
t, err := time.Parse(time.RFC3339Nano, lastLogin.String)
if err != nil {
return nil, fmt.Errorf("store: parse last_login_at: %w", err)
}
t, _ := time.Parse(time.RFC3339Nano, lastLogin.String)
u.LastLoginAt = &t
}
return &u, nil
+34
View File
@@ -131,6 +131,40 @@ func TestSessionLifecycle(t *testing.T) {
}
}
func TestCreateUserLowercasesUsername(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
if err := s.CreateUser(ctx, User{
ID: "u1", Username: "Alice",
PasswordHash: "x", Role: RoleAdmin, CreatedAt: now,
}); err != nil {
t.Fatalf("create: %v", err)
}
got, err := s.GetUserByUsername(ctx, "alice")
if err != nil {
t.Fatalf("get lower: %v", err)
}
if got.Username != "alice" {
t.Errorf("stored username: got %q want %q", got.Username, "alice")
}
got, err = s.GetUserByUsername(ctx, "ALICE")
if err != nil {
t.Fatalf("get upper: %v", err)
}
if got.ID != "u1" {
t.Errorf("upper-case lookup missed: got %+v", got)
}
if err := s.CreateUser(ctx, User{
ID: "u2", Username: "AlIcE",
PasswordHash: "x", Role: RoleAdmin, CreatedAt: now,
}); err == nil {
t.Error("duplicate (different case) should fail")
}
}
func TestEnrollmentTokenSingleUse(t *testing.T) {
t.Parallel()
s := openTestStore(t)
+14 -2
View File
@@ -296,8 +296,20 @@ Sizes: **S** = under a day, **M** = 13 days, **L** = 37 days.
- [ ] **P4-01** (M) Update delivery via OS package managers — host an apt repo (Linux) and Chocolatey package (Windows) on gitea releases. `restic-manager-agent update` is a thin wrapper over `apt-get install --only-upgrade restic-manager-agent` / `choco upgrade`. Trades flexibility for a much smaller security surface than bespoke signed binaries (see spec.md §4.2)
- [ ] **P4-02** (M) Agent version reporting on dashboard: surface "agent N versions behind server"; "update all" admin action calls the package-manager wrapper on each host
- [ ] **P4-03** (M) RBAC enforcement at API layer (admin / operator / viewer)
- [ ] **P4-04** (S) User management UI (create/edit/disable, role assignment, password reset)
- [x] **P4-03** (M) RBAC enforcement at API layer (admin / operator / viewer)
- [x] **P4-04** (S) User management UI (create/edit/disable, role assignment, password reset)
> **As shipped (2026-05-05):** Three-role hierarchy (admin > operator > viewer) enforced via chi route-group middleware (`requireRole`). Admin is the fail-closed default; agent endpoints stay on the bearer-token chain. Sessions re-validate `disabled_at` on every authenticated request — admin-driven changes (disable, force-logout) land immediately.
>
> **Setup-token flow** replaces temp passwords. Admin clicks `+ Add user`, picks username + email + role, server returns a one-time setup link valid for 1 hour (sha256-hashed at rest, raw shown to admin once). User clicks the link → sets a password (≥12 chars) → drops a session → lands on `/`. `/settings/users/{id}/regenerate-setup` issues a new link, replacing the old via INSERT OR REPLACE. Expired tokens are swept on the alert engine's 60s tick.
>
> **Disable-only lifecycle** — soft delete via `disabled_at`. Last-admin guard rejects "disable last admin" and "demote last admin to non-admin" (both server-side and UI-hinted). Re-enable on disabled-username collision: admin trying to add a name that matches a disabled user is redirected to that user's edit page rather than 409'd.
>
> **Self-service password change** at `/settings/account` available to any role. Skips current-password check when `must_change_password` is set so admin-initiated resets work without surfacing a credential the user doesn't know.
>
> **Schema:** migration 0017 adds `email`, `disabled_at`, `must_change_password` plus a UNIQUE INDEX on LOWER(username) (lowercase normalisation in Go on every CreateUser); 0018 adds `user_setup_tokens`. Both column-level ALTERs per CLAUDE.md preference. Email is metadata only in v1 (no SMTP-the-link); the SMTP channel infrastructure from P3-06 makes that a one-page follow-up.
>
> **Sweep verified (smoke env):** admin adds operator → setup link generated → curl-as-new-user fetches /setup (200, page shows username) → POSTs password → 303 to / + Set-Cookie → operator authenticated → 200 on /, 200 on /settings/account, **403 on /settings/users** (admin-only) → admin disables user → operator's next request is **401** + session row count drops to 0 → audit log shows `user.created` + `user.setup_completed` for the cycle. All 26 implementation tasks landed; full `go test ./...` green.
- [ ] **P4-05** (L) OIDC login (generic provider config, group → role mapping)
- [ ] **P4-06** (M) Repo size trend graphs (sparkline on host card, full chart on repo page)
- [ ] **P4-07** (S) Per-host tags + dashboard filtering by tag
File diff suppressed because one or more lines are too long
+30 -3
View File
@@ -328,12 +328,20 @@
text-transform: uppercase; letter-spacing: 0.08em;
}
.audit-row.head:hover { background: transparent; }
.audit-row.head .sort-header {
/* Sort-header link styling shared by .audit-row and .user-row
(and any other future sortable table headers). The selectors
scope to .head rows so hover and accent-glyph treatment only
apply to the header, not data rows that happen to contain a
<a class="sort-header">. */
.audit-row.head .sort-header,
.user-row.head .sort-header {
color: inherit; text-decoration: none; cursor: pointer;
display: inline-flex; align-items: baseline; gap: 4px;
}
.audit-row.head .sort-header:hover { color: var(--ink); }
.audit-row.head .sort-glyph {
.audit-row.head .sort-header:hover,
.user-row.head .sort-header:hover { color: var(--ink); }
.audit-row.head .sort-glyph,
.user-row.head .sort-glyph {
font-size: 9px; color: var(--accent);
/* keep the row height stable when the glyph appears/disappears */
min-width: 8px; display: inline-block;
@@ -563,6 +571,25 @@
background: var(--accent);
}
/* ---------- user-management rows (/settings/users) ---------- */
.user-row {
display: grid; align-items: center;
grid-template-columns: 180px 1fr 110px 160px 120px 90px;
column-gap: 16px;
padding: 11px 16px; font-size: 13px;
border-bottom: 1px solid var(--line-soft);
transition: background 100ms ease;
}
.user-row:hover { background: var(--panel-hi); }
.user-row:last-child { border-bottom: 0; }
.user-row.head {
cursor: default; padding-top: 9px; padding-bottom: 9px;
font-size: 11px; color: var(--ink-fade);
text-transform: uppercase; letter-spacing: 0.08em;
}
.user-row.head:hover { background: transparent; }
.user-row.disabled { opacity: 0.55; }
/* ---------- test-result pills (notification test button) ---------- */
.test-pill {
display: inline-block;
+46
View File
@@ -0,0 +1,46 @@
{{define "title"}}Account · restic-manager{{end}}
{{define "content"}}
{{$page := .Page}}
<div class="max-w-[520px] mx-auto px-8 pb-14">
<div class="crumbs pt-6">
<a href="/">Dashboard</a><span class="sep">/</span>
<span class="text-ink-mid">account</span>
</div>
<h1 class="text-[22px] font-medium tracking-[-0.005em] mt-3.5">Account</h1>
<div class="text-[12.5px] text-ink-mute mt-2 leading-[1.6]">
Signed in as <span class="mono text-ink-mid">{{$page.Username}}</span>
({{$page.Role}}). Change your password below.
</div>
{{if $page.Saved}}
<div class="mt-6 panel rounded-[7px] p-4"
style="border-color: color-mix(in oklch, var(--ok), transparent 60%);">
<div class="text-ok text-[13px]">Password updated.</div>
</div>
{{end}}
<form method="post" action="/settings/account" class="mt-6 panel rounded-[7px] p-6 space-y-4">
{{if not $page.MustChange}}
<div>
<label class="field-label" for="current">Current password</label>
<input id="current" name="current_password" type="password" class="field"
required autocomplete="current-password" />
</div>
{{end}}
<div>
<label class="field-label" for="new">New password</label>
<input id="new" name="new_password" type="password" class="field"
required minlength="12" autocomplete="new-password" />
</div>
<div>
<label class="field-label" for="confirm">Confirm new password</label>
<input id="confirm" name="confirm_password" type="password" class="field"
required minlength="12" autocomplete="new-password" />
</div>
{{if $page.Error}}<div class="text-bad text-[12.5px]">{{$page.Error}}</div>{{end}}
<button type="submit" class="btn btn-primary btn-block btn-lg">Update password</button>
</form>
</div>
{{end}}
+21
View File
@@ -0,0 +1,21 @@
{{define "title"}}Forbidden · restic-manager{{end}}
{{define "content"}}
{{$page := .Page}}
<div class="max-w-[1280px] mx-auto px-8 pb-14">
<div class="crumbs pt-6">
<a href="/">Dashboard</a><span class="sep">/</span>
<span class="text-ink-mid">forbidden</span>
</div>
<div class="panel mt-8 rounded-[7px] p-8 max-w-[640px]"
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
<div class="text-[14px] font-medium text-bad mb-2">403 — Insufficient role</div>
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6]">
Your role (<span class="mono">{{$page.Have}}</span>) does not permit
this page (<span class="mono">{{$page.Required}}</span> required).
Ask your administrator if you need access.
</p>
<a href="/" class="btn btn-primary mt-5">Back to dashboard</a>
</div>
</div>
{{end}}
+1 -1
View File
@@ -39,7 +39,7 @@
Notifications
{{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}}
</a>
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Users</span>
<a href="/settings/users" class="sub-tab {{if eq $page.ActiveTab "users"}}active{{end}}">Users</a>
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Authentication</span>
</div>
+44
View File
@@ -0,0 +1,44 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{$page := .Page}}
<div class="max-w-[520px] mx-auto px-8 pt-20 pb-14">
{{if eq $page.Error "expired"}}
<h1 class="text-[22px] font-medium tracking-[-0.005em]">Link expired</h1>
<p class="text-pretty text-[13px] text-ink-mute mt-3 leading-[1.6]">
This setup link has expired or is invalid. Setup links are valid
for one hour from the moment your administrator generates them.
</p>
<p class="text-[12.5px] text-ink-mute mt-3 leading-[1.6]">
Contact your administrator and ask them to regenerate the link.
</p>
{{else}}
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
Welcome, <span class="mono">{{$page.Username}}</span>
</h1>
<p class="text-pretty text-[13px] text-ink-mute mt-3 leading-[1.6]">
Pick a password to finish setting up your account. The link expires
one hour after your administrator generated it, so don't dawdle.
</p>
<form method="post" action="/setup" class="mt-7 space-y-4">
<input type="hidden" name="token" value="{{$page.Token}}" />
<div>
<label class="field-label" for="pw">New password</label>
<input id="pw" name="password" type="password" class="field"
required minlength="12" autocomplete="new-password" />
</div>
<div>
<label class="field-label" for="pw2">Confirm password</label>
<input id="pw2" name="password_confirm" type="password" class="field"
required minlength="12" autocomplete="new-password" />
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg">
Set password and sign in
</button>
</form>
{{if and $page.Error (ne $page.Error "expired")}}
<p class="text-bad text-[12.5px] mt-4">{{$page.Error}}</p>
{{end}}
{{end}}
</div>
{{end}}
+127
View File
@@ -0,0 +1,127 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{$page := .Page}}
<div class="max-w-[760px] mx-auto px-8 pb-14">
<div class="crumbs pt-6">
<a href="/">Dashboard</a><span class="sep">/</span>
<a href="/settings">Settings</a><span class="sep">/</span>
<a href="/settings/users">Users</a><span class="sep">/</span>
<span class="text-ink-mid">{{if eq $page.Mode "new"}}new{{else if eq $page.Mode "setup-link"}}setup link{{else}}{{$page.Username}}{{end}}</span>
</div>
<h1 class="text-[22px] font-medium tracking-[-0.005em] mt-3.5">
{{if eq $page.Mode "new"}}New user
{{else if eq $page.Mode "setup-link"}}Setup link for <span class="mono">{{$page.Username}}</span>
{{else}}Edit <span class="mono">{{$page.Username}}</span>{{end}}
</h1>
{{/* Re-enable banner — fires when the admin tried to add a user
whose name already exists in a disabled state. We were
redirected here from /settings/users/new with ?reenable=1. */}}
{{if and $page.Reenable $page.Disabled}}
<div class="panel mt-5 rounded-[7px] p-5"
style="border-color: color-mix(in oklch, var(--warn), transparent 50%);
background: color-mix(in oklch, var(--warn), transparent 95%);">
<div class="text-[13px] font-medium text-warn mb-2">Username already exists (disabled)</div>
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6] mb-4">
A user with this name was created previously and then disabled.
Re-enable them to restore access (their existing role + email
are kept), or pick a different name.
</p>
<div class="flex gap-2">
<form method="post" action="/settings/users/{{$page.ID}}/enable">
<button type="submit" class="btn btn-primary">Re-enable {{$page.Username}}</button>
</form>
<a href="/settings/users/new" class="btn">Pick a different username</a>
</div>
</div>
{{end}}
{{if eq $page.Mode "setup-link"}}
{{if eq $page.Error "expired"}}
<div class="panel mt-7 rounded-[7px] p-6"
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
<div class="text-[13px] font-medium text-bad mb-2">Link expired or already used</div>
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6]">
This user's setup token is no longer valid. Open their Edit page and click
<span class="mono">Regenerate setup link</span> to issue a new one.
</p>
<a href="/settings/users/{{$page.ID}}/edit" class="btn btn-primary mt-5">Open edit page</a>
</div>
{{else}}
<div class="panel mt-7 rounded-[7px] p-6">
<p class="text-pretty text-[13px] text-ink-mute leading-[1.6] mb-3">
Send this link to the user. It expires at
<span class="mono text-ink-mid">{{absTime $page.SetupExpAt}}</span> UTC
(~1 hour from now). This is the only time you'll see it — if you lose
it, regenerate from the Edit page.
</p>
<div class="mono text-[13px] text-ink p-3 rounded"
style="background: var(--bg); border: 1px solid var(--line-soft); word-break: break-all;"
id="setup-url">{{$page.SetupURL}}</div>
<button type="button" class="btn btn-primary mt-4"
onclick="navigator.clipboard.writeText(document.getElementById('setup-url').textContent.trim()).then(function(){var b=event.target;b.textContent='Copied';setTimeout(function(){b.textContent='Copy link';},1500)})">Copy link</button>
<a href="/settings/users" class="btn ml-2">Done</a>
</div>
{{end}}
{{else}}
{{/* new + edit form. */}}
<form method="post"
action="{{if eq $page.Mode "new"}}/settings/users/new{{else}}/settings/users/{{$page.ID}}/edit{{end}}"
class="panel mt-7 rounded-[7px] p-6 space-y-4">
<div>
<label class="field-label" for="username">Username</label>
<input id="username" name="username" type="text"
class="field mono"
{{if ne $page.Mode "new"}}readonly disabled{{end}}
value="{{$page.Username}}"
autocomplete="off" required />
<div class="field-help">Lowercased automatically.</div>
</div>
<div>
<label class="field-label" for="email">Email <span class="text-ink-fade font-normal">· optional</span></label>
<input id="email" name="email" type="email" class="field"
value="{{$page.Email}}" autocomplete="off" />
</div>
<div>
<label class="field-label" for="role">Role</label>
<select id="role" name="role" class="field">
<option value="admin" {{if eq $page.Role "admin"}}selected{{end}}>admin</option>
<option value="operator" {{if eq $page.Role "operator"}}selected{{end}}>operator</option>
<option value="viewer" {{if eq $page.Role "viewer"}}selected{{end}}>viewer</option>
</select>
</div>
{{if $page.Error}}<div class="text-bad text-[12.5px]">{{$page.Error}}</div>{{end}}
<div class="flex gap-2 pt-2">
<button type="submit" class="btn btn-primary">{{if eq $page.Mode "new"}}Create user{{else}}Save changes{{end}}</button>
<a href="/settings/users" class="btn">Cancel</a>
</div>
</form>
{{if eq $page.Mode "edit"}}
{{/* Side actions: regenerate setup link, disable / re-enable, force logout. */}}
<div class="panel mt-5 rounded-[7px] p-6">
<div class="text-[12.5px] text-ink mb-3 font-medium">Other actions</div>
<div class="flex gap-2 flex-wrap">
<form method="post" action="/settings/users/{{$page.ID}}/regenerate-setup">
<button type="submit" class="btn">Regenerate setup link</button>
</form>
<form method="post" action="/settings/users/{{$page.ID}}/force-logout">
<button type="submit" class="btn">Force logout</button>
</form>
{{if $page.Disabled}}
<form method="post" action="/settings/users/{{$page.ID}}/enable">
<button type="submit" class="btn">Re-enable user</button>
</form>
{{else}}
<form method="post" action="/settings/users/{{$page.ID}}/disable">
<button type="submit" class="btn btn-danger">Disable user</button>
</form>
{{end}}
</div>
</div>
{{end}}
{{end}}
</div>
{{end}}
+78
View File
@@ -0,0 +1,78 @@
{{define "title"}}Users · restic-manager{{end}}
{{define "content"}}
{{$page := .Page}}
<div class="max-w-[1280px] mx-auto px-8 pb-14">
<div class="crumbs pt-6">
<a href="/">Dashboard</a><span class="sep">/</span>
<a href="/settings">Settings</a><span class="sep">/</span>
<span class="text-ink-mid">users</span>
</div>
<div class="flex items-baseline justify-between mt-3.5">
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
Users
<span class="text-ink-fade font-normal text-[14px] ml-2">{{len $page.Users}}</span>
</h1>
<div class="flex gap-2">
<a href="/settings/users/new" class="btn btn-primary">+ Add user</a>
</div>
</div>
<form method="get" action="/settings/users" class="mt-3 text-[12px] text-ink-mute">
<label class="cursor-pointer flex items-center gap-2">
<input type="checkbox" name="show_disabled" value="1"
{{if $page.ShowDisabled}}checked{{end}}
onchange="this.form.submit()" />
Show disabled users
</label>
</form>
<div class="panel mt-4 rounded-[7px] overflow-hidden">
{{/* Header — Username/Email/Role/Last login are clickable sort
links. Hrefs are pre-built server-side ($page.SortHrefs) so
html/template's URL-attribute escaping doesn't trip on the
'=' chars. Same pattern as the audit log. */}}
<div class="user-row head">
<div>
<a href="{{index $page.SortHrefs "username"}}"
class="sort-header">Username <span class="sort-glyph">{{sortGlyph "username" $page.Sort $page.Dir}}</span></a>
</div>
<div>
<a href="{{index $page.SortHrefs "email"}}"
class="sort-header">Email <span class="sort-glyph">{{sortGlyph "email" $page.Sort $page.Dir}}</span></a>
</div>
<div>
<a href="{{index $page.SortHrefs "role"}}"
class="sort-header">Role <span class="sort-glyph">{{sortGlyph "role" $page.Sort $page.Dir}}</span></a>
</div>
<div>
<a href="{{index $page.SortHrefs "last_login_at"}}"
class="sort-header">Last login <span class="sort-glyph">{{sortGlyph "last_login_at" $page.Sort $page.Dir}}</span></a>
</div>
<div>Status</div>
<div></div>
</div>
{{range $page.Users}}
<div class="user-row{{if .Disabled}} disabled{{end}}">
<div class="mono text-ink">
<a href="/settings/users/{{.ID}}/edit" class="hover:underline">{{.Username}}</a>
</div>
<div class="mono text-ink-mid text-[12px]">{{if .Email}}{{.Email}}{{else}}<span class="text-ink-fade"></span>{{end}}</div>
<div class="mono text-[12px] text-ink-mid">{{.Role}}</div>
<div class="mono text-[12px] text-ink-mute">
{{if eq .LastLoginAt "never"}}<span class="text-ink-fade">never</span>{{else}}{{.LastLoginAt}}{{end}}
</div>
<div>
{{if .Disabled}}<span class="tag" style="color: var(--ink-fade);">disabled</span>
{{else if .MustChangePassword}}<span class="tag" style="color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%);">setup pending</span>
{{else}}<span class="tag" style="color: var(--ok);">enabled</span>{{end}}
</div>
<div class="text-right">
<a href="/settings/users/{{.ID}}/edit" class="btn">Edit</a>
</div>
</div>
{{end}}
</div>
</div>
{{end}}