Design round: nav, footer, privacy, colors (#10) !20
13 files changed, +121 −18
Layout: unified · split
e2e/accounts_test.go +1 −1
| @@ -87,7 +87,7 @@ func TestWebAccounts(t *testing.T) { | |||
| 87 | 87 | ||
| 88 | browser := newBrowser(t) | 88 | browser := newBrowser(t) |
| 89 | status, body := browserGet(t, browser, inst.base()+loginPath) | 89 | status, body := browserGet(t, browser, inst.base()+loginPath) |
| 90 | if status != 200 || !strings.Contains(body, "logged in as alice") { | 90 | if status != 200 || !strings.Contains(body, `logged in as <a href="/alice">alice</a>`) { |
| 91 | t.Fatalf("login redirect landed wrong: %d\n%s", status, body) | 91 | t.Fatalf("login redirect landed wrong: %d\n%s", status, body) |
| 92 | } | 92 | } |
| 93 | 93 | ||
e2e/dashboard_test.go +1 −1
| @@ -89,7 +89,7 @@ func TestDashboard(t *testing.T) { | |||
| 89 | t.Fatalf("login: %d", status) | 89 | t.Fatalf("login: %d", status) |
| 90 | } | 90 | } |
| 91 | status, body = browserGet(t, browser, inst.base()+"/") | 91 | status, body = browserGet(t, browser, inst.base()+"/") |
| 92 | if status != 200 || !strings.Contains(body, "logged in as alice") { | 92 | if status != 200 || !strings.Contains(body, `logged in as <a href="/alice">alice</a>`) { |
| 93 | t.Fatalf("dashboard: %d", status) | 93 | t.Fatalf("dashboard: %d", status) |
| 94 | } | 94 | } |
| 95 | for _, want := range []string{ | 95 | for _, want := range []string{ |
internal/config/config.go +3
| @@ -62,6 +62,9 @@ type GitDaemon struct { | |||
| 62 | type Web struct { | 62 | type Web struct { |
| 63 | Mode string `toml:"mode"` // view_only | accounts | 63 | Mode string `toml:"mode"` // view_only | accounts |
| 64 | PasswordAuth bool `toml:"password_auth"` | 64 | PasswordAuth bool `toml:"password_auth"` |
| 65 | // PrivacyNotice is operator-provided text shown on /privacy under the | ||
| 66 | // fixed project-level statement. Plain text; blank paragraphs split. | ||
| 67 | PrivacyNotice string `toml:"privacy_notice"` | ||
| 65 | } | 68 | } |
| 66 | 69 | ||
| 67 | type Registration struct { | 70 | type Registration struct { |
internal/httpd/accounts.go +8 −6
| @@ -63,17 +63,19 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { | |||
| 63 | token := r.URL.Query().Get("token") | 63 | token := r.URL.Query().Get("token") |
| 64 | if token == "" { | 64 | if token == "" { |
| 65 | s.render(w, "login.html", struct { | 65 | s.render(w, "login.html", struct { |
| 66 | Site string | 66 | Site string |
| 67 | Error string | 67 | Viewer string |
| 68 | }{s.siteName(), ""}) | 68 | Error string |
| 69 | }{s.siteName(), "", ""}) | ||
| 69 | return | 70 | return |
| 70 | } | 71 | } |
| 71 | userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) | 72 | userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) |
| 72 | if err != nil { | 73 | if err != nil { |
| 73 | s.render(w, "login.html", struct { | 74 | s.render(w, "login.html", struct { |
| 74 | Site string | 75 | Site string |
| 75 | Error string | 76 | Viewer string |
| 76 | }{s.siteName(), "that login link is invalid, expired, or already used — mint a new one"}) | 77 | Error string |
| 78 | }{s.siteName(), "", "that login link is invalid, expired, or already used — mint a new one"}) | ||
| 77 | return | 79 | return |
| 78 | } | 80 | } |
| 79 | sessTok, sessHash, err := store.NewToken() | 81 | sessTok, sessHash, err := store.NewToken() |
internal/httpd/routes.go +1
| @@ -33,6 +33,7 @@ func (s *Server) Routes() []Route { | |||
| 33 | routes = append(routes, | 33 | routes = append(routes, |
| 34 | Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, | 34 | Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, |
| 35 | Route{Method: "GET", Pattern: "/explore", Handler: s.explore}, | 35 | Route{Method: "GET", Pattern: "/explore", Handler: s.explore}, |
| 36 | Route{Method: "GET", Pattern: "/privacy", Handler: s.privacy}, | ||
| 36 | Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, | 37 | Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, |
| 37 | Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, | 38 | Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, |
| 38 | Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage}, | 39 | Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage}, |
internal/httpd/web.go +25 −2
| @@ -63,7 +63,10 @@ func (s *Server) favicon(w http.ResponseWriter, r *http.Request) { | |||
| 63 | // the stock plain-text response if the template fails. | 63 | // the stock plain-text response if the template fails. |
| 64 | func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { | 64 | func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { |
| 65 | var buf bytes.Buffer | 65 | var buf bytes.Buffer |
| 66 | if err := web.Render(&buf, "404.html", struct{ Site string }{s.siteName()}); err != nil { | 66 | if err := web.Render(&buf, "404.html", struct { |
| 67 | Site string | ||
| 68 | Viewer string | ||
| 69 | }{s.siteName(), s.viewerName(r)}); err != nil { | ||
| 67 | http.NotFound(w, r) | 70 | http.NotFound(w, r) |
| 68 | return | 71 | return |
| 69 | } | 72 | } |
| @@ -99,10 +102,11 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | |||
| 99 | s.cfg.Server.SiteURL, "https://"), "http://"), "/") | 102 | s.cfg.Server.SiteURL, "https://"), "http://"), "/") |
| 100 | s.render(w, "landing.html", struct { | 103 | s.render(w, "landing.html", struct { |
| 101 | Site string | 104 | Site string |
| 105 | Viewer string | ||
| 102 | Host string | 106 | Host string |
| 103 | Accounts bool | 107 | Accounts bool |
| 104 | Signup bool | 108 | Signup bool |
| 105 | }{s.siteName(), host, s.cfg.Web.Mode == "accounts", | 109 | }{s.siteName(), "", host, s.cfg.Web.Mode == "accounts", |
| 106 | s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"}) | 110 | s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"}) |
| 107 | } | 111 | } |
| 108 | 112 | ||
| @@ -145,6 +149,25 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | |||
| 145 | }{s.siteName(), viewer.Username, q, s.filterRepos(q, s.describeAll(repos))}) | 149 | }{s.siteName(), viewer.Username, q, s.filterRepos(q, s.describeAll(repos))}) |
| 146 | } | 150 | } |
| 147 | 151 | ||
| 152 | // viewerName returns the logged-in username for header rendering, or "". | ||
| 153 | func (s *Server) viewerName(r *http.Request) string { | ||
| 154 | if s.cfg.Web.Mode != "accounts" { | ||
| 155 | return "" | ||
| 156 | } | ||
| 157 | return s.viewer(r).Username | ||
| 158 | } | ||
| 159 | |||
| 160 | // privacy renders the privacy page: what the gitbay software does with | ||
| 161 | // data, plus this instance's operator-provided notes. | ||
| 162 | func (s *Server) privacy(w http.ResponseWriter, r *http.Request) { | ||
| 163 | s.render(w, "privacy.html", struct { | ||
| 164 | Site string | ||
| 165 | Viewer string | ||
| 166 | Host string | ||
| 167 | Notice string | ||
| 168 | }{s.siteName(), s.viewerName(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice}) | ||
| 169 | } | ||
| 170 | |||
| 148 | // filterRepos keeps repos whose path, description, or topics contain the | 171 | // filterRepos keeps repos whose path, description, or topics contain the |
| 149 | // query, case-insensitively. An empty query keeps everything. | 172 | // query, case-insensitively. An empty query keeps everything. |
| 150 | func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo { | 173 | func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo { |
internal/policy/names.go +1
| @@ -20,6 +20,7 @@ var reservedNames = map[string]bool{ | |||
| 20 | "login": true, | 20 | "login": true, |
| 21 | "logout": true, | 21 | "logout": true, |
| 22 | "new": true, | 22 | "new": true, |
| 23 | "privacy": true, | ||
| 23 | "raw": true, | 24 | "raw": true, |
| 24 | "register": true, | 25 | "register": true, |
| 25 | "settings": true, | 26 | "settings": true, |
internal/web/static/favicon.svg +1 −1
| @@ -1 +1 @@ | |||
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="6" fill="#0b6c80"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg> | 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="6" fill="#0000f0"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg> |
internal/web/static/style.css +14 −3
| @@ -13,7 +13,7 @@ | |||
| 13 | --faint: #eceff1; /* hairlines, row separators */ | 13 | --faint: #eceff1; /* hairlines, row separators */ |
| 14 | --line: #d7dde2; /* control borders, card edges */ | 14 | --line: #d7dde2; /* control borders, card edges */ |
| 15 | --surface: #f6f8fa; /* cards, code, table hover */ | 15 | --surface: #f6f8fa; /* cards, code, table hover */ |
| 16 | --accent: #0b6c80; /* sea blue: links, active tab, primary */ | 16 | --accent: #0000f0; /* blue: links, active tab, primary */ |
| 17 | --accent-fg: #ffffff; /* text on accent */ | 17 | --accent-fg: #ffffff; /* text on accent */ |
| 18 | --ok: #1a7f37; | 18 | --ok: #1a7f37; |
| 19 | --warn: #9a6700; | 19 | --warn: #9a6700; |
| @@ -56,8 +56,8 @@ | |||
| 56 | --faint: #1d242c; | 56 | --faint: #1d242c; |
| 57 | --line: #30363d; | 57 | --line: #30363d; |
| 58 | --surface: #161b22; | 58 | --surface: #161b22; |
| 59 | --accent: #58b7c9; | 59 | --accent: #8899ff; |
| 60 | --accent-fg: #06272e; | 60 | --accent-fg: #0a1030; |
| 61 | --ok: #3fb950; | 61 | --ok: #3fb950; |
| 62 | --warn: #d29922; | 62 | --warn: #d29922; |
| 63 | --bad: #f85149; | 63 | --bad: #f85149; |
| @@ -114,6 +114,17 @@ a.site { | |||
| 114 | } | 114 | } |
| 115 | a.site:hover { text-decoration: none; color: var(--accent); } | 115 | a.site:hover { text-decoration: none; color: var(--accent); } |
| 116 | a.site svg.mark { display: block; } | 116 | a.site svg.mark { display: block; } |
| 117 | nav.topnav { | ||
| 118 | display: flex; | ||
| 119 | align-items: center; | ||
| 120 | gap: var(--sp-4); | ||
| 121 | } | ||
| 122 | nav.topnav .spacer { flex: 1; } | ||
| 123 | nav.topnav > a:not(.site) { color: var(--fg); font-size: var(--fs-2); } | ||
| 124 | nav.topnav > a:not(.site):hover { color: var(--accent); text-decoration: none; } | ||
| 125 | nav.topnav a.navuser { font-weight: 600; } | ||
| 126 | nav.topnav button.linklike { color: var(--muted); } | ||
| 127 | nav.topnav button.linklike:hover { color: var(--accent); text-decoration: none; } | ||
| 117 | main.container { | 128 | main.container { |
| 118 | width: 100%; | 129 | width: 100%; |
| 119 | padding-top: var(--sp-3); | 130 | padding-top: var(--sp-3); |
internal/web/templates/dashboard.html +1 −2
| @@ -3,8 +3,7 @@ | |||
| 3 | <div class="headrow"> | 3 | <div class="headrow"> |
| 4 | <h1>dashboard</h1> | 4 | <h1>dashboard</h1> |
| 5 | <span class="spacer"></span> | 5 | <span class="spacer"></span> |
| 6 | <p class="toolbar">logged in as {{.Viewer}} · <a href="/{{.Viewer}}">your repositories</a> · <a href="/new">new repository</a> · <a href="/explore">explore</a> · | 6 | <p class="toolbar">logged in as <a href="/{{.Viewer}}">{{.Viewer}}</a></p> |
| 7 | <form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p> | ||
| 8 | </div> | 7 | </div> |
| 9 | {{if .Pinned}}<h2>pinned</h2> | 8 | {{if .Pinned}}<h2>pinned</h2> |
| 10 | <div class="repogrid"> | 9 | <div class="repogrid"> |
internal/web/templates/layout.html +9 −2
| @@ -9,13 +9,20 @@ | |||
| 9 | </head> | 9 | </head> |
| 10 | <body> | 10 | <body> |
| 11 | <header> | 11 | <header> |
| 12 | <nav class="container"><a class="site" href="/"><svg class="mark" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"><rect x="1" y="1" width="22" height="22" rx="6" fill="var(--accent)"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg>{{.Site}}</a></nav> | 12 | <nav class="container topnav"> |
| 13 | <a class="site" href="/"><svg class="mark" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"><rect x="1" y="1" width="22" height="22" rx="6" fill="var(--accent)"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg>{{.Site}}</a> | ||
| 14 | <span class="spacer"></span> | ||
| 15 | <a href="/explore">explore</a> | ||
| 16 | {{if .Viewer}}<a href="/new">new repository</a> | ||
| 17 | <a class="navuser" href="/{{.Viewer}}">{{.Viewer}}</a> | ||
| 18 | <form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form>{{end}} | ||
| 19 | </nav> | ||
| 13 | </header> | 20 | </header> |
| 14 | <main class="container"> | 21 | <main class="container"> |
| 15 | {{template "content" .}} | 22 | {{template "content" .}} |
| 16 | </main> | 23 | </main> |
| 17 | <footer> | 24 | <footer> |
| 18 | <div class="container"><p>powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code>{{.}}</code>{{end}}</p></div> | 25 | <div class="container"><p>powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code><a href="https://gitbay.org/krz/gitbay/commit/{{gitbayCommit}}">{{.}}</a></code>{{end}} · <a href="/privacy">privacy</a></p></div> |
| 19 | </footer> | 26 | </footer> |
| 20 | </body> | 27 | </body> |
| 21 | </html>{{end}} | 28 | </html>{{end}} |
internal/web/templates/privacy.html added +29
| @@ -0,0 +1,29 @@ | |||
| 1 | {{define "title"}}privacy · {{.Site}}{{end}} | ||
| 2 | {{define "content"}} | ||
| 3 | <div class="landing"> | ||
| 4 | <h1>privacy</h1> | ||
| 5 | |||
| 6 | <h2>the software</h2> | ||
| 7 | <p>This site runs <a href="https://gitbay.org/krz/gitbay">gitbay</a>, a | ||
| 8 | self-hosted, CLI-first git forge. The software makes no external | ||
| 9 | requests from your browser: no analytics, no CDNs, no webfonts, no | ||
| 10 | tracking of any kind. Everything you see is served from this host.</p> | ||
| 11 | <p>What the server stores is what a forge needs to function: your | ||
| 12 | account (username, email addresses, public SSH/PGP keys), the | ||
| 13 | repositories and their contents, issues, merge requests, comments, and | ||
| 14 | a security audit log of account and repository actions (recording the | ||
| 15 | acting user, the action, and the public key fingerprint used). Web | ||
| 16 | sessions use a single cookie, only after you log in. Private | ||
| 17 | repositories are visible only to accounts you grant; to everyone else | ||
| 18 | they are indistinguishable from nonexistent.</p> | ||
| 19 | <p>Your data is portable by design: <code>gitbay auth export</code> | ||
| 20 | downloads your account bundle, git data is yours by clone, and | ||
| 21 | <code>gitbay migrate</code> moves everything to another instance.</p> | ||
| 22 | |||
| 23 | <h2>this instance ({{.Host}})</h2> | ||
| 24 | {{if .Notice}}{{range paragraphs .Notice}}<p>{{.}}</p>{{end}} | ||
| 25 | {{else}}<p class="desc">The operator of this instance has not added | ||
| 26 | instance-specific notes. Questions about backups, retention, or | ||
| 27 | jurisdiction go to the operator.</p>{{end}} | ||
| 28 | </div> | ||
| 29 | {{end}} | ||
internal/web/web.go +27
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "html/template" | 7 | "html/template" |
| 8 | "io" | 8 | "io" |
| 9 | "runtime/debug" | 9 | "runtime/debug" |
| 10 | "strings" | ||
| 10 | "sync" | 11 | "sync" |
| 11 | "time" | 12 | "time" |
| 12 | ) | 13 | ) |
| @@ -35,8 +36,34 @@ var version = sync.OnceValue(func() string { | |||
| 35 | return "" | 36 | return "" |
| 36 | }) | 37 | }) |
| 37 | 38 | ||
| 39 | // fullVersion is the complete VCS revision, for linking the footer hash | ||
| 40 | // to the upstream commit page. | ||
| 41 | var fullVersion = sync.OnceValue(func() string { | ||
| 42 | info, ok := debug.ReadBuildInfo() | ||
| 43 | if !ok { | ||
| 44 | return "" | ||
| 45 | } | ||
| 46 | for _, s := range info.Settings { | ||
| 47 | if s.Key == "vcs.revision" { | ||
| 48 | return s.Value | ||
| 49 | } | ||
| 50 | } | ||
| 51 | return "" | ||
| 52 | }) | ||
| 53 | |||
| 38 | var funcs = template.FuncMap{ | 54 | var funcs = template.FuncMap{ |
| 39 | "gitbayVersion": func() string { return version() }, | 55 | "gitbayVersion": func() string { return version() }, |
| 56 | "gitbayCommit": func() string { return fullVersion() }, | ||
| 57 | // paragraphs splits plain text on blank lines for safe rich display. | ||
| 58 | "paragraphs": func(s string) []string { | ||
| 59 | var out []string | ||
| 60 | for _, p := range strings.Split(s, "\n\n") { | ||
| 61 | if p = strings.TrimSpace(p); p != "" { | ||
| 62 | out = append(out, p) | ||
| 63 | } | ||
| 64 | } | ||
| 65 | return out | ||
| 66 | }, | ||
| 40 | // short abbreviates a commit SHA for display. | 67 | // short abbreviates a commit SHA for display. |
| 41 | "short": func(s string) string { | 68 | "short": func(s string) string { |
| 42 | if len(s) > 10 { | 69 | if len(s) > 10 { |