tenseleyflow/shithub / bcbbbbf

Browse files

web: viewer-aware nav with avatar dropdown and sign-out form

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
bcbbbbfb999b6f8c01f50d12ad44234781048a81
Parents
e642477
Tree
fe6c6d4

8 changed files

StatusFile+-
M internal/web/handlers/auth/auth.go 1 1
M internal/web/handlers/hello.go 14 6
M internal/web/handlers/profile/profile.go 2 2
M internal/web/handlers/repo/repo.go 1 1
M internal/web/render/render.go 23 0
M internal/web/static/css/shithub.css 77 0
M internal/web/templates/_nav.html 22 0
M internal/web/templates/hello.html 11 0
internal/web/handlers/auth/auth.gomodified
@@ -764,7 +764,7 @@ func (h *Handlers) verifyResendSubmit(w http.ResponseWriter, r *http.Request) {
764764
 
765765
 func (h *Handlers) renderPage(w http.ResponseWriter, r *http.Request, page string, data any) {
766766
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
767
-	if err := h.d.Render.Render(w, page, data); err != nil {
767
+	if err := h.d.Render.RenderPage(w, r, page, data); err != nil {
768768
 		h.d.Logger.ErrorContext(r.Context(), "render", "page", page, "error", err)
769769
 	}
770770
 }
internal/web/handlers/hello.gomodified
@@ -8,6 +8,7 @@ import (
88
 	"net/http"
99
 
1010
 	"github.com/tenseleyFlow/shithub/internal/version"
11
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
1112
 	"github.com/tenseleyFlow/shithub/internal/web/render"
1213
 )
1314
 
@@ -23,6 +24,11 @@ type helloData struct {
2324
 	Commit  string
2425
 	BuiltAt string
2526
 	LogoSVG template.HTML
27
+	// Viewer + CSRFToken mirror the fields _nav.html branches on. Typed
28
+	// page-data structs must populate them explicitly — the renderer
29
+	// only auto-injects for map[string]any data.
30
+	Viewer    middleware.CurrentUser
31
+	CSRFToken string
2632
 	// OG* are referenced by the shared _layout.html (S09). The fields
2733
 	// must exist on every typed page-data struct that goes through the
2834
 	// layout — html/template evaluates `{{ if .X }}` even on nil-checks
@@ -34,14 +40,16 @@ type helloData struct {
3440
 
3541
 func (h helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
3642
 	data := helloData{
37
-		Title:   "Welcome",
38
-		Version: version.Version,
39
-		Commit:  version.Commit,
40
-		BuiltAt: version.BuiltAt,
41
-		LogoSVG: template.HTML(h.logoSVG), // #nosec G203 — embedded server-owned asset
43
+		Title:     "Welcome",
44
+		Version:   version.Version,
45
+		Commit:    version.Commit,
46
+		BuiltAt:   version.BuiltAt,
47
+		LogoSVG:   template.HTML(h.logoSVG), // #nosec G203 — embedded server-owned asset
48
+		Viewer:    middleware.CurrentUserFromContext(r.Context()),
49
+		CSRFToken: middleware.CSRFTokenForRequest(r),
4250
 	}
4351
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
44
-	if err := h.render.Render(w, "hello", data); err != nil {
52
+	if err := h.render.RenderPage(w, r, "hello", data); err != nil {
4553
 		h.logger.Error("render hello", "error", err)
4654
 		http.Error(w, "internal server error", http.StatusInternalServerError)
4755
 	}
internal/web/handlers/profile/profile.gomodified
@@ -134,7 +134,7 @@ func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) {
134134
 		"WebsiteSafe":     safeWebsite(user.Website),
135135
 	}
136136
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
137
-	if err := h.d.Render.Render(w, "profile/view", data); err != nil {
137
+	if err := h.d.Render.RenderPage(w, r, "profile/view", data); err != nil {
138138
 		h.d.Logger.ErrorContext(r.Context(), "profile: render", "error", err)
139139
 	}
140140
 }
@@ -157,7 +157,7 @@ func (h *Handlers) renderUnavailable(w http.ResponseWriter, r *http.Request, use
157157
 	w.Header().Set("Cache-Control", "no-store")
158158
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
159159
 	w.WriteHeader(http.StatusGone) // 410 — semantically "this resource is gone but we know it existed"
160
-	if err := h.d.Render.Render(w, "profile/suspended", map[string]any{
160
+	if err := h.d.Render.RenderPage(w, r, "profile/suspended", map[string]any{
161161
 		"Title":    "Account unavailable",
162162
 		"Username": username,
163163
 	}); err != nil {
internal/web/handlers/repo/repo.gomodified
@@ -152,7 +152,7 @@ func (h *Handlers) newRepoSubmit(w http.ResponseWriter, r *http.Request) {
152152
 
153153
 func (h *Handlers) renderNewForm(w http.ResponseWriter, r *http.Request, form formState, errMsg string) {
154154
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
155
-	if err := h.d.Render.Render(w, "repo/new", map[string]any{
155
+	if err := h.d.Render.RenderPage(w, r, "repo/new", map[string]any{
156156
 		"Title":      "New repository",
157157
 		"CSRFToken":  middleware.CSRFTokenForRequest(r),
158158
 		"Form":       form,
internal/web/render/render.gomodified
@@ -116,6 +116,10 @@ func New(tmplFS fs.FS, opts Options) (*Renderer, error) {
116116
 }
117117
 
118118
 // Render writes the named page to w using data as the template root context.
119
+//
120
+// Prefer RenderPage when a *http.Request is in scope — it auto-injects the
121
+// viewer (current logged-in user) into map data so partials like _nav.html
122
+// can branch on .Viewer without every handler remembering to thread it.
119123
 func (r *Renderer) Render(w io.Writer, name string, data any) error {
120124
 	t, ok := r.pages[name]
121125
 	if !ok {
@@ -129,6 +133,25 @@ func (r *Renderer) Render(w io.Writer, name string, data any) error {
129133
 	return err
130134
 }
131135
 
136
+// RenderPage is the request-aware Render: when data is a map[string]any, it
137
+// injects "Viewer" (from middleware.CurrentUserFromContext) and "CSRFToken"
138
+// (the per-request token) if the caller hasn't set them. The nav partial's
139
+// sign-out form uses the token, so every layout-rendered page needs it.
140
+// Typed-struct callers must include those fields themselves — we don't
141
+// reflect-mutate to avoid surprising aliasing.
142
+func (r *Renderer) RenderPage(w io.Writer, req *http.Request, name string, data any) error {
143
+	if m, ok := data.(map[string]any); ok {
144
+		if _, present := m["Viewer"]; !present {
145
+			m["Viewer"] = middleware.CurrentUserFromContext(req.Context())
146
+		}
147
+		if _, present := m["CSRFToken"]; !present {
148
+			m["CSRFToken"] = middleware.CSRFTokenForRequest(req)
149
+		}
150
+		data = m
151
+	}
152
+	return r.Render(w, name, data)
153
+}
154
+
132155
 // HTTPError writes an error page with the appropriate status code. If the
133156
 // named error template doesn't exist a plain-text fallback is written.
134157
 func (r *Renderer) HTTPError(w http.ResponseWriter, req *http.Request, status int, message string) {
internal/web/static/css/shithub.cssmodified
@@ -121,7 +121,84 @@ code {
121121
 .shithub-nav-actions {
122122
   display: flex;
123123
   gap: 0.5rem;
124
+  align-items: center;
125
+}
126
+
127
+/* User-menu dropdown — uses native <details>/<summary> so it works without JS. */
128
+.shithub-user-menu { position: relative; }
129
+.shithub-user-menu > summary {
130
+  list-style: none;
131
+  display: inline-flex;
132
+  align-items: center;
133
+  gap: 0.5rem;
134
+  padding: 0.25rem 0.6rem;
135
+  border: 1px solid var(--border-default);
136
+  border-radius: 6px;
137
+  background: transparent;
138
+  cursor: pointer;
139
+  font-size: 0.875rem;
140
+  color: var(--fg-default);
141
+}
142
+.shithub-user-menu > summary::-webkit-details-marker { display: none; }
143
+.shithub-user-menu-avatar {
144
+  width: 24px;
145
+  height: 24px;
146
+  border-radius: 50%;
147
+  display: block;
148
+  background: var(--canvas-default);
149
+}
150
+.shithub-user-menu-name { font-weight: 500; }
151
+.shithub-user-menu-panel {
152
+  position: absolute;
153
+  right: 0;
154
+  top: calc(100% + 0.35rem);
155
+  min-width: 220px;
156
+  background: var(--canvas-default);
157
+  border: 1px solid var(--border-default);
158
+  border-radius: 6px;
159
+  padding: 0.4rem 0;
160
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
161
+  z-index: 50;
162
+  display: flex;
163
+  flex-direction: column;
164
+}
165
+.shithub-user-menu-header {
166
+  padding: 0.5rem 0.85rem;
167
+  font-size: 0.8rem;
168
+  color: var(--fg-muted);
169
+  border-bottom: 1px solid var(--border-default);
170
+  margin-bottom: 0.25rem;
171
+}
172
+.shithub-user-menu-panel a,
173
+.shithub-user-menu-signout button {
174
+  display: block;
175
+  text-align: left;
176
+  width: 100%;
177
+  padding: 0.45rem 0.85rem;
178
+  background: transparent;
179
+  border: 0;
180
+  color: var(--fg-default);
181
+  font-size: 0.875rem;
182
+  cursor: pointer;
183
+}
184
+.shithub-user-menu-panel a:hover,
185
+.shithub-user-menu-signout button:hover {
186
+  background: var(--canvas-subtle);
187
+  text-decoration: none;
188
+}
189
+.shithub-user-menu-signout { margin: 0; padding: 0; border-top: 1px solid var(--border-default); margin-top: 0.25rem; }
190
+
191
+.hello-greeting {
192
+  margin: 1rem auto 1.5rem;
193
+  padding: 0.85rem 1rem;
194
+  border: 1px solid var(--border-default);
195
+  border-radius: 6px;
196
+  background: var(--canvas-subtle);
197
+  max-width: 32rem;
198
+  text-align: left;
124199
 }
200
+.hello-greeting p { margin: 0 0 0.5rem; }
201
+.hello-quicklinks { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.9rem; }
125202
 
126203
 .shithub-button {
127204
   display: inline-flex;
internal/web/templates/_nav.htmlmodified
@@ -9,8 +9,30 @@
99
     <a href="/about">About</a>
1010
   </nav>
1111
   <div class="shithub-nav-actions">
12
+  {{- if .Viewer.ID }}
13
+    <a href="/new" class="shithub-button shithub-button-ghost" title="New repository">+ New</a>
14
+    <details class="shithub-user-menu">
15
+      <summary aria-label="User menu" aria-haspopup="menu">
16
+        <img src="/avatars/{{ .Viewer.Username }}" alt="" class="shithub-user-menu-avatar" width="24" height="24">
17
+        <span class="shithub-user-menu-name">{{ .Viewer.Username }}</span>
18
+      </summary>
19
+      <div class="shithub-user-menu-panel" role="menu">
20
+        <div class="shithub-user-menu-header">
21
+          Signed in as <strong>@{{ .Viewer.Username }}</strong>
22
+        </div>
23
+        <a role="menuitem" href="/{{ .Viewer.Username }}">Your profile</a>
24
+        <a role="menuitem" href="/{{ .Viewer.Username }}?tab=repositories">Your repositories</a>
25
+        <a role="menuitem" href="/settings/profile">Settings</a>
26
+        <form method="POST" action="/logout" class="shithub-user-menu-signout">
27
+          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
28
+          <button type="submit" role="menuitem">Sign out</button>
29
+        </form>
30
+      </div>
31
+    </details>
32
+  {{- else }}
1233
     <a href="/login" class="shithub-button shithub-button-ghost">Sign in</a>
1334
     <a href="/signup" class="shithub-button shithub-button-primary">Sign up</a>
35
+  {{- end }}
1436
   </div>
1537
 </header>
1638
 {{- end }}
internal/web/templates/hello.htmlmodified
@@ -6,6 +6,17 @@
66
   <h1 class="hello-title">shithub</h1>
77
   <p class="hello-tagline">GitHub. Open source. Without Copilot.</p>
88
 
9
+  {{ if .Viewer.ID }}
10
+  <div class="hello-greeting">
11
+    <p>Signed in as <strong>@{{ .Viewer.Username }}</strong>.</p>
12
+    <nav class="hello-quicklinks" aria-label="Account quick links">
13
+      <a href="/{{ .Viewer.Username }}">Your profile</a>
14
+      <a href="/new">New repository</a>
15
+      <a href="/settings/profile">Settings</a>
16
+    </nav>
17
+  </div>
18
+  {{ end }}
19
+
920
   <dl class="hello-meta">
1021
     <dt>Version</dt><dd>{{ .Version }}</dd>
1122
     <dt>Commit</dt><dd>{{ .Commit }}</dd>