Commit 6b2b26db54
Verified · cmc ci/build: success
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 | 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 | 68 | // runControlJSON runs a command in JSON mode and returns its data object. |
| 43 | 69 | // In JSON mode a failure is an envelope carrying the message rather than |
| 44 | 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 | 88 | Route{Method: "POST", Pattern: "/logout", Mutating: true, |
| 89 | 89 | Handler: s.checkOrigin(s.logout)}, |
| 90 | 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 | 95 | // Web signup fronts the same registration path as SSH register, |
| 93 | 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 | 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 | 168 | // SetUserDisabled suspends or restores an account. Disabling also drops |
| 140 | 169 | // the user's web sessions; their keys and tokens stay registered but are |
| 141 | 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 | 270 | .railfoot a { color: var(--shell-fg); } |
| 271 | 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 | 274 | .railfoot .linklike { |
| 273 | 275 | background: none; border: 0; padding: 0; cursor: pointer; |
| 274 | 276 | color: var(--shell-muted); font: inherit; text-decoration: underline; |
| @@ -795,6 +797,21 @@ pre.message { | ||
| 795 | 797 | } |
| 796 | 798 | |
| 797 | 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 | 815 | .facts { margin: 0 0 var(--sp-4); } |
| 799 | 816 | .facts .counts { |
| 800 | 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 | 41 | </div> |
| 42 | 42 | <div class="railfoot"> |
| 43 | 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 | 45 | <form method="post" action="/logout"><button type="submit" class="linklike">Log out</button></form> |
| 45 | 46 | {{else}}<a href="/login">Sign in</a>{{end}} |
| 46 | 47 | </div> |