web: API tokens on the settings page; web UX final-review fixes !507

merged merged by cmc on 2026-09-28 23:23 UTC · krz/gitbay:web-api-tokens into main

29 files changed, +703 −107

Layout: unified · split

.gitbay/wiki/Parity.org +5 −4
@@ -55,7 +55,7 @@ browser-only and the iOS build screen unable to say more than the log.
55| preview body markup | n/a | yes | no | 55| preview body markup | n/a | yes | no |
56| stacked merge requests | yes | yes | yes | 56| stacked merge requests | yes | yes | yes |
57| revisions | yes | yes | yes | 57| revisions | yes | yes | yes |
58| range-diff | yes | no | yes | 58| range-diff | yes | yes | yes |
59| merge gates | yes | yes | yes | 59| merge gates | yes | yes | yes |
60 60
61Reviews and checks carry the time they last said something, and a 61Reviews and checks carry the time they last said something, and a
@@ -366,7 +366,8 @@ client has no use for one (krz/gitbay#57).
366| push device remove | yes | yes | yes | 366| push device remove | yes | yes | yes |
367| activity push on, off | yes | yes | yes | 367| activity push on, off | yes | yes | yes |
368| web colour scheme | yes | yes | n/a | 368| web colour scheme | yes | yes | n/a |
369| API token mint | yes | no | no | 369| API token mint | yes | yes | no |
370| API token list, revoke | yes | yes | no |
370| API token revoke with what it created | yes | no | no | 371| API token revoke with what it created | yes | no | no |
371| account export bundle | yes | yes | n/a | 372| account export bundle | yes | yes | n/a |
372| profile set | yes | yes | yes | 373| profile set | yes | yes | yes |
@@ -470,8 +471,8 @@ thirty with the same cursors; iOS pages with them too.
470* CLI only, for now 471* CLI only, for now
471 472
472Build secrets, mirror configuration and tokens, custom domain claims, 473Build secrets, mirror configuration and tokens, custom domain claims,
473API token minting, web session listing and revocation, deploy keys, 474web session listing and revocation, deploy keys, and instance
474and instance administration have no page yet. Until #234 these were 475administration have no page yet. Until #234 these were
475refused outright on the other surfaces; the refusal is gone, so each is 476refused outright on the other surfaces; the refusal is gone, so each is
476now a page waiting to be built rather than a rule. A credential still 477now a page waiting to be built rather than a rule. A credential still
477travels on stdin wherever it is set, since argv is world-readable in 478travels on stdin wherever it is set, since argv is world-readable in
CHANGELOG.org +31 −18
@@ -8,21 +8,35 @@ anything beyond "replace the binary and restart" is needed.
8 8
9- The builds page's status badge section gives an org-mode snippet 9- The builds page's status badge section gives an org-mode snippet
10 beside the Markdown one, for a README.org (#299). 10 beside the Markdown one, for a README.org (#299).
11- API tokens on the settings page: create with a scope and optional
12 expiry, shown once; list; revoke with the name typed back; the
13 registered page's next steps as a numbered list (#264).
11- A wiki link to an existing non-page file (an .svg, .txt, .pdf) now 14- A wiki link to an existing non-page file (an .svg, .txt, .pdf) now
12 resolves to the raw route instead of 404ing against the page route 15 resolves to the raw route instead of 404ing against the page route
13 (#283). 16 (#283).
14
15- The new-issue form takes labels, milestone and assignee in one step 17- The new-issue form takes labels, milestone and assignee in one step
16 for writers; the watch button names watching, muted and default; a 18 for writers; a Discussion heading sits before comment threads; the
17 Discussion heading sits before comment threads; the build page's 19 build page's live note says the page updates itself; and the rail
18 live note says the page updates itself; and the rail and the phone 20 and the phone More menu render from one list (#271).
19 More menu render from one list (#271). 21- The new-issue form keeps milestone and assignee through preview and
20 22 a refused create, the way it already kept title, body and labels;
23 a refused create re-renders the form with the draft and the
24 refusal instead of an error page (#271).
25- The merge request range-diff page renders a bad =from=/=to== query
26 parameter inline instead of 404ing; only an unknown merge request
27 404s (#271).
21- Empty states on the web state the fact instead of a CLI command, and 28- Empty states on the web state the fact instead of a CLI command, and
22 drop "yet" on a finished item; the merge request list offers a New 29 drop "yet" on a finished item; the merge request list offers a New
23 merge request link, a fork link, or a sign-in prompt depending on 30 merge request link, a fork link, or a sign-in prompt depending on
24 what the visitor can do; and the search page's scope caption is 31 what the visitor can do; and the search page's scope caption is
25 always visible, not only before a first search (#270). 32 always visible, not only before a first search (#270).
33- Issues, milestones, org milestones and releases drop the CLI command
34 from their empty states too, matching the rest of the register: a
35 link to the web form that does the thing when one exists, otherwise
36 just the fact (#270).
37- The merge request list and compare page offer New merge request to a
38 reader who owns a writable fork of the repository, not only to a
39 writer (#270).
26 40
27Credentials and sessions: revocation, delegation, expiry and an idle 41Credentials and sessions: revocation, delegation, expiry and an idle
28timeout (#256, #257, #276, #277). 42timeout (#256, #257, #276, #277).
@@ -145,18 +159,17 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
145- A =-- foreign_keys: off= migration's =foreign_key_check= now runs 159- A =-- foreign_keys: off= migration's =foreign_key_check= now runs
146 inside the migration's own transaction, before commit, so a 160 inside the migration's own transaction, before commit, so a
147 violation rolls the migration back instead of leaving the bad 161 violation rolls the migration back instead of leaving the bad
148 schema and =user_version= already persisted (#261). 162 schema and =user_version= already persisted; the web pin and watch
149- The web pin and watch buttons dispatch through =repo pin=/=unpin= 163 buttons dispatch through =repo pin=/=unpin= and =repo
150 and =repo watch=/=mute=/=unwatch= instead of writing the store 164 watch=/=mute=/=unwatch= instead of writing the store directly, so a
151 directly, so a refusal reaches the viewer as a message instead of 165 refusal reaches the viewer as a message instead of being dropped,
152 being dropped. The watch button now cycles three states — default, 166 and the watch button now cycles three states — default, watching,
153 watching, muted — instead of two (#261). 167 muted — instead of two; the response that consumes a login link's
154- The response that consumes a login link's =?token== sends 168 =?token== sends =Cache-Control: no-store=, so no intermediary keeps
155 =Cache-Control: no-store=, so no intermediary keeps a copy of the 169 a copy of the single-use URL; and wiki documentation fixes:
156 single-use URL (#261). 170 API.org clarifies token commands work on the API, Parity.org
157- Wiki documentation fixes: API.org clarifies token commands work on the 171 documents batched review and web watch/pin dispatch, Threat-Model.org
158 API, Parity.org documents batched review and web watch/pin dispatch, 172 documents the login-link URL exception (#261).
159 Threat-Model.org documents the login-link URL exception (#261).
160- The account settings page quotes the CLI and SSH command forms that 173- The account settings page quotes the CLI and SSH command forms that
161 actually resolve; a test runs every command a web page quotes against 174 actually resolve; a test runs every command a web page quotes against
162 the CLI and control registries so a renamed command fails CI instead 175 the CLI and control registries so a renamed command fails CI instead
e2e/accountweb_test.go +4 −5
@@ -106,11 +106,10 @@ func TestAccountSettingsWeb(t *testing.T) {
106 t.Error("invalid key accepted without an error") 106 t.Error("invalid key accepted without an error")
107 } 107 }
108 108
109 // The settings page has no token form. Nothing refuses one now 109 // The settings page has a token form (#264): API tokens: create,
110 // (#234); there is simply no page for it yet, and a minted token is 110 // list, revoke.
111 // shown once, which wants a page designed for it. 111 if !strings.Contains(body, `value="token-create"`) {
112 if strings.Contains(body, `value="token-mint"`) { 112 t.Error("token minting form missing from the web")
113 t.Error("token minting exposed on the web")
114 } 113 }
115 114
116 // The account bundle downloads as an attachment, carrying what 115 // The account bundle downloads as an attachment, carrying what
e2e/buildfollow_test.go +1 −1
@@ -143,7 +143,7 @@ func TestBuildLogFollow(t *testing.T) {
143 } 143 }
144 body, _ := io.ReadAll(resp.Body) 144 body, _ := io.ReadAll(resp.Body)
145 resp.Body.Close() 145 resp.Body.Close()
146 if strings.Contains(string(body), "Live:") { 146 if strings.Contains(string(body), "This page updates itself") {
147 t.Fatalf("?follow=0 rendered the live page:\n%s", body) 147 t.Fatalf("?follow=0 rendered the live page:\n%s", body)
148 } 148 }
149 149
e2e/websignup_test.go +1 −1
@@ -56,7 +56,7 @@ func TestWebSignup(t *testing.T) {
56 status, body = browserPost(t, browser, inst.base()+"/register", url.Values{ 56 status, body = browserPost(t, browser, inst.base()+"/register", url.Values{
57 "username": {"erin"}, "invite": {inviteCode}, "key": {string(pub)}}) 57 "username": {"erin"}, "invite": {inviteCode}, "key": {string(pub)}})
58 if status != 200 || !strings.Contains(body, "welcome, erin") || 58 if status != 200 || !strings.Contains(body, "welcome, erin") ||
59 !strings.Contains(body, `href="/settings"`) || !strings.Contains(body, "paste the code") { 59 !strings.Contains(body, `href="/settings#emails"`) || !strings.Contains(body, "Paste the code") {
60 t.Fatalf("signup: %d\n%s", status, body) 60 t.Fatalf("signup: %d\n%s", status, body)
61 } 61 }
62 out, errOut, code := inst.ssh(t, key, "", "whoami") 62 out, errOut, code := inst.ssh(t, key, "", "whoami")
internal/httpd/account.go +70 −1
@@ -45,6 +45,16 @@ type accountDevice struct {
45 Confirm string // the id as text, typed back to confirm removal 45 Confirm string // the id as text, typed back to confirm removal
46} 46}
47 47
48// accountToken is one API token as the settings page shows it: never
49// the token itself, only what identifies and describes it.
50type accountToken struct {
51 Name string
52 Scope string
53 Created string
54 Expires string // "never" or a formatted timestamp
55 LastUsed string // "never" or a formatted timestamp
56}
57
48// accountForm renders the account's own settings: keys, addresses, and the 58// accountForm renders the account's own settings: keys, addresses, and the
49// commands for everything that stays on SSH. 59// commands for everything that stays on SSH.
50func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) { 60func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -53,6 +63,12 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
53 63
54// accountPage renders the settings page. 64// accountPage renders the settings page.
55func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User) { 65func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User) {
66 s.renderAccount(w, r, u, "")
67}
68
69// renderAccount draws the settings page. tokenShown is a token minted
70// by the request being answered; it is shown in this response only.
71func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.User, tokenShown string) {
56 var keys []accountKey 72 var keys []accountKey
57 if list, err := s.st.ListSSHKeys(u.ID); err == nil { 73 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
58 for _, k := range list { 74 for _, k := range list {
@@ -90,6 +106,20 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use
90 } 106 }
91 } 107 }
92 108
109 var tokens []accountToken
110 if list, err := s.st.ListAPITokens(u.ID); err == nil {
111 for _, tk := range list {
112 expires, lastUsed := "never", "never"
113 if tk.ExpiresAt != nil {
114 expires = tk.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
115 }
116 if tk.LastUsedAt != nil {
117 lastUsed = tk.LastUsedAt.UTC().Format("2006-01-02 15:04 UTC")
118 }
119 tokens = append(tokens, accountToken{tk.Name, tk.Scope, tk.CreatedAt, expires, lastUsed})
120 }
121 }
122
93 // The about text is a file. The page points at it rather than editing 123 // The about text is a file. The page points at it rather than editing
94 // it: the repository's own editor already does that job. 124 // it: the repository's own editor already does that job.
95 aboutRepo := u.Username + "/" + control.ProfileRepoName 125 aboutRepo := u.Username + "/" + control.ProfileRepoName
@@ -116,9 +146,12 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use
116 PushOn bool 146 PushOn bool
117 Devices []accountDevice 147 Devices []accountDevice
118 ThemeSetting string // system, light or dark: the form's selected option 148 ThemeSetting string // system, light or dark: the form's selected option
149 Tokens []accountToken
150 TokenShown string // a token minted by this request, shown once
119 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), 151 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
120 aboutRepo, aboutEdit, s.cfg.SiteHost(), 152 aboutRepo, aboutEdit, s.cfg.SiteHost(),
121 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme}) 153 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme,
154 tokens, tokenShown})
122} 155}
123 156
124// accountExport hands the browser the same bundle `account export` 157// accountExport hands the browser the same bundle `account export`
@@ -263,6 +296,42 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
263 return 296 return
264 } 297 }
265 back("", "primary address changed") 298 back("", "primary address changed")
299 case "token-create":
300 name := strings.TrimSpace(r.FormValue("name"))
301 if name == "" {
302 back("name the token", "")
303 return
304 }
305 scope := r.FormValue("scope")
306 if scope != "full" {
307 scope = "read"
308 }
309 argv := []string{"token", "create", "--name", name, "--scope", scope}
310 if ttl := strings.TrimSpace(r.FormValue("ttl")); ttl != "" {
311 argv = append(argv, "--ttl", ttl)
312 }
313 var minted struct {
314 Token string `json:"token"`
315 }
316 if msg, ok := s.runControlInto(u, argv, &minted); !ok {
317 back(msg, "")
318 return
319 }
320 // The token is shown in this response and nowhere else: not in a
321 // redirect, a URL or a cookie, and never stored to be shown later.
322 w.Header().Set("Cache-Control", "no-store")
323 s.renderAccount(w, r, u, minted.Token)
324 case "token-revoke":
325 name := r.FormValue("name")
326 if ok, msg := confirmed(r, name); !ok {
327 back(msg, "")
328 return
329 }
330 if _, msg, ok := s.runControl(u, []string{"token", "revoke", "--", name}); !ok {
331 back(msg, "")
332 return
333 }
334 back("", "token revoked")
266 case "theme": 335 case "theme":
267 if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok { 336 if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok {
268 back(msg, "") 337 back(msg, "")
internal/httpd/account_test.go +213
@@ -284,3 +284,216 @@ func TestWatchToggleCyclesThroughMuted(t *testing.T) {
284 } 284 }
285 assertAudited(t, st, "cmd repo unwatch") 285 assertAudited(t, st, "cmd repo unwatch")
286} 286}
287
288// newTokenTestServer is a server over a fresh store with one user.
289func newTokenTestServer(t *testing.T) (*Server, *store.Store, store.User) {
290 t.Helper()
291 st, err := store.Open(":memory:")
292 if err != nil {
293 t.Fatal(err)
294 }
295 t.Cleanup(func() { st.Close() })
296 if err := st.MigrateUp(); err != nil {
297 t.Fatal(err)
298 }
299 uid, err := st.CreateUser("alice", false)
300 if err != nil {
301 t.Fatal(err)
302 }
303 return New(config.Default(), st, nil), st, store.User{ID: uid, Username: "alice"}
304}
305
306// The settings page lists a user's API tokens with scope and expiry,
307// never the hash (#264).
308func TestAccountPageListsTokens(t *testing.T) {
309 s, st, u := newTokenTestServer(t)
310 if err := st.CreateAPIToken(u.ID, "laptop", "somehash", "read", nil, 0); err != nil {
311 t.Fatal(err)
312 }
313 rr := httptest.NewRecorder()
314 s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), u)
315 body := rr.Body.String()
316 if !strings.Contains(body, "<td>laptop</td>") || !strings.Contains(body, "<td>read</td>") {
317 t.Fatalf("token row missing: %s", body)
318 }
319 if strings.Contains(body, "somehash") {
320 t.Fatal("the page printed a token hash")
321 }
322}
323
324// Creating a token answers the POST itself with the token, marked
325// no-store, and puts it in no header: not a Location, not a cookie. A
326// later GET of the page does not show it (#264).
327func TestAccountSubmitTokenCreateShownOnce(t *testing.T) {
328 s, st, u := newTokenTestServer(t)
329 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}, "scope": {"full"}})
330 if rr.Code != http.StatusOK {
331 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
332 }
333 if got := rr.Header().Get("Cache-Control"); got != "no-store" {
334 t.Errorf("Cache-Control = %q, want no-store", got)
335 }
336 body := rr.Body.String()
337 i := strings.Index(body, "gb_")
338 if i < 0 {
339 t.Fatalf("token not shown: %s", body)
340 }
341 token := body[i:]
342 token = token[:strings.IndexAny(token, "<\n")]
343 for name, vals := range rr.Header() {
344 for _, v := range vals {
345 if strings.Contains(v, token) {
346 t.Errorf("header %s carries the token", name)
347 }
348 }
349 }
350 got, tk, err := st.APITokenUser(store.HashToken(token))
351 if err != nil || got.ID != u.ID || tk.Name != "laptop" || tk.Scope != "full" {
352 t.Fatalf("shown token does not resolve: %+v %+v %v", got, tk, err)
353 }
354
355 rr = httptest.NewRecorder()
356 s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), u)
357 if strings.Contains(rr.Body.String(), token) {
358 t.Fatal("a later GET showed the token")
359 }
360 if !strings.Contains(rr.Body.String(), "<td>laptop</td>") {
361 t.Fatal("the new token is not listed")
362 }
363}
364
365// The form sends --scope explicitly, read unless full was picked, so the
366// page does not depend on token create's own default (#264, #257).
367func TestAccountSubmitTokenCreateScope(t *testing.T) {
368 s, st, u := newTokenTestServer(t)
369 for _, c := range []struct{ name, scope, want string }{
370 {"a", "", "read"}, {"b", "read", "read"}, {"c", "bogus", "read"}, {"d", "full", "full"},
371 } {
372 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {c.name}, "scope": {c.scope}})
373 if rr.Code != http.StatusOK {
374 t.Fatalf("%s: status %d", c.name, rr.Code)
375 }
376 }
377 tokens, err := st.ListAPITokens(u.ID)
378 if err != nil || len(tokens) != 4 {
379 t.Fatalf("tokens: %v %v", tokens, err)
380 }
381 want := map[string]string{"a": "read", "b": "read", "c": "read", "d": "full"}
382 for _, tk := range tokens {
383 if tk.Scope != want[tk.Name] {
384 t.Errorf("%s: scope %q, want %q", tk.Name, tk.Scope, want[tk.Name])
385 }
386 }
387}
388
389// A failed create redirects with the reason and shows no token.
390func TestAccountSubmitTokenCreateRefusal(t *testing.T) {
391 s, _, u := newTokenTestServer(t)
392 for _, form := range []url.Values{
393 {"field": {"token-create"}, "name": {""}},
394 {"field": {"token-create"}, "name": {"x"}, "ttl": {"-1h"}},
395 } {
396 rr := submitAccountForm(t, s, u, form)
397 if rr.Code != http.StatusSeeOther || strings.Contains(rr.Body.String(), "gb_") {
398 t.Errorf("%v: status %d, body %s", form, rr.Code, rr.Body.String())
399 }
400 }
401}
402
403// Revoking a token requires the name typed back, the same guard every
404// other removal on this page uses.
405func TestAccountSubmitTokenRevokeRequiresConfirm(t *testing.T) {
406 s, st, u := newTokenTestServer(t)
407 if err := st.CreateAPIToken(u.ID, "laptop", "somehash", "read", nil, 0); err != nil {
408 t.Fatal(err)
409 }
410 submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}})
411 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 1 {
412 t.Fatal("token revoked without confirmation")
413 }
414 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}, "confirm": {"laptop"}})
415 if rr.Code != http.StatusSeeOther {
416 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
417 }
418 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 0 {
419 t.Fatal("token not revoked")
420 }
421}
422
423// A cross-site POST to /settings is refused before a token is minted.
424func TestAccountTokenCreateCrossSiteRefused(t *testing.T) {
425 _, st, u := newTokenTestServer(t)
426 cfg := config.Default()
427 cfg.Web.Mode = "accounts"
428 s := New(cfg, st, nil)
429 form := url.Values{"field": {"token-create"}, "name": {"evil"}, "scope": {"full"}}
430 for _, r := range s.Routes() {
431 if r.Method != "POST" || r.Pattern != "/settings" {
432 continue
433 }
434 req := httptest.NewRequest("POST", "http://example.com/settings", strings.NewReader(form.Encode()))
435 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
436 req.Header.Set("Origin", "https://evil.example")
437 rr := httptest.NewRecorder()
438 r.Handler(rr, req)
439 if rr.Code != http.StatusForbidden {
440 t.Fatalf("status %d, want 403", rr.Code)
441 }
442 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 0 {
443 t.Fatal("a cross-site POST minted a token")
444 }
445 return
446 }
447 t.Fatal("no POST /settings route")
448}
449
450// A token named like a flag, which token create accepts, can still be
451// revoked from the page: the name goes after "--".
452func TestAccountSubmitTokenRevokeFlagLikeName(t *testing.T) {
453 s, st, u := newTokenTestServer(t)
454 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"--x"}})
455 if rr.Code != http.StatusOK {
456 t.Fatalf("create: status %d, body %s", rr.Code, rr.Body.String())
457 }
458 rr = submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"--x"}, "confirm": {"--x"}})
459 if rr.Code != http.StatusSeeOther {
460 t.Fatalf("revoke: status %d", rr.Code)
461 }
462 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 0 {
463 t.Fatalf("token not revoked: %+v", tokens)
464 }
465}
466
467// The audit row for a web token create records the command but not the
468// minted token.
469func TestAccountTokenCreateAuditOmitsToken(t *testing.T) {
470 s, st, u := newTokenTestServer(t)
471 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}})
472 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "gb_") {
473 t.Fatalf("create: status %d", rr.Code)
474 }
475 rows, err := st.DB.Query("SELECT action, data_json FROM audit_log")
476 if err != nil {
477 t.Fatal(err)
478 }
479 defer rows.Close()
480 found := false
481 for rows.Next() {
482 var action, data string
483 if err := rows.Scan(&action, &data); err != nil {
484 t.Fatal(err)
485 }
486 if action == "cmd token create" {
487 found = true
488 }
489 if strings.Contains(data, "gb_") {
490 t.Errorf("audit row %q carries the token: %s", action, data)
491 }
492 }
493 if err := rows.Err(); err != nil {
494 t.Fatal(err)
495 }
496 if !found {
497 t.Fatal("no cmd token create audit row")
498 }
499}
internal/httpd/accounts.go +40 −26
@@ -399,6 +399,24 @@ func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
399 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()}) 399 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()})
400} 400}
401 401
402// issueNewPage is what the new-issue form renders with, whether that is a
403// fresh form, a Preview round trip, or a refused create — each keeps
404// whatever the visitor typed (#271).
405type issueNewPage struct {
406 repoPage
407 Body string
408 Format string
409 Title string
410 Labels string
411 Milestone string
412 Assignee string
413 Template string
414 Templates []control.IssueTemplate
415 Draft *draft
416 CanWrite bool
417 Notice string
418}
419
402// issueCreateForm renders the new-issue form, prefilled from the repo's 420// issueCreateForm renders the new-issue form, prefilled from the repo's
403// default issue template when one exists. A Preview submit comes back 421// default issue template when one exists. A Preview submit comes back
404// here with the draft in the form, so the page returns with everything 422// here with the draft in the form, so the page returns with everything
@@ -411,18 +429,11 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
411 p.Tab = "issues" 429 p.Tab = "issues"
412 if wantsPreview(r) { 430 if wantsPreview(r) {
413 d := s.draftFor(r, p.Repo, "body", "body", bodyFormat(r)) 431 d := s.draftFor(r, p.Repo, "body", "body", bodyFormat(r))
414 s.render(w, "issuenew.html", struct { 432 s.render(w, "issuenew.html", issueNewPage{
415 repoPage 433 repoPage: p, Body: d.Body, Format: d.Format, Title: r.FormValue("title"),
416 Body string 434 Labels: r.FormValue("labels"), Milestone: r.FormValue("milestone"), Assignee: r.FormValue("assignee"),
417 Format string 435 Templates: control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), Draft: d, CanWrite: s.canWriteRepoAs(u, p.Repo),
418 Title string 436 })
419 Labels string
420 Template string
421 Templates []control.IssueTemplate
422 Draft *draft
423 CanWrite bool
424 }{p, d.Body, d.Format, r.FormValue("title"), r.FormValue("labels"),
425 "", control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), d, s.canWriteRepoAs(u, p.Repo)})
426 return 437 return
427 } 438 }
428 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch) 439 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
@@ -447,17 +458,10 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
447 if format != "org" { 458 if format != "org" {
448 format = "md" 459 format = "md"
449 } 460 }
450 s.render(w, "issuenew.html", struct { 461 s.render(w, "issuenew.html", issueNewPage{
451 repoPage 462 repoPage: p, Body: body, Format: format, Template: tplName, Templates: templates,
452 Body string 463 CanWrite: s.canWriteRepoAs(u, p.Repo),
453 Format string 464 })
454 Title string
455 Labels string
456 Template string
457 Templates []control.IssueTemplate
458 Draft *draft
459 CanWrite bool
460 }{p, body, format, "", "", tplName, templates, nil, s.canWriteRepoAs(u, p.Repo)})
461} 465}
462 466
463// Issue and merge request writes run the command the CLI runs, so the 467// Issue and merge request writes run the command the CLI runs, so the
@@ -465,13 +469,18 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
465// implementation. Bodies travel on stdin, the way --file - does. 469// implementation. Bodies travel on stdin, the way --file - does.
466 470
467func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 471func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
468 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") 472 p, ok := s.repoFor(w, r, "")
473 if !ok {
474 return
475 }
476 repoPath := p.Repo.Path()
469 title := strings.TrimSpace(r.FormValue("title")) 477 title := strings.TrimSpace(r.FormValue("title"))
470 format := bodyFormat(r) 478 format := bodyFormat(r)
471 if wantsPreview(r) { 479 if wantsPreview(r) {
472 s.issueCreateForm(w, r, u) 480 s.issueCreateForm(w, r, u)
473 return 481 return
474 } 482 }
483 canWrite := s.canWriteRepoAs(u, p.Repo)
475 var created control.Created 484 var created control.Created
476 argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"} 485 argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"}
477 // Labels, milestone and assignee go on the same dispatch issue create 486 // Labels, milestone and assignee go on the same dispatch issue create
@@ -480,7 +489,7 @@ func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u sto
480 // events happen (#271). issue create refuses the whole create when any 489 // events happen (#271). issue create refuses the whole create when any
481 // of them is set without write access, so a reader's hand-crafted POST 490 // of them is set without write access, so a reader's hand-crafted POST
482 // carrying one is dropped here rather than failing the create. 491 // carrying one is dropped here rather than failing the create.
483 if repo, err := s.st.RepoByPath(repoPath); err == nil && s.canWriteRepoAs(u, repo) { 492 if canWrite {
484 argv = append(argv, fieldArgs("--label", r.FormValue("labels"))...) 493 argv = append(argv, fieldArgs("--label", r.FormValue("labels"))...)
485 if milestone := strings.TrimSpace(r.FormValue("milestone")); milestone != "" { 494 if milestone := strings.TrimSpace(r.FormValue("milestone")); milestone != "" {
486 argv = append(argv, "--milestone", milestone) 495 argv = append(argv, "--milestone", milestone)
@@ -489,7 +498,12 @@ func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u sto
489 } 498 }
490 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created) 499 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created)
491 if code != protocol.ExitOK { 500 if code != protocol.ExitOK {
492 http.Error(w, msg, statusForExit(code)) 501 p.Tab = "issues"
502 s.render(w, "issuenew.html", issueNewPage{
503 repoPage: p, Body: r.FormValue("body"), Format: format, Title: title,
504 Labels: r.FormValue("labels"), Milestone: r.FormValue("milestone"), Assignee: r.FormValue("assignee"),
505 Templates: control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), CanWrite: canWrite, Notice: msg,
506 })
493 return 507 return
494 } 508 }
495 n := created.Number 509 n := created.Number
internal/httpd/accounts_test.go added +27
@@ -0,0 +1,27 @@
1package httpd
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/web"
8)
9
10// The registered page's web steps are a numbered list, and mention where
11// the iOS app gets a token, so a new user is not left guessing (#264).
12func TestRegisteredPageNumberedStepsAndTokenMention(t *testing.T) {
13 var sb strings.Builder
14 if err := web.Render(&sb, "registered.html", struct {
15 basePage
16 Username, Message, Host string
17 }{Username: "alice", Host: "gitbay.org"}); err != nil {
18 t.Fatalf("render: %v", err)
19 }
20 out := sb.String()
21 if !strings.Contains(out, "<ol>") {
22 t.Error("next steps are not a numbered list")
23 }
24 if !strings.Contains(out, "Settings → API tokens") {
25 t.Error("no mention of Settings → API tokens for the iOS app")
26 }
27}
internal/httpd/compare.go +4 −2
@@ -73,6 +73,8 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
73 } 73 }
74 commits = append(commits, cr) 74 commits = append(commits, cr)
75 } 75 }
76 canWrite := s.canWriteRepo(r, p.Repo)
77 canOpenMR := canWrite || len(s.writableForks(s.viewer(r), p.Repo)) > 0
76 s.render(w, "compare.html", struct { 78 s.render(w, "compare.html", struct {
77 repoPage 79 repoPage
78 Base, Head, BaseSHA, HeadSHA, MergeBase string 80 Base, Head, BaseSHA, HeadSHA, MergeBase string
@@ -81,6 +83,6 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
81 DiffFiles []diffFile 83 DiffFiles []diffFile
82 DiffTruncated bool 84 DiffTruncated bool
83 Stat diffStat 85 Stat diffStat
84 CanWrite bool 86 CanOpenMR bool
85 }{p, base, head, baseSHA, headSHA, mergeBase, commits, total, files, truncated, statOf(files), s.canWriteRepo(r, p.Repo)}) 87 }{p, base, head, baseSHA, headSHA, mergeBase, commits, total, files, truncated, statOf(files), canOpenMR})
86} 88}
internal/httpd/emptystates_test.go added +78
@@ -0,0 +1,78 @@
1package httpd
2
3import (
4 "html/template"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/store"
9 "gitbay.org/gitbay/internal/web"
10)
11
12// A signed-in viewer with zero issues gets a link to open one; a
13// signed-out visitor gets only the fact, and no CLI command either way
14// (#270).
15func TestIssuesEmptyStateOffersOpenLinkForViewer(t *testing.T) {
16 render := func(viewer string) string {
17 var sb strings.Builder
18 p := testRepoPage()
19 p.Viewer = viewer
20 if err := web.Render(&sb, "issues.html", struct {
21 repoPage
22 State string
23 Label string
24 Query string
25 Filters []listFilter
26 Facets []facetGroup
27 Issues []store.Issue
28 LabelColors map[string]template.CSS
29 Older string
30 }{repoPage: p, State: "open"}); err != nil {
31 t.Fatalf("render: %v", err)
32 }
33 return sb.String()
34 }
35
36 anon := render("")
37 if !strings.Contains(anon, "no open issues") {
38 t.Errorf("missing empty-state fact:\n%s", anon)
39 }
40 if strings.Contains(anon, "issues/new") {
41 t.Error("signed-out visitor should not see an open-issue link")
42 }
43 if strings.Contains(anon, "gitbay issue create") {
44 t.Error("empty state should not name a CLI command")
45 }
46
47 viewer := render("alice")
48 if !strings.Contains(viewer, `href="/krz/gitbay/issues/new"`) {
49 t.Errorf("signed-in viewer missing the open-issue link:\n%s", viewer)
50 }
51}
52
53// An empty release list states the fact without a CLI command; the page
54// already offers the create form above when the viewer can write and a
55// tag is free to release (#270).
56func TestReleasesEmptyStateHasNoCLICommand(t *testing.T) {
57 var sb strings.Builder
58 if err := web.Render(&sb, "releases.html", struct {
59 repoPage
60 Releases []struct {
61 store.Release
62 NotesHTML template.HTML
63 }
64 FreeTags []string
65 CanWrite bool
66 Notice string
67 Draft *draft
68 }{repoPage: testRepoPage()}); err != nil {
69 t.Fatalf("render: %v", err)
70 }
71 out := sb.String()
72 if !strings.Contains(out, "no releases yet") {
73 t.Errorf("missing empty-state fact:\n%s", out)
74 }
75 if strings.Contains(out, "gitbay release create") {
76 t.Error("empty state should not name a CLI command")
77 }
78}
internal/httpd/issuecreate_test.go +105
@@ -283,6 +283,111 @@ func TestIssueCreateSubmitSetsMilestoneAndAssignee(t *testing.T) {
283 } 283 }
284} 284}
285 285
286// Preview carries the milestone and assignee back into the form, the same
287// way it already carries title and labels, so a writer previewing the
288// body does not lose what they picked (#271).
289func TestIssueCreateFormPreviewKeepsMilestoneAndAssignee(t *testing.T) {
290 st, err := store.Open(":memory:")
291 if err != nil {
292 t.Fatal(err)
293 }
294 defer st.Close()
295 if err := st.MigrateUp(); err != nil {
296 t.Fatal(err)
297 }
298 uid, err := st.CreateUser("alice", false)
299 if err != nil {
300 t.Fatal(err)
301 }
302 u := store.User{ID: uid, Username: "alice"}
303 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
304 t.Fatal(err)
305 }
306
307 cfg := config.Default()
308 cfg.Web.Mode = "accounts"
309 s := New(cfg, st, nil)
310 form := url.Values{
311 "title": {"a bug"},
312 "body": {"**steps**"},
313 "milestone": {"v1"},
314 "assignee": {"bob"},
315 "preview": {"1"},
316 }
317 req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
318 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
319 req.SetPathValue("owner", "alice")
320 req.SetPathValue("repo", "app")
321 req.AddCookie(sessionCookieFor(t, s, st, uid))
322 rr := httptest.NewRecorder()
323 s.issueCreateSubmit(rr, req, u)
324
325 body := rr.Body.String()
326 if !strings.Contains(body, `value="v1"`) {
327 t.Errorf("preview lost the milestone:\n%s", body)
328 }
329 if !strings.Contains(body, `value="bob"`) {
330 t.Errorf("preview lost the assignee:\n%s", body)
331 }
332}
333
334// A refused create — here a bad milestone — re-renders the new-issue form
335// with the draft and a notice, rather than an http.Error page that drops
336// everything the visitor typed (#271).
337func TestIssueCreateSubmitRefusedKeepsDraft(t *testing.T) {
338 st, err := store.Open(":memory:")
339 if err != nil {
340 t.Fatal(err)
341 }
342 defer st.Close()
343 if err := st.MigrateUp(); err != nil {
344 t.Fatal(err)
345 }
346 uid, err := st.CreateUser("alice", false)
347 if err != nil {
348 t.Fatal(err)
349 }
350 u := store.User{ID: uid, Username: "alice"}
351 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
352 t.Fatal(err)
353 }
354 repo, err := st.RepoByPath("alice/app")
355 if err != nil {
356 t.Fatal(err)
357 }
358
359 s := New(config.Default(), st, nil)
360 form := url.Values{
361 "title": {"needs a fix"},
362 "body": {"details"},
363 "milestone": {"no-such-milestone"},
364 }
365 req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
366 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
367 req.SetPathValue("owner", "alice")
368 req.SetPathValue("repo", "app")
369 rr := httptest.NewRecorder()
370 s.issueCreateSubmit(rr, req, u)
371
372 if rr.Code == http.StatusSeeOther {
373 t.Fatalf("expected a failure status, got redirect")
374 }
375 body := rr.Body.String()
376 if !strings.Contains(body, `value="needs a fix"`) {
377 t.Errorf("refused create lost the title:\n%s", body)
378 }
379 if !strings.Contains(body, "details") {
380 t.Errorf("refused create lost the body:\n%s", body)
381 }
382 if !strings.Contains(body, `class="error"`) {
383 t.Errorf("refused create has no notice:\n%s", body)
384 }
385
386 if _, err := st.IssueByNumber(repo.ID, 1); err == nil {
387 t.Fatal("issue was created despite the bad milestone")
388 }
389}
390
286// A bad assignee creates nothing: issue create resolves the assignee 391// A bad assignee creates nothing: issue create resolves the assignee
287// before writing the issue, so a typo leaves the repo without a 392// before writing the issue, so a typo leaves the repo without a
288// half-created issue (#271). 393// half-created issue (#271).
internal/httpd/mractions.go +22 −12
@@ -187,26 +187,36 @@ type mrNewPage struct {
187 Draft *draft 187 Draft *draft
188} 188}
189 189
190// writableForks lists the forks of repo that u can push to — the source
191// half of what a merge request may be opened from, alongside the
192// repository's own branches. Write is the filter because a contributor
193// proposes from a fork they own; the command still checks the source for
194// itself (#168).
195func (s *Server) writableForks(u store.User, repo store.Repo) []store.Repo {
196 if u.ID == 0 {
197 return nil
198 }
199 forks, _ := s.st.ListForks(repo.ID)
200 var out []store.Repo
201 for _, f := range forks {
202 grant, _ := s.st.AccessRole(f.ID, u.ID)
203 if policy.CanWrite(u, f, grant) {
204 out = append(out, f)
205 }
206 }
207 return out
208}
209
190// mrSources lists the branches a merge request may be opened from, in the 210// mrSources lists the branches a merge request may be opened from, in the
191// form the command takes: this repository's branches by name, and those of 211// form the command takes: this repository's branches by name, and those of
192// any fork of it the viewer can push to as "owner/name:branch". 212// any writable fork as "owner/name:branch".
193// Write is the filter because a contributor proposes from a fork they
194// own; the command still checks the source for itself (#168).
195func (s *Server) mrSources(u store.User, p repoPage) []string { 213func (s *Server) mrSources(u store.User, p repoPage) []string {
196 var out []string 214 var out []string
197 branches, _ := gitutil.Refs(p.Dir, "heads") 215 branches, _ := gitutil.Refs(p.Dir, "heads")
198 for _, b := range branches { 216 for _, b := range branches {
199 out = append(out, b.Name) 217 out = append(out, b.Name)
200 } 218 }
201 if u.ID == 0 { 219 for _, f := range s.writableForks(u, p.Repo) {
202 return out
203 }
204 forks, _ := s.st.ListForks(p.Repo.ID)
205 for _, f := range forks {
206 grant, _ := s.st.AccessRole(f.ID, u.ID)
207 if !policy.CanWrite(u, f, grant) {
208 continue
209 }
210 dir := control.RepoDir(s.cfg.Server.Root, f.OwnerName, f.Name) 220 dir := control.RepoDir(s.cfg.Server.Root, f.OwnerName, f.Name)
211 refs, _ := gitutil.Refs(dir, "heads") 221 refs, _ := gitutil.Refs(dir, "heads")
212 for _, b := range refs { 222 for _, b := range refs {
internal/httpd/mrrangediff.go +4 −4
@@ -37,11 +37,11 @@ func (s *Server) mrRangeDiff(w http.ResponseWriter, r *http.Request) {
37 if to := r.URL.Query().Get("to"); to != "" { 37 if to := r.URL.Query().Get("to"); to != "" {
38 argv = append(argv, "--to", to) 38 argv = append(argv, "--to", to)
39 } 39 }
40 // Only an unknown MR 404s (checked above). A bad --from/--to also
41 // resolves to nothing in git, which range-diff reports as
42 // ExitNotFound too, so that result renders on the page instead of
43 // turning a bad query parameter into a 404 (#271).
40 out, msg, code := s.runControlCode(viewer, argv) 44 out, msg, code := s.runControlCode(viewer, argv)
41 if code == protocol.ExitNotFound {
42 s.notFound(w, r)
43 return
44 }
45 errMsg := "" 45 errMsg := ""
46 if code != protocol.ExitOK { 46 if code != protocol.ExitOK {
47 errMsg = msg 47 errMsg = msg
internal/httpd/mrrangediff_test.go +12 −5
@@ -228,8 +228,8 @@ func TestMRRangeDiffPagePrivateRepo(t *testing.T) {
228} 228}
229 229
230// --from/--to reach the control command as real argv: an unknown 230// --from/--to reach the control command as real argv: an unknown
231// revision is refused with not-found, and two real revisions render the 231// revision renders the command's refusal on the page, and two real
232// range-diff between exactly those two. 232// revisions render the range-diff between exactly those two.
233func TestMRRangeDiffPageFromToQuery(t *testing.T) { 233func TestMRRangeDiffPageFromToQuery(t *testing.T) {
234 st, cfg, alice, _, repo, n, v1, v2, _ := rangeDiffFixture(t) 234 st, cfg, alice, _, repo, n, v1, v2, _ := rangeDiffFixture(t)
235 s := New(cfg, st, nil) 235 s := New(cfg, st, nil)
@@ -244,11 +244,18 @@ func TestMRRangeDiffPageFromToQuery(t *testing.T) {
244 return httptest.NewRecorder(), req 244 return httptest.NewRecorder(), req
245 } 245 }
246 246
247 t.Run("unknown revision is 404", func(t *testing.T) { 247 // A bad --from/--to is a query parameter, not an unknown merge
248 // request: it renders the command's refusal inline rather than
249 // 404ing the page, which is reserved for an MR that does not exist
250 // (#271).
251 t.Run("unknown revision renders the refusal inline", func(t *testing.T) {
248 rr, req := newReq("?from=0000000000000000000000000000000000000000") 252 rr, req := newReq("?from=0000000000000000000000000000000000000000")
249 s.mrRangeDiff(rr, req) 253 s.mrRangeDiff(rr, req)
250 if rr.Code != 404 { 254 if rr.Code != 200 {
251 t.Fatalf("status %d, want 404, body %s", rr.Code, rr.Body.String()) 255 t.Fatalf("status %d, want 200 (the refusal renders on the page), body %s", rr.Code, rr.Body.String())
256 }
257 if !strings.Contains(rr.Body.String(), "is not a revision of") {
258 t.Errorf("page does not show the refusal:\n%s", rr.Body.String())
252 } 259 }
253 }) 260 })
254 261
internal/httpd/mrslist_test.go +17 −1
@@ -31,7 +31,15 @@ func TestMRsListContributionHintByAccess(t *testing.T) {
31 if err != nil { 31 if err != nil {
32 t.Fatal(err) 32 t.Fatal(err)
33 } 33 }
34 if _, err := st.CreateRepo("user", owner, "app", "public"); err != nil { 34 forker, err := st.CreateUser("carol", false)
35 if err != nil {
36 t.Fatal(err)
37 }
38 repoID, err := st.CreateRepo("user", owner, "app", "public")
39 if err != nil {
40 t.Fatal(err)
41 }
42 if _, err := st.CreateFork("user", forker, "app", "public", repoID); err != nil {
35 t.Fatal(err) 43 t.Fatal(err)
36 } 44 }
37 45
@@ -83,4 +91,12 @@ func TestMRsListContributionHintByAccess(t *testing.T) {
83 if !strings.Contains(ownerOut, "New merge request") { 91 if !strings.Contains(ownerOut, "New merge request") {
84 t.Errorf("owner: missing New merge request link:\n%s", ownerOut) 92 t.Errorf("owner: missing New merge request link:\n%s", ownerOut)
85 } 93 }
94
95 forkerOut := get(forker)
96 if !strings.Contains(forkerOut, "New merge request") {
97 t.Errorf("reader with a writable fork: missing New merge request link:\n%s", forkerOut)
98 }
99 if strings.Contains(forkerOut, "Fork this repository to propose a change") {
100 t.Error("reader with a writable fork should not see the fork hint")
101 }
86} 102}
internal/httpd/mrsrow_test.go +7 −7
@@ -10,13 +10,13 @@ import (
10// mrsPageData mirrors the anonymous struct the mrs handler renders with. 10// mrsPageData mirrors the anonymous struct the mrs handler renders with.
11type mrsPageData struct { 11type mrsPageData struct {
12 repoPage 12 repoPage
13 State string 13 State string
14 Query string 14 Query string
15 Filters []listFilter 15 Filters []listFilter
16 Facets []facetGroup 16 Facets []facetGroup
17 MRs []mrRow 17 MRs []mrRow
18 Older string 18 Older string
19 CanWrite bool 19 CanOpenMR bool
20} 20}
21 21
22func renderMRs(t *testing.T, rows []mrRow, state string) string { 22func renderMRs(t *testing.T, rows []mrRow, state string) string {
internal/httpd/web.go +3 −2
@@ -2095,6 +2095,7 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
2095 allLabels, _ := s.st.ListLabels(p.Repo, readable) 2095 allLabels, _ := s.st.ListLabels(p.Repo, readable)
2096 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable) 2096 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
2097 facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true) 2097 facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true)
2098 canOpenMR := canWrite || len(s.writableForks(s.viewer(r), p.Repo)) > 0
2098 s.render(w, "mrs.html", struct { 2099 s.render(w, "mrs.html", struct {
2099 repoPage 2100 repoPage
2100 State string 2101 State string
@@ -2104,10 +2105,10 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
2104 MRs []mrRow 2105 MRs []mrRow
2105 LabelColors map[string]template.CSS 2106 LabelColors map[string]template.CSS
2106 Older string 2107 Older string
2107 CanWrite bool 2108 CanOpenMR bool
2108 }{p, state, mf.Search, 2109 }{p, state, mf.Search,
2109 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}), 2110 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}),
2110 facets, rows, s.labelColors(p.Repo), older, canWrite}) 2111 facets, rows, s.labelColors(p.Repo), older, canOpenMR})
2111} 2112}
2112 2113
2113func (s *Server) mr(w http.ResponseWriter, r *http.Request) { 2114func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
internal/store/store.go +1 −1
@@ -284,7 +284,7 @@ func (s *Store) migrateStep(sqlText string, newVersion int, fkOff bool) (retErr
284 return err 284 return err
285 } 285 }
286 rows.Close() 286 rows.Close()
287 return fmt.Errorf("foreign_key_check failed after migration: %s row %v", table, rowid) 287 return fmt.Errorf("foreign_key_check failed after migration: %s row %d", table, rowid.Int64)
288 } 288 }
289 if err := rows.Err(); err != nil { 289 if err := rows.Err(); err != nil {
290 rows.Close() 290 rows.Close()
internal/web/templates/account.html +41 −5
@@ -18,6 +18,7 @@
18 <li><a href="#notifications">Notifications</a></li> 18 <li><a href="#notifications">Notifications</a></li>
19 <li><a href="#appearance">Appearance</a></li> 19 <li><a href="#appearance">Appearance</a></li>
20 <li><a href="#export">Export</a></li> 20 <li><a href="#export">Export</a></li>
21 <li><a href="#tokens">API tokens</a></li>
21 <li><a href="#cli">On the command line</a></li> 22 <li><a href="#cli">On the command line</a></li>
22 </ul></div> 23 </ul></div>
23</details> 24</details>
@@ -188,15 +189,50 @@ never included; a replayed bundle's emails arrive unverified.</p>
188<p><a href="/settings/export">Download bundle</a></p> 189<p><a href="/settings/export">Download bundle</a></p>
189</section> 190</section>
190 191
192<section id="tokens"><h2>API tokens</h2>
193<p class="meta">A token signs in the iOS app, or a script, without your
194password. A phone app needs full scope to comment and merge; full scope
195on an admin account can administer the instance, so give a token the
196narrowest scope and shortest lifetime the job needs.</p>
197{{if .TokenShown}}<p class="notice" role="status">Token created. It is shown once; store it now.</p>
198<pre class="message" tabindex="0">{{.TokenShown}}</pre>{{end}}
199{{if .Tokens}}<div class="tablewrap"><table class="keys nowrap">
200<tr class="cols"><th scope="col">name</th><th scope="col">scope</th><th scope="col">created</th><th scope="col">expires</th><th scope="col">last used</th><th scope="col"><span class="vh">actions</span></th></tr>
201{{range .Tokens}}<tr>
202 <td>{{.Name}}</td>
203 <td>{{.Scope}}</td>
204 <td>{{when .Created}}</td>
205 <td>{{.Expires}}</td>
206 <td>{{.LastUsed}}</td>
207 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="token-revoke"><input type="hidden" name="name" value="{{.Name}}">{{template "confirmfield" .Name}} <button type="submit" class="danger">Revoke</button></form></td>
208</tr>
209{{end}}</table></div>
210{{else}}<p class="none">No API tokens.</p>{{end}}
211<details class="editbox">
212 <summary>Create a token</summary>
213 <form method="post" action="/settings" class="setform stack">
214 <input type="hidden" name="field" value="token-create">
215 <label for="token-name">Name</label>
216 <input id="token-name" name="name" required autocomplete="off" placeholder="e.g. iphone">
217 <label for="token-scope">Scope</label>
218 <select id="token-scope" name="scope">
219 <option value="read" selected>read: read-only commands</option>
220 <option value="full">full: everything your account can do</option>
221 </select>
222 <label for="token-ttl">Expires after</label>
223 <input id="token-ttl" name="ttl" autocomplete="off" placeholder="e.g. 30d or 720h; empty never expires">
224 <button type="submit" class="btn">Create token</button>
225 </form>
226</details>
227</section>
228
191<section id="cli"><h2>On the command line</h2> 229<section id="cli"><h2>On the command line</h2>
192<p class="meta">No page here yet, and nothing refusing one: a credential is 230<p class="meta">No page here yet, and nothing refusing one.</p>
193easier to pipe than to paste, and a minted token is shown once.</p> 231<pre class="message" tabindex="0">gitbay web sessions list # browser sessions
194<pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens, read-only unless --scope full
195gitbay web sessions list # browser sessions
196gitbay admin ... # instance administration</pre> 232gitbay admin ... # instance administration</pre>
197<p class="meta">All of it works from stock OpenSSH too, with the CLI's 233<p class="meta">All of it works from stock OpenSSH too, with the CLI's
198grouping words dropped: <code>ssh git@{{.Host}} whoami</code>, 234grouping words dropped: <code>ssh git@{{.Host}} whoami</code>,
199<code>ssh git@{{.Host}} token create --name laptop</code>.</p> 235<code>ssh git@{{.Host}} web sessions list</code>.</p>
200</section> 236</section>
201</div> 237</div>
202</div> 238</div>
internal/web/templates/compare.html +1 −1
@@ -2,7 +2,7 @@
2{{define "title"}}compare {{.Base}}...{{.Head}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}compare {{.Base}}...{{.Head}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1>Compare <code>{{.Base}}</code> … <code>{{.Head}}</code></h1> 4<h1>Compare <code>{{.Base}}</code> … <code>{{.Head}}</code></h1>
5<p class="meta">{{len .Commits}}{{if gt .CommitsTotal (len .Commits)}} of {{.CommitsTotal}}{{end}} commit{{if ne .CommitsTotal 1}}s{{end}} on <code>{{.Head}}</code> since the merge base <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.MergeBase}}"><code>{{short .MergeBase}}</code></a>{{if .CanWrite}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new?source={{.Head}}&amp;target={{.Base}}">open a merge request</a>{{end}}</p> 5<p class="meta">{{len .Commits}}{{if gt .CommitsTotal (len .Commits)}} of {{.CommitsTotal}}{{end}} commit{{if ne .CommitsTotal 1}}s{{end}} on <code>{{.Head}}</code> since the merge base <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.MergeBase}}"><code>{{short .MergeBase}}</code></a>{{if .CanOpenMR}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new?source={{.Head}}&amp;target={{.Base}}">open a merge request</a>{{end}}</p>
6{{if .Commits}}<ul class="loglist"> 6{{if .Commits}}<ul class="loglist">
7{{range .Commits}}<li> 7{{range .Commits}}<li>
8 <div class="commitmain"> 8 <div class="commitmain">
internal/web/templates/issuenew.html +3 −2
@@ -2,6 +2,7 @@
2{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1>New issue</h1> 4<h1>New issue</h1>
5{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if gt (len .Templates) 1}}<p class="meta">template: 6{{if gt (len .Templates) 1}}<p class="meta">template:
6{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}} 7{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}}
7{{if .Draft.Is "body"}}{{template "previewblock" .Draft.HTML}}{{end}} 8{{if .Draft.Is "body"}}{{template "previewblock" .Draft.HTML}}{{end}}
@@ -11,8 +12,8 @@
11{{template "formatpicker" .Format}} 12{{template "formatpicker" .Format}}
12{{if .CanWrite}} 13{{if .CanWrite}}
13<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (optional)" value="{{.Labels}}"></p> 14<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (optional)" value="{{.Labels}}"></p>
14<p><input type="text" name="milestone" aria-label="Milestone" placeholder="milestone (optional)"></p> 15<p><input type="text" name="milestone" aria-label="Milestone" placeholder="milestone (optional)" value="{{.Milestone}}"></p>
15<p><input type="text" name="assignee" aria-label="Assignee" placeholder="assignees, space-separated (optional)"></p> 16<p><input type="text" name="assignee" aria-label="Assignee" placeholder="assignees, space-separated (optional)" value="{{.Assignee}}"></p>
16{{end}} 17{{end}}
17<p><button type="submit">Open issue</button> {{template "previewbtn"}}</p> 18<p><button type="submit">Open issue</button> {{template "previewbtn"}}</p>
18</form> 19</form>
internal/web/templates/issues.html +1 −1
@@ -25,7 +25,7 @@
25 {{if eq $.State "all"}}<span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span>{{end}} 25 {{if eq $.State "all"}}<span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span>{{end}}
26</li> 26</li>
27{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues matching “{{.Query}}”</li> 27{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues matching “{{.Query}}”</li>
28{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues — open one with <code>gitbay issue create {{.Repo.OwnerName}}/{{.Repo.Name}} --title "..."</code></li>{{end}}{{end}} 28{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues{{if .Viewer}} — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">open one</a>{{end}}</li>{{end}}{{end}}
29</ul> 29</ul>
30{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}} 30{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
31</div> 31</div>
internal/web/templates/milestones.html +1 −1
@@ -17,6 +17,6 @@
17 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div> 17 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div>
18 </div> 18 </div>
19</li> 19</li>
20{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}milestones — create one with <code>gitbay milestone create {{.Repo.OwnerName}}/{{.Repo.Name}} "v1.0"</code></li>{{end}} 20{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}milestones</li>{{end}}
21</ul> 21</ul>
22{{end}} 22{{end}}
internal/web/templates/mrs.html +1 −1
@@ -13,7 +13,7 @@
13 </form> 13 </form>
14 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}} 14 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}}
15</div> 15</div>
16{{if .CanWrite}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p> 16{{if .CanOpenMR}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>
17{{else if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">Fork this repository to propose a change</a></p> 17{{else if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">Fork this repository to propose a change</a></p>
18{{else}}<p class="meta"><a href="/login">Sign in to propose a change</a></p>{{end}} 18{{else}}<p class="meta"><a href="/login">Sign in to propose a change</a></p>{{end}}
19<ul class="issuelist rows"> 19<ul class="issuelist rows">
internal/web/templates/orgmilestones.html +1 −1
@@ -18,6 +18,6 @@
18 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div> 18 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div>
19 </div> 19 </div>
20</li> 20</li>
21{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}org milestones — create one with <code>gitbay org milestone create {{.Org}} "v1.0"</code></li>{{end}} 21{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}org milestones</li>{{end}}
22</ul> 22</ul>
23{{end}} 23{{end}}
internal/web/templates/privacy.html +1 −1
@@ -30,7 +30,7 @@ is active is kept in app preferences; nothing else is retained on the
30device. The app embeds no analytics, no crash reporting, and no 30device. The app embeds no analytics, no crash reporting, and no
31third-party SDK, so there is nothing to opt out of. Removing the account 31third-party SDK, so there is nothing to opt out of. Removing the account
32deletes the token from the Keychain.</p> 32deletes the token from the Keychain.</p>
33<p>Tokens are minted over SSH and can be revoked at any time with 33<p>Tokens are minted over SSH or on the settings page and can be revoked at any time with
34<code>gitbay auth token revoke</code>, which ends the app's access 34<code>gitbay auth token revoke</code>, which ends the app's access
35immediately.</p> 35immediately.</p>
36 36
internal/web/templates/registered.html +7 −3
@@ -5,9 +5,13 @@
5<h1>Welcome, {{.Username}}</h1> 5<h1>Welcome, {{.Username}}</h1>
6<pre class="quickstart" tabindex="0">{{.Message}}</pre> 6<pre class="quickstart" tabindex="0">{{.Message}}</pre>
7<h2>On the web</h2> 7<h2>On the web</h2>
8<p>Check your mail for the code, <a href="/login">sign in</a> with an emailed 8<ol>
9link, and paste the code under <a href="/settings">Settings</a>. Then + 9<li>Copy the verification code from the mail you were just sent.</li>
10creates your first repository.</p> 10<li><a href="/login">Sign in</a> with an emailed link.</li>
11<li>Paste the code in <a href="/settings#emails">Settings → Email addresses</a>.</li>
12</ol>
13<p>Then + creates your first repository. Using the iOS app? Create a
14token in <a href="/settings#tokens">Settings → API tokens</a>.</p>
11<h2>From the terminal</h2> 15<h2>From the terminal</h2>
12<pre class="quickstart" tabindex="0">ssh git@{{.Host}} whoami 16<pre class="quickstart" tabindex="0">ssh git@{{.Host}} whoami
13ssh git@{{.Host}} repo create {{.Username}}/hello 17ssh git@{{.Host}} repo create {{.Username}}/hello
internal/web/templates/releases.html +1 −1
@@ -51,5 +51,5 @@
51 </tr>{{end}} 51 </tr>{{end}}
52 </table>{{end}} 52 </table>{{end}}
53</article> 53</article>
54{{else}}<p class="empty-note">no releases yet — tag a commit, push the tag, then <code>gitbay release create {{.Repo.OwnerName}}/{{.Repo.Name}} v1.0</code></p>{{end}} 54{{else}}<p class="empty-note">no releases yet</p>{{end}}
55{{end}} 55{{end}}