gitmote

gitmote  ·  history  ·  branches

feat: management web UI — repos, tokens, ACLs behind admin login

a20ce8ea7f639672d8d0554c301d828815ed81c2
Atanas Minev <atmin@protonmail.com> · 2026-07-02 19:28

diff --git a/README.md b/README.md
index 4e50e5a..20631f6 100644
--- a/README.md
+++ b/README.md
@@ -32,3 +32,12 @@ sharing the same `GITMOTE_DB`) and clone/push with the token:
```sh
git clone http://atmin:<token>@<host>/atmin/gitmote
```
+
+## Management UI
+
+The things you can't do over git — create/list repos, mint/revoke tokens, and
+manage per-repo ACLs — live in a small server-rendered web UI under `/ui`. Set
+`GITMOTE_COOKIE_KEY` (the HMAC key that signs session cookies) to enable it; it
+runs alongside the git server. Sign in at `/login` by pasting an **admin** token
+(the same PAT format git uses); the server issues a signed, stateless session
+cookie. Access is limited to global admins.
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index dd36ee0..d0f8626 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -21,6 +21,7 @@ import (
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
"github.com/atmin/gitmote/internal/store"
+ "github.com/atmin/gitmote/internal/webui"
)
// version is stamped at build time via -ldflags "-X main.version=...".
@@ -96,7 +97,7 @@ func run(ctx context.Context, logger *slog.Logger, addr string) error {
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
- gitHandler, closeMeta, err := buildGitHandler(ctx, logger)
+ gitHandler, ui, closeMeta, err := buildGitHandler(ctx, logger)
if err != nil {
return err
}
@@ -108,7 +109,7 @@ func run(ctx context.Context, logger *slog.Logger, addr string) error {
srv := &http.Server{
Addr: addr,
- Handler: newHandler(gitHandler),
+ Handler: newHandler(gitHandler, ui),
}
errCh := make(chan error, 1)
@@ -135,10 +136,11 @@ func run(ctx context.Context, logger *slog.Logger, addr string) error {
return nil
}
-// newHandler returns the server's HTTP routes. gitHandler, when non-nil, serves
-// the git smart-HTTP endpoints at the catch-all "/"; the exact health/version
-// routes stay more specific and win under http.ServeMux.
-func newHandler(gitHandler http.Handler) http.Handler {
+// newHandler returns the server's HTTP routes. ui, when non-nil, registers the
+// management UI under /ui/ and /login (more specific patterns that win over the
+// git catch-all). gitHandler, when non-nil, serves the git smart-HTTP endpoints
+// at "/"; the exact health/version routes stay more specific.
+func newHandler(gitHandler http.Handler, ui *webui.Handler) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "ok")
@@ -146,6 +148,9 @@ func newHandler(gitHandler http.Handler) http.Handler {
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, version)
})
+ if ui != nil {
+ ui.Register(mux)
+ }
if gitHandler != nil {
mux.Handle("/", gitHandler)
}
@@ -156,36 +161,40 @@ func newHandler(gitHandler http.Handler) http.Handler {
// configured (GITMOTE_S3_BUCKET). Without it, the server runs health/version
// only — a dev convenience — and the returned close func is a no-op.
//
+// The management UI (second return) is built when GITMOTE_COOKIE_KEY is set (the
+// HMAC key that signs session cookies); it shares this metadata handle, so it
+// runs alongside the git server.
+//
// Metadata lives at GITMOTE_DB (default ./gitmote.sqlite3); materialized repos
// cache under GITMOTE_CACHE (default a temp dir). The push path listens on
// GITMOTE_SOCK (default a temp path) and installs the pre-receive hook binary
// at GITMOTE_HOOK (default gitmote-hook alongside this binary). Litestream
// replication of the metadata DB is wired in the deploy task (11); it is
// local-only here.
-func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, func() error, error) {
+func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *webui.Handler, func() error, error) {
noop := func() error { return nil }
if os.Getenv("GITMOTE_S3_BUCKET") == "" {
logger.Warn("GITMOTE_S3_BUCKET unset; git endpoints disabled (health/version only)")
- return nil, noop, nil
+ return nil, nil, noop, nil
}
objs, err := store.NewS3FromEnv(ctx)
if err != nil {
- return nil, noop, fmt.Errorf("object store: %w", err)
+ return nil, nil, noop, fmt.Errorf("object store: %w", err)
}
dbPath := envOr("GITMOTE_DB", "gitmote.sqlite3")
md, err := meta.Open(ctx, meta.Config{LocalPath: dbPath})
if err != nil {
- return nil, noop, fmt.Errorf("metadata: %w", err)
+ return nil, nil, noop, fmt.Errorf("metadata: %w", err)
}
sockPath := envOr("GITMOTE_SOCK", filepath.Join(os.TempDir(), "gitmote.sock"))
writer, err := githttp.NewWriter(md, objs, hookBinaryPath(), sockPath, logger)
if err != nil {
_ = md.Close()
- return nil, noop, fmt.Errorf("write path: %w", err)
+ return nil, nil, noop, fmt.Errorf("write path: %w", err)
}
cleanup := func() error {
err := writer.Close()
@@ -195,18 +204,37 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, fu
return err
}
+ guard := auth.NewGuard(md)
cacheRoot := envOr("GITMOTE_CACHE", filepath.Join(os.TempDir(), "gitmote-repos"))
handler, err := githttp.New(githttp.Config{
Materializer: repo.New(md, objs, cacheRoot),
- Authorizer: auth.NewGuard(md),
+ Authorizer: guard,
Writer: writer,
Logger: logger,
})
if err != nil {
_ = cleanup()
- return nil, noop, err
+ return nil, nil, noop, err
+ }
+
+ ui, err := buildUI(md, guard, logger)
+ if err != nil {
+ _ = cleanup()
+ return nil, nil, noop, err
+ }
+ return handler, ui, cleanup, nil
+}
+
+// buildUI constructs the management UI when GITMOTE_COOKIE_KEY is set; otherwise
+// it returns nil (UI disabled) so a misconfigured key never yields an insecurely
+// signed session.
+func buildUI(md *meta.Metadata, guard *auth.Guard, logger *slog.Logger) (*webui.Handler, error) {
+ key := os.Getenv("GITMOTE_COOKIE_KEY")
+ if key == "" {
+ logger.Warn("GITMOTE_COOKIE_KEY unset; management UI disabled")
+ return nil, nil
}
- return handler, cleanup, nil
+ return webui.New(md, guard, []byte(key), logger)
}
// hookBinaryPath resolves the pre-receive hook executable: GITMOTE_HOOK if set,
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index a23eec9..141b7d8 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -13,7 +13,7 @@ import (
)
func TestHealthz(t *testing.T) {
- srv := httptest.NewServer(newHandler(nil))
+ srv := httptest.NewServer(newHandler(nil, nil))
defer srv.Close()
resp, err := http.Get(srv.URL + "/healthz")
@@ -28,7 +28,7 @@ func TestHealthz(t *testing.T) {
}
func TestVersion(t *testing.T) {
- srv := httptest.NewServer(newHandler(nil))
+ srv := httptest.NewServer(newHandler(nil, nil))
defer srv.Close()
resp, err := http.Get(srv.URL + "/version")
@@ -43,7 +43,7 @@ func TestVersion(t *testing.T) {
}
func TestUnknownRouteNotFound(t *testing.T) {
- srv := httptest.NewServer(newHandler(nil))
+ srv := httptest.NewServer(newHandler(nil, nil))
defer srv.Close()
resp, err := http.Get(srv.URL + "/nope")
@@ -63,7 +63,7 @@ func TestGitHandlerMountedAtRoot(t *testing.T) {
gitHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
- srv := httptest.NewServer(newHandler(gitHandler))
+ srv := httptest.NewServer(newHandler(gitHandler, nil))
defer srv.Close()
health, err := http.Get(srv.URL + "/healthz")
diff --git a/docs/architecture/auth.md b/docs/architecture/auth.md
index fa0bced..fcb2457 100644
--- a/docs/architecture/auth.md
+++ b/docs/architecture/auth.md
@@ -6,6 +6,11 @@
bearer **personal access tokens** (hashed in `tokens`). Stateless, firewall-
friendly, and the natural fit for a scale-to-zero container.
- **Authorization is per-repo**, read from `acls` on every request.
+- **The management UI reuses the same tokens.** A browser signs in by pasting a
+ PAT once (verified through the identical path); the server then issues an
+ HMAC-signed, stateless session cookie (no sessions table — no new source of
+ truth). The UI is gated on the global-admin role (`users.is_admin`), distinct
+ from per-repo ACLs.
- **SSH is deferred.** It's the expected forge UX and the `ssh_keys` schema is
already there, but an SSH connection is stateful — awkward for a container that
sleeps. Revisit once HTTP is solid.
diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md
index 94d2689..bd2a9e8 100644
--- a/docs/architecture/open-questions.md
+++ b/docs/architecture/open-questions.md
@@ -9,9 +9,6 @@ Items with worked-out context live as one-concern notes under
the closure must be present before it runs ("on demand" doesn't apply at the
git layer); full-hydrate vs partial-clone/promisor —
[../notes/object-hydration.md](../notes/object-hydration.md).
-- **Repo-creation rules.** The `owner/name` convention, who may create repos,
- and reserved names — for the management UI (task 09). (First-admin bootstrap
- itself is done: `gitmote bootstrap`.)
- **gc / compaction.** Loose objects proliferate; a `cleanup` subcommand
(triggered by loose-object count) repacks and prunes orphans. Pruning must not
race in-flight reads — a fetch pins a ref snapshot from s3lite and pushes only
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
index 9b435df..0f1b71e 100644
--- a/internal/auth/auth_test.go
+++ b/internal/auth/auth_test.go
@@ -145,6 +145,34 @@ func TestAuthorize(t *testing.T) {
}
}
+func TestVerifyToken(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+ g := NewGuard(m)
+ raw, userID := seedToken(t, m)
+ sel, _, _ := split(raw)
+
+ // Valid raw token resolves to its owner — the web UI login path.
+ user, err := g.VerifyToken(ctx, raw)
+ if err != nil {
+ t.Fatalf("VerifyToken(valid): %v", err)
+ }
+ if user.ID != userID {
+ t.Errorf("user = %d, want %d", user.ID, userID)
+ }
+
+ // Malformed, unknown selector, and wrong secret all → ErrUnauthorized.
+ for name, tok := range map[string]string{
+ "malformed": "not-a-token",
+ "unknown": "gmt_ffffffffffffffffffffffffffffffff.1234",
+ "wrong secret": "gmt_" + sel + ".wrongsecret",
+ } {
+ if _, err := g.VerifyToken(ctx, tok); !errors.Is(err, ErrUnauthorized) {
+ t.Errorf("%s: VerifyToken = %v, want ErrUnauthorized", name, err)
+ }
+ }
+}
+
func TestBasicAuthCredentials(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
diff --git a/internal/auth/guard.go b/internal/auth/guard.go
index def4988..46800bc 100644
--- a/internal/auth/guard.go
+++ b/internal/auth/guard.go
@@ -62,6 +62,14 @@ func (g *Guard) authenticate(ctx context.Context, r *http.Request) (*meta.User,
if !ok {
return nil, ErrUnauthorized
}
+ return g.VerifyToken(ctx, raw)
+}
+
+// VerifyToken resolves a raw PAT string to its owner, or ErrUnauthorized. It is
+// the shared verification path: the request guard calls it after extracting the
+// token from the Authorization header, and the web UI's login form calls it with
+// a pasted token. All failures collapse to ErrUnauthorized.
+func (g *Guard) VerifyToken(ctx context.Context, raw string) (*meta.User, error) {
selector, secret, ok := split(raw)
if !ok {
return nil, ErrUnauthorized
diff --git a/internal/meta/acls.go b/internal/meta/acls.go
index 79cd3d1..d7b9e0d 100644
--- a/internal/meta/acls.go
+++ b/internal/meta/acls.go
@@ -22,6 +22,47 @@ func (m *Metadata) SetACL(ctx context.Context, repoID, userID int64, perm Perm)
return err
}
+// ACL is a grant on a repository, joined to the grantee's handle for display.
+type ACL struct {
+ UserID int64
+ Handle string
+ Perm Perm
+}
+
+// ListACLs returns all grants on repo, ordered by handle, for the management UI.
+func (m *Metadata) ListACLs(ctx context.Context, repoID int64) ([]ACL, error) {
+ rows, err := m.db.QueryContext(ctx,
+ `SELECT a.user_id, u.handle, a.perm
+ FROM acls a JOIN users u ON u.id = a.user_id
+ WHERE a.repo_id = ? ORDER BY u.handle`, repoID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var acls []ACL
+ for rows.Next() {
+ var (
+ a ACL
+ perm string
+ )
+ if err := rows.Scan(&a.UserID, &a.Handle, &perm); err != nil {
+ return nil, err
+ }
+ a.Perm = Perm(perm)
+ acls = append(acls, a)
+ }
+ return acls, rows.Err()
+}
+
+// DeleteACL revokes user's grant on repo. It is idempotent — revoking an absent
+// grant is not an error.
+func (m *Metadata) DeleteACL(ctx context.Context, repoID, userID int64) error {
+ _, err := m.db.ExecContext(ctx,
+ `DELETE FROM acls WHERE repo_id = ? AND user_id = ?`, repoID, userID)
+ return err
+}
+
// GetACL returns user's permission on repo, or ErrNotFound when none is set
// (no access).
func (m *Metadata) GetACL(ctx context.Context, repoID, userID int64) (Perm, error) {
diff --git a/internal/meta/repos.go b/internal/meta/repos.go
index 1df90dd..bff4de9 100644
--- a/internal/meta/repos.go
+++ b/internal/meta/repos.go
@@ -33,6 +33,24 @@ func (m *Metadata) CreateRepo(ctx context.Context, name, defaultBranch string) (
return &Repo{ID: id, Name: name, DefaultBranch: defaultBranch, CreatedAt: parseTime(created)}, nil
}
+// SetDefaultBranch updates a repository's default branch (the derived HEAD). It
+// returns ErrNotFound when no repo has the given id.
+func (m *Metadata) SetDefaultBranch(ctx context.Context, repoID int64, branch string) error {
+ res, err := m.db.ExecContext(ctx,
+ `UPDATE repos SET default_branch = ? WHERE id = ?`, branch, repoID)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
// GetRepo returns the repository named name, or ErrNotFound.
func (m *Metadata) GetRepo(ctx context.Context, name string) (*Repo, error) {
var (
diff --git a/internal/meta/ui_test.go b/internal/meta/ui_test.go
new file mode 100644
index 0000000..6393dba
--- /dev/null
+++ b/internal/meta/ui_test.go
@@ -0,0 +1,127 @@
+package meta
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestSetDefaultBranch(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ r := seedRepo(t, m, "atmin/app")
+
+ if err := m.SetDefaultBranch(ctx, r.ID, "trunk"); err != nil {
+ t.Fatalf("SetDefaultBranch: %v", err)
+ }
+ got, err := m.GetRepo(ctx, "atmin/app")
+ if err != nil {
+ t.Fatalf("GetRepo: %v", err)
+ }
+ if got.DefaultBranch != "trunk" {
+ t.Errorf("default branch = %q, want trunk", got.DefaultBranch)
+ }
+
+ // Unknown repo → ErrNotFound (failure path).
+ if err := m.SetDefaultBranch(ctx, 9999, "x"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("SetDefaultBranch(missing) = %v, want ErrNotFound", err)
+ }
+}
+
+func TestListUsersAndGetByID(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ admin, err := m.CreateAdmin(ctx, "root")
+ if err != nil {
+ t.Fatalf("CreateAdmin: %v", err)
+ }
+ if _, err := m.CreateUser(ctx, "alice"); err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+
+ users, err := m.ListUsers(ctx)
+ if err != nil {
+ t.Fatalf("ListUsers: %v", err)
+ }
+ // Ordered by handle: alice, root.
+ if len(users) != 2 || users[0].Handle != "alice" || users[1].Handle != "root" {
+ t.Fatalf("ListUsers = %+v, want [alice root]", users)
+ }
+ if users[0].IsAdmin || !users[1].IsAdmin {
+ t.Errorf("is_admin flags wrong: %+v", users)
+ }
+
+ got, err := m.GetUserByID(ctx, admin.ID)
+ if err != nil {
+ t.Fatalf("GetUserByID: %v", err)
+ }
+ if got.Handle != "root" || !got.IsAdmin {
+ t.Errorf("GetUserByID = %+v, want root/admin", got)
+ }
+ if _, err := m.GetUserByID(ctx, 9999); !errors.Is(err, ErrNotFound) {
+ t.Errorf("GetUserByID(missing) = %v, want ErrNotFound", err)
+ }
+}
+
+func TestDeleteToken(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ u, err := m.CreateUser(ctx, "alice")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ tok, err := m.CreateToken(ctx, u.ID, "sel", "ver", "laptop")
+ if err != nil {
+ t.Fatalf("CreateToken: %v", err)
+ }
+ if err := m.DeleteToken(ctx, tok.ID); err != nil {
+ t.Fatalf("DeleteToken: %v", err)
+ }
+ toks, err := m.ListTokens(ctx, u.ID)
+ if err != nil {
+ t.Fatalf("ListTokens: %v", err)
+ }
+ if len(toks) != 0 {
+ t.Errorf("after delete ListTokens = %+v, want empty", toks)
+ }
+ // Idempotent: deleting again is not an error.
+ if err := m.DeleteToken(ctx, tok.ID); err != nil {
+ t.Errorf("second DeleteToken = %v, want nil", err)
+ }
+}
+
+func TestListAndDeleteACL(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ r := seedRepo(t, m, "atmin/app")
+ alice, _ := m.CreateUser(ctx, "alice")
+ bob, _ := m.CreateUser(ctx, "bob")
+ if err := m.SetACL(ctx, r.ID, bob.ID, PermWrite); err != nil {
+ t.Fatalf("SetACL bob: %v", err)
+ }
+ if err := m.SetACL(ctx, r.ID, alice.ID, PermRead); err != nil {
+ t.Fatalf("SetACL alice: %v", err)
+ }
+
+ acls, err := m.ListACLs(ctx, r.ID)
+ if err != nil {
+ t.Fatalf("ListACLs: %v", err)
+ }
+ // Ordered by handle: alice(read), bob(write).
+ if len(acls) != 2 || acls[0].Handle != "alice" || acls[0].Perm != PermRead ||
+ acls[1].Handle != "bob" || acls[1].Perm != PermWrite {
+ t.Fatalf("ListACLs = %+v", acls)
+ }
+
+ if err := m.DeleteACL(ctx, r.ID, bob.ID); err != nil {
+ t.Fatalf("DeleteACL: %v", err)
+ }
+ acls, _ = m.ListACLs(ctx, r.ID)
+ if len(acls) != 1 || acls[0].Handle != "alice" {
+ t.Errorf("after delete ListACLs = %+v, want [alice]", acls)
+ }
+ // Revoked user now has no access.
+ if _, err := m.GetACL(ctx, r.ID, bob.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("GetACL after revoke = %v, want ErrNotFound", err)
+ }
+}
diff --git a/internal/meta/users.go b/internal/meta/users.go
index 76b9e16..c591a80 100644
--- a/internal/meta/users.go
+++ b/internal/meta/users.go
@@ -79,6 +79,55 @@ func (m *Metadata) GetUser(ctx context.Context, handle string) (*User, error) {
return &u, nil
}
+// GetUserByID returns the user with the given id, or ErrNotFound. The web UI
+// resolves the session's user this way on every request, so a deleted or
+// demoted account loses access immediately.
+func (m *Metadata) GetUserByID(ctx context.Context, id int64) (*User, error) {
+ var (
+ u User
+ isAdmin int64
+ ts string
+ )
+ err := m.db.QueryRowContext(ctx,
+ `SELECT id, handle, is_admin, created_at FROM users WHERE id = ?`, id).
+ Scan(&u.ID, &u.Handle, &isAdmin, &ts)
+ if isNoRows(err) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ u.IsAdmin = isAdmin != 0
+ u.CreatedAt = parseTime(ts)
+ return &u, nil
+}
+
+// ListUsers returns all users ordered by handle.
+func (m *Metadata) ListUsers(ctx context.Context) ([]User, error) {
+ rows, err := m.db.QueryContext(ctx,
+ `SELECT id, handle, is_admin, created_at FROM users ORDER BY handle`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var users []User
+ for rows.Next() {
+ var (
+ u User
+ isAdmin int64
+ ts string
+ )
+ if err := rows.Scan(&u.ID, &u.Handle, &isAdmin, &ts); err != nil {
+ return nil, err
+ }
+ u.IsAdmin = isAdmin != 0
+ u.CreatedAt = parseTime(ts)
+ users = append(users, u)
+ }
+ return users, rows.Err()
+}
+
// AdminExists reports whether any global administrator exists — the signal
// bootstrap uses to refuse clobbering an already-initialized instance.
func (m *Metadata) AdminExists(ctx context.Context) (bool, error) {
@@ -142,6 +191,13 @@ func (m *Metadata) TouchToken(ctx context.Context, tokenID int64) error {
return err
}
+// DeleteToken revokes a token by id. It is idempotent — deleting an absent
+// token is not an error, so a double revoke from the UI is harmless.
+func (m *Metadata) DeleteToken(ctx context.Context, tokenID int64) error {
+ _, err := m.db.ExecContext(ctx, `DELETE FROM tokens WHERE id = ?`, tokenID)
+ return err
+}
+
// ListTokens returns a user's tokens (metadata only, never the hash) ordered by
// creation time.
func (m *Metadata) ListTokens(ctx context.Context, userID int64) ([]Token, error) {
diff --git a/internal/webui/data.go b/internal/webui/data.go
new file mode 100644
index 0000000..5cfa7e0
--- /dev/null
+++ b/internal/webui/data.go
@@ -0,0 +1,41 @@
+package webui
+
+import "github.com/atmin/gitmote/internal/meta"
+
+// base is embedded in every authenticated page's data; its fields are promoted
+// so templates reference them as .Me / .Flash / .Err regardless of page.
+type base struct {
+ Me string // handle of the logged-in admin, for the nav
+ Flash string // success message after an action
+ Err string // error message after a failed action
+}
+
+// loginData backs the (unauthenticated) login page.
+type loginData struct {
+ Err string
+}
+
+type reposData struct {
+ base
+ Repos []meta.Repo
+}
+
+type usersData struct {
+ base
+ Users []meta.User
+}
+
+type tokensData struct {
+ base
+ Users []meta.User // for the user picker
+ Selected string // currently viewed user's handle
+ Tokens []meta.Token // the selected user's tokens
+ NewToken string // raw token, shown exactly once right after minting
+}
+
+type aclsData struct {
+ base
+ Repos []meta.Repo // for the repo picker
+ Selected string // currently viewed repo name
+ ACLs []meta.ACL // the selected repo's grants
+}
diff --git a/internal/webui/session.go b/internal/webui/session.go
new file mode 100644
index 0000000..b51fb56
--- /dev/null
+++ b/internal/webui/session.go
@@ -0,0 +1,96 @@
+package webui
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Session state is a signed cookie, not a server-side table: the value is
+// "<userID>.<expiryUnix>.<hmac>", where the HMAC-SHA256 over "<userID>.<expiry>"
+// is keyed by GITMOTE_COOKIE_KEY. This keeps the UI stateless — no sessions
+// table, no new source of truth — at the cost of not being able to revoke a
+// single cookie before it expires; the short TTL bounds that, and the admin
+// middleware re-checks the user (and is_admin) on every request.
+const (
+ cookieName = "gm_session"
+ sessionTTL = 12 * time.Hour
+)
+
+type sessions struct {
+ key []byte
+}
+
+// issue signs a session for userID and sets it as the response cookie. Secure is
+// set only on TLS requests so plain-HTTP local development still works while a
+// real (HTTPS) deployment gets a Secure cookie.
+func (s *sessions) issue(w http.ResponseWriter, r *http.Request, userID int64, now time.Time) {
+ exp := now.Add(sessionTTL).Unix()
+ payload := strconv.FormatInt(userID, 10) + "." + strconv.FormatInt(exp, 10)
+ value := payload + "." + s.sign(payload)
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: value,
+ Path: "/",
+ Expires: now.Add(sessionTTL),
+ HttpOnly: true,
+ Secure: r.TLS != nil,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+// verify returns the userID carried by a valid, unexpired session cookie.
+func (s *sessions) verify(r *http.Request, now time.Time) (int64, bool) {
+ c, err := r.Cookie(cookieName)
+ if err != nil {
+ return 0, false
+ }
+ uid, expStr, mac, ok := splitCookie(c.Value)
+ if !ok {
+ return 0, false
+ }
+ payload := uid + "." + expStr
+ if subtle.ConstantTimeCompare([]byte(mac), []byte(s.sign(payload))) != 1 {
+ return 0, false
+ }
+ exp, err := strconv.ParseInt(expStr, 10, 64)
+ if err != nil || now.Unix() >= exp {
+ return 0, false
+ }
+ id, err := strconv.ParseInt(uid, 10, 64)
+ if err != nil {
+ return 0, false
+ }
+ return id, true
+}
+
+// clear expires the session cookie on logout.
+func (s *sessions) clear(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func (s *sessions) sign(payload string) string {
+ mac := hmac.New(sha256.New, s.key)
+ mac.Write([]byte(payload))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+func splitCookie(v string) (uid, exp, mac string, ok bool) {
+ parts := strings.Split(v, ".")
+ if len(parts) != 3 {
+ return "", "", "", false
+ }
+ return parts[0], parts[1], parts[2], true
+}
diff --git a/internal/webui/session_test.go b/internal/webui/session_test.go
new file mode 100644
index 0000000..804b5b4
--- /dev/null
+++ b/internal/webui/session_test.go
@@ -0,0 +1,36 @@
+package webui
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestSessionRoundTrip(t *testing.T) {
+ s := &sessions{key: []byte("k")}
+ now := time.Unix(1_700_000_000, 0)
+
+ rec := httptest.NewRecorder()
+ s.issue(rec, httptest.NewRequest(http.MethodGet, "/", nil), 42, now)
+
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range rec.Result().Cookies() {
+ r.AddCookie(c)
+ }
+ uid, ok := s.verify(r, now.Add(time.Minute))
+ if !ok || uid != 42 {
+ t.Fatalf("verify = %d, %v; want 42, true", uid, ok)
+ }
+
+ // Expired session is rejected.
+ if _, ok := s.verify(r, now.Add(sessionTTL+time.Second)); ok {
+ t.Error("expired session verified")
+ }
+
+ // Tampered payload (different key) is rejected.
+ other := &sessions{key: []byte("different")}
+ if _, ok := other.verify(r, now.Add(time.Minute)); ok {
+ t.Error("session verified under a different signing key")
+ }
+}
diff --git a/internal/webui/templates/acls.html b/internal/webui/templates/acls.html
new file mode 100644
index 0000000..7ccae9b
--- /dev/null
+++ b/internal/webui/templates/acls.html
@@ -0,0 +1,54 @@
+{{define "acls.html"}}{{template "top" .}}
+<h1>Access control</h1>
+
+<form method="get" action="/ui/acls">
+ <label>Repository
+ <select name="repo" onchange="this.form.submit()">
+ <option value="">— choose —</option>
+ {{range .Repos}}<option value="{{.Name}}"{{if eq .Name $.Selected}} selected{{end}}>{{.Name}}</option>{{end}}
+ </select>
+ </label>
+ <noscript><button type="submit">Show</button></noscript>
+</form>
+
+{{if .Selected}}
+{{if .ACLs}}
+<table>
+ <thead><tr><th>User</th><th>Permission</th><th></th></tr></thead>
+ <tbody>
+ {{range .ACLs}}
+ <tr>
+ <td>{{.Handle}}</td>
+ <td>{{.Perm}}</td>
+ <td>
+ <form class="inline" method="post" action="/ui/acls/revoke">
+ <input type="hidden" name="repo" value="{{$.Selected}}">
+ <input type="hidden" name="user_id" value="{{.UserID}}">
+ <button type="submit">Revoke</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{else}}
+<p>No grants on {{.Selected}}.</p>
+{{end}}
+
+<fieldset>
+ <legend>Grant on {{.Selected}}</legend>
+ <form method="post" action="/ui/acls">
+ <input type="hidden" name="repo" value="{{.Selected}}">
+ <label>User <input type="text" name="handle" required></label>
+ <label>Permission
+ <select name="perm">
+ <option value="read">read</option>
+ <option value="write">write</option>
+ <option value="admin">admin</option>
+ </select>
+ </label>
+ <button type="submit">Grant</button>
+ </form>
+</fieldset>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/layout.html b/internal/webui/templates/layout.html
new file mode 100644
index 0000000..c0d2b73
--- /dev/null
+++ b/internal/webui/templates/layout.html
@@ -0,0 +1,52 @@
+{{define "top"}}<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>gitmote</title>
+<style>
+ :root { color-scheme: light dark; }
+ body { font-family: system-ui, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
+ nav { display: flex; gap: 1rem; align-items: baseline; border-bottom: 1px solid #8884; padding-bottom: .5rem; margin-bottom: 1.5rem; }
+ nav .spacer { flex: 1; }
+ nav a { text-decoration: none; }
+ h1 { font-size: 1.4rem; }
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
+ th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #8883; }
+ form.inline { display: inline; }
+ fieldset { border: 1px solid #8884; border-radius: 6px; margin: 1rem 0; }
+ label { display: inline-block; margin-right: 1rem; }
+ input, select { padding: .3rem; }
+ .flash { padding: .5rem .75rem; border-radius: 6px; margin: 1rem 0; }
+ .flash.ok { background: #1a7f371a; }
+ .flash.err { background: #cc00001a; }
+ code { word-break: break-all; }
+</style>
+</head>
+<body>
+{{template "nav" .}}
+{{template "flash" .}}
+{{end}}
+
+{{define "nav"}}
+<nav>
+ <strong>gitmote</strong>
+ <a href="/ui/repos">Repos</a>
+ <a href="/ui/users">Users</a>
+ <a href="/ui/tokens">Tokens</a>
+ <a href="/ui/acls">ACLs</a>
+ <span class="spacer"></span>
+ <span>{{.Me}}</span>
+ <form class="inline" method="post" action="/logout"><button type="submit">Log out</button></form>
+</nav>
+{{end}}
+
+{{define "flash"}}
+{{if .Flash}}<div class="flash ok">{{.Flash}}</div>{{end}}
+{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
+{{end}}
+
+{{define "bottom"}}
+</body>
+</html>
+{{end}}
diff --git a/internal/webui/templates/login.html b/internal/webui/templates/login.html
new file mode 100644
index 0000000..4b61a20
--- /dev/null
+++ b/internal/webui/templates/login.html
@@ -0,0 +1,26 @@
+{{define "login.html"}}<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>gitmote — sign in</title>
+<style>
+ body { font-family: system-ui, sans-serif; max-width: 24rem; margin: 4rem auto; padding: 0 1rem; line-height: 1.5; }
+ input { width: 100%; padding: .4rem; box-sizing: border-box; }
+ button { margin-top: .75rem; padding: .4rem .8rem; }
+ .err { background: #cc00001a; padding: .5rem .75rem; border-radius: 6px; margin: 1rem 0; }
+</style>
+</head>
+<body>
+<h1>gitmote</h1>
+{{if .Err}}<div class="err">{{.Err}}</div>{{end}}
+<form method="post" action="/login">
+ <label>Access token
+ <input type="password" name="token" placeholder="gmt_…" autofocus required>
+ </label>
+ <button type="submit">Sign in</button>
+</form>
+<p><small>Paste an admin personal access token. Manage tokens with the CLI (<code>gitmote bootstrap</code>) or here once signed in.</small></p>
+</body>
+</html>
+{{end}}
diff --git a/internal/webui/templates/repos.html b/internal/webui/templates/repos.html
new file mode 100644
index 0000000..248db60
--- /dev/null
+++ b/internal/webui/templates/repos.html
@@ -0,0 +1,36 @@
+{{define "repos.html"}}{{template "top" .}}
+<h1>Repositories</h1>
+
+{{if .Repos}}
+<table>
+ <thead><tr><th>Name</th><th>Default branch</th><th>Set branch</th></tr></thead>
+ <tbody>
+ {{range .Repos}}
+ <tr>
+ <td>{{.Name}}</td>
+ <td>{{.DefaultBranch}}</td>
+ <td>
+ <form class="inline" method="post" action="/ui/repos/default-branch">
+ <input type="hidden" name="repo" value="{{.Name}}">
+ <input type="text" name="default_branch" value="{{.DefaultBranch}}" size="12" required>
+ <button type="submit">Update</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{else}}
+<p>No repositories yet.</p>
+{{end}}
+
+<fieldset>
+ <legend>Create repository</legend>
+ <form method="post" action="/ui/repos">
+ <label>Owner <input type="text" name="owner" placeholder="atmin" required></label>
+ <label>Name <input type="text" name="name" placeholder="gitmote" required></label>
+ <label>Default branch <input type="text" name="default_branch" value="main"></label>
+ <button type="submit">Create</button>
+ </form>
+</fieldset>
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/tokens.html b/internal/webui/templates/tokens.html
new file mode 100644
index 0000000..e655deb
--- /dev/null
+++ b/internal/webui/templates/tokens.html
@@ -0,0 +1,55 @@
+{{define "tokens.html"}}{{template "top" .}}
+<h1>Tokens</h1>
+
+{{if .NewToken}}
+<div class="flash ok">
+ New token (copy it now — it is shown only once):<br>
+ <code>{{.NewToken}}</code>
+</div>
+{{end}}
+
+<form method="get" action="/ui/tokens">
+ <label>User
+ <select name="user" onchange="this.form.submit()">
+ <option value="">— choose —</option>
+ {{range .Users}}<option value="{{.Handle}}"{{if eq .Handle $.Selected}} selected{{end}}>{{.Handle}}</option>{{end}}
+ </select>
+ </label>
+ <noscript><button type="submit">Show</button></noscript>
+</form>
+
+{{if .Selected}}
+{{if .Tokens}}
+<table>
+ <thead><tr><th>Label</th><th>Created</th><th>Last used</th><th></th></tr></thead>
+ <tbody>
+ {{range .Tokens}}
+ <tr>
+ <td>{{if .Label}}{{.Label}}{{else}}<em>(none)</em>{{end}}</td>
+ <td>{{.CreatedAt.Format "2006-01-02"}}</td>
+ <td>{{if .LastUsed}}{{.LastUsed.Format "2006-01-02"}}{{else}}never{{end}}</td>
+ <td>
+ <form class="inline" method="post" action="/ui/tokens/revoke">
+ <input type="hidden" name="user" value="{{$.Selected}}">
+ <input type="hidden" name="id" value="{{.ID}}">
+ <button type="submit">Revoke</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{else}}
+<p>No tokens for {{.Selected}}.</p>
+{{end}}
+
+<fieldset>
+ <legend>Mint token for {{.Selected}}</legend>
+ <form method="post" action="/ui/tokens">
+ <input type="hidden" name="user" value="{{.Selected}}">
+ <label>Label <input type="text" name="label" placeholder="laptop"></label>
+ <button type="submit">Mint</button>
+ </form>
+</fieldset>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/users.html b/internal/webui/templates/users.html
new file mode 100644
index 0000000..4b4ee1f
--- /dev/null
+++ b/internal/webui/templates/users.html
@@ -0,0 +1,29 @@
+{{define "users.html"}}{{template "top" .}}
+<h1>Users</h1>
+
+{{if .Users}}
+<table>
+ <thead><tr><th>Handle</th><th>Role</th><th>Created</th></tr></thead>
+ <tbody>
+ {{range .Users}}
+ <tr>
+ <td>{{.Handle}}</td>
+ <td>{{if .IsAdmin}}admin{{else}}user{{end}}</td>
+ <td>{{.CreatedAt.Format "2006-01-02"}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{else}}
+<p>No users yet.</p>
+{{end}}
+
+<fieldset>
+ <legend>Create user</legend>
+ <form method="post" action="/ui/users">
+ <label>Handle <input type="text" name="handle" required></label>
+ <label><input type="checkbox" name="is_admin" value="1"> Global admin</label>
+ <button type="submit">Create</button>
+ </form>
+</fieldset>
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
new file mode 100644
index 0000000..47507b0
--- /dev/null
+++ b/internal/webui/webui.go
@@ -0,0 +1,481 @@
+// Package webui serves the authenticated management UI — the operations that
+// can't be done over git: create/list repos, mint/revoke tokens, and manage
+// per-repo ACLs (docs/architecture/storage.md, the "web UI" component). It is a
+// thin server-rendered layer over the same s3lite tables the git path uses; it
+// introduces no new source of truth.
+//
+// Access is gated on the global-admin role (users.is_admin). A browser logs in
+// by pasting a personal access token once (verified through the same path git
+// auth uses); the server then issues a signed, stateless session cookie.
+package webui
+
+import (
+ "context"
+ "embed"
+ "errors"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/atmin/gitmote/internal/auth"
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+//go:embed templates/*.html
+var templatesFS embed.FS
+
+// Authenticator verifies a raw personal access token, resolving it to its owner.
+// *auth.Guard satisfies it; the login form is the only caller.
+type Authenticator interface {
+ VerifyToken(ctx context.Context, raw string) (*meta.User, error)
+}
+
+// reservedOwners are top-level path segments the server already routes; a repo
+// whose owner is one of these would shadow those routes, so creation refuses it.
+var reservedOwners = map[string]bool{
+ "ui": true, "login": true, "logout": true, "healthz": true, "version": true,
+}
+
+// nameSegment matches a valid owner or repo path segment.
+var nameSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
+
+// Handler serves the management UI.
+type Handler struct {
+ md *meta.Metadata
+ auth Authenticator
+ sess *sessions
+ tmpl *template.Template
+ log *slog.Logger
+ now func() time.Time
+}
+
+// New builds the UI handler. cookieKey signs session cookies and must be
+// non-empty; the auth verifier backs the login form.
+func New(md *meta.Metadata, a Authenticator, cookieKey []byte, logger *slog.Logger) (*Handler, error) {
+ if len(cookieKey) == 0 {
+ return nil, errors.New("webui: empty cookie key")
+ }
+ tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
+ if err != nil {
+ return nil, err
+ }
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &Handler{
+ md: md,
+ auth: a,
+ sess: &sessions{key: cookieKey},
+ tmpl: tmpl,
+ log: logger,
+ now: time.Now,
+ }, nil
+}
+
+// Register mounts the UI routes on mux. Login/logout are reachable
+// unauthenticated; everything under /ui/ requires a global-admin session.
+func (h *Handler) Register(mux *http.ServeMux) {
+ mux.HandleFunc("GET /login", h.getLogin)
+ mux.HandleFunc("POST /login", h.postLogin)
+ mux.HandleFunc("POST /logout", h.postLogout)
+
+ mux.HandleFunc("GET /ui/{$}", h.requireAdmin(h.redirectRepos))
+ mux.HandleFunc("GET /ui/repos", h.requireAdmin(h.getRepos))
+ mux.HandleFunc("POST /ui/repos", h.requireAdmin(h.postRepo))
+ mux.HandleFunc("POST /ui/repos/default-branch", h.requireAdmin(h.postDefaultBranch))
+ mux.HandleFunc("GET /ui/users", h.requireAdmin(h.getUsers))
+ mux.HandleFunc("POST /ui/users", h.requireAdmin(h.postUser))
+ mux.HandleFunc("GET /ui/tokens", h.requireAdmin(h.getTokens))
+ mux.HandleFunc("POST /ui/tokens", h.requireAdmin(h.postToken))
+ mux.HandleFunc("POST /ui/tokens/revoke", h.requireAdmin(h.postRevokeToken))
+ mux.HandleFunc("GET /ui/acls", h.requireAdmin(h.getACLs))
+ mux.HandleFunc("POST /ui/acls", h.requireAdmin(h.postACL))
+ mux.HandleFunc("POST /ui/acls/revoke", h.requireAdmin(h.postRevokeACL))
+}
+
+// ctxUser carries the authenticated admin from the middleware to the handler.
+type ctxKey int
+
+const userKey ctxKey = 0
+
+func userFrom(ctx context.Context) *meta.User {
+ u, _ := ctx.Value(userKey).(*meta.User)
+ return u
+}
+
+// requireAdmin resolves the session cookie to a current, still-admin user on
+// every request. Missing/invalid session → redirect to login (GET) or 401;
+// authenticated but not an admin → 403.
+func (h *Handler) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ uid, ok := h.sess.verify(r, h.now())
+ if !ok {
+ h.denyUnauth(w, r)
+ return
+ }
+ user, err := h.md.GetUserByID(r.Context(), uid)
+ if errors.Is(err, meta.ErrNotFound) {
+ h.denyUnauth(w, r)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "load session user", err)
+ return
+ }
+ if !user.IsAdmin {
+ http.Error(w, "forbidden: admin only", http.StatusForbidden)
+ return
+ }
+ next(w, r.WithContext(context.WithValue(r.Context(), userKey, user)))
+ }
+}
+
+func (h *Handler) denyUnauth(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+}
+
+// --- login / logout ---
+
+func (h *Handler) getLogin(w http.ResponseWriter, r *http.Request) {
+ h.render(w, "login.html", loginData{})
+}
+
+func (h *Handler) postLogin(w http.ResponseWriter, r *http.Request) {
+ token := strings.TrimSpace(r.FormValue("token"))
+ user, err := h.auth.VerifyToken(r.Context(), token)
+ if err != nil {
+ w.WriteHeader(http.StatusUnauthorized)
+ h.render(w, "login.html", loginData{Err: "invalid token"})
+ return
+ }
+ if !user.IsAdmin {
+ w.WriteHeader(http.StatusForbidden)
+ h.render(w, "login.html", loginData{Err: "not an administrator"})
+ return
+ }
+ h.sess.issue(w, r, user.ID, h.now())
+ http.Redirect(w, r, "/ui/repos", http.StatusSeeOther)
+}
+
+func (h *Handler) postLogout(w http.ResponseWriter, r *http.Request) {
+ h.sess.clear(w)
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+}
+
+// --- repos ---
+
+func (h *Handler) redirectRepos(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/ui/repos", http.StatusSeeOther)
+}
+
+func (h *Handler) getRepos(w http.ResponseWriter, r *http.Request) {
+ h.renderRepos(w, r, "", "")
+}
+
+func (h *Handler) renderRepos(w http.ResponseWriter, r *http.Request, flash, errMsg string) {
+ repos, err := h.md.ListRepos(r.Context())
+ if err != nil {
+ h.serverError(w, "list repos", err)
+ return
+ }
+ h.render(w, "repos.html", reposData{
+ base: h.base(r, flash, errMsg),
+ Repos: repos,
+ })
+}
+
+func (h *Handler) postRepo(w http.ResponseWriter, r *http.Request) {
+ owner := strings.TrimSpace(r.FormValue("owner"))
+ name := strings.TrimSpace(r.FormValue("name"))
+ branch := strings.TrimSpace(r.FormValue("default_branch"))
+
+ if !nameSegment.MatchString(owner) || !nameSegment.MatchString(name) {
+ h.renderRepos(w, r, "", "owner and name must be alphanumeric (._- allowed, not leading)")
+ return
+ }
+ if reservedOwners[owner] {
+ h.renderRepos(w, r, "", "owner is reserved: "+owner)
+ return
+ }
+ if _, err := h.md.GetUser(r.Context(), owner); errors.Is(err, meta.ErrNotFound) {
+ h.renderRepos(w, r, "", "no such user for owner: "+owner)
+ return
+ } else if err != nil {
+ h.serverError(w, "lookup owner", err)
+ return
+ }
+
+ full := owner + "/" + name
+ if _, err := h.md.CreateRepo(r.Context(), full, branch); err != nil {
+ h.renderRepos(w, r, "", "create repo: "+err.Error())
+ return
+ }
+ h.renderRepos(w, r, "created "+full, "")
+}
+
+func (h *Handler) postDefaultBranch(w http.ResponseWriter, r *http.Request) {
+ name := strings.TrimSpace(r.FormValue("repo"))
+ branch := strings.TrimSpace(r.FormValue("default_branch"))
+ if branch == "" {
+ h.renderRepos(w, r, "", "default branch cannot be empty")
+ return
+ }
+ repo, err := h.md.GetRepo(r.Context(), name)
+ if errors.Is(err, meta.ErrNotFound) {
+ h.renderRepos(w, r, "", "no such repo: "+name)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return
+ }
+ if err := h.md.SetDefaultBranch(r.Context(), repo.ID, branch); err != nil {
+ h.serverError(w, "set default branch", err)
+ return
+ }
+ h.renderRepos(w, r, "default branch of "+name+" set to "+branch, "")
+}
+
+// --- users ---
+
+func (h *Handler) getUsers(w http.ResponseWriter, r *http.Request) {
+ h.renderUsers(w, r, "", "")
+}
+
+func (h *Handler) renderUsers(w http.ResponseWriter, r *http.Request, flash, errMsg string) {
+ users, err := h.md.ListUsers(r.Context())
+ if err != nil {
+ h.serverError(w, "list users", err)
+ return
+ }
+ h.render(w, "users.html", usersData{
+ base: h.base(r, flash, errMsg),
+ Users: users,
+ })
+}
+
+func (h *Handler) postUser(w http.ResponseWriter, r *http.Request) {
+ handle := strings.TrimSpace(r.FormValue("handle"))
+ if !nameSegment.MatchString(handle) {
+ h.renderUsers(w, r, "", "handle must be alphanumeric (._- allowed, not leading)")
+ return
+ }
+ admin := r.FormValue("is_admin") != ""
+ var err error
+ if admin {
+ _, err = h.md.CreateAdmin(r.Context(), handle)
+ } else {
+ _, err = h.md.CreateUser(r.Context(), handle)
+ }
+ if err != nil {
+ h.renderUsers(w, r, "", "create user: "+err.Error())
+ return
+ }
+ h.renderUsers(w, r, "created user "+handle, "")
+}
+
+// --- tokens ---
+
+func (h *Handler) getTokens(w http.ResponseWriter, r *http.Request) {
+ h.renderTokens(w, r, "", "", "")
+}
+
+// renderTokens lists tokens for the selected user (query/form "user"). newRaw,
+// when set, is the just-minted token shown exactly once.
+func (h *Handler) renderTokens(w http.ResponseWriter, r *http.Request, selected, newRaw, errMsg string) {
+ if selected == "" {
+ selected = strings.TrimSpace(r.FormValue("user"))
+ }
+ users, err := h.md.ListUsers(r.Context())
+ if err != nil {
+ h.serverError(w, "list users", err)
+ return
+ }
+ data := tokensData{
+ base: h.base(r, "", errMsg),
+ Users: users,
+ Selected: selected,
+ NewToken: newRaw,
+ }
+ if selected != "" {
+ u, err := h.md.GetUser(r.Context(), selected)
+ if errors.Is(err, meta.ErrNotFound) {
+ data.Err = "no such user: " + selected
+ h.render(w, "tokens.html", data)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get user", err)
+ return
+ }
+ toks, err := h.md.ListTokens(r.Context(), u.ID)
+ if err != nil {
+ h.serverError(w, "list tokens", err)
+ return
+ }
+ data.Tokens = toks
+ }
+ h.render(w, "tokens.html", data)
+}
+
+func (h *Handler) postToken(w http.ResponseWriter, r *http.Request) {
+ handle := strings.TrimSpace(r.FormValue("user"))
+ label := strings.TrimSpace(r.FormValue("label"))
+ u, err := h.md.GetUser(r.Context(), handle)
+ if errors.Is(err, meta.ErrNotFound) {
+ h.renderTokens(w, r, handle, "", "no such user: "+handle)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get user", err)
+ return
+ }
+ raw, selector, verifier, err := auth.Mint()
+ if err != nil {
+ h.serverError(w, "mint token", err)
+ return
+ }
+ if _, err := h.md.CreateToken(r.Context(), u.ID, selector, verifier, label); err != nil {
+ h.serverError(w, "store token", err)
+ return
+ }
+ h.renderTokens(w, r, handle, raw, "")
+}
+
+func (h *Handler) postRevokeToken(w http.ResponseWriter, r *http.Request) {
+ handle := strings.TrimSpace(r.FormValue("user"))
+ id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
+ if err != nil {
+ h.renderTokens(w, r, handle, "", "bad token id")
+ return
+ }
+ if err := h.md.DeleteToken(r.Context(), id); err != nil {
+ h.serverError(w, "revoke token", err)
+ return
+ }
+ h.renderTokens(w, r, handle, "", "")
+}
+
+// --- acls ---
+
+func (h *Handler) getACLs(w http.ResponseWriter, r *http.Request) {
+ h.renderACLs(w, r, strings.TrimSpace(r.FormValue("repo")), "", "")
+}
+
+func (h *Handler) renderACLs(w http.ResponseWriter, r *http.Request, repoName, flash, errMsg string) {
+ repos, err := h.md.ListRepos(r.Context())
+ if err != nil {
+ h.serverError(w, "list repos", err)
+ return
+ }
+ data := aclsData{
+ base: h.base(r, flash, errMsg),
+ Repos: repos,
+ Selected: repoName,
+ }
+ if repoName != "" {
+ repo, err := h.md.GetRepo(r.Context(), repoName)
+ if errors.Is(err, meta.ErrNotFound) {
+ data.Err = "no such repo: " + repoName
+ h.render(w, "acls.html", data)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return
+ }
+ acls, err := h.md.ListACLs(r.Context(), repo.ID)
+ if err != nil {
+ h.serverError(w, "list acls", err)
+ return
+ }
+ data.ACLs = acls
+ }
+ h.render(w, "acls.html", data)
+}
+
+func (h *Handler) postACL(w http.ResponseWriter, r *http.Request) {
+ repoName := strings.TrimSpace(r.FormValue("repo"))
+ handle := strings.TrimSpace(r.FormValue("handle"))
+ perm := meta.Perm(strings.TrimSpace(r.FormValue("perm")))
+ if perm != meta.PermRead && perm != meta.PermWrite && perm != meta.PermAdmin {
+ h.renderACLs(w, r, repoName, "", "perm must be read, write, or admin")
+ return
+ }
+ repo, err := h.md.GetRepo(r.Context(), repoName)
+ if errors.Is(err, meta.ErrNotFound) {
+ h.renderACLs(w, r, repoName, "", "no such repo: "+repoName)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return
+ }
+ u, err := h.md.GetUser(r.Context(), handle)
+ if errors.Is(err, meta.ErrNotFound) {
+ h.renderACLs(w, r, repoName, "", "no such user: "+handle)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get user", err)
+ return
+ }
+ if err := h.md.SetACL(r.Context(), repo.ID, u.ID, perm); err != nil {
+ h.serverError(w, "set acl", err)
+ return
+ }
+ h.renderACLs(w, r, repoName, "granted "+string(perm)+" to "+handle, "")
+}
+
+func (h *Handler) postRevokeACL(w http.ResponseWriter, r *http.Request) {
+ repoName := strings.TrimSpace(r.FormValue("repo"))
+ userID, err := strconv.ParseInt(r.FormValue("user_id"), 10, 64)
+ if err != nil {
+ h.renderACLs(w, r, repoName, "", "bad user id")
+ return
+ }
+ repo, err := h.md.GetRepo(r.Context(), repoName)
+ if errors.Is(err, meta.ErrNotFound) {
+ h.renderACLs(w, r, repoName, "", "no such repo: "+repoName)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return
+ }
+ if err := h.md.DeleteACL(r.Context(), repo.ID, userID); err != nil {
+ h.serverError(w, "revoke acl", err)
+ return
+ }
+ h.renderACLs(w, r, repoName, "revoked", "")
+}
+
+// --- rendering ---
+
+func (h *Handler) base(r *http.Request, flash, errMsg string) base {
+ var me string
+ if u := userFrom(r.Context()); u != nil {
+ me = u.Handle
+ }
+ return base{Me: me, Flash: flash, Err: errMsg}
+}
+
+func (h *Handler) render(w http.ResponseWriter, name string, data any) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
+ h.log.Error("render template", "template", name, "error", err)
+ }
+}
+
+func (h *Handler) serverError(w http.ResponseWriter, msg string, err error) {
+ h.log.Error(msg, "error", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+}
diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
new file mode 100644
index 0000000..e66bc67
--- /dev/null
+++ b/internal/webui/webui_test.go
@@ -0,0 +1,238 @@
+package webui
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/atmin/gitmote/internal/auth"
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+// harness wires a real meta DB + auth guard behind the UI handler, seeds one
+// global admin with a token, and returns the admin's raw token for login.
+type harness struct {
+ t *testing.T
+ h *Handler
+ md *meta.Metadata
+ mux *http.ServeMux
+ admin *meta.User
+}
+
+func newHarness(t *testing.T) *harness {
+ t.Helper()
+ ctx := context.Background()
+ md, err := meta.Open(ctx, meta.Config{LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3")})
+ if err != nil {
+ t.Fatalf("meta.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = md.Close() })
+
+ h, err := New(md, auth.NewGuard(md), []byte("test-cookie-key"), nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ mux := http.NewServeMux()
+ h.Register(mux)
+
+ admin, err := md.CreateAdmin(ctx, "root")
+ if err != nil {
+ t.Fatalf("CreateAdmin: %v", err)
+ }
+ return &harness{t: t, h: h, md: md, mux: mux, admin: admin}
+}
+
+// mintTokenFor mints a token for an existing user and returns the raw string.
+func (x *harness) mintTokenFor(userID int64) string {
+ x.t.Helper()
+ raw, sel, ver, err := auth.Mint()
+ if err != nil {
+ x.t.Fatalf("Mint: %v", err)
+ }
+ if _, err := x.md.CreateToken(context.Background(), userID, sel, ver, "test"); err != nil {
+ x.t.Fatalf("CreateToken: %v", err)
+ }
+ return raw
+}
+
+func (x *harness) do(method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
+ x.t.Helper()
+ var body io.Reader
+ if form != nil {
+ body = strings.NewReader(form.Encode())
+ }
+ r := httptest.NewRequest(method, target, body)
+ if form != nil {
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ if cookie != nil {
+ r.AddCookie(cookie)
+ }
+ rec := httptest.NewRecorder()
+ x.mux.ServeHTTP(rec, r)
+ return rec
+}
+
+// login posts the admin token and returns the resulting session cookie.
+func (x *harness) login(raw string) *http.Cookie {
+ x.t.Helper()
+ rec := x.do(http.MethodPost, "/login", url.Values{"token": {raw}}, nil)
+ if rec.Code != http.StatusSeeOther {
+ x.t.Fatalf("login status = %d, want 303 (body: %s)", rec.Code, rec.Body)
+ }
+ for _, c := range rec.Result().Cookies() {
+ if c.Name == cookieName {
+ return c
+ }
+ }
+ x.t.Fatal("login set no session cookie")
+ return nil
+}
+
+func TestGoldenPath(t *testing.T) {
+ x := newHarness(t)
+ ctx := context.Background()
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ // Create a user to own a repo and receive grants/tokens.
+ if rec := x.do(http.MethodPost, "/ui/users", url.Values{"handle": {"alice"}}, session); rec.Code != http.StatusOK {
+ t.Fatalf("create user: %d (%s)", rec.Code, rec.Body)
+ }
+ if _, err := x.md.GetUser(ctx, "alice"); err != nil {
+ t.Fatalf("alice not created: %v", err)
+ }
+
+ // Create a repo owned by alice.
+ rec := x.do(http.MethodPost, "/ui/repos",
+ url.Values{"owner": {"alice"}, "name": {"app"}, "default_branch": {"main"}}, session)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "created alice/app") {
+ t.Fatalf("create repo: %d (%s)", rec.Code, rec.Body)
+ }
+ repo, err := x.md.GetRepo(ctx, "alice/app")
+ if err != nil {
+ t.Fatalf("repo not created: %v", err)
+ }
+
+ // Set default branch.
+ rec = x.do(http.MethodPost, "/ui/repos/default-branch",
+ url.Values{"repo": {"alice/app"}, "default_branch": {"trunk"}}, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("set default branch: %d (%s)", rec.Code, rec.Body)
+ }
+ if got, _ := x.md.GetRepo(ctx, "alice/app"); got.DefaultBranch != "trunk" {
+ t.Errorf("default branch = %q, want trunk", got.DefaultBranch)
+ }
+
+ // Mint a token for alice — the raw token is shown exactly once in the body.
+ rec = x.do(http.MethodPost, "/ui/tokens", url.Values{"user": {"alice"}, "label": {"laptop"}}, session)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "gmt_") {
+ t.Fatalf("mint token: %d (%s)", rec.Code, rec.Body)
+ }
+ alice, _ := x.md.GetUser(ctx, "alice")
+ toks, _ := x.md.ListTokens(ctx, alice.ID)
+ if len(toks) != 1 {
+ t.Fatalf("alice tokens = %d, want 1", len(toks))
+ }
+
+ // Grant write to alice on her repo.
+ rec = x.do(http.MethodPost, "/ui/acls",
+ url.Values{"repo": {"alice/app"}, "handle": {"alice"}, "perm": {"write"}}, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("grant acl: %d (%s)", rec.Code, rec.Body)
+ }
+ if perm, err := x.md.GetACL(ctx, repo.ID, alice.ID); err != nil || perm != meta.PermWrite {
+ t.Errorf("GetACL = %q, %v; want write", perm, err)
+ }
+
+ // Revoke it again.
+ rec = x.do(http.MethodPost, "/ui/acls/revoke",
+ url.Values{"repo": {"alice/app"}, "user_id": {itoa(alice.ID)}}, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("revoke acl: %d (%s)", rec.Code, rec.Body)
+ }
+ if _, err := x.md.GetACL(ctx, repo.ID, alice.ID); err == nil {
+ t.Error("acl still present after revoke")
+ }
+}
+
+func TestUnauthenticatedDenied(t *testing.T) {
+ x := newHarness(t)
+
+ // GET without a session → redirect to login.
+ rec := x.do(http.MethodGet, "/ui/repos", nil, nil)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
+ t.Errorf("GET /ui/repos unauth = %d loc %q, want 303 /login", rec.Code, rec.Header().Get("Location"))
+ }
+
+ // POST without a session → 401 (not a browser navigation).
+ rec = x.do(http.MethodPost, "/ui/repos",
+ url.Values{"owner": {"root"}, "name": {"x"}}, nil)
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("POST /ui/repos unauth = %d, want 401", rec.Code)
+ }
+}
+
+func TestNonAdminDenied(t *testing.T) {
+ x := newHarness(t)
+ ctx := context.Background()
+
+ // A regular user with a valid token cannot log in to the UI.
+ bob, _ := x.md.CreateUser(ctx, "bob")
+ bobToken := x.mintTokenFor(bob.ID)
+ if rec := x.do(http.MethodPost, "/login", url.Values{"token": {bobToken}}, nil); rec.Code != http.StatusForbidden {
+ t.Errorf("non-admin login = %d, want 403", rec.Code)
+ }
+
+ // Even a validly-signed session for a non-admin is rejected by the guard
+ // (covers demotion mid-session): forge bob's cookie and hit an admin page.
+ forged := x.sessionFor(bob.ID)
+ if rec := x.do(http.MethodGet, "/ui/repos", nil, forged); rec.Code != http.StatusForbidden {
+ t.Errorf("non-admin session = %d, want 403", rec.Code)
+ }
+}
+
+func TestCreateRepoRejectsReservedAndUnknownOwner(t *testing.T) {
+ x := newHarness(t)
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ // Reserved top-level owner would shadow a route.
+ rec := x.do(http.MethodPost, "/ui/repos", url.Values{"owner": {"ui"}, "name": {"x"}}, session)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "reserved") {
+ t.Errorf("reserved owner: %d (%s)", rec.Code, rec.Body)
+ }
+
+ // Owner must be an existing user.
+ rec = x.do(http.MethodPost, "/ui/repos", url.Values{"owner": {"ghost"}, "name": {"x"}}, session)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "no such user") {
+ t.Errorf("unknown owner: %d (%s)", rec.Code, rec.Body)
+ }
+ if _, err := x.md.GetRepo(context.Background(), "ghost/x"); err == nil {
+ t.Error("repo created despite unknown owner")
+ }
+}
+
+// sessionFor forges a valid signed session cookie for userID, used to test the
+// middleware's admin re-check independent of the login form.
+func (x *harness) sessionFor(userID int64) *http.Cookie {
+ x.t.Helper()
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ x.h.sess.issue(rec, r, userID, x.h.now())
+ for _, c := range rec.Result().Cookies() {
+ if c.Name == cookieName {
+ return c
+ }
+ }
+ x.t.Fatal("issue set no cookie")
+ return nil
+}
+
+func itoa(n int64) string {
+ return strconv.FormatInt(n, 10)
+}
diff --git a/tasks/09-management-web-ui.md b/tasks/09-management-web-ui.md
deleted file mode 100644
index 3728ab5..0000000
--- a/tasks/09-management-web-ui.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# 09 — Management web UI
-
-Depends on: 06, 08.
-
-## Spec
-
-A minimal authenticated web UI for the operations you can't do over git:
-create/list repos, mint/revoke tokens, and manage per-repo ACLs. Backed by the
-same s3lite tables (the "web UI" component in
-[storage.md](../docs/architecture/storage.md)). Repo browsing and web-authored
-commits stay out of scope.
-
-## Current
-
-Management is CLI-only (bootstrap) plus direct git. No UI.
-
-## Change
-
-- Server-rendered pages (Go `html/template`) under an authed prefix — **decide:**
- browser login as a session cookie vs. a token pasted into the UI.
-- Screens: repos (list / create / set default branch), tokens (mint shows the
- raw token once, revoke), ACLs (grant/revoke read/write/admin per user).
-- Reuse the task-03 query layer and task-06 auth; introduce no new source of
- truth.
-
-## Verify
-
-- Handler tests: golden path for create-repo / mint-token / grant-ACL, **plus**
- auth rejections (unauthenticated and non-admin → denied).
-- Manual: create a repo in the UI, mint a token, push to it.
-- Non-breaking: additive routes behind auth; git paths untouched.
diff --git a/tasks/README.md b/tasks/README.md
index 7d77383..b7ef4d0 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -6,7 +6,6 @@ lands** (commits are the record — this list never becomes a changelog).
Ordered by dependency; each task lands in a non-breaking, tested state.
-- [09-management-web-ui.md](09-management-web-ui.md) — Authenticated UI: repos, tokens, ACLs.
- [10-self-host-local.md](10-self-host-local.md) — MinIO + Docker; e2e clone+push of the gitmote repo.
- [11-deploy.md](11-deploy.md) — Real object storage + single-writer deploy; hosts itself for real.