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.
5555| preview body markup | n/a | yes | no |
5656| stacked merge requests | yes | yes | yes |
5757| revisions | yes | yes | yes |
58| range-diff | yes | no | yes |
58| range-diff | yes | yes | yes |
5959| merge gates | yes | yes | yes |
6060
6161Reviews and checks carry the time they last said something, and a
@@ -366,7 +366,8 @@ client has no use for one (krz/gitbay#57).
366366| push device remove | yes | yes | yes |
367367| activity push on, off | yes | yes | yes |
368368| 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 |
370371| API token revoke with what it created | yes | no | no |
371372| account export bundle | yes | yes | n/a |
372373| profile set | yes | yes | yes |
@@ -470,8 +471,8 @@ thirty with the same cursors; iOS pages with them too.
470471* CLI only, for now
471472
472473Build secrets, mirror configuration and tokens, custom domain claims,
473API token minting, web session listing and revocation, deploy keys,
474and instance administration have no page yet. Until #234 these were
474web session listing and revocation, deploy keys, and instance
475administration have no page yet. Until #234 these were
475476refused outright on the other surfaces; the refusal is gone, so each is
476477now a page waiting to be built rather than a rule. A credential still
477478travels 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.
88
99- The builds page's status badge section gives an org-mode snippet
1010 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).
1114- A wiki link to an existing non-page file (an .svg, .txt, .pdf) now
1215 resolves to the raw route instead of 404ing against the page route
1316 (#283).
14
1517- The new-issue form takes labels, milestone and assignee in one step
16 for writers; the watch button names watching, muted and default; a
17 Discussion heading sits before comment threads; the build page's
18 live note says the page updates itself; and the rail and the phone
19 More menu render from one list (#271).
20
18 for writers; a Discussion heading sits before comment threads; the
19 build page's live note says the page updates itself; and the rail
20 and the phone More menu render from one list (#271).
21- The new-issue form keeps milestone and assignee through preview and
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).
2128- Empty states on the web state the fact instead of a CLI command, and
2229 drop "yet" on a finished item; the merge request list offers a New
2330 merge request link, a fork link, or a sign-in prompt depending on
2431 what the visitor can do; and the search page's scope caption is
2532 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).
2640
2741Credentials and sessions: revocation, delegation, expiry and an idle
2842timeout (#256, #257, #276, #277).
@@ -145,18 +159,17 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
145159- A =-- foreign_keys: off= migration's =foreign_key_check= now runs
146160 inside the migration's own transaction, before commit, so a
147161 violation rolls the migration back instead of leaving the bad
148 schema and =user_version= already persisted (#261).
149- The web pin and watch buttons dispatch through =repo pin=/=unpin=
150 and =repo watch=/=mute=/=unwatch= instead of writing the store
151 directly, so a refusal reaches the viewer as a message instead of
152 being dropped. The watch button now cycles three states — default,
153 watching, muted — instead of two (#261).
154- The response that consumes a login link's =?token== sends
155 =Cache-Control: no-store=, so no intermediary keeps a copy of the
156 single-use URL (#261).
157- Wiki documentation fixes: API.org clarifies token commands work on the
158 API, Parity.org documents batched review and web watch/pin dispatch,
159 Threat-Model.org documents the login-link URL exception (#261).
162 schema and =user_version= already persisted; the web pin and watch
163 buttons dispatch through =repo pin=/=unpin= and =repo
164 watch=/=mute=/=unwatch= instead of writing the store directly, so a
165 refusal reaches the viewer as a message instead of being dropped,
166 and the watch button now cycles three states — default, watching,
167 muted — instead of two; the response that consumes a login link's
168 =?token== sends =Cache-Control: no-store=, so no intermediary keeps
169 a copy of the single-use URL; and wiki documentation fixes:
170 API.org clarifies token commands work on the API, Parity.org
171 documents batched review and web watch/pin dispatch, Threat-Model.org
172 documents the login-link URL exception (#261).
160173- The account settings page quotes the CLI and SSH command forms that
161174 actually resolve; a test runs every command a web page quotes against
162175 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) {
106106 t.Error("invalid key accepted without an error")
107107 }
108108
109 // The settings page has no token form. Nothing refuses one now
110 // (#234); there is simply no page for it yet, and a minted token is
111 // shown once, which wants a page designed for it.
112 if strings.Contains(body, `value="token-mint"`) {
113 t.Error("token minting exposed on the web")
109 // The settings page has a token form (#264): API tokens: create,
110 // list, revoke.
111 if !strings.Contains(body, `value="token-create"`) {
112 t.Error("token minting form missing from the web")
114113 }
115114
116115 // The account bundle downloads as an attachment, carrying what
e2e/buildfollow_test.go +1 −1
@@ -143,7 +143,7 @@ func TestBuildLogFollow(t *testing.T) {
143143 }
144144 body, _ := io.ReadAll(resp.Body)
145145 resp.Body.Close()
146 if strings.Contains(string(body), "Live:") {
146 if strings.Contains(string(body), "This page updates itself") {
147147 t.Fatalf("?follow=0 rendered the live page:\n%s", body)
148148 }
149149
e2e/websignup_test.go +1 −1
@@ -56,7 +56,7 @@ func TestWebSignup(t *testing.T) {
5656 status, body = browserPost(t, browser, inst.base()+"/register", url.Values{
5757 "username": {"erin"}, "invite": {inviteCode}, "key": {string(pub)}})
5858 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") {
6060 t.Fatalf("signup: %d\n%s", status, body)
6161 }
6262 out, errOut, code := inst.ssh(t, key, "", "whoami")
internal/httpd/account.go +70 −1
@@ -45,6 +45,16 @@ type accountDevice struct {
4545 Confirm string // the id as text, typed back to confirm removal
4646}
4747
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
4858// accountForm renders the account's own settings: keys, addresses, and the
4959// commands for everything that stays on SSH.
5060func (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
5363
5464// accountPage renders the settings page.
5565func (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) {
5672 var keys []accountKey
5773 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
5874 for _, k := range list {
@@ -90,6 +106,20 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use
90106 }
91107 }
92108
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
93123 // The about text is a file. The page points at it rather than editing
94124 // it: the repository's own editor already does that job.
95125 aboutRepo := u.Username + "/" + control.ProfileRepoName
@@ -116,9 +146,12 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use
116146 PushOn bool
117147 Devices []accountDevice
118148 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
119151 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
120152 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})
122155}
123156
124157// 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
263296 return
264297 }
265298 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")
266335 case "theme":
267336 if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok {
268337 back(msg, "")
internal/httpd/account_test.go +213
@@ -284,3 +284,216 @@ func TestWatchToggleCyclesThroughMuted(t *testing.T) {
284284 }
285285 assertAudited(t, st, "cmd repo unwatch")
286286}
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) {
399399 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()})
400400}
401401
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
402420// issueCreateForm renders the new-issue form, prefilled from the repo's
403421// default issue template when one exists. A Preview submit comes back
404422// 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
411429 p.Tab = "issues"
412430 if wantsPreview(r) {
413431 d := s.draftFor(r, p.Repo, "body", "body", bodyFormat(r))
414 s.render(w, "issuenew.html", struct {
415 repoPage
416 Body string
417 Format string
418 Title string
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)})
432 s.render(w, "issuenew.html", issueNewPage{
433 repoPage: p, Body: d.Body, Format: d.Format, Title: r.FormValue("title"),
434 Labels: r.FormValue("labels"), Milestone: r.FormValue("milestone"), Assignee: r.FormValue("assignee"),
435 Templates: control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), Draft: d, CanWrite: s.canWriteRepoAs(u, p.Repo),
436 })
426437 return
427438 }
428439 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
@@ -447,17 +458,10 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
447458 if format != "org" {
448459 format = "md"
449460 }
450 s.render(w, "issuenew.html", struct {
451 repoPage
452 Body string
453 Format string
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 s.render(w, "issuenew.html", issueNewPage{
462 repoPage: p, Body: body, Format: format, Template: tplName, Templates: templates,
463 CanWrite: s.canWriteRepoAs(u, p.Repo),
464 })
461465}
462466
463467// 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
465469// implementation. Bodies travel on stdin, the way --file - does.
466470
467471func (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()
469477 title := strings.TrimSpace(r.FormValue("title"))
470478 format := bodyFormat(r)
471479 if wantsPreview(r) {
472480 s.issueCreateForm(w, r, u)
473481 return
474482 }
483 canWrite := s.canWriteRepoAs(u, p.Repo)
475484 var created control.Created
476485 argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"}
477486 // 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
480489 // events happen (#271). issue create refuses the whole create when any
481490 // of them is set without write access, so a reader's hand-crafted POST
482491 // 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 {
484493 argv = append(argv, fieldArgs("--label", r.FormValue("labels"))...)
485494 if milestone := strings.TrimSpace(r.FormValue("milestone")); milestone != "" {
486495 argv = append(argv, "--milestone", milestone)
@@ -489,7 +498,12 @@ func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u sto
489498 }
490499 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created)
491500 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 })
493507 return
494508 }
495509 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) {
7373 }
7474 commits = append(commits, cr)
7575 }
76 canWrite := s.canWriteRepo(r, p.Repo)
77 canOpenMR := canWrite || len(s.writableForks(s.viewer(r), p.Repo)) > 0
7678 s.render(w, "compare.html", struct {
7779 repoPage
7880 Base, Head, BaseSHA, HeadSHA, MergeBase string
@@ -81,6 +83,6 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
8183 DiffFiles []diffFile
8284 DiffTruncated bool
8385 Stat diffStat
84 CanWrite bool
85 }{p, base, head, baseSHA, headSHA, mergeBase, commits, total, files, truncated, statOf(files), s.canWriteRepo(r, p.Repo)})
86 CanOpenMR bool
87 }{p, base, head, baseSHA, headSHA, mergeBase, commits, total, files, truncated, statOf(files), canOpenMR})
8688}
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) {
283283 }
284284}
285285
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
286391// A bad assignee creates nothing: issue create resolves the assignee
287392// before writing the issue, so a typo leaves the repo without a
288393// half-created issue (#271).
internal/httpd/mractions.go +22 −12
@@ -187,26 +187,36 @@ type mrNewPage struct {
187187 Draft *draft
188188}
189189
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
190210// mrSources lists the branches a merge request may be opened from, in the
191211// 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".
193// Write is the filter because a contributor proposes from a fork they
194// own; the command still checks the source for itself (#168).
212// any writable fork as "owner/name:branch".
195213func (s *Server) mrSources(u store.User, p repoPage) []string {
196214 var out []string
197215 branches, _ := gitutil.Refs(p.Dir, "heads")
198216 for _, b := range branches {
199217 out = append(out, b.Name)
200218 }
201 if u.ID == 0 {
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 }
219 for _, f := range s.writableForks(u, p.Repo) {
210220 dir := control.RepoDir(s.cfg.Server.Root, f.OwnerName, f.Name)
211221 refs, _ := gitutil.Refs(dir, "heads")
212222 for _, b := range refs {
internal/httpd/mrrangediff.go +4 −4
@@ -37,11 +37,11 @@ func (s *Server) mrRangeDiff(w http.ResponseWriter, r *http.Request) {
3737 if to := r.URL.Query().Get("to"); to != "" {
3838 argv = append(argv, "--to", to)
3939 }
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).
4044 out, msg, code := s.runControlCode(viewer, argv)
41 if code == protocol.ExitNotFound {
42 s.notFound(w, r)
43 return
44 }
4545 errMsg := ""
4646 if code != protocol.ExitOK {
4747 errMsg = msg
internal/httpd/mrrangediff_test.go +12 −5
@@ -228,8 +228,8 @@ func TestMRRangeDiffPagePrivateRepo(t *testing.T) {
228228}
229229
230230// --from/--to reach the control command as real argv: an unknown
231// revision is refused with not-found, and two real revisions render the
232// range-diff between exactly those two.
231// revision renders the command's refusal on the page, and two real
232// revisions render the range-diff between exactly those two.
233233func TestMRRangeDiffPageFromToQuery(t *testing.T) {
234234 st, cfg, alice, _, repo, n, v1, v2, _ := rangeDiffFixture(t)
235235 s := New(cfg, st, nil)
@@ -244,11 +244,18 @@ func TestMRRangeDiffPageFromToQuery(t *testing.T) {
244244 return httptest.NewRecorder(), req
245245 }
246246
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) {
248252 rr, req := newReq("?from=0000000000000000000000000000000000000000")
249253 s.mrRangeDiff(rr, req)
250 if rr.Code != 404 {
251 t.Fatalf("status %d, want 404, body %s", rr.Code, rr.Body.String())
254 if rr.Code != 200 {
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())
252259 }
253260 })
254261
internal/httpd/mrslist_test.go +17 −1
@@ -31,7 +31,15 @@ func TestMRsListContributionHintByAccess(t *testing.T) {
3131 if err != nil {
3232 t.Fatal(err)
3333 }
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 {
3543 t.Fatal(err)
3644 }
3745
@@ -83,4 +91,12 @@ func TestMRsListContributionHintByAccess(t *testing.T) {
8391 if !strings.Contains(ownerOut, "New merge request") {
8492 t.Errorf("owner: missing New merge request link:\n%s", ownerOut)
8593 }
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 }
86102}
internal/httpd/mrsrow_test.go +7 −7
@@ -10,13 +10,13 @@ import (
1010// mrsPageData mirrors the anonymous struct the mrs handler renders with.
1111type mrsPageData struct {
1212 repoPage
13 State string
14 Query string
15 Filters []listFilter
16 Facets []facetGroup
17 MRs []mrRow
18 Older string
19 CanWrite bool
13 State string
14 Query string
15 Filters []listFilter
16 Facets []facetGroup
17 MRs []mrRow
18 Older string
19 CanOpenMR bool
2020}
2121
2222func 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) {
20952095 allLabels, _ := s.st.ListLabels(p.Repo, readable)
20962096 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
20972097 facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true)
2098 canOpenMR := canWrite || len(s.writableForks(s.viewer(r), p.Repo)) > 0
20982099 s.render(w, "mrs.html", struct {
20992100 repoPage
21002101 State string
@@ -2104,10 +2105,10 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
21042105 MRs []mrRow
21052106 LabelColors map[string]template.CSS
21062107 Older string
2107 CanWrite bool
2108 CanOpenMR bool
21082109 }{p, state, mf.Search,
21092110 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})
21112112}
21122113
21132114func (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
284284 return err
285285 }
286286 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)
288288 }
289289 if err := rows.Err(); err != nil {
290290 rows.Close()
internal/web/templates/account.html +41 −5
@@ -18,6 +18,7 @@
1818 <li><a href="#notifications">Notifications</a></li>
1919 <li><a href="#appearance">Appearance</a></li>
2020 <li><a href="#export">Export</a></li>
21 <li><a href="#tokens">API tokens</a></li>
2122 <li><a href="#cli">On the command line</a></li>
2223 </ul></div>
2324</details>
@@ -188,15 +189,50 @@ never included; a replayed bundle's emails arrive unverified.</p>
188189<p><a href="/settings/export">Download bundle</a></p>
189190</section>
190191
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
191229<section id="cli"><h2>On the command line</h2>
192<p class="meta">No page here yet, and nothing refusing one: a credential is
193easier to pipe than to paste, and a minted token is shown once.</p>
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
230<p class="meta">No page here yet, and nothing refusing one.</p>
231<pre class="message" tabindex="0">gitbay web sessions list # browser sessions
196232gitbay admin ... # instance administration</pre>
197233<p class="meta">All of it works from stock OpenSSH too, with the CLI's
198234grouping 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>
200236</section>
201237</div>
202238</div>
internal/web/templates/compare.html +1 −1
@@ -2,7 +2,7 @@
22{{define "title"}}compare {{.Base}}...{{.Head}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
33{{define "content"}}
44<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>
66{{if .Commits}}<ul class="loglist">
77{{range .Commits}}<li>
88 <div class="commitmain">
internal/web/templates/issuenew.html +3 −2
@@ -2,6 +2,7 @@
22{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
33{{define "content"}}
44<h1>New issue</h1>
5{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
56{{if gt (len .Templates) 1}}<p class="meta">template:
67{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}}
78{{if .Draft.Is "body"}}{{template "previewblock" .Draft.HTML}}{{end}}
@@ -11,8 +12,8 @@
1112{{template "formatpicker" .Format}}
1213{{if .CanWrite}}
1314<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="assignee" aria-label="Assignee" placeholder="assignees, space-separated (optional)"></p>
15<p><input type="text" name="milestone" aria-label="Milestone" placeholder="milestone (optional)" value="{{.Milestone}}"></p>
16<p><input type="text" name="assignee" aria-label="Assignee" placeholder="assignees, space-separated (optional)" value="{{.Assignee}}"></p>
1617{{end}}
1718<p><button type="submit">Open issue</button> {{template "previewbtn"}}</p>
1819</form>
internal/web/templates/issues.html +1 −1
@@ -25,7 +25,7 @@
2525 {{if eq $.State "all"}}<span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span>{{end}}
2626</li>
2727{{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}}
2929</ul>
3030{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
3131</div>
internal/web/templates/milestones.html +1 −1
@@ -17,6 +17,6 @@
1717 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div>
1818 </div>
1919</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}}
2121</ul>
2222{{end}}
internal/web/templates/mrs.html +1 −1
@@ -13,7 +13,7 @@
1313 </form>
1414 {{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}}
1515</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>
1717{{else if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">Fork this repository to propose a change</a></p>
1818{{else}}<p class="meta"><a href="/login">Sign in to propose a change</a></p>{{end}}
1919<ul class="issuelist rows">
internal/web/templates/orgmilestones.html +1 −1
@@ -18,6 +18,6 @@
1818 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div>
1919 </div>
2020</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}}
2222</ul>
2323{{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
3030device. The app embeds no analytics, no crash reporting, and no
3131third-party SDK, so there is nothing to opt out of. Removing the account
3232deletes 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
3434<code>gitbay auth token revoke</code>, which ends the app's access
3535immediately.</p>
3636
internal/web/templates/registered.html +7 −3
@@ -5,9 +5,13 @@
55<h1>Welcome, {{.Username}}</h1>
66<pre class="quickstart" tabindex="0">{{.Message}}</pre>
77<h2>On the web</h2>
8<p>Check your mail for the code, <a href="/login">sign in</a> with an emailed
9link, and paste the code under <a href="/settings">Settings</a>. Then +
10creates your first repository.</p>
8<ol>
9<li>Copy the verification code from the mail you were just sent.</li>
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>
1115<h2>From the terminal</h2>
1216<pre class="quickstart" tabindex="0">ssh git@{{.Host}} whoami
1317ssh git@{{.Host}} repo create {{.Username}}/hello
internal/web/templates/releases.html +1 −1
@@ -51,5 +51,5 @@
5151 </tr>{{end}}
5252 </table>{{end}}
5353</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}}
5555{{end}}