diff --git a/README.md b/README.md
index 8820f74..3df58fc 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ token to the logs behind a `SAVE IT NOW` banner — grab it there (no setup page
Then sign in at <http://localhost:8080/login> by pasting it, and clone/push:
```sh
-git clone http://admin:<token>@localhost:8080/<repo> # create the repo in /ui first
+git clone http://admin:<token>@localhost:8080/<repo> # create the repo on the dashboard (/) first
```
Lost the token? It's never stored (only its hash), so re-mint one — stop the
@@ -68,7 +68,7 @@ make dev # first run prints the token, clone URL, and UI URL
make dev-reset # wipe MinIO + data/ for a clean slate
```
-Sign in to the UI at <http://localhost:8080/ui> by pasting the token, or
+Sign in to the UI at <http://localhost:8080/> by pasting the token, or
clone/push straight away with the printed URL. Requires Docker + Docker Compose.
**CI works locally too.** Push a repo with `.github/workflows/*.yml` and gitmote
@@ -103,14 +103,19 @@ You can still bootstrap by hand against the bucket **before** the server is live
Only the token's hash is ever stored, so a lost token is re-minted, never
recovered.
-## Management UI
+## Web 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`, always
-on. Sign in at `/login` by pasting an **admin** token (the same PAT format git
-uses); the server issues a signed, stateless session cookie signed with a key it
-auto-generates and persists (override with `GITMOTE_COOKIE_KEY`). Access is
-limited to global admins.
+The bare root `/` is a **dashboard**: a viewer-scoped repo list (public repos to
+anyone, private to those with access, all to an admin) with browse pages for each
+repo. Sign in at `/login` by pasting a token (the same PAT format git uses) — any
+user may sign in to see the repos they can access; the server issues a signed,
+stateless session cookie keyed by an auto-generated, persisted secret (override
+with `GITMOTE_COOKIE_KEY`).
+
+The things you can't do over git are **admin-only**: create repos and mint/revoke
+tokens from the top-level `/users` and `/tokens` pages, and manage a repo from its
+`/<repo>/settings` (visibility, default branch), `/<repo>/access` (invite
+spectators/collaborators), and `/<repo>/secrets` pages.
---
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index a3fcb29..eb7f17a 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -299,7 +299,7 @@ func leaderGate(next http.Handler, isLeader func() bool) http.Handler {
// alwaysServable reports whether a path is answerable by a follower: the liveness
// probes and static assets, which carry no metadata.
func alwaysServable(p string) bool {
- return p == "/healthz" || p == "/version" || strings.HasPrefix(p, "/ui/static/")
+ return p == "/healthz" || p == "/version" || strings.HasPrefix(p, "/static/")
}
// buildGitHandler wires the git read + write paths when an object store is
diff --git a/docs/architecture/ci.md b/docs/architecture/ci.md
index 223d2d8..fbda82b 100644
--- a/docs/architecture/ci.md
+++ b/docs/architecture/ci.md
@@ -67,7 +67,7 @@ are append-only blobs under a top-level `ci/` key space
(`ci/{repoID}/{runID}/{jobID}.log`), PUT before the `ci_jobs` pointer is
recorded, with a size cap (explicit truncation marker, never silent).
-The runner-facing report API lives outside `/ui`
+The runner-facing report API lives under `/internal`, apart from the browser UI
([`internal/ci/report.go`](../../internal/ci/report.go)):
`GET /internal/ci/jobs/{id}` claims a job (`queued→running`),
`POST …/complete` uploads the log and sets a terminal status (idempotent). Both
diff --git a/docs/architecture/urls.md b/docs/architecture/urls.md
index 6fefda4..7e34645 100644
--- a/docs/architecture/urls.md
+++ b/docs/architecture/urls.md
@@ -5,15 +5,15 @@ people. That single fact sets the whole URL shape — repos live in a **flat,
single-segment namespace** (`gitmote.example.com/<repo>`), not `user/repo`. There
is no per-user namespace to disambiguate, so there is nothing to disambiguate.
-> **Status:** partially landed. The **flat single-segment namespace** and
-> **access & visibility** (public → anonymous read, repo-read browse, admin-only
-> default-branch force-push) are implemented and reconciled into
-> [storage.md](storage.md) and [auth.md](auth.md); the **`tree`/`blob`/`raw`
-> content routing** (ref-in-path greedy resolution, unified `tree`, self-healing
-> `blob`, the bare `/{repo}` landing) and **rendered-markdown link rewriting**
-> (relative nav → `blob`/`tree`, embeds → `raw`, ref preserved) are implemented
-> per the sections below. Still to land from the `tasks/urls-*` chain: the
-> dashboard UI on the new routes.
+> **Status:** landed. The **flat single-segment namespace**, **access &
+> visibility** (public → anonymous read, repo-read browse, admin-only
+> default-branch force-push), **`tree`/`blob`/`raw` content routing** (ref-in-path
+> greedy resolution, unified `tree`, self-healing `blob`, the bare `/{repo}`
+> landing), **rendered-markdown link rewriting** (relative nav → `blob`/`tree`,
+> embeds → `raw`, ref preserved), and the **dashboard + UI reshape** (viewer-scoped
+> `/`, top-level `/login`·`/users`·`/tokens`·`/static`, per-repo
+> `settings`·`access`·`secrets`) are all implemented and reconciled into
+> [storage.md](storage.md) and [auth.md](auth.md).
## The namespace
@@ -129,17 +129,19 @@ simplification the flat namespace buys.
Two non-obvious constraints the flat scheme creates:
-- **The browse routes share the mux with the git smart-HTTP catch-all.** Today the
- UI is quarantined (browse under `/browse/…`, root as `GET /{$}`); the git handler
- owns the `/` catch-all ([request-flows.md](request-flows.md)). Moving browse into
- `/{repo}/…` puts it beside git's `info/refs`, `git-upload-pack`,
- `git-receive-pack`, `HEAD`, and `objects/`. The rule that keeps clone/push
- working: **register only enumerated verb routes** (`GET /{repo}`,
- `GET /{repo}/tree/…`, `/{repo}/blob/…`, …) — **never** a broad
- `/{repo}/{rest...}`, which would swallow the git endpoints. Go's ServeMux
- (most-specific-wins) then routes verbs to the UI and everything else on
- `/{repo}/…` to the git catch-all; the one-segment `GET /{repo}` landing is
- distinct from `GET /{repo}/info/refs`.
+- **The browse and per-repo management routes share the mux with the git
+ smart-HTTP catch-all.** The UI's `/{repo}/…` routes sit beside git's
+ `info/refs`, `git-upload-pack`, `git-receive-pack`, `HEAD`, and `objects/`; the
+ git handler owns the `/` catch-all ([request-flows.md](request-flows.md)). The
+ rule that keeps clone/push working: **register only enumerated verb routes**
+ (`GET /{repo}`, `GET /{repo}/tree/…`, `/{repo}/blob/…`, `/{repo}/settings`,
+ `/{repo}/access`, `/{repo}/secrets`, …) — **never** a broad `/{repo}/{rest...}`,
+ which would swallow the git endpoints. Go's ServeMux (most-specific-wins) then
+ routes verbs to the UI and everything else on `/{repo}/…` to the git catch-all;
+ the one-segment `GET /{repo}` landing is distinct from `GET /{repo}/info/refs`.
+ The top-level globals (`/`, `/login`, `/users`, `/tokens`, `/static/…`) are
+ literal or exact-root patterns, so they win over `GET /{repo}` without a broad
+ match — the reserved-name set keeps a repo from ever claiming one.
- **The default-branch force-push guard lives in the pre-receive hook.** It must
reject *per ref* so the client sees a clean "force-push to `main` requires admin"
— not a post-hoc failure. That point needs three inputs threaded to it: the
diff --git a/internal/meta/meta_test.go b/internal/meta/meta_test.go
index 5cbc311..2f1f700 100644
--- a/internal/meta/meta_test.go
+++ b/internal/meta/meta_test.go
@@ -243,6 +243,42 @@ func TestCanRead(t *testing.T) {
}
}
+func TestListReposForViewer(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ pub := seedRepo(t, m, "pub")
+ if err := m.SetVisibility(ctx, pub.ID, VisibilityPublic); err != nil {
+ t.Fatalf("SetVisibility: %v", err)
+ }
+ seedRepo(t, m, "priv") // admin-only, no ACL
+ mine := seedRepo(t, m, "mine")
+ u, _ := m.CreateUser(ctx, "alice")
+ if err := m.SetACL(ctx, mine.ID, u.ID, PermRead); err != nil {
+ t.Fatalf("SetACL: %v", err)
+ }
+
+ names := func(userID int64) []string {
+ repos, err := m.ListReposForViewer(ctx, userID)
+ if err != nil {
+ t.Fatalf("ListReposForViewer: %v", err)
+ }
+ var out []string
+ for _, r := range repos {
+ out = append(out, r.Name)
+ }
+ return out
+ }
+
+ // Anonymous (userID 0) sees only public repos.
+ if got := names(0); len(got) != 1 || got[0] != "pub" {
+ t.Errorf("anonymous viewer repos = %v, want [pub]", got)
+ }
+ // alice sees public + the one she has an ACL on (ordered by name).
+ if got := names(u.ID); len(got) != 2 || got[0] != "mine" || got[1] != "pub" {
+ t.Errorf("alice viewer repos = %v, want [mine pub]", got)
+ }
+}
+
func TestTokenStorage(t *testing.T) {
ctx := context.Background()
m := open(t)
diff --git a/internal/meta/repos.go b/internal/meta/repos.go
index 8408cef..c03ec90 100644
--- a/internal/meta/repos.go
+++ b/internal/meta/repos.go
@@ -197,6 +197,37 @@ func (m *Metadata) ListRepos(ctx context.Context) ([]Repo, error) {
return repos, rows.Err()
}
+// ListReposForViewer returns the repos a viewer may see, ordered by name: every
+// public repo plus any private repo on which the viewer holds an ACL. userID 0
+// is anonymous (public only). The visibility/ACL filter lives here, in SQL, so
+// the dashboard handler cannot drift from the repo-read rule (CanRead). An admin
+// sees everything — the caller uses ListRepos for that.
+func (m *Metadata) ListReposForViewer(ctx context.Context, userID int64) ([]Repo, error) {
+ rows, err := m.db.QueryContext(ctx,
+ `SELECT id, name, default_branch, visibility, created_at FROM repos r
+ WHERE r.visibility = ?
+ OR EXISTS (SELECT 1 FROM acls a WHERE a.repo_id = r.id AND a.user_id = ?)
+ ORDER BY name`, VisibilityPublic, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var repos []Repo
+ for rows.Next() {
+ var (
+ r Repo
+ ts string
+ )
+ if err := rows.Scan(&r.ID, &r.Name, &r.DefaultBranch, &r.Visibility, &ts); err != nil {
+ return nil, err
+ }
+ r.CreatedAt = parseTime(ts)
+ repos = append(repos, r)
+ }
+ return repos, rows.Err()
+}
+
// parseTime parses an RFC 3339 timestamp written by now(); a parse failure
// yields the zero time rather than an error, since the column is always
// written by this package.
diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go
index b23daac..784b224 100644
--- a/internal/webui/browse_test.go
+++ b/internal/webui/browse_test.go
@@ -363,7 +363,7 @@ func TestBrowseMermaid(t *testing.T) {
x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
- const script = "/ui/static/mermaid.min.js"
+ const script = "/static/mermaid.min.js"
// A markdown blob with a mermaid fence renders the diagram container AND pulls
// in the mermaid script.
diff --git a/internal/webui/data.go b/internal/webui/data.go
index 2e8b155..8b3a375 100644
--- a/internal/webui/data.go
+++ b/internal/webui/data.go
@@ -7,10 +7,11 @@ import (
"github.com/atmin/gitmote/internal/repo"
)
-// base is embedded in every authenticated page's data; its fields are promoted
-// so templates reference them as .Me / .Flash / .Err regardless of page.
+// base is embedded in every page's data; its fields are promoted so templates
+// reference them as .Me / .IsAdmin / .Flash / .Err regardless of page.
type base struct {
- Me string // handle of the logged-in admin, for the nav
+ Me string // handle of the signed-in viewer (empty = anonymous), for the nav
+ IsAdmin bool // viewer is a global admin → show management affordances
Flash string // success message after an action
Err string // error message after a failed action
Mermaid bool // page has a rendered mermaid diagram → include the script
@@ -21,11 +22,19 @@ type loginData struct {
Err string
}
-type reposData struct {
+// dashboardData backs the "/" dashboard: the viewer-scoped repo list (create
+// form and per-repo management links are gated on .IsAdmin in the template).
+type dashboardData struct {
base
Repos []meta.Repo
}
+// settingsData backs a repo's /settings page: its visibility and default branch.
+type settingsData struct {
+ base
+ Repo meta.Repo
+}
+
type usersData struct {
base
Users []meta.User
@@ -39,19 +48,20 @@ type tokensData struct {
NewToken string // raw token, shown exactly once right after minting
}
-type aclsData struct {
+// accessData backs a repo's /access page: its grants, worded as
+// spectator (read) / collaborator (write) / admin in the template.
+type accessData struct {
base
- Repos []meta.Repo // for the repo picker
- Selected string // currently viewed repo name
- ACLs []meta.ACL // the selected repo's grants
+ Repo string // the repo name
+ ACLs []meta.ACL // the repo's grants
}
+// secretsData backs a repo's /secrets page: the (name-only) secrets it holds.
type secretsData struct {
base
- Repos []meta.Repo // for the repo picker
- Selected string // currently viewed repo name
- Names []string // the selected repo's secret names (never values)
- Enabled bool // whether a master key is configured (gates the set form)
+ Repo string // the repo name
+ Names []string // the repo's secret names (never values)
+ Enabled bool // whether a master key is configured (gates the set form)
}
// --- browse ---
diff --git a/internal/webui/secrets_test.go b/internal/webui/secrets_test.go
index dc732dd..6e03a80 100644
--- a/internal/webui/secrets_test.go
+++ b/internal/webui/secrets_test.go
@@ -20,8 +20,8 @@ func TestSecretsGoldenPath(t *testing.T) {
// Set a secret. The value must never appear in the response.
const value = "hunter2-NEEDLE"
- rec := x.do(http.MethodPost, "/ui/secrets",
- url.Values{"repo": {"app"}, "name": {"API_TOKEN"}, "value": {value}}, session)
+ rec := x.do(http.MethodPost, "/app/secrets",
+ url.Values{"name": {"API_TOKEN"}, "value": {value}}, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "saved secret API_TOKEN") {
t.Fatalf("set secret: %d (%s)", rec.Code, rec.Body)
}
@@ -39,7 +39,7 @@ func TestSecretsGoldenPath(t *testing.T) {
}
// The list page shows the name but never the value.
- rec = x.do(http.MethodGet, "/ui/secrets?repo=app", nil, session)
+ rec = x.do(http.MethodGet, "/app/secrets", nil, session)
if !strings.Contains(rec.Body.String(), "API_TOKEN") {
t.Errorf("list page missing the secret name (body: %s)", rec.Body)
}
@@ -48,8 +48,8 @@ func TestSecretsGoldenPath(t *testing.T) {
}
// Delete it.
- rec = x.do(http.MethodPost, "/ui/secrets/delete",
- url.Values{"repo": {"app"}, "name": {"API_TOKEN"}}, session)
+ rec = x.do(http.MethodPost, "/app/secrets/delete",
+ url.Values{"name": {"API_TOKEN"}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("delete secret: %d (%s)", rec.Code, rec.Body)
}
@@ -66,8 +66,8 @@ func TestSecretsRejectsReservedName(t *testing.T) {
t.Fatalf("CreateRepo: %v", err)
}
- rec := x.do(http.MethodPost, "/ui/secrets",
- url.Values{"repo": {"app"}, "name": {"WORKER_SECRET"}, "value": {"x"}}, session)
+ rec := x.do(http.MethodPost, "/app/secrets",
+ url.Values{"name": {"WORKER_SECRET"}, "value": {"x"}}, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "reserved") {
t.Errorf("reserved name: %d (%s), want a rejection message", rec.Code, rec.Body)
}
@@ -77,13 +77,13 @@ func TestSecretsRequireAdmin(t *testing.T) {
x := newHarness(t)
// Unauthenticated GET redirects to login; POST is 401 — the panel is gated
- // like the rest of /ui.
- if rec := x.do(http.MethodGet, "/ui/secrets", nil, nil); rec.Code != http.StatusSeeOther {
- t.Errorf("unauth GET /ui/secrets = %d, want 303 redirect", rec.Code)
+ // by requireAdmin, which runs before the repo is even looked up.
+ if rec := x.do(http.MethodGet, "/app/secrets", nil, nil); rec.Code != http.StatusSeeOther {
+ t.Errorf("unauth GET /app/secrets = %d, want 303 redirect", rec.Code)
}
- rec := x.do(http.MethodPost, "/ui/secrets",
- url.Values{"repo": {"app"}, "name": {"K"}, "value": {"v"}}, nil)
+ rec := x.do(http.MethodPost, "/app/secrets",
+ url.Values{"name": {"K"}, "value": {"v"}}, nil)
if rec.Code != http.StatusUnauthorized {
- t.Errorf("unauth POST /ui/secrets = %d, want 401", rec.Code)
+ t.Errorf("unauth POST /app/secrets = %d, want 401", rec.Code)
}
}
diff --git a/internal/webui/templates/access.html b/internal/webui/templates/access.html
new file mode 100644
index 0000000..f8064a0
--- /dev/null
+++ b/internal/webui/templates/access.html
@@ -0,0 +1,41 @@
+{{define "access.html"}}{{template "top" .}}
+<h1>Access — <a href="/{{.Repo}}">{{.Repo}}</a></h1>
+<p>A <strong>spectator</strong> (read) may browse and clone; a <strong>collaborator</strong> (write) may also push; <strong>admin</strong> may manage the repo.</p>
+
+{{if .ACLs}}
+<table>
+ <thead><tr><th>User</th><th>Role</th><th></th></tr></thead>
+ <tbody>
+ {{range .ACLs}}
+ <tr>
+ <td>{{.Handle}}</td>
+ <td>{{.Perm}}{{if eq .Perm "read"}} (spectator){{else if eq .Perm "write"}} (collaborator){{end}}</td>
+ <td>
+ <form class="inline" method="post" action="/{{$.Repo}}/access/revoke">
+ <input type="hidden" name="user_id" value="{{.UserID}}">
+ <button type="submit">Revoke</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{else}}
+<p>No grants on {{.Repo}}.</p>
+{{end}}
+
+<fieldset>
+ <legend>Invite</legend>
+ <form method="post" action="/{{.Repo}}/access">
+ <label>User <input type="text" name="handle" required></label>
+ <label>Role
+ <select name="perm">
+ <option value="read">spectator (read)</option>
+ <option value="write">collaborator (write)</option>
+ <option value="admin">admin</option>
+ </select>
+ </label>
+ <button type="submit">Grant</button>
+ </form>
+</fieldset>
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/acls.html b/internal/webui/templates/acls.html
deleted file mode 100644
index 7ccae9b..0000000
--- a/internal/webui/templates/acls.html
+++ /dev/null
@@ -1,54 +0,0 @@
-{{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/repos.html b/internal/webui/templates/dashboard.html
similarity index 55%
rename from internal/webui/templates/repos.html
rename to internal/webui/templates/dashboard.html
index 4ce547f..bf89fd4 100644
--- a/internal/webui/templates/repos.html
+++ b/internal/webui/templates/dashboard.html
@@ -1,36 +1,39 @@
-{{define "repos.html"}}{{template "top" .}}
+{{define "dashboard.html"}}{{template "top" .}}
<h1>Repositories</h1>
{{if .Repos}}
<table>
- <thead><tr><th>Name</th><th>Default branch</th><th>Set branch</th></tr></thead>
+ <thead><tr><th>Name</th><th>Visibility</th><th>Default branch</th>{{if .IsAdmin}}<th>Manage</th>{{end}}</tr></thead>
<tbody>
{{range .Repos}}
<tr>
<td><a href="/{{.Name}}">{{.Name}}</a> · <a href="/{{.Name}}/runs">CI</a></td>
+ <td>{{.Visibility}}</td>
<td>{{.DefaultBranch}}</td>
+ {{if $.IsAdmin}}
<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>
+ <a href="/{{.Name}}/settings">settings</a> ·
+ <a href="/{{.Name}}/access">access</a> ·
+ <a href="/{{.Name}}/secrets">secrets</a>
</td>
+ {{end}}
</tr>
{{end}}
</tbody>
</table>
{{else}}
-<p>No repositories yet.</p>
+<p>No repositories to show.{{if not .Me}} <a href="/login">Sign in</a> to see private repositories.{{end}}</p>
{{end}}
+{{if .IsAdmin}}
<fieldset>
<legend>Create repository</legend>
- <form method="post" action="/ui/repos">
+ <form method="post" action="/">
<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>
+{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/layout.html b/internal/webui/templates/layout.html
index ce99342..b0de99f 100644
--- a/internal/webui/templates/layout.html
+++ b/internal/webui/templates/layout.html
@@ -34,15 +34,18 @@
{{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>
- <a href="/ui/secrets">Secrets</a>
+ <strong><a href="/">gitmote</a></strong>
+ {{if .IsAdmin}}
+ <a href="/users">Users</a>
+ <a href="/tokens">Tokens</a>
+ {{end}}
<span class="spacer"></span>
+ {{if .Me}}
<span>{{.Me}}</span>
<form class="inline" method="post" action="/logout"><button type="submit">Log out</button></form>
+ {{else}}
+ <a href="/login">Sign in</a>
+ {{end}}
</nav>
{{end}}
@@ -53,7 +56,7 @@
{{define "bottom"}}
{{if .Mermaid}}
-<script src="/ui/static/mermaid.min.js"></script>
+<script src="/static/mermaid.min.js"></script>
<script>
var dark = matchMedia('(prefers-color-scheme: dark)').matches;
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' });
diff --git a/internal/webui/templates/login.html b/internal/webui/templates/login.html
index 4b61a20..d3ebd39 100644
--- a/internal/webui/templates/login.html
+++ b/internal/webui/templates/login.html
@@ -20,7 +20,7 @@
</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>
+<p><small>Paste a personal access token. Mint one with the CLI (<code>gitmote bootstrap</code>) or, as an admin, from the Tokens page.</small></p>
</body>
</html>
{{end}}
diff --git a/internal/webui/templates/secrets.html b/internal/webui/templates/secrets.html
index e36f69c..128369c 100644
--- a/internal/webui/templates/secrets.html
+++ b/internal/webui/templates/secrets.html
@@ -1,21 +1,10 @@
{{define "secrets.html"}}{{template "top" .}}
-<h1>CI secrets</h1>
-
-<form method="get" action="/ui/secrets">
- <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>
+<h1>CI secrets — <a href="/{{.Repo}}">{{.Repo}}</a></h1>
{{if not .Enabled}}
<div class="flash err">Secrets are disabled — set <code>GITMOTE_CI_SECRET_KEY_V1</code> (base64 of 32 bytes) to enable encryption.</div>
{{end}}
-{{if .Selected}}
{{if .Names}}
<table>
<thead><tr><th>Name</th><th></th></tr></thead>
@@ -24,8 +13,7 @@
<tr>
<td>{{.}}</td>
<td>
- <form class="inline" method="post" action="/ui/secrets/delete">
- <input type="hidden" name="repo" value="{{$.Selected}}">
+ <form class="inline" method="post" action="/{{$.Repo}}/secrets/delete">
<input type="hidden" name="name" value="{{.}}">
<button type="submit">Delete</button>
</form>
@@ -35,20 +23,18 @@
</tbody>
</table>
{{else}}
-<p>No secrets on {{.Selected}}.</p>
+<p>No secrets on {{.Repo}}.</p>
{{end}}
{{if .Enabled}}
<fieldset>
- <legend>Set secret on {{.Selected}}</legend>
+ <legend>Set secret on {{.Repo}}</legend>
<p>Values are write-only — they are encrypted at rest and never shown again. Setting an existing name overwrites it.</p>
- <form method="post" action="/ui/secrets" autocomplete="off">
- <input type="hidden" name="repo" value="{{.Selected}}">
+ <form method="post" action="/{{.Repo}}/secrets" autocomplete="off">
<label>Name <input type="text" name="name" pattern="[A-Za-z_][A-Za-z0-9_]*" required></label>
<label>Value <input type="password" name="value" autocomplete="new-password" required></label>
<button type="submit">Save</button>
</form>
</fieldset>
{{end}}
-{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/settings.html b/internal/webui/templates/settings.html
new file mode 100644
index 0000000..dfc290a
--- /dev/null
+++ b/internal/webui/templates/settings.html
@@ -0,0 +1,26 @@
+{{define "settings.html"}}{{template "top" .}}
+<h1>Settings — <a href="/{{.Repo.Name}}">{{.Repo.Name}}</a></h1>
+
+<fieldset>
+ <legend>Visibility</legend>
+ <p>A <strong>public</strong> repo is readable by anyone with no token (clone, fetch, browse). Writes always require access.</p>
+ <form class="inline" method="post" action="/{{.Repo.Name}}/settings">
+ <label>Visibility
+ <select name="visibility">
+ <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>private</option>
+ <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>public</option>
+ </select>
+ </label>
+ <button type="submit">Save</button>
+ </form>
+</fieldset>
+
+<fieldset>
+ <legend>Default branch</legend>
+ <p>The branch HEAD points at, and the one the force-push guard protects (a non-fast-forward here needs admin).</p>
+ <form class="inline" method="post" action="/{{.Repo.Name}}/settings">
+ <label>Default branch <input type="text" name="default_branch" value="{{.Repo.DefaultBranch}}" size="16" required></label>
+ <button type="submit">Update</button>
+ </form>
+</fieldset>
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/tokens.html b/internal/webui/templates/tokens.html
index e655deb..f8beb63 100644
--- a/internal/webui/templates/tokens.html
+++ b/internal/webui/templates/tokens.html
@@ -8,7 +8,7 @@
</div>
{{end}}
-<form method="get" action="/ui/tokens">
+<form method="get" action="/tokens">
<label>User
<select name="user" onchange="this.form.submit()">
<option value="">— choose —</option>
@@ -29,7 +29,7 @@
<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">
+ <form class="inline" method="post" action="/tokens/revoke">
<input type="hidden" name="user" value="{{$.Selected}}">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit">Revoke</button>
@@ -45,7 +45,7 @@
<fieldset>
<legend>Mint token for {{.Selected}}</legend>
- <form method="post" action="/ui/tokens">
+ <form method="post" action="/tokens">
<input type="hidden" name="user" value="{{.Selected}}">
<label>Label <input type="text" name="label" placeholder="laptop"></label>
<button type="submit">Mint</button>
diff --git a/internal/webui/templates/users.html b/internal/webui/templates/users.html
index 4b4ee1f..36ef0fb 100644
--- a/internal/webui/templates/users.html
+++ b/internal/webui/templates/users.html
@@ -20,7 +20,7 @@
<fieldset>
<legend>Create user</legend>
- <form method="post" action="/ui/users">
+ <form method="post" action="/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>
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
index dc01cc9..5fb6a4a 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -134,34 +134,37 @@ func New(md *meta.Metadata, mz *repo.Materializer, objs store.Store, a Authentic
}, nil
}
-// Register mounts the UI routes on mux. Login/logout are reachable
-// unauthenticated; everything under /ui/ requires a global-admin session.
+// Register mounts the UI routes on mux. The dashboard, login, and read-only
+// browsing are reachable unauthenticated (viewer-scoped); the global-management
+// and per-repo-management routes require 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)
- // The bare root is the git smart-HTTP catch-all's territory, but a browser
- // hitting it should land in the UI rather than a 404. GET /{$} matches only
- // the exact root, so it never shadows a repo path (/owner/repo/…). Registered
- // with the UI, so it exists only when the UI does.
- mux.HandleFunc("GET /{$}", h.redirectRepos)
-
- 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))
- mux.HandleFunc("GET /ui/secrets", h.requireAdmin(h.getSecrets))
- mux.HandleFunc("POST /ui/secrets", h.requireAdmin(h.postSecret))
- mux.HandleFunc("POST /ui/secrets/delete", h.requireAdmin(h.postDeleteSecret))
+ // The bare root is the dashboard — a viewer-scoped repo list, not a redirect.
+ // GET /{$} matches only the exact root, so it never shadows a repo path;
+ // POST /{$} is the admin-only repo-create. Both exist only when the UI does.
+ mux.HandleFunc("GET /{$}", h.getDashboard)
+ mux.HandleFunc("POST /{$}", h.requireAdmin(h.postRepo))
+
+ // Global management — top-level, admin-only (no /ui/ prefix).
+ mux.HandleFunc("GET /users", h.requireAdmin(h.getUsers))
+ mux.HandleFunc("POST /users", h.requireAdmin(h.postUser))
+ mux.HandleFunc("GET /tokens", h.requireAdmin(h.getTokens))
+ mux.HandleFunc("POST /tokens", h.requireAdmin(h.postToken))
+ mux.HandleFunc("POST /tokens/revoke", h.requireAdmin(h.postRevokeToken))
+
+ // Per-repo management — segment-2 verbs, admin-only. Enumerated (never a broad
+ // /{repo}/{rest...}), so git's own suffixes still fall through to the catch-all.
+ mux.HandleFunc("GET /{repo}/settings", h.requireAdmin(h.getSettings))
+ mux.HandleFunc("POST /{repo}/settings", h.requireAdmin(h.postSettings))
+ mux.HandleFunc("GET /{repo}/access", h.requireAdmin(h.getAccess))
+ mux.HandleFunc("POST /{repo}/access", h.requireAdmin(h.postAccess))
+ mux.HandleFunc("POST /{repo}/access/revoke", h.requireAdmin(h.postRevokeAccess))
+ mux.HandleFunc("GET /{repo}/secrets", h.requireAdmin(h.getSecrets))
+ mux.HandleFunc("POST /{repo}/secrets", h.requireAdmin(h.postSecret))
+ mux.HandleFunc("POST /{repo}/secrets/delete", h.requireAdmin(h.postDeleteSecret))
// Read-only browsing. Gated on repo-read (public → anyone, private → an ACL),
// not on admin — this is what makes spectators and public repos usable; the
@@ -194,7 +197,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
// The vendored mermaid library for diagram rendering, served same-origin.
// Public and non-sensitive (a pinned copy of an open-source library), so it
// needs no admin session; hard-cached, since the file itself is the version pin.
- mux.HandleFunc("GET /ui/static/mermaid.min.js", h.serveMermaidJS)
+ mux.HandleFunc("GET /static/mermaid.min.js", h.serveMermaidJS)
}
// serveMermaidJS serves the embedded mermaid library for the browse UI's diagram
@@ -265,13 +268,11 @@ func (h *Handler) postLogin(w http.ResponseWriter, r *http.Request) {
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
- }
+ // Any valid user may sign in — a spectator/collaborator needs a session to
+ // browse the private repos they have access to on the dashboard. Management
+ // stays admin-only, enforced per-route by requireAdmin (not here).
h.sess.issue(w, r, user.ID, h.now())
- http.Redirect(w, r, "/ui/repos", http.StatusSeeOther)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) postLogout(w http.ResponseWriter, r *http.Request) {
@@ -279,24 +280,33 @@ func (h *Handler) postLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
-// --- repos ---
+// --- dashboard / repos ---
-func (h *Handler) redirectRepos(w http.ResponseWriter, r *http.Request) {
- http.Redirect(w, r, "/ui/repos", http.StatusSeeOther)
+// getDashboard renders the viewer-scoped repo list at "/": public repos to
+// anyone, private repos to a viewer holding an ACL, all repos to an admin. It is
+// the unauthenticated landing — a signed-out visitor sees public repos and a
+// sign-in link, never a 403 wall.
+func (h *Handler) getDashboard(w http.ResponseWriter, r *http.Request) {
+ h.renderDashboard(w, r, "", "")
}
-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())
+func (h *Handler) renderDashboard(w http.ResponseWriter, r *http.Request, flash, errMsg string) {
+ u := h.viewer(r)
+ var (
+ repos []meta.Repo
+ err error
+ )
+ if u != nil && u.IsAdmin {
+ repos, err = h.md.ListRepos(r.Context())
+ } else {
+ repos, err = h.md.ListReposForViewer(r.Context(), userID(u))
+ }
if err != nil {
h.serverError(w, "list repos", err)
return
}
- h.render(w, "repos.html", reposData{
- base: h.base(r, flash, errMsg),
+ h.render(w, "dashboard.html", dashboardData{
+ base: h.baseFor(u, flash, errMsg),
Repos: repos,
})
}
@@ -307,14 +317,14 @@ func (h *Handler) postRepo(w http.ResponseWriter, r *http.Request) {
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)")
+ h.renderDashboard(w, r, "", "owner and name must be alphanumeric (._- allowed, not leading)")
return
}
// The reserved-name and structural rules live in meta.CreateRepo (one source
// for routing + validation); its error is surfaced below.
ownerUser, err := h.md.GetUser(r.Context(), owner)
if errors.Is(err, meta.ErrNotFound) {
- h.renderRepos(w, r, "", "no such user for owner: "+owner)
+ h.renderDashboard(w, r, "", "no such user for owner: "+owner)
return
} else if err != nil {
h.serverError(w, "lookup owner", err)
@@ -325,7 +335,7 @@ func (h *Handler) postRepo(w http.ResponseWriter, r *http.Request) {
// CreateRepo enforces the reserved-name and structural rules.
repo, err := h.md.CreateRepo(r.Context(), name, branch)
if err != nil {
- h.renderRepos(w, r, "", "create repo: "+err.Error())
+ h.renderDashboard(w, r, "", "create repo: "+err.Error())
return
}
// Grant the owner admin on their new repo so it is immediately usable
@@ -334,30 +344,66 @@ func (h *Handler) postRepo(w http.ResponseWriter, r *http.Request) {
h.serverError(w, "grant owner acl", err)
return
}
- h.renderRepos(w, r, "created "+name, "")
+ h.renderDashboard(w, r, "created "+name, "")
}
-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")
+// --- per-repo settings ---
+
+func (h *Handler) getSettings(w http.ResponseWriter, r *http.Request) {
+ h.renderSettings(w, r, r.PathValue("repo"), "", "")
+}
+
+func (h *Handler) renderSettings(w http.ResponseWriter, r *http.Request, name, flash, errMsg string) {
+ rp, err := h.md.GetRepo(r.Context(), name)
+ if errors.Is(err, meta.ErrNotFound) {
+ http.NotFound(w, r)
return
}
- repo, err := h.md.GetRepo(r.Context(), name)
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return
+ }
+ h.render(w, "settings.html", settingsData{
+ base: h.base(r, flash, errMsg),
+ Repo: *rp,
+ })
+}
+
+// postSettings applies whichever settings the form carries: a non-empty
+// default_branch and/or a visibility value. A form with neither is a no-op.
+func (h *Handler) postSettings(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("repo")
+ rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
- h.renderRepos(w, r, "", "no such repo: "+name)
+ http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
- if err := h.md.SetDefaultBranch(r.Context(), repo.ID, branch); err != nil {
+ if v := strings.TrimSpace(r.FormValue("visibility")); v != "" {
+ if v != meta.VisibilityPrivate && v != meta.VisibilityPublic {
+ h.renderSettings(w, r, name, "", "visibility must be private or public")
+ return
+ }
+ if err := h.md.SetVisibility(r.Context(), rp.ID, v); err != nil {
+ h.serverError(w, "set visibility", err)
+ return
+ }
+ h.renderSettings(w, r, name, "visibility set to "+v, "")
+ return
+ }
+ branch := strings.TrimSpace(r.FormValue("default_branch"))
+ if branch == "" {
+ h.renderSettings(w, r, name, "", "default branch cannot be empty")
+ return
+ }
+ if err := h.md.SetDefaultBranch(r.Context(), rp.ID, branch); err != nil {
h.serverError(w, "set default branch", err)
return
}
- h.renderRepos(w, r, "default branch of "+name+" set to "+branch, "")
+ h.renderSettings(w, r, name, "default branch set to "+branch, "")
}
// --- users ---
@@ -480,55 +526,45 @@ func (h *Handler) postRevokeToken(w http.ResponseWriter, r *http.Request) {
h.renderTokens(w, r, handle, "", "")
}
-// --- acls ---
+// --- per-repo access (ACLs) ---
-func (h *Handler) getACLs(w http.ResponseWriter, r *http.Request) {
- h.renderACLs(w, r, strings.TrimSpace(r.FormValue("repo")), "", "")
+func (h *Handler) getAccess(w http.ResponseWriter, r *http.Request) {
+ h.renderAccess(w, r, r.PathValue("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)
+func (h *Handler) renderAccess(w http.ResponseWriter, r *http.Request, name, flash, errMsg string) {
+ rp, err := h.md.GetRepo(r.Context(), name)
+ if errors.Is(err, meta.ErrNotFound) {
+ http.NotFound(w, r)
return
}
- data := aclsData{
- base: h.base(r, flash, errMsg),
- Repos: repos,
- Selected: repoName,
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return
}
- 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
+ acls, err := h.md.ListACLs(r.Context(), rp.ID)
+ if err != nil {
+ h.serverError(w, "list acls", err)
+ return
}
- h.render(w, "acls.html", data)
+ h.render(w, "access.html", accessData{
+ base: h.base(r, flash, errMsg),
+ Repo: rp.Name,
+ ACLs: acls,
+ })
}
-func (h *Handler) postACL(w http.ResponseWriter, r *http.Request) {
- repoName := strings.TrimSpace(r.FormValue("repo"))
+func (h *Handler) postAccess(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("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")
+ h.renderAccess(w, r, name, "", "permission must be read, write, or admin")
return
}
- repo, err := h.md.GetRepo(r.Context(), repoName)
+ rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
- h.renderACLs(w, r, repoName, "", "no such repo: "+repoName)
+ http.NotFound(w, r)
return
}
if err != nil {
@@ -537,73 +573,66 @@ func (h *Handler) postACL(w http.ResponseWriter, r *http.Request) {
}
u, err := h.md.GetUser(r.Context(), handle)
if errors.Is(err, meta.ErrNotFound) {
- h.renderACLs(w, r, repoName, "", "no such user: "+handle)
+ h.renderAccess(w, r, name, "", "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 {
+ if err := h.md.SetACL(r.Context(), rp.ID, u.ID, perm); err != nil {
h.serverError(w, "set acl", err)
return
}
- h.renderACLs(w, r, repoName, "granted "+string(perm)+" to "+handle, "")
+ h.renderAccess(w, r, name, "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)
+func (h *Handler) postRevokeAccess(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("repo")
+ uid, err := strconv.ParseInt(r.FormValue("user_id"), 10, 64)
if err != nil {
- h.renderACLs(w, r, repoName, "", "bad user id")
+ h.renderAccess(w, r, name, "", "bad user id")
return
}
- repo, err := h.md.GetRepo(r.Context(), repoName)
+ rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
- h.renderACLs(w, r, repoName, "", "no such repo: "+repoName)
+ http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
- if err := h.md.DeleteACL(r.Context(), repo.ID, userID); err != nil {
+ if err := h.md.DeleteACL(r.Context(), rp.ID, uid); err != nil {
h.serverError(w, "revoke acl", err)
return
}
- h.renderACLs(w, r, repoName, "revoked", "")
+ h.renderAccess(w, r, name, "revoked", "")
}
-// --- ci secrets ---
+// --- per-repo ci secrets ---
func (h *Handler) getSecrets(w http.ResponseWriter, r *http.Request) {
- h.renderSecrets(w, r, strings.TrimSpace(r.FormValue("repo")), "", "")
+ h.renderSecrets(w, r, r.PathValue("repo"), "", "")
}
-func (h *Handler) renderSecrets(w http.ResponseWriter, r *http.Request, repoName, flash, errMsg string) {
- repos, err := h.md.ListRepos(r.Context())
+func (h *Handler) renderSecrets(w http.ResponseWriter, r *http.Request, name, flash, errMsg string) {
+ rp, err := h.md.GetRepo(r.Context(), name)
+ if errors.Is(err, meta.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
if err != nil {
- h.serverError(w, "list repos", err)
+ h.serverError(w, "get repo", err)
return
}
data := secretsData{
- base: h.base(r, flash, errMsg),
- Repos: repos,
- Selected: repoName,
- Enabled: h.secrets != nil && h.secrets.Enabled(),
+ base: h.base(r, flash, errMsg),
+ Repo: rp.Name,
+ Enabled: h.secrets != nil && h.secrets.Enabled(),
}
- 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, "secrets.html", data)
- return
- }
- if err != nil {
- h.serverError(w, "get repo", err)
- return
- }
- names, err := h.secrets.ListSecretNames(r.Context(), repo.ID)
+ if data.Enabled {
+ names, err := h.secrets.ListSecretNames(r.Context(), rp.ID)
if err != nil {
h.serverError(w, "list secret names", err)
return
@@ -614,61 +643,91 @@ func (h *Handler) renderSecrets(w http.ResponseWriter, r *http.Request, repoName
}
func (h *Handler) postSecret(w http.ResponseWriter, r *http.Request) {
- repoName := strings.TrimSpace(r.FormValue("repo"))
- name := strings.TrimSpace(r.FormValue("name"))
+ name := r.PathValue("repo")
+ secretName := strings.TrimSpace(r.FormValue("name"))
value := r.FormValue("value") // not trimmed: a secret may have meaningful edges
if h.secrets == nil || !h.secrets.Enabled() {
- h.renderSecrets(w, r, repoName, "", "secrets are disabled: set GITMOTE_CI_SECRET_KEY_V1")
+ h.renderSecrets(w, r, name, "", "secrets are disabled: set GITMOTE_CI_SECRET_KEY_V1")
return
}
if value == "" {
- h.renderSecrets(w, r, repoName, "", "value cannot be empty")
+ h.renderSecrets(w, r, name, "", "value cannot be empty")
return
}
- repo, err := h.md.GetRepo(r.Context(), repoName)
+ rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
- h.renderSecrets(w, r, repoName, "", "no such repo: "+repoName)
+ http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
- if err := h.secrets.SetSecret(r.Context(), repo.ID, name, value); err != nil {
+ if err := h.secrets.SetSecret(r.Context(), rp.ID, secretName, value); err != nil {
// The error names the (public) secret name at worst, never the value.
- h.renderSecrets(w, r, repoName, "", "set secret: "+err.Error())
+ h.renderSecrets(w, r, name, "", "set secret: "+err.Error())
return
}
- h.renderSecrets(w, r, repoName, "saved secret "+name, "")
+ h.renderSecrets(w, r, name, "saved secret "+secretName, "")
}
func (h *Handler) postDeleteSecret(w http.ResponseWriter, r *http.Request) {
- repoName := strings.TrimSpace(r.FormValue("repo"))
- name := strings.TrimSpace(r.FormValue("name"))
- repo, err := h.md.GetRepo(r.Context(), repoName)
+ name := r.PathValue("repo")
+ secretName := strings.TrimSpace(r.FormValue("name"))
+ rp, err := h.md.GetRepo(r.Context(), name)
if errors.Is(err, meta.ErrNotFound) {
- h.renderSecrets(w, r, repoName, "", "no such repo: "+repoName)
+ http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
- if err := h.secrets.DeleteSecret(r.Context(), repo.ID, name); err != nil {
+ if err := h.secrets.DeleteSecret(r.Context(), rp.ID, secretName); err != nil {
h.serverError(w, "delete secret", err)
return
}
- h.renderSecrets(w, r, repoName, "deleted secret "+name, "")
+ h.renderSecrets(w, r, name, "deleted secret "+secretName, "")
}
// --- rendering ---
-func (h *Handler) base(r *http.Request, flash, errMsg string) base {
- var me string
+// viewer resolves the optional signed-in user for a request: the admin injected
+// by requireAdmin if present, else the session cookie's user, else nil
+// (anonymous). Best-effort — a missing/expired/invalid cookie is anonymous.
+func (h *Handler) viewer(r *http.Request) *meta.User {
if u := userFrom(r.Context()); u != nil {
- me = u.Handle
+ return u
+ }
+ if id, ok := h.sess.verify(r, h.now()); ok {
+ if u, err := h.md.GetUserByID(r.Context(), id); err == nil {
+ return u
+ }
+ }
+ return nil
+}
+
+// userID is the user's id, or 0 for a nil (anonymous) viewer.
+func userID(u *meta.User) int64 {
+ if u != nil {
+ return u.ID
+ }
+ return 0
+}
+
+func (h *Handler) base(r *http.Request, flash, errMsg string) base {
+ return h.baseFor(h.viewer(r), flash, errMsg)
+}
+
+// baseFor builds the shared page header from an already-resolved viewer, so a
+// handler that looked the viewer up (the dashboard) needn't do it twice.
+func (h *Handler) baseFor(u *meta.User, flash, errMsg string) base {
+ b := base{Flash: flash, Err: errMsg}
+ if u != nil {
+ b.Me = u.Handle
+ b.IsAdmin = u.IsAdmin
}
- return base{Me: me, Flash: flash, Err: errMsg}
+ return b
}
func (h *Handler) render(w http.ResponseWriter, name string, data any) {
diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
index bd80908..d3cb61b 100644
--- a/internal/webui/webui_test.go
+++ b/internal/webui/webui_test.go
@@ -112,16 +112,65 @@ func (x *harness) login(raw string) *http.Cookie {
return nil
}
-// TestRootRedirectsToUI: the bare root sends a browser into the UI instead of
-// 404ing. It matches only the exact root, leaving repo paths to the git handler.
-func TestRootRedirectsToUI(t *testing.T) {
+// TestRootDashboard: the bare root renders the viewer-scoped dashboard (not a
+// redirect, not a 404). It matches only the exact root, leaving repo paths to
+// the git handler.
+func TestRootDashboard(t *testing.T) {
x := newHarness(t)
rec := x.do(http.MethodGet, "/", nil, nil)
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("GET / = %d, want 303", rec.Code)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET / = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "Repositories") {
+ t.Errorf("dashboard body missing heading:\n%s", rec.Body)
+ }
+ // Anonymous sees a sign-in link, not admin nav.
+ if !strings.Contains(rec.Body.String(), `href="/login"`) {
+ t.Errorf("anonymous dashboard missing sign-in link:\n%s", rec.Body)
+ }
+}
+
+// TestDashboardScoping: the dashboard lists exactly the repos a viewer may see —
+// anonymous → public only; an ACL user → their repos + public; admin → all.
+func TestDashboardScoping(t *testing.T) {
+ x := newHarness(t)
+ ctx := context.Background()
+
+ pub, _ := x.md.CreateRepo(ctx, "pub", "main")
+ if err := x.md.SetVisibility(ctx, pub.ID, meta.VisibilityPublic); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := x.md.CreateRepo(ctx, "priv", "main"); err != nil { // admin-only
+ t.Fatal(err)
+ }
+ mine, _ := x.md.CreateRepo(ctx, "mine", "main") // granted to alice
+ alice, _ := x.md.CreateUser(ctx, "alice")
+ if err := x.md.SetACL(ctx, mine.ID, alice.ID, meta.PermRead); err != nil {
+ t.Fatal(err)
+ }
+
+ body := func(cookie *http.Cookie) string {
+ rec := x.do(http.MethodGet, "/", nil, cookie)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("dashboard = %d", rec.Code)
+ }
+ return rec.Body.String()
+ }
+ has := func(s, name string) bool { return strings.Contains(s, `/`+name+`"`) }
+
+ anon := body(nil)
+ if !has(anon, "pub") || has(anon, "priv") || has(anon, "mine") {
+ t.Errorf("anonymous dashboard scoping wrong:\n%s", anon)
+ }
+
+ as := body(x.sessionFor(alice.ID))
+ if !has(as, "pub") || !has(as, "mine") || has(as, "priv") {
+ t.Errorf("ACL-user dashboard scoping wrong:\n%s", as)
}
- if loc := rec.Header().Get("Location"); loc != "/ui/repos" {
- t.Errorf("GET / redirected to %q, want /ui/repos", loc)
+
+ admin := body(x.login(x.mintTokenFor(x.admin.ID)))
+ if !has(admin, "pub") || !has(admin, "priv") || !has(admin, "mine") {
+ t.Errorf("admin dashboard should list all repos:\n%s", admin)
}
}
@@ -131,15 +180,15 @@ func TestGoldenPath(t *testing.T) {
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 {
+ if rec := x.do(http.MethodPost, "/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",
+ // Create a repo owned by alice (POST to the dashboard).
+ rec := x.do(http.MethodPost, "/",
url.Values{"owner": {"alice"}, "name": {"app"}, "default_branch": {"main"}}, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "created app") {
t.Fatalf("create repo: %d (%s)", rec.Code, rec.Body)
@@ -149,9 +198,9 @@ func TestGoldenPath(t *testing.T) {
t.Fatalf("repo not created: %v", err)
}
- // Set default branch.
- rec = x.do(http.MethodPost, "/ui/repos/default-branch",
- url.Values{"repo": {"app"}, "default_branch": {"trunk"}}, session)
+ // Set default branch via the repo's settings page.
+ rec = x.do(http.MethodPost, "/app/settings",
+ url.Values{"default_branch": {"trunk"}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("set default branch: %d (%s)", rec.Code, rec.Body)
}
@@ -159,8 +208,17 @@ func TestGoldenPath(t *testing.T) {
t.Errorf("default branch = %q, want trunk", got.DefaultBranch)
}
+ // Toggle visibility to public via settings.
+ rec = x.do(http.MethodPost, "/app/settings", url.Values{"visibility": {"public"}}, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("set visibility: %d (%s)", rec.Code, rec.Body)
+ }
+ if got, _ := x.md.GetRepo(ctx, "app"); got.Visibility != meta.VisibilityPublic {
+ t.Errorf("visibility = %q, want public", got.Visibility)
+ }
+
// 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)
+ rec = x.do(http.MethodPost, "/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)
}
@@ -170,9 +228,9 @@ func TestGoldenPath(t *testing.T) {
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": {"app"}, "handle": {"alice"}, "perm": {"write"}}, session)
+ // Grant write (collaborator) to alice on her repo via the access page.
+ rec = x.do(http.MethodPost, "/app/access",
+ url.Values{"handle": {"alice"}, "perm": {"write"}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("grant acl: %d (%s)", rec.Code, rec.Body)
}
@@ -181,8 +239,8 @@ func TestGoldenPath(t *testing.T) {
}
// Revoke it again.
- rec = x.do(http.MethodPost, "/ui/acls/revoke",
- url.Values{"repo": {"app"}, "user_id": {itoa(alice.ID)}}, session)
+ rec = x.do(http.MethodPost, "/app/access/revoke",
+ url.Values{"user_id": {itoa(alice.ID)}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("revoke acl: %d (%s)", rec.Code, rec.Body)
}
@@ -199,10 +257,10 @@ func TestCreateRepoGrantsOwnerAdmin(t *testing.T) {
ctx := context.Background()
session := x.login(x.mintTokenFor(x.admin.ID))
- if rec := x.do(http.MethodPost, "/ui/users", url.Values{"handle": {"bob"}}, session); rec.Code != http.StatusOK {
+ if rec := x.do(http.MethodPost, "/users", url.Values{"handle": {"bob"}}, session); rec.Code != http.StatusOK {
t.Fatalf("create user: %d (%s)", rec.Code, rec.Body)
}
- if rec := x.do(http.MethodPost, "/ui/repos",
+ if rec := x.do(http.MethodPost, "/",
url.Values{"owner": {"bob"}, "name": {"proj"}, "default_branch": {"main"}}, session); rec.Code != http.StatusOK {
t.Fatalf("create repo: %d (%s)", rec.Code, rec.Body)
}
@@ -220,17 +278,17 @@ func TestCreateRepoGrantsOwnerAdmin(t *testing.T) {
func TestUnauthenticatedDenied(t *testing.T) {
x := newHarness(t)
- // GET without a session → redirect to login.
- rec := x.do(http.MethodGet, "/ui/repos", nil, nil)
+ // GET a management page without a session → redirect to login.
+ rec := x.do(http.MethodGet, "/users", 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"))
+ t.Errorf("GET /users 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",
+ rec = x.do(http.MethodPost, "/",
url.Values{"owner": {"root"}, "name": {"x"}}, nil)
if rec.Code != http.StatusUnauthorized {
- t.Errorf("POST /ui/repos unauth = %d, want 401", rec.Code)
+ t.Errorf("POST / (create) unauth = %d, want 401", rec.Code)
}
}
@@ -238,18 +296,18 @@ func TestNonAdminDenied(t *testing.T) {
x := newHarness(t)
ctx := context.Background()
- // A regular user with a valid token cannot log in to the UI.
+ // A regular user with a valid token CAN sign in (to browse their repos)...
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)
+ if rec := x.do(http.MethodPost, "/login", url.Values{"token": {bobToken}}, nil); rec.Code != http.StatusSeeOther {
+ t.Errorf("non-admin login = %d, want 303 (allowed)", 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.
+ // ...but a non-admin session cannot reach a management page (403), covering
+ // demotion mid-session too — the guard re-checks is_admin every request.
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)
+ if rec := x.do(http.MethodGet, "/users", nil, forged); rec.Code != http.StatusForbidden {
+ t.Errorf("non-admin session on /users = %d, want 403", rec.Code)
}
}
@@ -258,7 +316,7 @@ func TestCreateRepoRejectsReservedAndUnknownOwner(t *testing.T) {
session := x.login(x.mintTokenFor(x.admin.ID))
// A reserved repo name (a global route) is refused by meta.CreateRepo.
- rec := x.do(http.MethodPost, "/ui/repos", url.Values{"owner": {"root"}, "name": {"login"}}, session)
+ rec := x.do(http.MethodPost, "/", url.Values{"owner": {"root"}, "name": {"login"}}, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "reserved") {
t.Errorf("reserved name: %d (%s)", rec.Code, rec.Body)
}
@@ -267,7 +325,7 @@ func TestCreateRepoRejectsReservedAndUnknownOwner(t *testing.T) {
}
// Owner must be an existing user.
- rec = x.do(http.MethodPost, "/ui/repos", url.Values{"owner": {"ghost"}, "name": {"x"}}, session)
+ rec = x.do(http.MethodPost, "/", 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)
}
@@ -276,6 +334,33 @@ func TestCreateRepoRejectsReservedAndUnknownOwner(t *testing.T) {
}
}
+// TestSettingsVisibilityAffectsAnonBrowse: flipping a repo public via the
+// settings page opens it to anonymous browsing; flipping it back closes it.
+func TestSettingsVisibilityAffectsAnonBrowse(t *testing.T) {
+ x := newHarness(t)
+ x.seedBrowseRepo("app", "main") // private by default
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ const target = "/app/tree/main"
+ if rec := x.do(http.MethodGet, target, nil, nil); rec.Code != http.StatusSeeOther {
+ t.Fatalf("anon browse private = %d, want 303 (login)", rec.Code)
+ }
+
+ if rec := x.do(http.MethodPost, "/app/settings", url.Values{"visibility": {"public"}}, session); rec.Code != http.StatusOK {
+ t.Fatalf("set public = %d (%s)", rec.Code, rec.Body)
+ }
+ if rec := x.do(http.MethodGet, target, nil, nil); rec.Code != http.StatusOK {
+ t.Fatalf("anon browse public = %d, want 200", rec.Code)
+ }
+
+ if rec := x.do(http.MethodPost, "/app/settings", url.Values{"visibility": {"private"}}, session); rec.Code != http.StatusOK {
+ t.Fatalf("set private = %d (%s)", rec.Code, rec.Body)
+ }
+ if rec := x.do(http.MethodGet, target, nil, nil); rec.Code != http.StatusSeeOther {
+ t.Fatalf("anon browse re-privatized = %d, want 303", rec.Code)
+ }
+}
+
// 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 {
diff --git a/scripts/dev.sh b/scripts/dev.sh
index 86d22d1..55e5d15 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -31,7 +31,7 @@ export GITMOTE_COOKIE_KEY="dev-cookie-key-not-for-production"
export GITMOTE_URL="http://localhost:8080"
export WORKER_SECRET="dev-worker-secret-not-for-production"
-# Enable per-repo CI secrets locally (Secrets panel in /ui). A fixed dev master
+# Enable per-repo CI secrets locally (per-repo Secrets page). A fixed dev master
# key — base64 of 32 bytes — obviously not for production. Rotate by adding _V2.
export GITMOTE_CI_SECRET_KEY_V1="ZGV2LW9ubHktY2ktc2VjcmV0LW1hc3Rlci1rZXktMzI="
@@ -66,7 +66,7 @@ cat <<EOF
gitmote dev is ready.
- UI: http://localhost:8080/ui (sign in at /login by pasting the token)
+ UI: http://localhost:8080/ (sign in at /login by pasting the token)
token: $TOKEN
clone: git clone http://atmin:$TOKEN@localhost:8080/gitmote
push: git push http://atmin:$TOKEN@localhost:8080/gitmote HEAD:refs/heads/master
diff --git a/tasks/README.md b/tasks/README.md
index c1578fe..ac983d6 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -10,23 +10,11 @@ first-run auto-bootstrap (shrink the surface), public GHCR images with
CI-substrate docs (the cap). `docker run ghcr.io/atmin/gitmote` with a bucket and
credentials is now a working forge; kill/restart restores from S3.
-## URLs, urls, urls
+The **URLs** epic has fully landed: a flat single-segment repo namespace,
+`tree`/`blob`/`raw` content routing with in-path greedy refs, rendered-markdown
+link rewriting, public/private visibility with spectators & collaborators, and a
+viewer-scoped dashboard with top-level globals and per-repo management. See
+[../docs/architecture/urls.md](../docs/architecture/urls.md).
-Make a personal instance's URLs simple and correct: a **flat single-segment repo
-namespace** (`host/<repo>`, no `owner/`, no `/-/`), content addressed in-path
-(`tree`/`blob`/`raw` + greedy ref, no `?ref=` query param), public/private
-visibility with spectators & collaborators, and rendered-markdown links that
-actually resolve. Greenfield — **reset the bucket + meta**, no migration. Design +
-rationale: [../docs/architecture/urls.md](../docs/architecture/urls.md). Order
-within the chain matters — the **flat namespace + visibility & access**, the
-**content routing**, and the **markdown link rewriting** slices have landed
-(single-segment repos, `visibility`, repo-read browse authz, admin-only
-default-branch force-push; `tree`/`blob`/`raw` verbs with ref-in-path greedy
-resolution and the bare `/<repo>` landing; rendered relative links → `blob`/`tree`
-and embeds → `raw`, ref preserved); one remains:
-
-- [urls-dashboard-ui.md](urls-dashboard-ui.md) — `/` dashboard, top-level globals
- (`/login`, `/users`, …), per-repo `settings`/`access`/`secrets`. *(last —
- reshapes the UI onto the new routes.)*
-
-Speculative directions live in [../docs/evolution/](../docs/evolution/).
+No active tasks. Speculative directions live in
+[../docs/evolution/](../docs/evolution/).
diff --git a/tasks/urls-dashboard-ui.md b/tasks/urls-dashboard-ui.md
deleted file mode 100644
index 38cf0b8..0000000
--- a/tasks/urls-dashboard-ui.md
+++ /dev/null
@@ -1,65 +0,0 @@
-# Dashboard + UI reshape
-
-Part of the **URLs** epic ([README](README.md)); implements the top-level globals
-and per-repo management surface of
-[../docs/architecture/urls.md](../docs/architecture/urls.md). Last in the chain —
-it reshapes the UI onto the routes the earlier tasks established.
-
-## Spec
-
-- **`/` is the dashboard / repo list**, scoped to the viewer: public repos to
- anyone, private repos to those with access, management affordances for admin.
-- **Global routes are top-level** (no `/ui/` prefix, no `/-/`): `/login`,
- `/logout`, `/users`, `/tokens`, `/static/…`. `/healthz`, `/version` stay.
-- **Per-repo management** pages: `/<repo>/settings` (default branch, visibility
- toggle, rename, delete), `/<repo>/access` (list ACLs; invite a **spectator**
- = `read` or a **collaborator** = `write`; revoke), `/<repo>/secrets`.
-
-## Current
-
-Management lives under `/ui/*` behind `requireAdmin`, `/login` is top-level, and
-`GET /{$}` redirects to the repo list ([`webui.go`](../internal/webui/webui.go)).
-There is no per-repo settings/access page (ACLs are edited on a global
-`/ui/acls`), no visibility control, and no viewer-scoped dashboard — everything
-UI is admin-only. Static assets are at `/ui/static/…` and are `alwaysServable`
-past the leader gate ([`main.go`](../cmd/gitmote/main.go) `alwaysServable`).
-
-## Change
-
-- **Routes** ([`webui.go`](../internal/webui/webui.go) `Register`): move `/ui/repos`,
- `/ui/users`, `/ui/tokens` → `/`, `/users`, `/tokens`; `/ui/static/` → `/static/`;
- keep `/login`,`/logout`. Fold the global `/ui/acls` into per-repo
- `/<repo>/access`; add `/<repo>/settings`, `/<repo>/secrets`.
-- **Dashboard** `GET /`: list repos filtered by the viewer (public + those the
- viewer has an ACL on; all for admin), replacing the redirect.
- [`meta.ListRepos`](../internal/meta/repos.go) exists but returns *all* repos —
- add the viewer/visibility filter (join `visibility` + the viewer's ACLs) rather
- than filtering in the handler.
-- **Access page** `/<repo>/access`: view ACLs and add/revoke `read`/`write`/`admin`,
- worded as spectator/collaborator; **settings** exposes the `visibility` toggle
- and default branch (feeds the force-push guard from
- [urls-namespace-access.md](urls-namespace-access.md)).
-- **Authz**: management routes stay admin; the dashboard and per-repo read pages
- use repo-read (public → anyone, private → ACL), consistent with the access task.
-- **Leader gate** ([`main.go`](../cmd/gitmote/main.go) `alwaysServable`): update the
- static prefix to `/static/`.
-- **Templates/nav** ([`internal/webui/templates`](../internal/webui/templates)):
- point every generated link at the new scheme; a signed-out visitor sees public
- repos and a login link, not a 403 wall.
-- **Docs**: refresh the Management-UI / Quickstart sections of
- [README.md](../README.md) and [ops.md](../docs/ops.md) to the new URLs.
-
-## Verify
-
-- `/` lists exactly the repos the viewer may see (anonymous → public only; ACL
- user → their repos + public; admin → all).
-- `/login`, `/logout`, `/users`, `/tokens`, `/static/…` resolve; old `/ui/*` is
- gone.
-- `/<repo>/settings` toggles `private`↔`public` and it takes effect (an anonymous
- clone/browse starts/stops working accordingly).
-- `/<repo>/access` invites a spectator (read) and a collaborator (write) and
- revokes them; the granted user's access changes accordingly.
-- A non-admin cannot reach `settings`/`users`; a signed-out visitor can browse a
- public repo and reach `/login`.
-- `gofmt`/`golangci-lint`/`go test ./...` clean; golden **and** failure paths
- (visibility scoping of the dashboard, non-admin management refusal).