web: account settings for keys and addresses !78
8 files changed, +426 −0
Layout: unified · split
e2e/accountweb_test.go added +116
| @@ -0,0 +1,116 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "net/url" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // TestAccountSettingsWeb covers managing your own keys and addresses from a | ||
| 12 | // browser session. Public keys are the only credential-shaped input the web | ||
| 13 | // accepts; secrets and token minting stay on SSH. | ||
| 14 | func TestAccountSettingsWeb(t *testing.T) { | ||
| 15 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 16 | aliceKey := inst.newKey(t, "alice") | ||
| 17 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 18 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 19 | |||
| 20 | out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json") | ||
| 21 | if code != 0 { | ||
| 22 | t.Fatal("web login failed") | ||
| 23 | } | ||
| 24 | var env struct { | ||
| 25 | Data struct { | ||
| 26 | URL string `json:"url"` | ||
| 27 | } `json:"data"` | ||
| 28 | } | ||
| 29 | json.Unmarshal([]byte(out), &env) | ||
| 30 | browser := newBrowser(t) | ||
| 31 | browserGet(t, browser, inst.base()+env.Data.URL[strings.Index(env.Data.URL, "/login"):]) | ||
| 32 | |||
| 33 | status, body := browserGet(t, browser, inst.base()+"/settings") | ||
| 34 | if status != 200 { | ||
| 35 | t.Fatalf("account settings: %d", status) | ||
| 36 | } | ||
| 37 | // The key that signed us in is listed, and its address shows verified. | ||
| 38 | if !strings.Contains(body, "SHA256:") { | ||
| 39 | t.Error("no SSH key fingerprint listed") | ||
| 40 | } | ||
| 41 | if !strings.Contains(body, "alice@example.test") || !strings.Contains(body, "verified") { | ||
| 42 | t.Error("verified address not shown") | ||
| 43 | } | ||
| 44 | |||
| 45 | // Add a second key through the form, then confirm it over SSH — the | ||
| 46 | // web write must land in the same place the CLI reads. | ||
| 47 | second := inst.newKey(t, "alice2") | ||
| 48 | raw, err := os.ReadFile(second + ".pub") | ||
| 49 | if err != nil { | ||
| 50 | t.Fatal(err) | ||
| 51 | } | ||
| 52 | pub := string(raw) | ||
| 53 | if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{ | ||
| 54 | "field": {"key-add"}, "key": {pub}, "scope": {"git"}, | ||
| 55 | }); status != 303 && status != 200 { | ||
| 56 | t.Fatalf("key add: %d", status) | ||
| 57 | } | ||
| 58 | out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json") | ||
| 59 | if strings.Count(out, "SHA256:") != 2 || !strings.Contains(out, `"scope":"git"`) { | ||
| 60 | t.Fatalf("key not registered with its scope: %s", out) | ||
| 61 | } | ||
| 62 | |||
| 63 | // A git-scoped key can move git data but cannot run commands, so the | ||
| 64 | // scope the form set is really enforced. | ||
| 65 | if _, _, code := inst.ssh(t, second, "", "whoami"); code == 0 { | ||
| 66 | t.Error("git-scoped key ran a control command") | ||
| 67 | } | ||
| 68 | |||
| 69 | // Removing it through the form removes it for SSH too. | ||
| 70 | fp := gitScopedFingerprint(t, out) | ||
| 71 | if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{ | ||
| 72 | "field": {"key-remove"}, "fingerprint": {fp}, | ||
| 73 | }); status != 303 && status != 200 { | ||
| 74 | t.Fatalf("key remove: %d", status) | ||
| 75 | } | ||
| 76 | out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json") | ||
| 77 | if strings.Count(out, "SHA256:") != 1 { | ||
| 78 | t.Fatalf("key not removed: %s", out) | ||
| 79 | } | ||
| 80 | |||
| 81 | // Garbage is refused by the same validation the CLI uses, and says so. | ||
| 82 | // The redirect carries the message, so the followed page shows it. | ||
| 83 | _, body = browserPost(t, browser, inst.base()+"/settings", url.Values{ | ||
| 84 | "field": {"key-add"}, "key": {"not a key"}, | ||
| 85 | }) | ||
| 86 | if !strings.Contains(body, `class="error"`) { | ||
| 87 | t.Error("invalid key accepted without an error") | ||
| 88 | } | ||
| 89 | |||
| 90 | // Token minting is SSHOnly and has no web form to reach it. | ||
| 91 | if strings.Contains(body, `value="token-mint"`) { | ||
| 92 | t.Error("token minting exposed on the web") | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | // gitScopedFingerprint pulls the fingerprint of the git-scoped key out of | ||
| 97 | // "auth keys list --json". | ||
| 98 | func gitScopedFingerprint(t *testing.T, blob string) string { | ||
| 99 | t.Helper() | ||
| 100 | var env struct { | ||
| 101 | Data []struct { | ||
| 102 | Fingerprint string `json:"fingerprint"` | ||
| 103 | Scope string `json:"scope"` | ||
| 104 | } `json:"data"` | ||
| 105 | } | ||
| 106 | if err := json.Unmarshal([]byte(blob), &env); err != nil { | ||
| 107 | t.Fatalf("keys list JSON: %v\n%s", err, blob) | ||
| 108 | } | ||
| 109 | for _, k := range env.Data { | ||
| 110 | if k.Scope == "git" { | ||
| 111 | return k.Fingerprint | ||
| 112 | } | ||
| 113 | } | ||
| 114 | t.Fatalf("no git-scoped key in %s", blob) | ||
| 115 | return "" | ||
| 116 | } | ||
internal/httpd/account.go added +143
| @@ -0,0 +1,143 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "net/http" | ||
| 6 | "net/url" | ||
| 7 | "strings" | ||
| 8 | |||
| 9 | "gitbay.org/gitbay/internal/store" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // accountKey is one SSH key as the settings page shows it: enough to | ||
| 13 | // recognise which key this is without printing the whole blob. | ||
| 14 | type accountKey struct { | ||
| 15 | Fingerprint string | ||
| 16 | Algo string | ||
| 17 | Scope string | ||
| 18 | Comment string | ||
| 19 | } | ||
| 20 | |||
| 21 | type accountPGP struct { | ||
| 22 | Fingerprint string | ||
| 23 | UIDs []string | ||
| 24 | Expired bool | ||
| 25 | Revoked bool | ||
| 26 | } | ||
| 27 | |||
| 28 | // accountForm renders the account's own settings: keys, addresses, and the | ||
| 29 | // commands for everything that stays on SSH. | ||
| 30 | func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 31 | var keys []accountKey | ||
| 32 | if list, err := s.st.ListSSHKeys(u.ID); err == nil { | ||
| 33 | for _, k := range list { | ||
| 34 | keys = append(keys, accountKey{ | ||
| 35 | Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, | ||
| 36 | Comment: keyComment(k.Blob), | ||
| 37 | }) | ||
| 38 | } | ||
| 39 | } | ||
| 40 | var pgp []accountPGP | ||
| 41 | if list, err := s.st.ListPGPKeys(u.ID); err == nil { | ||
| 42 | for _, k := range list { | ||
| 43 | var uids []string | ||
| 44 | json.Unmarshal([]byte(k.UIDsJSON), &uids) | ||
| 45 | pgp = append(pgp, accountPGP{ | ||
| 46 | Fingerprint: k.Fingerprint, UIDs: uids, | ||
| 47 | Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil, | ||
| 48 | }) | ||
| 49 | } | ||
| 50 | } | ||
| 51 | emails, _ := s.st.ListEmails(u.ID) | ||
| 52 | |||
| 53 | s.render(w, "account.html", struct { | ||
| 54 | basePage | ||
| 55 | Keys []accountKey | ||
| 56 | PGP []accountPGP | ||
| 57 | Emails []store.Email | ||
| 58 | Host string | ||
| 59 | Notice string | ||
| 60 | Message string | ||
| 61 | }{s.baseFor(u), keys, pgp, emails, s.cfg.SiteHost(), | ||
| 62 | r.URL.Query().Get("e"), r.URL.Query().Get("m")}) | ||
| 63 | } | ||
| 64 | |||
| 65 | // keyComment pulls the trailing comment off an authorized_keys blob, which | ||
| 66 | // is how people tell their own keys apart. | ||
| 67 | func keyComment(blob []byte) string { | ||
| 68 | f := strings.Fields(string(blob)) | ||
| 69 | if len(f) < 3 { | ||
| 70 | return "" | ||
| 71 | } | ||
| 72 | return strings.Join(f[2:], " ") | ||
| 73 | } | ||
| 74 | |||
| 75 | // accountSubmit routes the account forms to their commands. Everything | ||
| 76 | // here is a public key or an address — no secret is accepted over the web. | ||
| 77 | func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 78 | back := func(msg, note string) { | ||
| 79 | q := "" | ||
| 80 | switch { | ||
| 81 | case msg != "": | ||
| 82 | q = "?e=" + url.QueryEscape(msg) | ||
| 83 | case note != "": | ||
| 84 | q = "?m=" + url.QueryEscape(note) | ||
| 85 | } | ||
| 86 | http.Redirect(w, r, "/settings"+q, http.StatusSeeOther) | ||
| 87 | } | ||
| 88 | |||
| 89 | switch r.FormValue("field") { | ||
| 90 | case "key-add": | ||
| 91 | body := strings.TrimSpace(r.FormValue("key")) | ||
| 92 | if body == "" { | ||
| 93 | back("paste a public key in authorized_keys format", "") | ||
| 94 | return | ||
| 95 | } | ||
| 96 | argv := []string{"keys", "add"} | ||
| 97 | if scope := r.FormValue("scope"); scope == "git" { | ||
| 98 | argv = append(argv, "--scope", "git") | ||
| 99 | } | ||
| 100 | if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok { | ||
| 101 | back(msg, "") | ||
| 102 | return | ||
| 103 | } | ||
| 104 | back("", "key registered") | ||
| 105 | case "key-remove": | ||
| 106 | if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok { | ||
| 107 | back(msg, "") | ||
| 108 | return | ||
| 109 | } | ||
| 110 | back("", "key removed") | ||
| 111 | case "pgp-add": | ||
| 112 | body := strings.TrimSpace(r.FormValue("key")) | ||
| 113 | if body == "" { | ||
| 114 | back("paste an armored OpenPGP public key", "") | ||
| 115 | return | ||
| 116 | } | ||
| 117 | if msg, ok := s.runControlStdin(u, []string{"pgp", "add"}, body+"\n"); !ok { | ||
| 118 | back(msg, "") | ||
| 119 | return | ||
| 120 | } | ||
| 121 | back("", "PGP key registered") | ||
| 122 | case "pgp-remove": | ||
| 123 | if _, msg, ok := s.runControl(u, []string{"pgp", "remove", r.FormValue("fingerprint")}); !ok { | ||
| 124 | back(msg, "") | ||
| 125 | return | ||
| 126 | } | ||
| 127 | back("", "PGP key removed") | ||
| 128 | case "email-add": | ||
| 129 | if _, msg, ok := s.runControl(u, []string{"email", "add", strings.TrimSpace(r.FormValue("address"))}); !ok { | ||
| 130 | back(msg, "") | ||
| 131 | return | ||
| 132 | } | ||
| 133 | back("", "check that inbox for a verification code") | ||
| 134 | case "email-verify": | ||
| 135 | if _, msg, ok := s.runControl(u, []string{"email", "verify", strings.TrimSpace(r.FormValue("code"))}); !ok { | ||
| 136 | back(msg, "") | ||
| 137 | return | ||
| 138 | } | ||
| 139 | back("", "address verified") | ||
| 140 | default: | ||
| 141 | back("unknown form", "") | ||
| 142 | } | ||
| 143 | } | ||
internal/httpd/control.go +26
| @@ -39,6 +39,32 @@ func (s *Server) runControl(u store.User, argv []string) (out string, msg string | |||
| 39 | return stdout.String(), m, code == protocol.ExitOK | 39 | return stdout.String(), m, code == protocol.ExitOK |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | // runControlStdin is runControl for the handful of commands whose input | ||
| 43 | // arrives on stdin. Public keys are the only such input the web accepts: | ||
| 44 | // they are not secret, and pasting one into a browser is how people who | ||
| 45 | // have not set up the CLI get their first key registered. Secrets, tokens | ||
| 46 | // and mirror credentials remain SSHOnly and are refused by the dispatcher. | ||
| 47 | func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) { | ||
| 48 | var stdout, stderr bytes.Buffer | ||
| 49 | ctx := &control.Ctx{ | ||
| 50 | User: u, | ||
| 51 | Source: "web", | ||
| 52 | Scope: "full", | ||
| 53 | Store: s.st, | ||
| 54 | Cfg: s.cfg, | ||
| 55 | Stdin: strings.NewReader(stdin), | ||
| 56 | Stdout: &stdout, | ||
| 57 | Stderr: &stderr, | ||
| 58 | ViaAPI: true, | ||
| 59 | } | ||
| 60 | code := control.Dispatch(ctx, argv) | ||
| 61 | m := strings.TrimSpace(stderr.String()) | ||
| 62 | if m == "" { | ||
| 63 | m = strings.TrimSpace(stdout.String()) | ||
| 64 | } | ||
| 65 | return m, code == protocol.ExitOK | ||
| 66 | } | ||
| 67 | |||
| 42 | // runControlJSON runs a command in JSON mode and returns its data object. | 68 | // runControlJSON runs a command in JSON mode and returns its data object. |
| 43 | // In JSON mode a failure is an envelope carrying the message rather than | 69 | // In JSON mode a failure is an envelope carrying the message rather than |
| 44 | // stderr text, so both paths are read from the same envelope. | 70 | // stderr text, so both paths are read from the same envelope. |
internal/httpd/routes.go +3
| @@ -88,6 +88,9 @@ func (s *Server) Routes() []Route { | |||
| 88 | Route{Method: "POST", Pattern: "/logout", Mutating: true, | 88 | Route{Method: "POST", Pattern: "/logout", Mutating: true, |
| 89 | Handler: s.checkOrigin(s.logout)}, | 89 | Handler: s.checkOrigin(s.logout)}, |
| 90 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, | 90 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, |
| 91 | Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)}, | ||
| 92 | Route{Method: "POST", Pattern: "/settings", Mutating: true, | ||
| 93 | Handler: s.checkOrigin(s.requireUser(s.accountSubmit))}, | ||
| 91 | ) | 94 | ) |
| 92 | // Web signup fronts the same registration path as SSH register, | 95 | // Web signup fronts the same registration path as SSH register, |
| 93 | // so it exists only when registration is open or invite. | 96 | // so it exists only when registration is open or invite. |
internal/store/users.go +29
| @@ -136,6 +136,35 @@ func (s *Store) UserEmailAddresses(userID int64) ([]string, error) { | |||
| 136 | return out, rows.Err() | 136 | return out, rows.Err() |
| 137 | } | 137 | } |
| 138 | 138 | ||
| 139 | // Email is one address on an account, with the state the signature rules | ||
| 140 | // and notification routing depend on. | ||
| 141 | type Email struct { | ||
| 142 | Address string | ||
| 143 | Verified bool | ||
| 144 | VerifiedBy string // smtp | admin, empty when unverified | ||
| 145 | Primary bool | ||
| 146 | } | ||
| 147 | |||
| 148 | // ListEmails returns every address on the account with its state. | ||
| 149 | func (s *Store) ListEmails(userID int64) ([]Email, error) { | ||
| 150 | rows, err := s.DB.Query(`SELECT address, verified_at IS NOT NULL, | ||
| 151 | COALESCE(verified_by, ''), is_primary | ||
| 152 | FROM emails WHERE user_id = ? ORDER BY is_primary DESC, address`, userID) | ||
| 153 | if err != nil { | ||
| 154 | return nil, err | ||
| 155 | } | ||
| 156 | defer rows.Close() | ||
| 157 | var out []Email | ||
| 158 | for rows.Next() { | ||
| 159 | var e Email | ||
| 160 | if err := rows.Scan(&e.Address, &e.Verified, &e.VerifiedBy, &e.Primary); err != nil { | ||
| 161 | return nil, err | ||
| 162 | } | ||
| 163 | out = append(out, e) | ||
| 164 | } | ||
| 165 | return out, rows.Err() | ||
| 166 | } | ||
| 167 | |||
| 139 | // SetUserDisabled suspends or restores an account. Disabling also drops | 168 | // SetUserDisabled suspends or restores an account. Disabling also drops |
| 140 | // the user's web sessions; their keys and tokens stay registered but are | 169 | // the user's web sessions; their keys and tokens stay registered but are |
| 141 | // refused at every entry point until re-enabled. | 170 | // refused at every entry point until re-enabled. |
internal/web/static/style.css +17
| @@ -269,6 +269,8 @@ ul.raillist.wide .owner { font-size: var(--fs-0); } | |||
| 269 | } | 269 | } |
| 270 | .railfoot a { color: var(--shell-fg); } | 270 | .railfoot a { color: var(--shell-fg); } |
| 271 | a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); } | 271 | a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); } |
| 272 | .railfoot .railsettings { color: var(--shell-muted); display: inline-flex; } | ||
| 273 | .railfoot .railsettings:hover { color: var(--shell-fg); } | ||
| 272 | .railfoot .linklike { | 274 | .railfoot .linklike { |
| 273 | background: none; border: 0; padding: 0; cursor: pointer; | 275 | background: none; border: 0; padding: 0; cursor: pointer; |
| 274 | color: var(--shell-muted); font: inherit; text-decoration: underline; | 276 | color: var(--shell-muted); font: inherit; text-decoration: underline; |
| @@ -795,6 +797,21 @@ pre.message { | |||
| 795 | } | 797 | } |
| 796 | 798 | ||
| 797 | /* repo facts: the counts a visitor sizes a project up with */ | 799 | /* repo facts: the counts a visitor sizes a project up with */ |
| 800 | /* account settings */ | ||
| 801 | table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; } | ||
| 802 | table.keys td.mono, .mono { font-family: var(--mono); font-size: var(--fs-1); overflow-wrap: anywhere; } | ||
| 803 | table.keys td.act { text-align: right; } | ||
| 804 | ul.plain { list-style: none; margin: 0 0 var(--sp-3); padding: 0; } | ||
| 805 | ul.plain li { padding: var(--sp-1) 0; display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } | ||
| 806 | form.setform.stack { display: block; } | ||
| 807 | form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; display: block; margin-bottom: var(--sp-2); } | ||
| 808 | p.notice { | ||
| 809 | border-left: 3px solid var(--ok); | ||
| 810 | background: var(--hover); | ||
| 811 | padding: var(--sp-2) var(--sp-3); | ||
| 812 | margin: 0 0 var(--sp-4); | ||
| 813 | } | ||
| 814 | |||
| 798 | .facts { margin: 0 0 var(--sp-4); } | 815 | .facts { margin: 0 0 var(--sp-4); } |
| 799 | .facts .counts { | 816 | .facts .counts { |
| 800 | display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); | 817 | display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); |
internal/web/templates/account.html added +91
| @@ -0,0 +1,91 @@ | |||
| 1 | {{define "title"}}account settings{{end}} | ||
| 2 | {{define "content"}} | ||
| 3 | <h1>Account settings</h1> | ||
| 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | ||
| 5 | {{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} | ||
| 6 | |||
| 7 | <h2>SSH keys</h2> | ||
| 8 | <p class="meta">Your keys are your identity here. A <code>full</code> key can run | ||
| 9 | commands and push; a <code>git</code> key can only move git data, which is what | ||
| 10 | a CI checkout wants.</p> | ||
| 11 | {{if .Keys}}<div class="tablewrap"><table class="keys"> | ||
| 12 | <tr class="cols"><th scope="col">fingerprint</th><th scope="col">type</th><th scope="col">scope</th><th scope="col"></th></tr> | ||
| 13 | {{range .Keys}}<tr> | ||
| 14 | <td class="mono">{{.Fingerprint}}{{with .Comment}}<br><span class="muted">{{.}}</span>{{end}}</td> | ||
| 15 | <td>{{.Algo}}</td> | ||
| 16 | <td>{{.Scope}}</td> | ||
| 17 | <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="key-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}"><button type="submit" class="linklike">Remove</button></form></td> | ||
| 18 | </tr> | ||
| 19 | {{end}}</table></div> | ||
| 20 | {{else}}<p class="none">No SSH keys — which cannot be right, since you signed in.</p>{{end}} | ||
| 21 | <details class="editbox"> | ||
| 22 | <summary>Add an SSH key</summary> | ||
| 23 | <form method="post" action="/settings" class="setform stack"> | ||
| 24 | <input type="hidden" name="field" value="key-add"> | ||
| 25 | <label for="key">Public key</label> | ||
| 26 | <textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@machine"></textarea> | ||
| 27 | <label for="scope">Scope</label> | ||
| 28 | <select id="scope" name="scope"> | ||
| 29 | <option value="full">full — commands and git</option> | ||
| 30 | <option value="git">git — git transport only</option> | ||
| 31 | </select> | ||
| 32 | <button type="submit" class="primary">Add key</button> | ||
| 33 | </form> | ||
| 34 | </details> | ||
| 35 | |||
| 36 | <h2>Email addresses</h2> | ||
| 37 | <p class="meta">A verified address is what ties your signed commits to this | ||
| 38 | account, and where notifications go.</p> | ||
| 39 | {{if .Emails}}<ul class="plain"> | ||
| 40 | {{range .Emails}}<li>{{.Address}} | ||
| 41 | {{if .Primary}}<span class="chip">primary</span>{{end}} | ||
| 42 | {{if .Verified}}<span class="badge badge-verified">verified{{with .VerifiedBy}} · {{.}}{{end}}</span> | ||
| 43 | {{else}}<span class="badge badge-unsigned">unverified</span>{{end}}</li> | ||
| 44 | {{end}}</ul>{{end}} | ||
| 45 | <details class="editbox"> | ||
| 46 | <summary>Add an address</summary> | ||
| 47 | <form method="post" action="/settings" class="setform"> | ||
| 48 | <input type="hidden" name="field" value="email-add"> | ||
| 49 | <label for="address">Address</label> | ||
| 50 | <input type="email" id="address" name="address" required placeholder="you@example.org"> | ||
| 51 | <button type="submit">Send code</button> | ||
| 52 | </form> | ||
| 53 | <form method="post" action="/settings" class="setform"> | ||
| 54 | <input type="hidden" name="field" value="email-verify"> | ||
| 55 | <label for="code">Verification code</label> | ||
| 56 | <input type="text" id="code" name="code" required placeholder="from the mail"> | ||
| 57 | <button type="submit">Verify</button> | ||
| 58 | </form> | ||
| 59 | </details> | ||
| 60 | |||
| 61 | <h2>OpenPGP keys</h2> | ||
| 62 | <p class="meta">Only needed if you sign commits with GPG. SSH signing | ||
| 63 | (<code>gpg.format = ssh</code>) uses the keys above.</p> | ||
| 64 | {{if .PGP}}<div class="tablewrap"><table class="keys"> | ||
| 65 | <tr class="cols"><th scope="col">fingerprint</th><th scope="col">identities</th><th scope="col"></th></tr> | ||
| 66 | {{range .PGP}}<tr> | ||
| 67 | <td class="mono">{{.Fingerprint}}</td> | ||
| 68 | <td>{{range .UIDs}}{{.}}<br>{{end}}{{if .Revoked}}<span class="badge badge-bad_signature">revoked</span>{{else if .Expired}}<span class="badge badge-signed_key_expired">expired</span>{{end}}</td> | ||
| 69 | <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="pgp-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}"><button type="submit" class="linklike">Remove</button></form></td> | ||
| 70 | </tr> | ||
| 71 | {{end}}</table></div> | ||
| 72 | {{else}}<p class="none">No OpenPGP keys.</p>{{end}} | ||
| 73 | <details class="editbox"> | ||
| 74 | <summary>Add a PGP key</summary> | ||
| 75 | <form method="post" action="/settings" class="setform stack"> | ||
| 76 | <input type="hidden" name="field" value="pgp-add"> | ||
| 77 | <label for="pgpkey">Armored public key</label> | ||
| 78 | <textarea id="pgpkey" name="key" rows="6" required placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"></textarea> | ||
| 79 | <button type="submit" class="primary">Add key</button> | ||
| 80 | </form> | ||
| 81 | </details> | ||
| 82 | |||
| 83 | <h2>On SSH only</h2> | ||
| 84 | <p class="meta">Anything whose input is a credential stays on the command line, | ||
| 85 | where it can be piped instead of pasted:</p> | ||
| 86 | <pre class="message">gitbay auth token mint --name laptop # API tokens | ||
| 87 | gitbay auth export > account.bundle # your account, portable | ||
| 88 | gitbay admin ... # instance administration</pre> | ||
| 89 | <p class="meta">All of the above works from stock OpenSSH too: | ||
| 90 | <code>ssh git@{{.Host}} auth whoami</code>.</p> | ||
| 91 | {{end}} | ||
internal/web/templates/layout.html +1
| @@ -41,6 +41,7 @@ | |||
| 41 | </div> | 41 | </div> |
| 42 | <div class="railfoot"> | 42 | <div class="railfoot"> |
| 43 | {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span>{{.Viewer}}</a> | 43 | {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span>{{.Viewer}}</a> |
| 44 | <a class="railsettings" href="/settings" title="Account settings" aria-label="Account settings"><svg class="icon" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492ZM6.254 8a1.746 1.746 0 1 1 3.492 0 1.746 1.746 0 0 1-3.492 0Z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319Zm-2.153.425c.18-.613 1.048-.613 1.229 0l.093.319a2.373 2.373 0 0 0 3.416 1.416l.292-.16c.561-.306 1.175.308.87.87l-.16.292a2.373 2.373 0 0 0 1.415 3.416l.319.093c.613.18.613 1.048 0 1.229l-.319.093a2.373 2.373 0 0 0-1.416 3.416l.16.292c.305.561-.309 1.175-.87.87l-.292-.16a2.373 2.373 0 0 0-3.416 1.415l-.093.319c-.181.613-1.049.613-1.229 0l-.093-.319a2.373 2.373 0 0 0-3.416-1.416l-.292.16c-.561.305-1.175-.309-.87-.87l.16-.292a2.373 2.373 0 0 0-1.415-3.416l-.32-.093c-.612-.181-.612-1.049 0-1.229l.32-.093a2.373 2.373 0 0 0 1.416-3.416l-.16-.292c-.306-.561.308-1.175.87-.87l.292.16a2.373 2.373 0 0 0 3.416-1.416l.093-.318Z"/></svg></a> | ||
| 44 | <form method="post" action="/logout"><button type="submit" class="linklike">Log out</button></form> | 45 | <form method="post" action="/logout"><button type="submit" class="linklike">Log out</button></form> |
| 45 | {{else}}<a href="/login">Sign in</a>{{end}} | 46 | {{else}}<a href="/login">Sign in</a>{{end}} |
| 46 | </div> | 47 | </div> |